From a70ee3e8113937694c6d5f5ed845767e202aa603 Mon Sep 17 00:00:00 2001 From: WrBug Date: Wed, 27 May 2026 05:31:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(whale-monitor):=20=E5=B8=82=E5=9C=BA?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E6=94=B9=E4=B8=BA=E5=BC=B9=E7=AA=97=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=88=97=E8=A1=A8=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 策略编辑内嵌市场选择弹窗,移除路由跳转与 sessionStorage 草稿 - 列表区固定高度内部滚动,底部仅保留一处确认操作 - 并发缓存 markets 时捕获唯一键冲突并回读已有记录 Co-authored-by: Cursor --- .../service/common/MarketService.kt | 18 +- .../WhaleMonitorMarketSelectModal.tsx | 454 ++++++++++++++++++ .../src/pages/WhaleMonitorStrategyList.tsx | 87 +--- 3 files changed, 484 insertions(+), 75 deletions(-) create mode 100644 frontend/src/components/WhaleMonitorMarketSelectModal.tsx diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt index 5f98480..baee306 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt @@ -11,6 +11,7 @@ import com.wrbug.polymarketbot.util.getEventSlug import com.wrbug.polymarketbot.util.parseStringArray import kotlinx.coroutines.runBlocking import org.slf4j.LoggerFactory +import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service import java.time.Instant import java.time.format.DateTimeFormatter @@ -205,9 +206,20 @@ class MarketService( ) } - val savedMarket = marketRepository.save(market) - marketCache.put(marketId, savedMarket) - savedMarket + try { + val savedMarket = marketRepository.save(market) + marketCache.put(marketId, savedMarket) + savedMarket + } catch (e: DataIntegrityViolationException) { + // 并发写入同一个 marketId 时可能触发唯一索引冲突,这里降级为查询并返回已有记录 + val existingAfter = marketRepository.findByMarketId(marketId) + if (existingAfter != null) { + marketCache.put(marketId, existingAfter) + existingAfter + } else { + throw e + } + } } catch (e: Exception) { logger.error("保存市场信息失败: marketId=$marketId, error=${e.message}", e) null diff --git a/frontend/src/components/WhaleMonitorMarketSelectModal.tsx b/frontend/src/components/WhaleMonitorMarketSelectModal.tsx new file mode 100644 index 0000000..434a113 --- /dev/null +++ b/frontend/src/components/WhaleMonitorMarketSelectModal.tsx @@ -0,0 +1,454 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Button, Card, Empty, Input, Modal, Segmented, Select, Space, Spin, Tag, Typography } from 'antd' +import { SearchOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { useMediaQuery } from 'react-responsive' +import { apiService } from '../services/api' +import WhaleMonitorMarketGroupedList from './WhaleMonitorMarketGroupedList' +import WhaleMonitorMarketThumbnail from './WhaleMonitorMarketThumbnail' +import { toWhaleMonitorMarketItem } from '../constants/whaleMonitor' +import type { WhaleMonitorMarketItem } from '../types' + +const { Text } = Typography + +const TAG_OPTIONS = [ + { 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}` + +interface WhaleMonitorMarketSelectModalProps { + open: boolean + initialSelected?: WhaleMonitorMarketItem[] + onCancel: () => void + onConfirm: (selected: WhaleMonitorMarketItem[]) => void +} + +const WhaleMonitorMarketSelectModal: React.FC = ({ + open, + initialSelected, + onCancel, + onConfirm +}) => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + + const [keyword, setKeyword] = useState('') + const [debouncedKeyword, setDebouncedKeyword] = 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 }> + >([]) + const [marketList, setMarketList] = useState([]) + const [loading, setLoading] = useState(false) + const [selectedMap, setSelectedMap] = useState>(new Map()) + + const tagViewCacheRef = useRef>(new Map()) + const listCacheRef = useRef>(new Map()) + const skipNextFetchRef = useRef(false) + const initialTagSetRef = useRef(false) + const initialSportLeagueSetRef = useRef(false) + + useEffect(() => { + if (!open) return + const map = new Map() + ;(initialSelected ?? []).forEach(m => map.set(m.conditionId, m)) + setSelectedMap(map) + }, [open, initialSelected]) + + const tagSegmentOptions = useMemo( + () => + TAG_OPTIONS.map(opt => ({ + label: t(`whaleMonitorStrategy.marketSelect.tag.${opt.key}`), + value: opt.value + })), + [t] + ) + + useEffect(() => { + const timer = setTimeout(() => setDebouncedKeyword(keyword.trim()), 400) + return () => clearTimeout(timer) + }, [keyword]) + + const fetchSportSubCategories = useCallback(async () => { + try { + const res = await apiService.markets.sportsCategories() + if (res.data.code === 0 && res.data.data) { + setSportSubCategories( + res.data.data.filter((s): s is typeof s & { seriesId: string } => Boolean(s.seriesId)) + ) + } else { + setSportSubCategories([]) + } + } catch { + setSportSubCategories([]) + } + }, []) + + useEffect(() => { + if (!open) return + if (initialTagSetRef.current) return + initialTagSetRef.current = true + setTagId(TAG_OPTIONS[0].value) + }, [open]) + + useEffect(() => { + if (!open) return + if (tagId === '1') { + if (sportSubCategories.length === 0) { + fetchSportSubCategories() + } + } else { + setSportSubCategories([]) + } + }, [open, tagId, sportSubCategories.length, fetchSportSubCategories]) + + useEffect(() => { + if (!open) return + 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) + } + }, [open, 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 (!open) return + if (!tagId) return + + const selectedSport = sportSubCategories.find(s => s.seriesId === sportSubSeriesId) + const seriesId = sportSubSeriesId + const sportSlug = selectedSport?.slug + 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 && !hasKeyword) { + setMarketList([]) + return + } + 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: hasKeyword ? searchKeyword : '', + seriesId: seriesId || undefined, + sportSlug: seriesId ? sportSlug : undefined, + tagId: seriesId ? undefined : effectiveTagId, + limit: 200 + }) + if (res.data.code === 0 && res.data.data) { + 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([]) + } + } catch { + setMarketList([]) + } finally { + setLoading(false) + } + }, [open, debouncedKeyword, tagId, sportSubSeriesId, sportSubCategories, keyword]) + + useEffect(() => { + if (!open) return + if (skipNextFetchRef.current) { + skipNextFetchRef.current = false + return + } + fetchMarkets() + }, [open, fetchMarkets]) + + const handleSportLeagueChange = (seriesId: string | undefined) => { + saveCurrentTagView() + setSportSubSeriesId(seriesId) + } + + const selectedList = useMemo(() => Array.from(selectedMap.values()), [selectedMap]) + + const toggleMarket = (market: WhaleMonitorMarketItem, checked: boolean) => { + setSelectedMap(prev => { + const next = new Map(prev) + if (checked) { + next.set(market.conditionId, market) + } else { + next.delete(market.conditionId) + } + return next + }) + } + + const toggleGroupMarkets = (markets: WhaleMonitorMarketItem[], checked: boolean) => { + setSelectedMap(prev => { + const next = new Map(prev) + for (const market of markets) { + if (checked) { + next.set(market.conditionId, market) + } else { + next.delete(market.conditionId) + } + } + return next + }) + } + + const showSelectSportHint = tagId === '1' && !sportSubSeriesId && debouncedKeyword.length < MIN_SEARCH_LENGTH + const showSportsGamesEmpty = + tagId === '1' && !!sportSubSeriesId && marketList.length === 0 && !loading && debouncedKeyword.length < MIN_SEARCH_LENGTH + + const listScrollHeight = isMobile ? 'min(360px, calc(70vh - 240px))' : 400 + + return ( + + + {t('whaleMonitorStrategy.marketSelect.selectedCount', { count: selectedList.length })} + + + + + + + } + styles={{ + body: { + padding: isMobile ? 12 : 16, + maxHeight: isMobile ? '75vh' : '80vh', + overflow: 'hidden' + } + }} + > + + {t('whaleMonitorStrategy.marketSelect.subtitle')} + + + + } + placeholder={t('whaleMonitorStrategy.marketSelect.searchPlaceholder')} + value={keyword} + onChange={e => setKeyword(e.target.value)} + onPressEnter={flushSearch} + size={isMobile ? 'large' : 'middle'} + style={{ marginBottom: 12 }} + /> + handleTagChange(val)} + style={{ marginBottom: tagId === '1' ? 12 : 0 }} + /> + {tagId === '1' && sportSubCategories.length > 0 && ( + <> +