feat(whale-monitor): 优化市场选择页交互与分类缓存
- 体育联赛通过 series_id 展示单场比赛,长期市场分块可折叠 - 移除全部分类,支持搜索与分类切换缓存、首屏自动加载 - 单组列表扁平展示,列表行不展示图片(联赛下拉保留 logo) - 补充大单监听功能说明与 Code Review 清单文档 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<T>()` / `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. 合并前最后一眼
|
||||||
|
- 变更是否可回滚(开关、配置、禁用策略等)
|
||||||
|
- 默认值是否安全(默认禁用自动下单/默认阈值合理/冷却存在)
|
||||||
|
- 文档是否更新(字段、接口、流程)
|
||||||
|
|
||||||
@@ -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<TradePoint(tsMillis, notional)>`
|
||||||
|
- `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)作为另一种策略类型
|
||||||
|
|
||||||
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { groupMarketsBySection, type WhaleMonitorMarketGroup } from '../constants/whaleMonitor'
|
import { groupMarketsBySection, type WhaleMonitorMarketGroup } from '../constants/whaleMonitor'
|
||||||
import type { WhaleMonitorMarketItem } from '../types'
|
import type { WhaleMonitorMarketItem } from '../types'
|
||||||
import WhaleMonitorMarketListItem from './WhaleMonitorMarketListItem'
|
import WhaleMonitorMarketListItem from './WhaleMonitorMarketListItem'
|
||||||
import WhaleMonitorMarketThumbnail from './WhaleMonitorMarketThumbnail'
|
|
||||||
|
|
||||||
const { Text, Title } = Typography
|
const { Text, Title } = Typography
|
||||||
|
|
||||||
@@ -16,31 +15,44 @@ interface WhaleMonitorMarketGroupedListProps {
|
|||||||
onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void
|
onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderFlatMarketList = (
|
||||||
|
markets: WhaleMonitorMarketItem[],
|
||||||
|
selectedMap: Map<string, WhaleMonitorMarketItem>,
|
||||||
|
isMobile: boolean,
|
||||||
|
onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void,
|
||||||
|
compact = false
|
||||||
|
) => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: isMobile ? 4 : 8 }}>
|
||||||
|
{markets.map(market => (
|
||||||
|
<WhaleMonitorMarketListItem
|
||||||
|
key={market.conditionId}
|
||||||
|
market={market}
|
||||||
|
checked={selectedMap.has(market.conditionId)}
|
||||||
|
isMobile={isMobile}
|
||||||
|
hideEventTitle
|
||||||
|
compact={compact}
|
||||||
|
onToggle={checked => onToggleMarket(market, checked)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const renderMarketGroups = (
|
const renderMarketGroups = (
|
||||||
groups: WhaleMonitorMarketGroup[],
|
groups: WhaleMonitorMarketGroup[],
|
||||||
selectedMap: Map<string, WhaleMonitorMarketItem>,
|
selectedMap: Map<string, WhaleMonitorMarketItem>,
|
||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void,
|
onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void,
|
||||||
onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void,
|
onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void,
|
||||||
t: (key: string, options?: Record<string, number>) => string
|
t: (key: string, options?: Record<string, number>) => string,
|
||||||
|
defaultActiveKeys?: string[]
|
||||||
) => {
|
) => {
|
||||||
const showAsGroups = groups.length > 1 || (groups.length === 1 && groups[0].key.startsWith('event:'))
|
if (groups.length <= 1) {
|
||||||
|
return renderFlatMarketList(
|
||||||
if (!showAsGroups) {
|
groups.flatMap(g => g.markets),
|
||||||
const flatMarkets = groups.flatMap(g => g.markets)
|
selectedMap,
|
||||||
return (
|
isMobile,
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: isMobile ? 4 : 8 }}>
|
onToggleMarket,
|
||||||
{flatMarkets.map(market => (
|
groups.length === 1 && groups[0].key.startsWith('event:')
|
||||||
<WhaleMonitorMarketListItem
|
|
||||||
key={market.conditionId}
|
|
||||||
market={market}
|
|
||||||
checked={selectedMap.has(market.conditionId)}
|
|
||||||
isMobile={isMobile}
|
|
||||||
hideEventTitle
|
|
||||||
onToggle={checked => onToggleMarket(market, checked)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,16 +74,13 @@ const renderMarketGroups = (
|
|||||||
paddingRight: 8
|
paddingRight: 8
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<WhaleMonitorMarketThumbnail src={group.imageUrl} size={32} alt={group.title} />
|
<Text strong style={{ wordBreak: 'break-word' }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
{group.title}
|
||||||
<Text strong style={{ wordBreak: 'break-word' }}>
|
</Text>
|
||||||
{group.title}
|
<Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
|
||||||
</Text>
|
{t('whaleMonitorStrategy.marketSelect.marketsInGroup', { count: group.markets.length })}
|
||||||
<Text type="secondary" style={{ fontSize: 12, marginLeft: 0, display: 'block' }}>
|
</Text>
|
||||||
{t('whaleMonitorStrategy.marketSelect.marketsInGroup', { count: group.markets.length })}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div onClick={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
|
<div onClick={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -105,13 +114,44 @@ const renderMarketGroups = (
|
|||||||
return (
|
return (
|
||||||
<Collapse
|
<Collapse
|
||||||
bordered={false}
|
bordered={false}
|
||||||
defaultActiveKey={groups.map(g => g.key)}
|
defaultActiveKey={defaultActiveKeys ?? groups.map(g => g.key)}
|
||||||
items={collapseItems}
|
items={collapseItems}
|
||||||
style={{ background: 'transparent' }}
|
style={{ background: 'transparent' }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderSectionHeader = (sectionTitle: string, marketCount: number) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Text strong style={{ fontSize: 16 }}>
|
||||||
|
{sectionTitle}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13, fontWeight: 400 }}>
|
||||||
|
({marketCount})
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderSectionBody = (
|
||||||
|
section: { key: 'game' | 'season'; groups: WhaleMonitorMarketGroup[] },
|
||||||
|
selectedMap: Map<string, WhaleMonitorMarketItem>,
|
||||||
|
isMobile: boolean,
|
||||||
|
onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void,
|
||||||
|
onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void,
|
||||||
|
t: (key: string, options?: Record<string, number>) => 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<WhaleMonitorMarketGroupedListProps> = ({
|
const WhaleMonitorMarketGroupedList: React.FC<WhaleMonitorMarketGroupedListProps> = ({
|
||||||
markets,
|
markets,
|
||||||
selectedMap,
|
selectedMap,
|
||||||
@@ -131,37 +171,95 @@ const WhaleMonitorMarketGroupedList: React.FC<WhaleMonitorMarketGroupedListProps
|
|||||||
[markets, t]
|
[markets, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const totalGroups = sections.reduce((sum, s) => 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) {
|
if (sections.length === 1) {
|
||||||
const section = sections[0]
|
const section = sections[0]
|
||||||
const showSectionTitle =
|
const marketCount = section.groups.reduce((sum, g) => sum + g.markets.length, 0)
|
||||||
markets.some(m => m.marketType === 'game' || m.marketType === 'season') ||
|
|
||||||
section.groups.some(g => g.key.startsWith('event:'))
|
if (section.key === 'season' && section.groups.length > 1) {
|
||||||
|
return (
|
||||||
|
<Collapse
|
||||||
|
bordered={false}
|
||||||
|
defaultActiveKey={[]}
|
||||||
|
style={{ background: 'transparent' }}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'season-section',
|
||||||
|
label: renderSectionHeader(section.title, marketCount),
|
||||||
|
children: renderSectionBody(
|
||||||
|
section,
|
||||||
|
selectedMap,
|
||||||
|
isMobile,
|
||||||
|
onToggleMarket,
|
||||||
|
onToggleGroup,
|
||||||
|
t
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showSectionTitle && (
|
<Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
|
||||||
<Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
|
{section.title}
|
||||||
{section.title}
|
<Text type="secondary" style={{ fontSize: 13, fontWeight: 400, marginLeft: 8 }}>
|
||||||
</Title>
|
({marketCount})
|
||||||
)}
|
</Text>
|
||||||
{renderMarketGroups(section.groups, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)}
|
</Title>
|
||||||
|
{renderSectionBody(section, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{sections.map((section, index) => (
|
{sections.map((section, index) => {
|
||||||
<div key={section.key}>
|
const marketCount = section.groups.reduce((sum, g) => sum + g.markets.length, 0)
|
||||||
{index > 0 && <Divider style={{ margin: '16px 0' }} />}
|
return (
|
||||||
<Title level={5} style={{ marginTop: index === 0 ? 0 : undefined, marginBottom: 12 }}>
|
<div key={section.key}>
|
||||||
{section.title}
|
{index > 0 && <Divider style={{ margin: '16px 0' }} />}
|
||||||
<Text type="secondary" style={{ fontSize: 13, fontWeight: 400, marginLeft: 8 }}>
|
{section.key === 'season' && section.groups.length > 1 ? (
|
||||||
({section.groups.reduce((sum, g) => sum + g.markets.length, 0)})
|
<Collapse
|
||||||
</Text>
|
bordered={false}
|
||||||
</Title>
|
defaultActiveKey={[]}
|
||||||
{renderMarketGroups(section.groups, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)}
|
style={{ background: 'transparent' }}
|
||||||
</div>
|
items={[
|
||||||
))}
|
{
|
||||||
|
key: 'season-section',
|
||||||
|
label: renderSectionHeader(section.title, marketCount),
|
||||||
|
children: renderSectionBody(
|
||||||
|
section,
|
||||||
|
selectedMap,
|
||||||
|
isMobile,
|
||||||
|
onToggleMarket,
|
||||||
|
onToggleGroup,
|
||||||
|
t
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Title level={5} style={{ marginTop: index === 0 ? 0 : undefined, marginBottom: 12 }}>
|
||||||
|
{section.title}
|
||||||
|
<Text type="secondary" style={{ fontSize: 13, fontWeight: 400, marginLeft: 8 }}>
|
||||||
|
({marketCount})
|
||||||
|
</Text>
|
||||||
|
</Title>
|
||||||
|
{renderSectionBody(section, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Checkbox, Tag, Typography } from 'antd'
|
import { Checkbox, Tag, Typography } from 'antd'
|
||||||
import { LinkOutlined } from '@ant-design/icons'
|
import { LinkOutlined } from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { parseMarketOutcomes, pickMarketImageUrl } from '../constants/whaleMonitor'
|
import { parseMarketOutcomes } from '../constants/whaleMonitor'
|
||||||
import type { WhaleMonitorMarketItem } from '../types'
|
import type { WhaleMonitorMarketItem } from '../types'
|
||||||
import { formatUSDC } from '../utils'
|
import { formatUSDC } from '../utils'
|
||||||
import WhaleMonitorMarketThumbnail from './WhaleMonitorMarketThumbnail'
|
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
@@ -33,9 +32,6 @@ const WhaleMonitorMarketListItem: React.FC<WhaleMonitorMarketListItemProps> = ({
|
|||||||
const volumeDisplay =
|
const volumeDisplay =
|
||||||
market.volume && parseFloat(market.volume) > 0 ? formatUSDC(market.volume) : null
|
market.volume && parseFloat(market.volume) > 0 ? formatUSDC(market.volume) : null
|
||||||
const polymarketUrl = market.slug ? `https://polymarket.com/event/${market.slug}` : 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) => {
|
const handleOpenLink = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (polymarketUrl) {
|
if (polymarketUrl) {
|
||||||
@@ -72,7 +68,6 @@ const WhaleMonitorMarketListItem: React.FC<WhaleMonitorMarketListItemProps> = ({
|
|||||||
onChange={e => onToggle(e.target.checked)}
|
onChange={e => onToggle(e.target.checked)}
|
||||||
style={{ marginTop: 2 }}
|
style={{ marginTop: 2 }}
|
||||||
/>
|
/>
|
||||||
<WhaleMonitorMarketThumbnail src={imageUrl} size={thumbSize} />
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{market.eventTitle && !hideEventTitle && (
|
{market.eventTitle && !hideEventTitle && (
|
||||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 2 }}>
|
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 2 }}>
|
||||||
|
|||||||
@@ -1907,7 +1907,7 @@
|
|||||||
"subtitle": "Filter by category or search by name. Multiple selection supported.",
|
"subtitle": "Filter by category or search by name. Multiple selection supported.",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"searchPlaceholder": "Search market name (min. 2 characters)",
|
"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",
|
"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.",
|
"sportsGamesEmpty": "No active game markets for this league. Try searching a team name or check back later.",
|
||||||
"empty": "No markets found",
|
"empty": "No markets found",
|
||||||
|
|||||||
@@ -1907,7 +1907,7 @@
|
|||||||
"subtitle": "按分类筛选或搜索市场名称,可多选",
|
"subtitle": "按分类筛选或搜索市场名称,可多选",
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
"searchPlaceholder": "搜索市场名称(至少 2 个字符)",
|
"searchPlaceholder": "搜索市场名称(至少 2 个字符)",
|
||||||
"searchHint": "请输入关键词搜索,或选择上方分类筛选",
|
"searchHint": "请选择上方分类,或输入至少 2 个字符搜索",
|
||||||
"sportsGamesHint": "体育联赛将展示单场比赛盘口;也可输入队名搜索",
|
"sportsGamesHint": "体育联赛将展示单场比赛盘口;也可输入队名搜索",
|
||||||
"sportsGamesEmpty": "该联赛当前暂无进行中的单场比赛盘口,可尝试搜索队名或稍后再试",
|
"sportsGamesEmpty": "该联赛当前暂无进行中的单场比赛盘口,可尝试搜索队名或稍后再试",
|
||||||
"empty": "未找到匹配的市场",
|
"empty": "未找到匹配的市场",
|
||||||
|
|||||||
@@ -1907,7 +1907,7 @@
|
|||||||
"subtitle": "按分類篩選或搜索市場名稱,可多選",
|
"subtitle": "按分類篩選或搜索市場名稱,可多選",
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
"searchPlaceholder": "搜索市場名稱(至少 2 個字符)",
|
"searchPlaceholder": "搜索市場名稱(至少 2 個字符)",
|
||||||
"searchHint": "請輸入關鍵詞搜索,或選擇上方分類篩選",
|
"searchHint": "請選擇上方分類,或輸入至少 2 個字符搜索",
|
||||||
"sportsGamesHint": "體育聯賽將展示單場比賽盤口;也可輸入隊名搜索",
|
"sportsGamesHint": "體育聯賽將展示單場比賽盤口;也可輸入隊名搜索",
|
||||||
"sportsGamesEmpty": "該聯賽當前暫無進行中的單場比賽盤口,可嘗試搜索隊名或稍後再試",
|
"sportsGamesEmpty": "該聯賽當前暫無進行中的單場比賽盤口,可嘗試搜索隊名或稍後再試",
|
||||||
"empty": "未找到匹配的市場",
|
"empty": "未找到匹配的市場",
|
||||||
|
|||||||
@@ -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 { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -23,13 +23,27 @@ import type { WhaleMonitorMarketItem, WhaleMonitorMarketSelectLocationState } fr
|
|||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
const TAG_OPTIONS = [
|
const TAG_OPTIONS = [
|
||||||
{ key: 'all', value: '' },
|
|
||||||
{ key: 'sports', value: '1' },
|
{ key: 'sports', value: '1' },
|
||||||
{ key: 'politics', value: '2' },
|
{ key: 'politics', value: '2' },
|
||||||
{ key: 'crypto', value: '21' },
|
{ key: 'crypto', value: '21' },
|
||||||
{ key: 'popCulture', value: '100639' }
|
{ key: 'popCulture', value: '100639' }
|
||||||
] as const
|
] 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 WhaleMonitorMarketSelect: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -41,7 +55,7 @@ const WhaleMonitorMarketSelect: React.FC = () => {
|
|||||||
|
|
||||||
const [keyword, setKeyword] = useState('')
|
const [keyword, setKeyword] = useState('')
|
||||||
const [debouncedKeyword, setDebouncedKeyword] = useState('')
|
const [debouncedKeyword, setDebouncedKeyword] = useState('')
|
||||||
const [tagId, setTagId] = useState<string>('')
|
const [tagId, setTagId] = useState<string | undefined>(undefined)
|
||||||
const [sportSubSeriesId, setSportSubSeriesId] = useState<string | undefined>(undefined)
|
const [sportSubSeriesId, setSportSubSeriesId] = useState<string | undefined>(undefined)
|
||||||
const [sportSubCategories, setSportSubCategories] = useState<
|
const [sportSubCategories, setSportSubCategories] = useState<
|
||||||
Array<{ id: number; slug: string; label: string; tagId: string; seriesId: string; image?: string }>
|
Array<{ id: number; slug: string; label: string; tagId: string; seriesId: string; image?: string }>
|
||||||
@@ -54,6 +68,12 @@ const WhaleMonitorMarketSelect: React.FC = () => {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const tagViewCacheRef = useRef<Map<string, TagViewCache>>(new Map())
|
||||||
|
const listCacheRef = useRef<Map<string, WhaleMonitorMarketItem[]>>(new Map())
|
||||||
|
const skipNextFetchRef = useRef(false)
|
||||||
|
const initialTagSetRef = useRef(false)
|
||||||
|
const initialSportLeagueSetRef = useRef(false)
|
||||||
|
|
||||||
const tagSegmentOptions = useMemo(
|
const tagSegmentOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
TAG_OPTIONS.map(opt => ({
|
TAG_OPTIONS.map(opt => ({
|
||||||
@@ -68,17 +88,7 @@ const WhaleMonitorMarketSelect: React.FC = () => {
|
|||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [keyword])
|
}, [keyword])
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchSportSubCategories = useCallback(async () => {
|
||||||
if (tagId === '1') {
|
|
||||||
fetchSportSubCategories()
|
|
||||||
setSportSubSeriesId(undefined)
|
|
||||||
} else {
|
|
||||||
setSportSubCategories([])
|
|
||||||
setSportSubSeriesId(undefined)
|
|
||||||
}
|
|
||||||
}, [tagId])
|
|
||||||
|
|
||||||
const fetchSportSubCategories = async () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await apiService.markets.sportsCategories()
|
const res = await apiService.markets.sportsCategories()
|
||||||
if (res.data.code === 0 && res.data.data) {
|
if (res.data.code === 0 && res.data.data) {
|
||||||
@@ -91,35 +101,137 @@ const WhaleMonitorMarketSelect: React.FC = () => {
|
|||||||
} catch {
|
} catch {
|
||||||
setSportSubCategories([])
|
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 () => {
|
const fetchMarkets = useCallback(async () => {
|
||||||
|
if (!tagId) return
|
||||||
|
|
||||||
const selectedSport = sportSubCategories.find(s => s.seriesId === sportSubSeriesId)
|
const selectedSport = sportSubCategories.find(s => s.seriesId === sportSubSeriesId)
|
||||||
const seriesId = sportSubSeriesId
|
const seriesId = sportSubSeriesId
|
||||||
const sportSlug = selectedSport?.slug
|
const sportSlug = selectedSport?.slug
|
||||||
const effectiveTagId = tagId && tagId !== '1' ? tagId : undefined
|
|
||||||
const searchKeyword = debouncedKeyword
|
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([])
|
setMarketList([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!seriesId && !effectiveTagId && searchKeyword.length < 2) {
|
if (!seriesId && !effectiveTagId && !hasKeyword) {
|
||||||
setMarketList([])
|
setMarketList([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listCached = listCacheRef.current.get(listCacheKey)
|
||||||
|
if (listCached) {
|
||||||
|
setMarketList(listCached)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await apiService.markets.search({
|
const res = await apiService.markets.search({
|
||||||
keyword: searchKeyword.length >= 2 ? searchKeyword : '',
|
keyword: hasKeyword ? searchKeyword : '',
|
||||||
seriesId: seriesId || undefined,
|
seriesId: seriesId || undefined,
|
||||||
sportSlug: seriesId ? sportSlug : undefined,
|
sportSlug: seriesId ? sportSlug : undefined,
|
||||||
tagId: seriesId ? undefined : effectiveTagId,
|
tagId: seriesId ? undefined : effectiveTagId,
|
||||||
limit: 200
|
limit: 200
|
||||||
})
|
})
|
||||||
if (res.data.code === 0 && res.data.data) {
|
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 {
|
} else {
|
||||||
setMarketList([])
|
setMarketList([])
|
||||||
}
|
}
|
||||||
@@ -128,12 +240,21 @@ const WhaleMonitorMarketSelect: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [debouncedKeyword, tagId, sportSubSeriesId, sportSubCategories])
|
}, [debouncedKeyword, tagId, sportSubSeriesId, sportSubCategories, keyword])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (skipNextFetchRef.current) {
|
||||||
|
skipNextFetchRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
fetchMarkets()
|
fetchMarkets()
|
||||||
}, [fetchMarkets])
|
}, [fetchMarkets])
|
||||||
|
|
||||||
|
const handleSportLeagueChange = (seriesId: string | undefined) => {
|
||||||
|
saveCurrentTagView()
|
||||||
|
setSportSubSeriesId(seriesId)
|
||||||
|
}
|
||||||
|
|
||||||
const selectedList = useMemo(() => Array.from(selectedMap.values()), [selectedMap])
|
const selectedList = useMemo(() => Array.from(selectedMap.values()), [selectedMap])
|
||||||
|
|
||||||
const toggleMarket = (market: WhaleMonitorMarketItem, checked: boolean) => {
|
const toggleMarket = (market: WhaleMonitorMarketItem, checked: boolean) => {
|
||||||
@@ -172,10 +293,14 @@ const WhaleMonitorMarketSelect: React.FC = () => {
|
|||||||
navigate('/whale-monitor-strategy')
|
navigate('/whale-monitor-strategy')
|
||||||
}
|
}
|
||||||
|
|
||||||
const showSelectSportHint = tagId === '1' && !sportSubSeriesId && debouncedKeyword.length < 2
|
const showSelectSportHint = tagId === '1' && !sportSubSeriesId && debouncedKeyword.length < MIN_SEARCH_LENGTH
|
||||||
const showSportsGamesEmpty =
|
const showSportsGamesEmpty =
|
||||||
tagId === '1' && !!sportSubSeriesId && marketList.length === 0 && !loading && debouncedKeyword.length < 2
|
tagId === '1' &&
|
||||||
const showSearchHint = !tagId && debouncedKeyword.length < 2
|
!!sportSubSeriesId &&
|
||||||
|
marketList.length === 0 &&
|
||||||
|
!loading &&
|
||||||
|
debouncedKeyword.length < MIN_SEARCH_LENGTH
|
||||||
|
const showSearchHint = !tagId && debouncedKeyword.length < MIN_SEARCH_LENGTH
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: isMobile ? 12 : 24, paddingBottom: isMobile ? 88 : 24 }}>
|
<div style={{ padding: isMobile ? 12 : 24, paddingBottom: isMobile ? 88 : 24 }}>
|
||||||
@@ -202,6 +327,7 @@ const WhaleMonitorMarketSelect: React.FC = () => {
|
|||||||
placeholder={t('whaleMonitorStrategy.marketSelect.searchPlaceholder')}
|
placeholder={t('whaleMonitorStrategy.marketSelect.searchPlaceholder')}
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={e => setKeyword(e.target.value)}
|
onChange={e => setKeyword(e.target.value)}
|
||||||
|
onPressEnter={flushSearch}
|
||||||
size={isMobile ? 'large' : 'middle'}
|
size={isMobile ? 'large' : 'middle'}
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
/>
|
/>
|
||||||
@@ -209,7 +335,7 @@ const WhaleMonitorMarketSelect: React.FC = () => {
|
|||||||
block={isMobile}
|
block={isMobile}
|
||||||
options={tagSegmentOptions}
|
options={tagSegmentOptions}
|
||||||
value={tagId}
|
value={tagId}
|
||||||
onChange={val => setTagId(val as string)}
|
onChange={val => handleTagChange(val)}
|
||||||
style={{ marginBottom: tagId === '1' ? 12 : 0 }}
|
style={{ marginBottom: tagId === '1' ? 12 : 0 }}
|
||||||
/>
|
/>
|
||||||
{tagId === '1' && sportSubCategories.length > 0 && (
|
{tagId === '1' && sportSubCategories.length > 0 && (
|
||||||
@@ -220,7 +346,7 @@ const WhaleMonitorMarketSelect: React.FC = () => {
|
|||||||
placeholder={t('whaleMonitorStrategy.form.sportLeague')}
|
placeholder={t('whaleMonitorStrategy.form.sportLeague')}
|
||||||
style={{ width: '100%', marginTop: 12 }}
|
style={{ width: '100%', marginTop: 12 }}
|
||||||
value={sportSubSeriesId}
|
value={sportSubSeriesId}
|
||||||
onChange={setSportSubSeriesId}
|
onChange={handleSportLeagueChange}
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
size={isMobile ? 'large' : 'middle'}
|
size={isMobile ? 'large' : 'middle'}
|
||||||
options={sportSubCategories.map(s => ({
|
options={sportSubCategories.map(s => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user