feat(whale-monitor): 市场选择改为弹窗并优化列表交互

- 策略编辑内嵌市场选择弹窗,移除路由跳转与 sessionStorage 草稿
- 列表区固定高度内部滚动,底部仅保留一处确认操作
- 并发缓存 markets 时捕获唯一键冲突并回读已有记录

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WrBug
2026-05-27 05:31:50 +08:00
co-authored by Cursor
parent 1d47cf0451
commit a70ee3e811
3 changed files with 484 additions and 75 deletions
@@ -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
@@ -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<WhaleMonitorMarketSelectModalProps> = ({
open,
initialSelected,
onCancel,
onConfirm
}) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [keyword, setKeyword] = useState('')
const [debouncedKeyword, setDebouncedKeyword] = useState('')
const [tagId, setTagId] = useState<string | undefined>(undefined)
const [sportSubSeriesId, setSportSubSeriesId] = useState<string | undefined>(undefined)
const [sportSubCategories, setSportSubCategories] = useState<
Array<{ id: number; slug: string; label: string; tagId: string; seriesId: string; image?: string }>
>([])
const [marketList, setMarketList] = useState<WhaleMonitorMarketItem[]>([])
const [loading, setLoading] = useState(false)
const [selectedMap, setSelectedMap] = useState<Map<string, WhaleMonitorMarketItem>>(new 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)
useEffect(() => {
if (!open) return
const map = new Map<string, WhaleMonitorMarketItem>()
;(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 (
<Modal
open={open}
onCancel={onCancel}
title={t('whaleMonitorStrategy.marketSelect.title')}
width={isMobile ? '95%' : 980}
destroyOnClose
footer={
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 8
}}
>
<Text type="secondary">
{t('whaleMonitorStrategy.marketSelect.selectedCount', { count: selectedList.length })}
</Text>
<Space>
<Button onClick={onCancel} style={{ minHeight: 44 }}>
{t('common.cancel')}
</Button>
<Button type="primary" onClick={() => onConfirm(selectedList)} style={{ minHeight: 44 }}>
{isMobile
? t('whaleMonitorStrategy.marketSelect.confirmWithCount', { count: selectedList.length })
: t('whaleMonitorStrategy.marketSelect.confirm')}
</Button>
</Space>
</div>
}
styles={{
body: {
padding: isMobile ? 12 : 16,
maxHeight: isMobile ? '75vh' : '80vh',
overflow: 'hidden'
}
}}
>
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
{t('whaleMonitorStrategy.marketSelect.subtitle')}
</Text>
<Card size="small" style={{ marginBottom: 12, flexShrink: 0 }}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder={t('whaleMonitorStrategy.marketSelect.searchPlaceholder')}
value={keyword}
onChange={e => setKeyword(e.target.value)}
onPressEnter={flushSearch}
size={isMobile ? 'large' : 'middle'}
style={{ marginBottom: 12 }}
/>
<Segmented
block={isMobile}
options={tagSegmentOptions}
value={tagId}
onChange={val => handleTagChange(val)}
style={{ marginBottom: tagId === '1' ? 12 : 0 }}
/>
{tagId === '1' && sportSubCategories.length > 0 && (
<>
<Select
allowClear
showSearch
placeholder={t('whaleMonitorStrategy.form.sportLeague')}
style={{ width: '100%', marginTop: 12 }}
value={sportSubSeriesId}
onChange={handleSportLeagueChange}
optionFilterProp="label"
size={isMobile ? 'large' : 'middle'}
options={sportSubCategories.map(s => ({
label: s.label,
value: s.seriesId,
image: s.image
}))}
optionRender={option => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<WhaleMonitorMarketThumbnail src={option.data.image as string | undefined} size={28} />
<span>{option.label}</span>
</div>
)}
labelRender={props => {
const sport = sportSubCategories.find(s => s.seriesId === props.value)
if (!sport) return props.label
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<WhaleMonitorMarketThumbnail src={sport.image} size={22} />
<span>{sport.label}</span>
</div>
)
}}
/>
{sportSubSeriesId && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
{t('whaleMonitorStrategy.marketSelect.sportsGamesHint')}
</Text>
)}
</>
)}
</Card>
{selectedList.length > 0 && (
<Card
size="small"
title={t('whaleMonitorStrategy.marketSelect.selectedTitle', { count: selectedList.length })}
style={{ marginBottom: 12, flexShrink: 0 }}
styles={{ body: { maxHeight: 96, overflowY: 'auto', padding: '8px 12px' } }}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{selectedList.map(m => (
<Tag
key={m.conditionId}
closable
onClose={() => toggleMarket(m, false)}
style={{ margin: 0, maxWidth: '100%' }}
>
<span style={{ wordBreak: 'break-all' }}>{m.title}</span>
</Tag>
))}
</div>
</Card>
)}
<Card
size="small"
styles={{ body: { padding: isMobile ? 8 : 16, paddingBottom: 8 } }}
style={{ marginBottom: 0 }}
>
<div
style={{
height: listScrollHeight,
minHeight: 200,
overflowY: 'auto',
overflowX: 'hidden',
WebkitOverflowScrolling: 'touch'
}}
>
<Spin spinning={loading}>
{showSelectSportHint ? (
<Empty description={t('whaleMonitorStrategy.form.selectSportFirst')} />
) : showSportsGamesEmpty ? (
<Empty description={t('whaleMonitorStrategy.marketSelect.sportsGamesEmpty')} />
) : marketList.length === 0 && !loading ? (
<Empty description={t('whaleMonitorStrategy.marketSelect.empty')} />
) : (
<WhaleMonitorMarketGroupedList
markets={marketList}
selectedMap={selectedMap}
isMobile={isMobile}
onToggleMarket={toggleMarket}
onToggleGroup={toggleGroupMarkets}
/>
)}
</Spin>
</div>
</Card>
</Modal>
)
}
export default WhaleMonitorMarketSelectModal
+15 -72
View File
@@ -1,31 +1,25 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Modal, Form, Input, InputNumber, Tabs, Empty, Tooltip } from 'antd'
import { PlusOutlined, EditOutlined, UnorderedListOutlined, DeleteOutlined, RightOutlined } from '@ant-design/icons'
import { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore'
import {
clearWhaleMonitorFormDraft,
conditionIdsToMarkets,
loadWhaleMonitorFormDraft,
saveWhaleMonitorFormDraft,
strategyMarketsToItems
} from '../constants/whaleMonitor'
import type {
WhaleMonitorStrategyDto,
WhaleMonitorTriggerDto,
WhaleMonitorMarketItem,
WhaleMonitorStrategyListLocationState,
Account
} from '../types'
import { formatUSDC } from '../utils'
import WhaleMonitorMarketSelectModal from '../components/WhaleMonitorMarketSelectModal'
const WhaleMonitorStrategyList: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, fetchAccounts } = useAccountStore()
@@ -39,8 +33,7 @@ const WhaleMonitorStrategyList: React.FC = () => {
const [form] = Form.useForm()
const [submitting, setSubmitting] = useState(false)
const [selectedMarkets, setSelectedMarkets] = useState<WhaleMonitorMarketItem[]>([])
const [pendingFormDraft, setPendingFormDraft] = useState<ReturnType<typeof loadWhaleMonitorFormDraft>>(null)
const draftRestoreStarted = useRef(false)
const [marketSelectVisible, setMarketSelectVisible] = useState(false)
const [triggerVisible, setTriggerVisible] = useState(false)
const [triggerStrategyId, setTriggerStrategyId] = useState<number>(0)
@@ -58,57 +51,6 @@ const WhaleMonitorStrategyList: React.FC = () => {
fetchList()
}, [filterAccountId, filterEnabled])
useEffect(() => {
if (draftRestoreStarted.current) return
const draft = loadWhaleMonitorFormDraft()
const state = location.state as WhaleMonitorStrategyListLocationState | null
if (!draft && !state?.selectedMarkets) return
draftRestoreStarted.current = true
if (draft) clearWhaleMonitorFormDraft()
if (state?.selectedMarkets) {
navigate(location.pathname, { replace: true, state: null })
}
if (draft) {
setPendingFormDraft({
...draft,
selectedMarkets: state?.selectedMarkets ?? draft.selectedMarkets
})
}
}, [location.pathname, location.state, navigate])
useEffect(() => {
if (!pendingFormDraft) return
if (pendingFormDraft.editingStrategyId && loading) return
if (pendingFormDraft.editingStrategyId) {
const strategy = strategies.find(s => s.id === pendingFormDraft.editingStrategyId)
if (!strategy) {
setPendingFormDraft(null)
return
}
setEditingStrategy(strategy)
} else {
setEditingStrategy(null)
}
const draftStrategy = pendingFormDraft.editingStrategyId
? strategies.find(s => s.id === pendingFormDraft.editingStrategyId)
: undefined
const knownMarkets =
draftStrategy?.markets && draftStrategy.markets.length > 0
? strategyMarketsToItems(draftStrategy.markets)
: pendingFormDraft.selectedMarkets
setSelectedMarkets(
conditionIdsToMarkets(
draftStrategy?.conditionIds ?? knownMarkets.map(m => m.conditionId),
knownMarkets
)
)
form.setFieldsValue(pendingFormDraft.formValues)
setFormVisible(true)
setPendingFormDraft(null)
}, [pendingFormDraft, strategies, loading, form])
const fetchList = async () => {
setLoading(true)
try {
@@ -159,16 +101,7 @@ const WhaleMonitorStrategyList: React.FC = () => {
}
}
const goToMarketSelect = () => {
saveWhaleMonitorFormDraft({
formValues: form.getFieldsValue(),
selectedMarkets,
editingStrategyId: editingStrategy?.id
})
navigate('/whale-monitor-strategy/markets', {
state: { selectedMarkets }
})
}
const openMarketSelect = () => setMarketSelectVisible(true)
const showCreateModal = () => {
setEditingStrategy(null)
@@ -582,7 +515,7 @@ const WhaleMonitorStrategyList: React.FC = () => {
)}
<Button
block={isMobile}
onClick={goToMarketSelect}
onClick={openMarketSelect}
style={{ minHeight: 44, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<span>
@@ -623,6 +556,16 @@ const WhaleMonitorStrategyList: React.FC = () => {
</Form>
</Modal>
<WhaleMonitorMarketSelectModal
open={marketSelectVisible}
initialSelected={selectedMarkets}
onCancel={() => setMarketSelectVisible(false)}
onConfirm={(selected) => {
setSelectedMarkets(selected)
setMarketSelectVisible(false)
}}
/>
<Modal
title={t('whaleMonitorStrategy.triggerRecords.title')}
open={triggerVisible}