feat: 添加市场大单监听策略(Whale Monitor Strategy)
支持按市场分类/体育联赛筛选并选择市场,实时监听 Activity WebSocket 交易流, 在滑动窗口内聚合 BUY 成交额,达到阈值后自动下 FAK 单。 后端:策略 CRUD、WS 监听、滑动窗口聚合、订单执行、触发记录 前端:策略列表/创建/编辑、市场搜索选择(分类+联赛)、触发记录查看 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,8 @@ import BacktestList from './pages/BacktestList'
|
||||
import BacktestDetail from './pages/BacktestDetail'
|
||||
import CryptoTailStrategyList from './pages/CryptoTailStrategyList'
|
||||
import CryptoTailMonitor from './pages/CryptoTailMonitor'
|
||||
import WhaleMonitorStrategyList from './pages/WhaleMonitorStrategyList'
|
||||
import WhaleMonitorMarketSelect from './pages/WhaleMonitorMarketSelect'
|
||||
import { wsManager } from './services/websocket'
|
||||
import type { OrderPushMessage } from './types'
|
||||
import { apiService } from './services/api'
|
||||
@@ -273,6 +275,8 @@ function App() {
|
||||
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
|
||||
<Route path="/crypto-tail-strategy" element={<ProtectedRoute><CryptoTailStrategyList /></ProtectedRoute>} />
|
||||
<Route path="/crypto-tail-monitor" element={<ProtectedRoute><CryptoTailMonitor /></ProtectedRoute>} />
|
||||
<Route path="/whale-monitor-strategy" element={<ProtectedRoute><WhaleMonitorStrategyList /></ProtectedRoute>} />
|
||||
<Route path="/whale-monitor-strategy/markets" element={<ProtectedRoute><WhaleMonitorMarketSelect /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
|
||||
{/* 保留旧路由以保持向后兼容 */}
|
||||
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
NotificationOutlined,
|
||||
LineChartOutlined,
|
||||
RocketOutlined,
|
||||
DashboardOutlined
|
||||
DashboardOutlined,
|
||||
EyeOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -81,14 +82,17 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
|
||||
keys.push('/crypto-tail-management')
|
||||
}
|
||||
if (path.startsWith('/whale-monitor-strategy')) {
|
||||
keys.push('/whale-monitor-management')
|
||||
}
|
||||
if (path.startsWith('/system-settings')) {
|
||||
keys.push('/system-settings')
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
|
||||
const [openKeys, setOpenKeys] = useState<string[]>(getInitialOpenKeys())
|
||||
|
||||
|
||||
// 当路径变化时,自动打开对应的父菜单
|
||||
useEffect(() => {
|
||||
const path = location.pathname
|
||||
@@ -99,6 +103,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
|
||||
keys.push('/crypto-tail-management')
|
||||
}
|
||||
if (path.startsWith('/whale-monitor-strategy')) {
|
||||
keys.push('/whale-monitor-management')
|
||||
}
|
||||
if (path.startsWith('/system-settings')) {
|
||||
keys.push('/system-settings')
|
||||
}
|
||||
@@ -183,6 +190,18 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/whale-monitor-management',
|
||||
icon: <EyeOutlined />,
|
||||
label: t('menu.whaleMonitorStrategy'),
|
||||
children: [
|
||||
{
|
||||
key: '/whale-monitor-strategy',
|
||||
icon: <DashboardOutlined />,
|
||||
label: t('menu.whaleMonitorStrategyConfig')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/positions',
|
||||
icon: <UnorderedListOutlined />,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Checkbox, Collapse, Divider, Typography } from 'antd'
|
||||
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
|
||||
|
||||
interface WhaleMonitorMarketGroupedListProps {
|
||||
markets: WhaleMonitorMarketItem[]
|
||||
selectedMap: Map<string, WhaleMonitorMarketItem>
|
||||
isMobile: boolean
|
||||
onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void
|
||||
onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void
|
||||
}
|
||||
|
||||
const renderMarketGroups = (
|
||||
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 showAsGroups = groups.length > 1 || (groups.length === 1 && groups[0].key.startsWith('event:'))
|
||||
|
||||
if (!showAsGroups) {
|
||||
const flatMarkets = groups.flatMap(g => g.markets)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: isMobile ? 4 : 8 }}>
|
||||
{flatMarkets.map(market => (
|
||||
<WhaleMonitorMarketListItem
|
||||
key={market.conditionId}
|
||||
market={market}
|
||||
checked={selectedMap.has(market.conditionId)}
|
||||
isMobile={isMobile}
|
||||
hideEventTitle
|
||||
onToggle={checked => onToggleMarket(market, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const collapseItems = groups.map(group => {
|
||||
const selectedInGroup = group.markets.filter(m => selectedMap.has(m.conditionId)).length
|
||||
const allSelected = selectedInGroup === group.markets.length && group.markets.length > 0
|
||||
const indeterminate = selectedInGroup > 0 && !allSelected
|
||||
|
||||
return {
|
||||
key: group.key,
|
||||
label: (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
paddingRight: 8
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}>
|
||||
<WhaleMonitorMarketThumbnail src={group.imageUrl} size={32} alt={group.title} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text strong style={{ wordBreak: 'break-word' }}>
|
||||
{group.title}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginLeft: 0, display: 'block' }}>
|
||||
{t('whaleMonitorStrategy.marketSelect.marketsInGroup', { count: group.markets.length })}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={indeterminate}
|
||||
onChange={e => onToggleGroup(group.markets, e.target.checked)}
|
||||
>
|
||||
<span style={{ fontSize: 12 }}>{t('whaleMonitorStrategy.marketSelect.selectGroup')}</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: isMobile ? 4 : 6 }}>
|
||||
{group.markets.map(market => (
|
||||
<WhaleMonitorMarketListItem
|
||||
key={market.conditionId}
|
||||
market={market}
|
||||
checked={selectedMap.has(market.conditionId)}
|
||||
isMobile={isMobile}
|
||||
hideEventTitle
|
||||
compact
|
||||
onToggle={checked => onToggleMarket(market, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
bordered={false}
|
||||
defaultActiveKey={groups.map(g => g.key)}
|
||||
items={collapseItems}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const WhaleMonitorMarketGroupedList: React.FC<WhaleMonitorMarketGroupedListProps> = ({
|
||||
markets,
|
||||
selectedMap,
|
||||
isMobile,
|
||||
onToggleMarket,
|
||||
onToggleGroup
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
groupMarketsBySection(markets, {
|
||||
game: t('whaleMonitorStrategy.marketSelect.sectionGame'),
|
||||
season: t('whaleMonitorStrategy.marketSelect.sectionSeason'),
|
||||
ungrouped: t('whaleMonitorStrategy.marketSelect.ungrouped')
|
||||
}),
|
||||
[markets, t]
|
||||
)
|
||||
|
||||
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:'))
|
||||
return (
|
||||
<>
|
||||
{showSectionTitle && (
|
||||
<Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
{section.title}
|
||||
</Title>
|
||||
)}
|
||||
{renderMarketGroups(section.groups, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{sections.map((section, index) => (
|
||||
<div key={section.key}>
|
||||
{index > 0 && <Divider style={{ margin: '16px 0' }} />}
|
||||
<Title level={5} style={{ marginTop: index === 0 ? 0 : undefined, marginBottom: 12 }}>
|
||||
{section.title}
|
||||
<Text type="secondary" style={{ fontSize: 13, fontWeight: 400, marginLeft: 8 }}>
|
||||
({section.groups.reduce((sum, g) => sum + g.markets.length, 0)})
|
||||
</Text>
|
||||
</Title>
|
||||
{renderMarketGroups(section.groups, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WhaleMonitorMarketGroupedList
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Checkbox, Tag, Typography } from 'antd'
|
||||
import { LinkOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { parseMarketOutcomes, pickMarketImageUrl } from '../constants/whaleMonitor'
|
||||
import type { WhaleMonitorMarketItem } from '../types'
|
||||
import { formatUSDC } from '../utils'
|
||||
import WhaleMonitorMarketThumbnail from './WhaleMonitorMarketThumbnail'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface WhaleMonitorMarketListItemProps {
|
||||
market: WhaleMonitorMarketItem
|
||||
checked: boolean
|
||||
isMobile: boolean
|
||||
hideEventTitle?: boolean
|
||||
compact?: boolean
|
||||
onToggle: (checked: boolean) => void
|
||||
}
|
||||
|
||||
const formatConditionId = (id: string): string =>
|
||||
id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id
|
||||
|
||||
const WhaleMonitorMarketListItem: React.FC<WhaleMonitorMarketListItemProps> = ({
|
||||
market,
|
||||
checked,
|
||||
isMobile,
|
||||
hideEventTitle = false,
|
||||
compact = false,
|
||||
onToggle
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const outcomeLabels = parseMarketOutcomes(market.outcomes)
|
||||
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) {
|
||||
window.open(polymarketUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onToggle(!checked)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle(!checked)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
padding: compact ? (isMobile ? '8px 8px' : '8px 10px') : isMobile ? '12px 8px' : '10px 12px',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
minHeight: 44,
|
||||
background: checked ? 'rgba(22, 119, 255, 0.06)' : undefined,
|
||||
border: checked ? '1px solid rgba(22, 119, 255, 0.3)' : '1px solid transparent'
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={e => onToggle(e.target.checked)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<WhaleMonitorMarketThumbnail src={imageUrl} size={thumbSize} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{market.eventTitle && !hideEventTitle && (
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 2 }}>
|
||||
{market.eventTitle}
|
||||
</Text>
|
||||
)}
|
||||
<div style={{ fontWeight: 500, wordBreak: 'break-word', lineHeight: 1.4 }}>{market.title}</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginTop: 6
|
||||
}}
|
||||
>
|
||||
{market.category && (
|
||||
<Tag style={{ margin: 0 }}>{market.category}</Tag>
|
||||
)}
|
||||
{volumeDisplay && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('whaleMonitorStrategy.marketSelect.volume')}: ${volumeDisplay}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{outcomeLabels.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 6 }}>
|
||||
{outcomeLabels.map(label => (
|
||||
<Tag key={label} bordered={false} style={{ margin: 0, fontSize: 11 }}>
|
||||
{label}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginTop: 6
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{formatConditionId(market.conditionId)}
|
||||
</Text>
|
||||
{polymarketUrl && (
|
||||
<a
|
||||
href={polymarketUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleOpenLink}
|
||||
style={{ fontSize: 12, display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<LinkOutlined />
|
||||
{t('whaleMonitorStrategy.marketSelect.viewMarket')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WhaleMonitorMarketListItem
|
||||
@@ -0,0 +1,36 @@
|
||||
interface WhaleMonitorMarketThumbnailProps {
|
||||
src?: string
|
||||
size?: number
|
||||
alt?: string
|
||||
}
|
||||
|
||||
const WhaleMonitorMarketThumbnail: React.FC<WhaleMonitorMarketThumbnailProps> = ({
|
||||
src,
|
||||
size = 40,
|
||||
alt = ''
|
||||
}) => {
|
||||
if (!src?.trim()) return null
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 6,
|
||||
objectFit: 'cover',
|
||||
flexShrink: 0,
|
||||
background: '#f5f5f5'
|
||||
}}
|
||||
onError={e => {
|
||||
const target = e.currentTarget
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default WhaleMonitorMarketThumbnail
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { WhaleMonitorFormDraft, WhaleMonitorMarketItem } from '../types'
|
||||
|
||||
export const WHALE_MONITOR_FORM_DRAFT_KEY = 'whale_monitor_form_draft'
|
||||
|
||||
export function saveWhaleMonitorFormDraft(draft: WhaleMonitorFormDraft): void {
|
||||
sessionStorage.setItem(WHALE_MONITOR_FORM_DRAFT_KEY, JSON.stringify(draft))
|
||||
}
|
||||
|
||||
export function loadWhaleMonitorFormDraft(): WhaleMonitorFormDraft | null {
|
||||
const raw = sessionStorage.getItem(WHALE_MONITOR_FORM_DRAFT_KEY)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as WhaleMonitorFormDraft
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWhaleMonitorFormDraft(): void {
|
||||
sessionStorage.removeItem(WHALE_MONITOR_FORM_DRAFT_KEY)
|
||||
}
|
||||
|
||||
export function conditionIdsToMarkets(
|
||||
conditionIds: string[],
|
||||
known: WhaleMonitorMarketItem[] = []
|
||||
): WhaleMonitorMarketItem[] {
|
||||
return conditionIds.map(id => {
|
||||
const found = known.find(m => m.conditionId === id)
|
||||
return found ?? { conditionId: id, title: id }
|
||||
})
|
||||
}
|
||||
|
||||
/** 解析 Gamma 返回的 outcomes JSON 字符串 */
|
||||
export function parseMarketOutcomes(outcomes?: string): string[] {
|
||||
if (!outcomes?.trim()) return []
|
||||
try {
|
||||
const parsed = JSON.parse(outcomes) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((item): item is string => typeof item === 'string' && item.length > 0)
|
||||
}
|
||||
} catch {
|
||||
return outcomes.split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export interface WhaleMonitorMarketGroup {
|
||||
key: string
|
||||
title: string
|
||||
markets: WhaleMonitorMarketItem[]
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
/** 市场行展示图:image > icon > eventImage */
|
||||
export function pickMarketImageUrl(market?: WhaleMonitorMarketItem | null): string | undefined {
|
||||
if (!market) return undefined
|
||||
return market.image?.trim() || market.icon?.trim() || market.eventImage?.trim() || undefined
|
||||
}
|
||||
|
||||
/** 赛事分组头图:优先 eventImage,否则取组内首个市场图 */
|
||||
export function pickGroupImageUrl(markets: WhaleMonitorMarketItem[]): string | undefined {
|
||||
if (markets.length === 0) return undefined
|
||||
const eventImg = markets[0].eventImage?.trim()
|
||||
if (eventImg) return eventImg
|
||||
return pickMarketImageUrl(markets[0])
|
||||
}
|
||||
|
||||
export interface WhaleMonitorMarketSection {
|
||||
key: 'game' | 'season'
|
||||
title: string
|
||||
groups: WhaleMonitorMarketGroup[]
|
||||
}
|
||||
|
||||
export interface WhaleMonitorMarketSectionLabels {
|
||||
game: string
|
||||
season: string
|
||||
ungrouped: string
|
||||
}
|
||||
|
||||
/** 先按单场/长期分块,再在块内按赛事或分类分组 */
|
||||
export function groupMarketsBySection(
|
||||
markets: WhaleMonitorMarketItem[],
|
||||
labels: WhaleMonitorMarketSectionLabels
|
||||
): WhaleMonitorMarketSection[] {
|
||||
if (markets.length === 0) return []
|
||||
|
||||
const hasTyped = markets.some(m => m.marketType === 'game' || m.marketType === 'season')
|
||||
if (!hasTyped) {
|
||||
const groups = groupMarketsForDisplay(markets, labels.ungrouped)
|
||||
if (groups.length === 0) return []
|
||||
return [{ key: 'season', title: labels.ungrouped, groups }]
|
||||
}
|
||||
|
||||
const sections: WhaleMonitorMarketSection[] = []
|
||||
const games = markets.filter(m => m.marketType === 'game')
|
||||
const seasons = markets.filter(m => m.marketType === 'season')
|
||||
|
||||
if (games.length > 0) {
|
||||
sections.push({
|
||||
key: 'game',
|
||||
title: labels.game,
|
||||
groups: groupMarketsForDisplay(games, labels.ungrouped)
|
||||
})
|
||||
}
|
||||
if (seasons.length > 0) {
|
||||
sections.push({
|
||||
key: 'season',
|
||||
title: labels.season,
|
||||
groups: groupMarketsForDisplay(seasons, labels.ungrouped)
|
||||
})
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
/** 按赛事/分类分组展示市场列表 */
|
||||
export function groupMarketsForDisplay(
|
||||
markets: WhaleMonitorMarketItem[],
|
||||
ungroupedLabel: string
|
||||
): WhaleMonitorMarketGroup[] {
|
||||
if (markets.length === 0) return []
|
||||
|
||||
const eventMap = new Map<string, WhaleMonitorMarketItem[]>()
|
||||
const miscMarkets: WhaleMonitorMarketItem[] = []
|
||||
|
||||
for (const market of markets) {
|
||||
if (market.marketType === 'season') {
|
||||
miscMarkets.push(market)
|
||||
continue
|
||||
}
|
||||
const eventTitle = market.eventTitle?.trim()
|
||||
if (eventTitle) {
|
||||
const list = eventMap.get(eventTitle) ?? []
|
||||
list.push(market)
|
||||
eventMap.set(eventTitle, list)
|
||||
} else {
|
||||
miscMarkets.push(market)
|
||||
}
|
||||
}
|
||||
|
||||
if (eventMap.size > 0) {
|
||||
const groups: WhaleMonitorMarketGroup[] = Array.from(eventMap.entries())
|
||||
.map(([title, items]) => ({
|
||||
key: `event:${title}`,
|
||||
title,
|
||||
markets: items,
|
||||
imageUrl: pickGroupImageUrl(items)
|
||||
}))
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
if (miscMarkets.length > 0) {
|
||||
groups.push({
|
||||
key: 'misc',
|
||||
title: ungroupedLabel,
|
||||
markets: miscMarkets,
|
||||
imageUrl: pickMarketImageUrl(miscMarkets[0])
|
||||
})
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
const categoryMap = new Map<string, WhaleMonitorMarketItem[]>()
|
||||
for (const market of markets) {
|
||||
const category = market.category?.trim() || ungroupedLabel
|
||||
const list = categoryMap.get(category) ?? []
|
||||
list.push(market)
|
||||
categoryMap.set(category, list)
|
||||
}
|
||||
|
||||
if (categoryMap.size <= 1) {
|
||||
return [{ key: 'all', title: ungroupedLabel, markets }]
|
||||
}
|
||||
|
||||
return Array.from(categoryMap.entries())
|
||||
.map(([title, items]) => ({
|
||||
key: `cat:${title}`,
|
||||
title,
|
||||
markets: items,
|
||||
imageUrl: pickMarketImageUrl(items[0])
|
||||
}))
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
}
|
||||
|
||||
export function toWhaleMonitorMarketItem(m: {
|
||||
conditionId: string
|
||||
title: string
|
||||
slug?: string
|
||||
category?: string
|
||||
volume?: string
|
||||
outcomes?: string
|
||||
eventTitle?: string
|
||||
marketType?: 'game' | 'season'
|
||||
image?: string
|
||||
icon?: string
|
||||
eventImage?: string
|
||||
}): WhaleMonitorMarketItem {
|
||||
return {
|
||||
conditionId: m.conditionId,
|
||||
title: m.title,
|
||||
slug: m.slug,
|
||||
category: m.category,
|
||||
volume: m.volume,
|
||||
outcomes: m.outcomes,
|
||||
eventTitle: m.eventTitle,
|
||||
marketType: m.marketType,
|
||||
image: m.image,
|
||||
icon: m.icon,
|
||||
eventImage: m.eventImage
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,8 @@
|
||||
"cryptoSpreadStrategy": "Crypto Spread Strategy",
|
||||
"cryptoTailStrategy": "Strategy Config",
|
||||
"cryptoTailMonitor": "Real-time Monitor",
|
||||
"whaleMonitorStrategy": "Whale Monitor",
|
||||
"whaleMonitorStrategyConfig": "Strategy Config",
|
||||
"positions": "Position Management",
|
||||
"backtest": "Backtest",
|
||||
"statistics": "Statistics",
|
||||
@@ -1846,6 +1848,113 @@
|
||||
"periodChanged": "Period has changed, popup closed"
|
||||
}
|
||||
},
|
||||
"whaleMonitorStrategy": {
|
||||
"list": {
|
||||
"title": "Whale Monitor Strategy",
|
||||
"addStrategy": "New Strategy",
|
||||
"strategyName": "Strategy Name",
|
||||
"account": "Account",
|
||||
"markets": "Markets",
|
||||
"marketCount": "Markets",
|
||||
"window": "Window",
|
||||
"threshold": "Threshold",
|
||||
"orderAmount": "Order Amount",
|
||||
"priceRange": "Price Range",
|
||||
"cooldown": "Cooldown",
|
||||
"enabled": "Enabled",
|
||||
"lastTrigger": "Last Trigger",
|
||||
"triggerCount": "Triggers",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"delete": "Delete",
|
||||
"viewTriggers": "Trigger Records",
|
||||
"deleteConfirm": "Are you sure you want to delete this strategy?",
|
||||
"fetchFailed": "Failed to fetch strategy list",
|
||||
"seconds": "sec",
|
||||
"conditionId": "ConditionId"
|
||||
},
|
||||
"form": {
|
||||
"strategyName": "Strategy Name",
|
||||
"strategyNamePlaceholder": "Optional, auto-generated if empty",
|
||||
"selectAccount": "Select Account",
|
||||
"conditionIds": "Monitored Markets",
|
||||
"conditionIdsPlaceholder": "Search market name or enter conditionId",
|
||||
"selectMarket": "Select a market",
|
||||
"sportLeague": "Select League",
|
||||
"selectSportFirst": "Select a league first",
|
||||
"tagFilter": "Category",
|
||||
"tagSports": "Sports",
|
||||
"tagCrypto": "Crypto",
|
||||
"tagPolitics": "Politics",
|
||||
"tagPopCulture": "Pop Culture",
|
||||
"windowSeconds": "Aggregation Window (sec)",
|
||||
"thresholdAmount": "Threshold Amount",
|
||||
"orderAmount": "Fixed Order Amount",
|
||||
"minPrice": "Min Price",
|
||||
"maxPrice": "Max Price",
|
||||
"priceTip": "Range 0~1, max 2 decimal places",
|
||||
"cooldownSeconds": "Cooldown (sec)",
|
||||
"enabled": "Enable Strategy",
|
||||
"create": "Create",
|
||||
"update": "Update",
|
||||
"selectMarkets": "Select Markets",
|
||||
"marketsSelected": "{{count}} market(s) selected"
|
||||
},
|
||||
"marketSelect": {
|
||||
"title": "Select Markets",
|
||||
"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",
|
||||
"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",
|
||||
"selectedTitle": "Selected ({{count}})",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"confirm": "Confirm",
|
||||
"confirmWithCount": "Confirm ({{count}})",
|
||||
"volume": "Volume",
|
||||
"viewMarket": "View market",
|
||||
"sectionGame": "Individual games",
|
||||
"sectionSeason": "Long-term markets",
|
||||
"ungrouped": "Other markets",
|
||||
"selectGroup": "Select all",
|
||||
"marketsInGroup": "{{count}} market(s)",
|
||||
"tag": {
|
||||
"all": "All",
|
||||
"sports": "Sports",
|
||||
"politics": "Politics",
|
||||
"crypto": "Crypto",
|
||||
"popCulture": "Pop Culture"
|
||||
}
|
||||
},
|
||||
"triggerRecords": {
|
||||
"title": "Trigger Records",
|
||||
"timeRange": "Time Range",
|
||||
"startDate": "Start Date",
|
||||
"endDate": "End Date",
|
||||
"successTab": "Success",
|
||||
"failTab": "Failed",
|
||||
"triggerTime": "Trigger Time",
|
||||
"conditionId": "Market",
|
||||
"tokenId": "Token ID",
|
||||
"side": "Side",
|
||||
"triggerVolume": "Trigger Volume",
|
||||
"orderPrice": "Order Price",
|
||||
"orderSize": "Order Size",
|
||||
"orderAmount": "Order Amount",
|
||||
"orderId": "Order ID",
|
||||
"status": "Status",
|
||||
"success": "Success",
|
||||
"fail": "Failed",
|
||||
"failReason": "Fail Reason",
|
||||
"emptySuccess": "No successful records",
|
||||
"emptyFail": "No failed records",
|
||||
"totalCount": "{{count}} records in total"
|
||||
}
|
||||
},
|
||||
"clobMigration": {
|
||||
"title": "CLOB 2.0 Migration Notice",
|
||||
"description": "Polymarket CLOB has been upgraded to V2. Please go to the Accounts page to complete the USDC migration to ensure trading functions properly.",
|
||||
|
||||
@@ -317,6 +317,8 @@
|
||||
"cryptoSpreadStrategy": "加密价差策略",
|
||||
"cryptoTailStrategy": "策略配置",
|
||||
"cryptoTailMonitor": "实时监控",
|
||||
"whaleMonitorStrategy": "大单监听",
|
||||
"whaleMonitorStrategyConfig": "策略配置",
|
||||
"positions": "仓位管理",
|
||||
"backtest": "回测",
|
||||
"statistics": "统计信息",
|
||||
@@ -1846,6 +1848,113 @@
|
||||
"periodChanged": "周期已切换,弹窗已关闭"
|
||||
}
|
||||
},
|
||||
"whaleMonitorStrategy": {
|
||||
"list": {
|
||||
"title": "大单监听策略",
|
||||
"addStrategy": "新建策略",
|
||||
"strategyName": "策略名称",
|
||||
"account": "绑定账户",
|
||||
"markets": "监听市场",
|
||||
"marketCount": "市场数",
|
||||
"window": "聚合窗口",
|
||||
"threshold": "触发阈值",
|
||||
"orderAmount": "下单金额",
|
||||
"priceRange": "价格区间",
|
||||
"cooldown": "冷却时间",
|
||||
"enabled": "启用",
|
||||
"lastTrigger": "最近触发",
|
||||
"triggerCount": "触发次数",
|
||||
"actions": "操作",
|
||||
"edit": "编辑",
|
||||
"enable": "启用",
|
||||
"disable": "停用",
|
||||
"delete": "删除",
|
||||
"viewTriggers": "触发记录",
|
||||
"deleteConfirm": "确定要删除该策略吗?",
|
||||
"fetchFailed": "获取策略列表失败",
|
||||
"seconds": "秒",
|
||||
"conditionId": "ConditionId"
|
||||
},
|
||||
"form": {
|
||||
"strategyName": "策略名称",
|
||||
"strategyNamePlaceholder": "可选,留空自动生成",
|
||||
"selectAccount": "选择账户",
|
||||
"conditionIds": "监听市场",
|
||||
"conditionIdsPlaceholder": "搜索市场名称或输入 conditionId",
|
||||
"selectMarket": "请选择市场",
|
||||
"sportLeague": "选择联赛",
|
||||
"selectSportFirst": "请先选择联赛",
|
||||
"tagFilter": "市场分类",
|
||||
"tagSports": "体育",
|
||||
"tagCrypto": "加密货币",
|
||||
"tagPolitics": "政治",
|
||||
"tagPopCulture": "流行文化",
|
||||
"windowSeconds": "聚合窗口(秒)",
|
||||
"thresholdAmount": "触发阈值金额",
|
||||
"orderAmount": "固定下单金额",
|
||||
"minPrice": "最低价格",
|
||||
"maxPrice": "最高价格",
|
||||
"priceTip": "范围 0~1,最多两位小数",
|
||||
"cooldownSeconds": "冷却时间(秒)",
|
||||
"enabled": "启用策略",
|
||||
"create": "创建",
|
||||
"update": "更新",
|
||||
"selectMarkets": "选择监听市场",
|
||||
"marketsSelected": "已选 {{count}} 个市场"
|
||||
},
|
||||
"marketSelect": {
|
||||
"title": "选择市场",
|
||||
"subtitle": "按分类筛选或搜索市场名称,可多选",
|
||||
"back": "返回",
|
||||
"searchPlaceholder": "搜索市场名称(至少 2 个字符)",
|
||||
"searchHint": "请输入关键词搜索,或选择上方分类筛选",
|
||||
"sportsGamesHint": "体育联赛将展示单场比赛盘口;也可输入队名搜索",
|
||||
"sportsGamesEmpty": "该联赛当前暂无进行中的单场比赛盘口,可尝试搜索队名或稍后再试",
|
||||
"empty": "未找到匹配的市场",
|
||||
"selectedTitle": "已选市场({{count}})",
|
||||
"selectedCount": "已选 {{count}} 个",
|
||||
"confirm": "确认选择",
|
||||
"confirmWithCount": "确认({{count}})",
|
||||
"volume": "成交量",
|
||||
"viewMarket": "查看市场",
|
||||
"sectionGame": "单场比赛",
|
||||
"sectionSeason": "长期市场",
|
||||
"ungrouped": "其他市场",
|
||||
"selectGroup": "全选",
|
||||
"marketsInGroup": "{{count}} 个盘口",
|
||||
"tag": {
|
||||
"all": "全部",
|
||||
"sports": "体育",
|
||||
"politics": "政治",
|
||||
"crypto": "加密货币",
|
||||
"popCulture": "流行文化"
|
||||
}
|
||||
},
|
||||
"triggerRecords": {
|
||||
"title": "触发记录",
|
||||
"timeRange": "时间范围",
|
||||
"startDate": "开始日期",
|
||||
"endDate": "结束日期",
|
||||
"successTab": "成功",
|
||||
"failTab": "失败",
|
||||
"triggerTime": "触发时间",
|
||||
"conditionId": "市场",
|
||||
"tokenId": "Token ID",
|
||||
"side": "方向",
|
||||
"triggerVolume": "触发金额",
|
||||
"orderPrice": "下单价格",
|
||||
"orderSize": "下单数量",
|
||||
"orderAmount": "下单金额",
|
||||
"orderId": "订单ID",
|
||||
"status": "状态",
|
||||
"success": "成功",
|
||||
"fail": "失败",
|
||||
"failReason": "失败原因",
|
||||
"emptySuccess": "暂无成功记录",
|
||||
"emptyFail": "暂无失败记录",
|
||||
"totalCount": "共 {{count}} 条记录"
|
||||
}
|
||||
},
|
||||
"clobMigration": {
|
||||
"title": "CLOB 2.0 迁移提醒",
|
||||
"description": "Polymarket CLOB 已升级至 V2 版本,您需要前往账户页完成 USDC 迁移操作,以确保交易功能正常使用。",
|
||||
|
||||
@@ -317,6 +317,8 @@
|
||||
"cryptoSpreadStrategy": "加密價差策略",
|
||||
"cryptoTailStrategy": "策略配置",
|
||||
"cryptoTailMonitor": "即時監控",
|
||||
"whaleMonitorStrategy": "大單監聽",
|
||||
"whaleMonitorStrategyConfig": "策略配置",
|
||||
"positions": "倉位管理",
|
||||
"backtest": "回測",
|
||||
"statistics": "統計信息",
|
||||
@@ -1846,6 +1848,113 @@
|
||||
"periodChanged": "週期已切換,彈窗已關閉"
|
||||
}
|
||||
},
|
||||
"whaleMonitorStrategy": {
|
||||
"list": {
|
||||
"title": "大單監聽策略",
|
||||
"addStrategy": "新建策略",
|
||||
"strategyName": "策略名稱",
|
||||
"account": "綁定賬戶",
|
||||
"markets": "監聽市場",
|
||||
"marketCount": "市場數",
|
||||
"window": "聚合窗口",
|
||||
"threshold": "觸發閾值",
|
||||
"orderAmount": "下單金額",
|
||||
"priceRange": "價格區間",
|
||||
"cooldown": "冷卻時間",
|
||||
"enabled": "啟用",
|
||||
"lastTrigger": "最近觸發",
|
||||
"triggerCount": "觸發次數",
|
||||
"actions": "操作",
|
||||
"edit": "編輯",
|
||||
"enable": "啟用",
|
||||
"disable": "停用",
|
||||
"delete": "刪除",
|
||||
"viewTriggers": "觸發記錄",
|
||||
"deleteConfirm": "確定要刪除該策略嗎?",
|
||||
"fetchFailed": "獲取策略列表失敗",
|
||||
"seconds": "秒",
|
||||
"conditionId": "ConditionId"
|
||||
},
|
||||
"form": {
|
||||
"strategyName": "策略名稱",
|
||||
"strategyNamePlaceholder": "可選,留空自動生成",
|
||||
"selectAccount": "選擇賬戶",
|
||||
"conditionIds": "監聯市場",
|
||||
"conditionIdsPlaceholder": "搜索市場名稱或輸入 conditionId",
|
||||
"selectMarket": "請選擇市場",
|
||||
"sportLeague": "選擇聯賽",
|
||||
"selectSportFirst": "請先選擇聯賽",
|
||||
"tagFilter": "市場分類",
|
||||
"tagSports": "體育",
|
||||
"tagCrypto": "加密貨幣",
|
||||
"tagPolitics": "政治",
|
||||
"tagPopCulture": "流行文化",
|
||||
"windowSeconds": "聚合窗口(秒)",
|
||||
"thresholdAmount": "觸發閾值金額",
|
||||
"orderAmount": "固定下單金額",
|
||||
"minPrice": "最低價格",
|
||||
"maxPrice": "最高價格",
|
||||
"priceTip": "範圍 0~1,最多兩位小數",
|
||||
"cooldownSeconds": "冷卻時間(秒)",
|
||||
"enabled": "啟用策略",
|
||||
"create": "創建",
|
||||
"update": "更新",
|
||||
"selectMarkets": "選擇監聽市場",
|
||||
"marketsSelected": "已選 {{count}} 個市場"
|
||||
},
|
||||
"marketSelect": {
|
||||
"title": "選擇市場",
|
||||
"subtitle": "按分類篩選或搜索市場名稱,可多選",
|
||||
"back": "返回",
|
||||
"searchPlaceholder": "搜索市場名稱(至少 2 個字符)",
|
||||
"searchHint": "請輸入關鍵詞搜索,或選擇上方分類篩選",
|
||||
"sportsGamesHint": "體育聯賽將展示單場比賽盤口;也可輸入隊名搜索",
|
||||
"sportsGamesEmpty": "該聯賽當前暫無進行中的單場比賽盤口,可嘗試搜索隊名或稍後再試",
|
||||
"empty": "未找到匹配的市場",
|
||||
"selectedTitle": "已選市場({{count}})",
|
||||
"selectedCount": "已選 {{count}} 個",
|
||||
"confirm": "確認選擇",
|
||||
"confirmWithCount": "確認({{count}})",
|
||||
"volume": "成交量",
|
||||
"viewMarket": "查看市場",
|
||||
"sectionGame": "單場比賽",
|
||||
"sectionSeason": "長期市場",
|
||||
"ungrouped": "其他市場",
|
||||
"selectGroup": "全選",
|
||||
"marketsInGroup": "{{count}} 個盤口",
|
||||
"tag": {
|
||||
"all": "全部",
|
||||
"sports": "體育",
|
||||
"politics": "政治",
|
||||
"crypto": "加密貨幣",
|
||||
"popCulture": "流行文化"
|
||||
}
|
||||
},
|
||||
"triggerRecords": {
|
||||
"title": "觸發記錄",
|
||||
"timeRange": "時間範圍",
|
||||
"startDate": "開始日期",
|
||||
"endDate": "結束日期",
|
||||
"successTab": "成功",
|
||||
"failTab": "失敗",
|
||||
"triggerTime": "觸發時間",
|
||||
"conditionId": "市場",
|
||||
"tokenId": "Token ID",
|
||||
"side": "方向",
|
||||
"triggerVolume": "觸發金額",
|
||||
"orderPrice": "下單價格",
|
||||
"orderSize": "下單數量",
|
||||
"orderAmount": "下單金額",
|
||||
"orderId": "訂單ID",
|
||||
"status": "狀態",
|
||||
"success": "成功",
|
||||
"fail": "失敗",
|
||||
"failReason": "失敗原因",
|
||||
"emptySuccess": "暫無成功記錄",
|
||||
"emptyFail": "暫無失敗記錄",
|
||||
"totalCount": "共 {{count}} 條記錄"
|
||||
}
|
||||
},
|
||||
"clobMigration": {
|
||||
"title": "CLOB 2.0 遷移提醒",
|
||||
"description": "Polymarket CLOB 已升級至 V2 版本,您需要前往帳戶頁完成 USDC 遷移操作,以確保交易功能正常使用。",
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Input,
|
||||
Select,
|
||||
Segmented,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography
|
||||
} from 'antd'
|
||||
import { ArrowLeftOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { apiService } from '../services/api'
|
||||
import WhaleMonitorMarketGroupedList from '../components/WhaleMonitorMarketGroupedList'
|
||||
import WhaleMonitorMarketThumbnail from '../components/WhaleMonitorMarketThumbnail'
|
||||
import { toWhaleMonitorMarketItem } from '../constants/whaleMonitor'
|
||||
import type { WhaleMonitorMarketItem, WhaleMonitorMarketSelectLocationState } from '../types'
|
||||
|
||||
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 WhaleMonitorMarketSelect: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
const locationState = (location.state as WhaleMonitorMarketSelectLocationState | null) ?? null
|
||||
const initialSelected = locationState?.selectedMarkets ?? []
|
||||
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [debouncedKeyword, setDebouncedKeyword] = useState('')
|
||||
const [tagId, setTagId] = useState<string>('')
|
||||
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>>(() => {
|
||||
const map = new Map<string, WhaleMonitorMarketItem>()
|
||||
initialSelected.forEach(m => map.set(m.conditionId, m))
|
||||
return map
|
||||
})
|
||||
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (tagId === '1') {
|
||||
fetchSportSubCategories()
|
||||
setSportSubSeriesId(undefined)
|
||||
} else {
|
||||
setSportSubCategories([])
|
||||
setSportSubSeriesId(undefined)
|
||||
}
|
||||
}, [tagId])
|
||||
|
||||
const fetchSportSubCategories = 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([])
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMarkets = useCallback(async () => {
|
||||
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
|
||||
|
||||
if (tagId === '1' && !sportSubSeriesId && searchKeyword.length < 2) {
|
||||
setMarketList([])
|
||||
return
|
||||
}
|
||||
if (!seriesId && !effectiveTagId && searchKeyword.length < 2) {
|
||||
setMarketList([])
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await apiService.markets.search({
|
||||
keyword: searchKeyword.length >= 2 ? 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)))
|
||||
} else {
|
||||
setMarketList([])
|
||||
}
|
||||
} catch {
|
||||
setMarketList([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [debouncedKeyword, tagId, sportSubSeriesId, sportSubCategories])
|
||||
|
||||
useEffect(() => {
|
||||
fetchMarkets()
|
||||
}, [fetchMarkets])
|
||||
|
||||
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 handleConfirm = () => {
|
||||
navigate('/whale-monitor-strategy', {
|
||||
state: { selectedMarkets: selectedList }
|
||||
})
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/whale-monitor-strategy')
|
||||
}
|
||||
|
||||
const showSelectSportHint = tagId === '1' && !sportSubSeriesId && debouncedKeyword.length < 2
|
||||
const showSportsGamesEmpty =
|
||||
tagId === '1' && !!sportSubSeriesId && marketList.length === 0 && !loading && debouncedKeyword.length < 2
|
||||
const showSearchHint = !tagId && debouncedKeyword.length < 2
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 12 : 24, paddingBottom: isMobile ? 88 : 24 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={handleBack}
|
||||
style={{ marginBottom: 12, minHeight: 44 }}
|
||||
>
|
||||
{t('whaleMonitorStrategy.marketSelect.back')}
|
||||
</Button>
|
||||
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
|
||||
{t('whaleMonitorStrategy.marketSelect.title')}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
|
||||
{t('whaleMonitorStrategy.marketSelect.subtitle')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder={t('whaleMonitorStrategy.marketSelect.searchPlaceholder')}
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<Segmented
|
||||
block={isMobile}
|
||||
options={tagSegmentOptions}
|
||||
value={tagId}
|
||||
onChange={val => setTagId(val as string)}
|
||||
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={setSportSubSeriesId}
|
||||
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 }}>
|
||||
<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" bodyStyle={{ padding: isMobile ? 8 : 16 }}>
|
||||
<Spin spinning={loading}>
|
||||
{showSearchHint ? (
|
||||
<Empty description={t('whaleMonitorStrategy.marketSelect.searchHint')} />
|
||||
) : 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>
|
||||
</Card>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: isMobile ? 'fixed' : 'sticky',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: isMobile ? '12px 16px' : '16px 0 0',
|
||||
background: isMobile ? '#fff' : 'transparent',
|
||||
borderTop: isMobile ? '1px solid #f0f0f0' : undefined,
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end'
|
||||
}}
|
||||
>
|
||||
{!isMobile && (
|
||||
<Text type="secondary">
|
||||
{t('whaleMonitorStrategy.marketSelect.selectedCount', { count: selectedList.length })}
|
||||
</Text>
|
||||
)}
|
||||
<Button onClick={handleBack} style={{ minHeight: 44 }}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleConfirm} style={{ minHeight: 44 }}>
|
||||
{isMobile
|
||||
? t('whaleMonitorStrategy.marketSelect.confirmWithCount', { count: selectedList.length })
|
||||
: t('whaleMonitorStrategy.marketSelect.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WhaleMonitorMarketSelect
|
||||
@@ -0,0 +1,650 @@
|
||||
import { useEffect, useRef, 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
|
||||
} from '../constants/whaleMonitor'
|
||||
import type {
|
||||
WhaleMonitorStrategyDto,
|
||||
WhaleMonitorTriggerDto,
|
||||
WhaleMonitorMarketItem,
|
||||
WhaleMonitorStrategyListLocationState,
|
||||
Account
|
||||
} from '../types'
|
||||
import { formatUSDC } from '../utils'
|
||||
|
||||
const WhaleMonitorStrategyList: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { accounts, fetchAccounts } = useAccountStore()
|
||||
|
||||
const [strategies, setStrategies] = useState<WhaleMonitorStrategyDto[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filterAccountId, setFilterAccountId] = useState<number | undefined>()
|
||||
const [filterEnabled, setFilterEnabled] = useState<boolean | undefined>()
|
||||
|
||||
const [formVisible, setFormVisible] = useState(false)
|
||||
const [editingStrategy, setEditingStrategy] = useState<WhaleMonitorStrategyDto | null>(null)
|
||||
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 [triggerVisible, setTriggerVisible] = useState(false)
|
||||
const [triggerStrategyId, setTriggerStrategyId] = useState<number>(0)
|
||||
const [triggerRecords, setTriggerRecords] = useState<WhaleMonitorTriggerDto[]>([])
|
||||
const [triggerTotal, setTriggerTotal] = useState(0)
|
||||
const [triggerPage, setTriggerPage] = useState(1)
|
||||
const [triggerTab, setTriggerTab] = useState<string>('success')
|
||||
const [triggerLoading, setTriggerLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
}, [fetchAccounts])
|
||||
|
||||
useEffect(() => {
|
||||
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)
|
||||
}
|
||||
setSelectedMarkets(pendingFormDraft.selectedMarkets)
|
||||
form.setFieldsValue(pendingFormDraft.formValues)
|
||||
setFormVisible(true)
|
||||
setPendingFormDraft(null)
|
||||
}, [pendingFormDraft, strategies, loading, form])
|
||||
|
||||
const fetchList = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: { accountId?: number; enabled?: boolean } = {}
|
||||
if (filterAccountId) params.accountId = filterAccountId
|
||||
if (filterEnabled !== undefined) params.enabled = filterEnabled
|
||||
const res = await apiService.whaleMonitorStrategy.list(params)
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
setStrategies(res.data.data.list || [])
|
||||
} else {
|
||||
message.error(t('whaleMonitorStrategy.list.fetchFailed'))
|
||||
}
|
||||
} catch (_e) {
|
||||
message.error(t('whaleMonitorStrategy.list.fetchFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (strategy: WhaleMonitorStrategyDto) => {
|
||||
try {
|
||||
const res = await apiService.whaleMonitorStrategy.update({
|
||||
strategyId: strategy.id,
|
||||
enabled: !strategy.enabled
|
||||
})
|
||||
if (res.data.code === 0) {
|
||||
message.success(strategy.enabled ? t('whaleMonitorStrategy.list.disable') : t('whaleMonitorStrategy.list.enable'))
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(res.data.msg)
|
||||
}
|
||||
} catch (_e) {
|
||||
message.error('Error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (strategyId: number) => {
|
||||
try {
|
||||
const res = await apiService.whaleMonitorStrategy.delete({ strategyId })
|
||||
if (res.data.code === 0) {
|
||||
message.success('OK')
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(res.data.msg)
|
||||
}
|
||||
} catch (_e) {
|
||||
message.error('Error')
|
||||
}
|
||||
}
|
||||
|
||||
const goToMarketSelect = () => {
|
||||
saveWhaleMonitorFormDraft({
|
||||
formValues: form.getFieldsValue(),
|
||||
selectedMarkets,
|
||||
editingStrategyId: editingStrategy?.id
|
||||
})
|
||||
navigate('/whale-monitor-strategy/markets', {
|
||||
state: { selectedMarkets }
|
||||
})
|
||||
}
|
||||
|
||||
const showCreateModal = () => {
|
||||
setEditingStrategy(null)
|
||||
setSelectedMarkets([])
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
windowSeconds: 10,
|
||||
minPrice: 0,
|
||||
maxPrice: 1,
|
||||
cooldownSeconds: 60,
|
||||
enabled: true
|
||||
})
|
||||
setFormVisible(true)
|
||||
}
|
||||
|
||||
const showEditModal = (strategy: WhaleMonitorStrategyDto) => {
|
||||
setEditingStrategy(strategy)
|
||||
setSelectedMarkets(conditionIdsToMarkets(strategy.conditionIds))
|
||||
form.setFieldsValue({
|
||||
accountId: strategy.accountId,
|
||||
name: strategy.name,
|
||||
windowSeconds: strategy.windowSeconds,
|
||||
thresholdAmount: strategy.thresholdAmount,
|
||||
orderAmount: strategy.orderAmount,
|
||||
minPrice: parseFloat(strategy.minPrice),
|
||||
maxPrice: parseFloat(strategy.maxPrice),
|
||||
cooldownSeconds: strategy.cooldownSeconds,
|
||||
enabled: strategy.enabled
|
||||
})
|
||||
setFormVisible(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setSubmitting(true)
|
||||
|
||||
if (selectedMarkets.length === 0) {
|
||||
message.error(t('whaleMonitorStrategy.form.conditionIds'))
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...values,
|
||||
conditionIds: selectedMarkets.map(m => m.conditionId),
|
||||
minPrice: String(values.minPrice ?? 0),
|
||||
maxPrice: String(values.maxPrice ?? 1),
|
||||
thresholdAmount: String(values.thresholdAmount),
|
||||
orderAmount: String(values.orderAmount)
|
||||
}
|
||||
|
||||
if (editingStrategy) {
|
||||
const res = await apiService.whaleMonitorStrategy.update({
|
||||
strategyId: editingStrategy.id,
|
||||
...payload
|
||||
})
|
||||
if (res.data.code === 0) {
|
||||
message.success('OK')
|
||||
setFormVisible(false)
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(res.data.msg)
|
||||
}
|
||||
} else {
|
||||
const res = await apiService.whaleMonitorStrategy.create(payload)
|
||||
if (res.data.code === 0) {
|
||||
message.success('OK')
|
||||
setFormVisible(false)
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(res.data.msg)
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// form validation error
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTriggerRecords = async (strategyId: number, page: number, status?: string) => {
|
||||
setTriggerLoading(true)
|
||||
try {
|
||||
const params: { strategyId: number; page: number; pageSize: number; status?: string } = {
|
||||
strategyId,
|
||||
page,
|
||||
pageSize: 20,
|
||||
}
|
||||
if (status) params.status = status
|
||||
const res = await apiService.whaleMonitorStrategy.triggers(params)
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
setTriggerRecords(res.data.data.list || [])
|
||||
setTriggerTotal(res.data.data.total || 0)
|
||||
}
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
} finally {
|
||||
setTriggerLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showTriggerRecords = (strategyId: number) => {
|
||||
setTriggerStrategyId(strategyId)
|
||||
setTriggerPage(1)
|
||||
setTriggerTab('success')
|
||||
setTriggerVisible(true)
|
||||
fetchTriggerRecords(strategyId, 1, 'success')
|
||||
}
|
||||
|
||||
const handleTriggerTabChange = (tab: string) => {
|
||||
setTriggerTab(tab)
|
||||
setTriggerPage(1)
|
||||
fetchTriggerRecords(triggerStrategyId, 1, tab === 'all' ? undefined : tab)
|
||||
}
|
||||
|
||||
const getAccountName = (accountId: number): string => {
|
||||
const acc = accounts.find((a: Account) => a.id === accountId)
|
||||
if (!acc) return String(accountId)
|
||||
return acc.accountName || `${acc.walletAddress.slice(0, 6)}...${acc.walletAddress.slice(-4)}`
|
||||
}
|
||||
|
||||
const formatTime = (ts: number): string => {
|
||||
if (!ts) return '-'
|
||||
return new Date(ts).toLocaleString()
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('whaleMonitorStrategy.list.strategyName'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
ellipsis: true,
|
||||
render: (name: string) => name || '-'
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.list.account'),
|
||||
dataIndex: 'accountId',
|
||||
key: 'accountId',
|
||||
render: (accountId: number) => getAccountName(accountId)
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.list.marketCount'),
|
||||
dataIndex: 'conditionIds',
|
||||
key: 'marketCount',
|
||||
render: (ids: string[]) => (
|
||||
<Tooltip title={ids.join(', ')}>
|
||||
<Tag>{ids.length}</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.list.window'),
|
||||
dataIndex: 'windowSeconds',
|
||||
key: 'windowSeconds',
|
||||
render: (v: number) => `${v}${t('whaleMonitorStrategy.list.seconds')}`
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.list.threshold'),
|
||||
dataIndex: 'thresholdAmount',
|
||||
key: 'thresholdAmount',
|
||||
render: (v: string) => `$${formatUSDC(v)}`
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.list.orderAmount'),
|
||||
dataIndex: 'orderAmount',
|
||||
key: 'orderAmount',
|
||||
render: (v: string) => `$${formatUSDC(v)}`
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.list.priceRange'),
|
||||
key: 'priceRange',
|
||||
render: (_: unknown, record: WhaleMonitorStrategyDto) => `${record.minPrice} ~ ${record.maxPrice}`
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.list.cooldown'),
|
||||
dataIndex: 'cooldownSeconds',
|
||||
key: 'cooldownSeconds',
|
||||
render: (v: number) => `${v}${t('whaleMonitorStrategy.list.seconds')}`
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.list.enabled'),
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
render: (enabled: boolean, record: WhaleMonitorStrategyDto) => (
|
||||
<Switch checked={enabled} onChange={() => handleToggleEnabled(record)} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.list.triggerCount'),
|
||||
dataIndex: 'triggerCount',
|
||||
key: 'triggerCount',
|
||||
render: (count: number) => count || 0
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.list.actions'),
|
||||
key: 'actions',
|
||||
render: (_: unknown, record: WhaleMonitorStrategyDto) => (
|
||||
<Space size="small">
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => showEditModal(record)}>
|
||||
{t('whaleMonitorStrategy.list.edit')}
|
||||
</Button>
|
||||
<Button type="link" size="small" icon={<UnorderedListOutlined />} onClick={() => showTriggerRecords(record.id)}>
|
||||
{t('whaleMonitorStrategy.list.viewTriggers')}
|
||||
</Button>
|
||||
<Popconfirm title={t('whaleMonitorStrategy.list.deleteConfirm')} onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
{t('whaleMonitorStrategy.list.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const triggerColumns = [
|
||||
{
|
||||
title: t('whaleMonitorStrategy.triggerRecords.triggerTime'),
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (ts: number) => formatTime(ts)
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.triggerRecords.conditionId'),
|
||||
dataIndex: 'conditionId',
|
||||
key: 'conditionId',
|
||||
ellipsis: true,
|
||||
render: (v: string) => (
|
||||
<Tooltip title={v}>
|
||||
<span>{v.slice(0, 10)}...</span>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.triggerRecords.tokenId'),
|
||||
dataIndex: 'tokenId',
|
||||
key: 'tokenId',
|
||||
ellipsis: true,
|
||||
render: (v: string) => (
|
||||
<Tooltip title={v}>
|
||||
<span>{v.slice(0, 10)}...</span>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.triggerRecords.triggerVolume'),
|
||||
dataIndex: 'triggerVolume',
|
||||
key: 'triggerVolume',
|
||||
render: (v: string) => `$${formatUSDC(v)}`
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.triggerRecords.orderPrice'),
|
||||
dataIndex: 'orderPrice',
|
||||
key: 'orderPrice',
|
||||
render: (v: string) => formatUSDC(v)
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.triggerRecords.orderAmount'),
|
||||
dataIndex: 'orderAmount',
|
||||
key: 'orderAmount',
|
||||
render: (v: string) => `$${formatUSDC(v)}`
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.triggerRecords.orderId'),
|
||||
dataIndex: 'orderId',
|
||||
key: 'orderId',
|
||||
ellipsis: true,
|
||||
render: (v: string) => v ? (
|
||||
<Tooltip title={v}><span>{v.slice(0, 10)}...</span></Tooltip>
|
||||
) : '-'
|
||||
},
|
||||
{
|
||||
title: t('whaleMonitorStrategy.triggerRecords.failReason'),
|
||||
dataIndex: 'failReason',
|
||||
key: 'failReason',
|
||||
ellipsis: true,
|
||||
render: (v: string) => v ? <Tooltip title={v}><span>{v}</span></Tooltip> : '-'
|
||||
}
|
||||
]
|
||||
|
||||
const renderMobileCard = (strategy: WhaleMonitorStrategyDto) => (
|
||||
<Card key={strategy.id} size="small" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<strong>{strategy.name || '-'}</strong>
|
||||
<Switch checked={strategy.enabled} onChange={() => handleToggleEnabled(strategy)} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
|
||||
{t('whaleMonitorStrategy.list.account')}: {getAccountName(strategy.accountId)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
|
||||
{t('whaleMonitorStrategy.list.marketCount')}: <Tag>{strategy.conditionIds.length}</Tag>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
|
||||
{t('whaleMonitorStrategy.list.window')}: {strategy.windowSeconds}{t('whaleMonitorStrategy.list.seconds')}
|
||||
|
|
||||
{t('whaleMonitorStrategy.list.threshold')}: ${formatUSDC(strategy.thresholdAmount)}
|
||||
|
|
||||
{t('whaleMonitorStrategy.list.orderAmount')}: ${formatUSDC(strategy.orderAmount)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>
|
||||
{t('whaleMonitorStrategy.list.priceRange')}: {strategy.minPrice} ~ {strategy.maxPrice}
|
||||
|
|
||||
{t('whaleMonitorStrategy.list.cooldown')}: {strategy.cooldownSeconds}{t('whaleMonitorStrategy.list.seconds')}
|
||||
</div>
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => showEditModal(strategy)}>
|
||||
{t('whaleMonitorStrategy.list.edit')}
|
||||
</Button>
|
||||
<Button size="small" icon={<UnorderedListOutlined />} onClick={() => showTriggerRecords(strategy.id)}>
|
||||
{t('whaleMonitorStrategy.list.viewTriggers')}
|
||||
</Button>
|
||||
<Popconfirm title={t('whaleMonitorStrategy.list.deleteConfirm')} onConfirm={() => handleDelete(strategy.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
{t('whaleMonitorStrategy.list.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Card>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 12 : 24 }}>
|
||||
<Card>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
||||
<h2 style={{ margin: 0 }}>{t('whaleMonitorStrategy.list.title')}</h2>
|
||||
<Space wrap>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('whaleMonitorStrategy.list.account')}
|
||||
style={{ width: 160 }}
|
||||
value={filterAccountId}
|
||||
onChange={setFilterAccountId}
|
||||
options={accounts.map((a: Account) => ({
|
||||
label: a.accountName || `${a.walletAddress?.slice(0, 6)}...${a.walletAddress?.slice(-4)}`,
|
||||
value: a.id
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('whaleMonitorStrategy.list.enabled')}
|
||||
style={{ width: 100 }}
|
||||
value={filterEnabled}
|
||||
onChange={setFilterEnabled}
|
||||
options={[
|
||||
{ label: t('whaleMonitorStrategy.list.enable'), value: true },
|
||||
{ label: t('whaleMonitorStrategy.list.disable'), value: false }
|
||||
]}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={showCreateModal}>
|
||||
{t('whaleMonitorStrategy.list.addStrategy')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
<div>{strategies.map(renderMobileCard)}</div>
|
||||
) : (
|
||||
<Table
|
||||
dataSource={strategies}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200 }}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingStrategy ? t('whaleMonitorStrategy.list.edit') : t('whaleMonitorStrategy.list.addStrategy')}
|
||||
open={formVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setFormVisible(false)}
|
||||
confirmLoading={submitting}
|
||||
width={isMobile ? '95%' : 600}
|
||||
okText={editingStrategy ? t('whaleMonitorStrategy.form.update') : t('whaleMonitorStrategy.form.create')}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="accountId" label={t('whaleMonitorStrategy.form.selectAccount')} rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={accounts.map((a: Account) => ({
|
||||
label: a.accountName || `${a.walletAddress?.slice(0, 6)}...${a.walletAddress?.slice(-4)}`,
|
||||
value: a.id
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label={t('whaleMonitorStrategy.form.strategyName')}>
|
||||
<Input placeholder={t('whaleMonitorStrategy.form.strategyNamePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('whaleMonitorStrategy.form.conditionIds')} required>
|
||||
<div>
|
||||
{selectedMarkets.length > 0 && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{selectedMarkets.map(m => (
|
||||
<Tag
|
||||
key={m.conditionId}
|
||||
closable
|
||||
onClose={() =>
|
||||
setSelectedMarkets(prev => prev.filter(item => item.conditionId !== m.conditionId))
|
||||
}
|
||||
>
|
||||
{m.title}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
block={isMobile}
|
||||
onClick={goToMarketSelect}
|
||||
style={{ minHeight: 44, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
||||
>
|
||||
<span>
|
||||
{selectedMarkets.length > 0
|
||||
? t('whaleMonitorStrategy.form.marketsSelected', { count: selectedMarkets.length })
|
||||
: t('whaleMonitorStrategy.form.selectMarkets')}
|
||||
</span>
|
||||
<RightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item name="windowSeconds" label={t('whaleMonitorStrategy.form.windowSeconds')} rules={[{ required: true }]}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="thresholdAmount" label={t('whaleMonitorStrategy.form.thresholdAmount')} rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} step={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="orderAmount" label={t('whaleMonitorStrategy.form.orderAmount')} rules={[{ required: true }]}>
|
||||
<InputNumber min={1} step={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Form.Item name="minPrice" label={t('whaleMonitorStrategy.form.minPrice')} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={1} step={0.01} precision={2} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxPrice" label={t('whaleMonitorStrategy.form.maxPrice')} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={1} step={0.01} precision={2} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: -16, marginBottom: 16 }}>
|
||||
{t('whaleMonitorStrategy.form.priceTip')}
|
||||
</div>
|
||||
<Form.Item name="cooldownSeconds" label={t('whaleMonitorStrategy.form.cooldownSeconds')} rules={[{ required: true }]}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label={t('whaleMonitorStrategy.form.enabled')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('whaleMonitorStrategy.triggerRecords.title')}
|
||||
open={triggerVisible}
|
||||
onCancel={() => setTriggerVisible(false)}
|
||||
footer={null}
|
||||
width={isMobile ? '95%' : 900}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={triggerTab}
|
||||
onChange={handleTriggerTabChange}
|
||||
items={[
|
||||
{ key: 'success', label: t('whaleMonitorStrategy.triggerRecords.successTab') },
|
||||
{ key: 'fail', label: t('whaleMonitorStrategy.triggerRecords.failTab') }
|
||||
]}
|
||||
/>
|
||||
{triggerRecords.length === 0 ? (
|
||||
<Empty description={triggerTab === 'success' ? t('whaleMonitorStrategy.triggerRecords.emptySuccess') : t('whaleMonitorStrategy.triggerRecords.emptyFail')} />
|
||||
) : (
|
||||
<Table
|
||||
dataSource={triggerRecords}
|
||||
columns={triggerColumns}
|
||||
rowKey="id"
|
||||
loading={triggerLoading}
|
||||
size="small"
|
||||
scroll={{ x: isMobile ? 700 : 'auto' }}
|
||||
pagination={{
|
||||
current: triggerPage,
|
||||
total: triggerTotal,
|
||||
pageSize: 20,
|
||||
onChange: (page) => {
|
||||
setTriggerPage(page)
|
||||
fetchTriggerRecords(triggerStrategyId, page, triggerTab === 'all' ? undefined : triggerTab)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WhaleMonitorStrategyList
|
||||
@@ -314,8 +314,39 @@ export const apiService = {
|
||||
/**
|
||||
* 获取最新价(从订单表获取,供前端下单时显示)
|
||||
*/
|
||||
getLatestPrice: (data: { tokenId: string }) =>
|
||||
apiClient.post<ApiResponse<any>>('/markets/latest-price', data)
|
||||
getLatestPrice: (data: { tokenId: string }) =>
|
||||
apiClient.post<ApiResponse<any>>('/markets/latest-price', data),
|
||||
|
||||
/**
|
||||
* 搜索市场(按标题关键词)
|
||||
*/
|
||||
search: (data: { keyword: string; tagId?: string; seriesId?: string; sportSlug?: string; limit?: number }) =>
|
||||
apiClient.post<ApiResponse<Array<{
|
||||
conditionId: string
|
||||
title: string
|
||||
slug?: string
|
||||
category?: string
|
||||
volume?: string
|
||||
outcomes?: string
|
||||
eventTitle?: string
|
||||
marketType?: 'game' | 'season'
|
||||
image?: string
|
||||
icon?: string
|
||||
eventImage?: string
|
||||
}>>>('/markets/search', data),
|
||||
|
||||
/**
|
||||
* 获取体育联赛子分类列表
|
||||
*/
|
||||
sportsCategories: () =>
|
||||
apiClient.post<ApiResponse<Array<{
|
||||
id: number
|
||||
slug: string
|
||||
label: string
|
||||
tagId: string
|
||||
seriesId?: string
|
||||
image?: string
|
||||
}>>>('/markets/sports-categories', {})
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -526,6 +557,51 @@ export const apiService = {
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailManualOrderResponse>>('/crypto-tail-strategy/manual-order', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 大单监听策略 API
|
||||
*/
|
||||
whaleMonitorStrategy: {
|
||||
list: (data: { accountId?: number; enabled?: boolean } = {}) =>
|
||||
apiClient.post<ApiResponse<{ list: import('../types').WhaleMonitorStrategyDto[] }>>('/whale-monitor-strategy/list', data),
|
||||
create: (data: {
|
||||
accountId: number
|
||||
name?: string
|
||||
conditionIds: string[]
|
||||
windowSeconds?: number
|
||||
thresholdAmount: string
|
||||
orderAmount: string
|
||||
minPrice?: string
|
||||
maxPrice?: string
|
||||
cooldownSeconds?: number
|
||||
enabled?: boolean
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<import('../types').WhaleMonitorStrategyDto>>('/whale-monitor-strategy/create', data),
|
||||
update: (data: {
|
||||
strategyId: number
|
||||
name?: string
|
||||
conditionIds?: string[]
|
||||
windowSeconds?: number
|
||||
thresholdAmount?: string
|
||||
orderAmount?: string
|
||||
minPrice?: string
|
||||
maxPrice?: string
|
||||
cooldownSeconds?: number
|
||||
enabled?: boolean
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<import('../types').WhaleMonitorStrategyDto>>('/whale-monitor-strategy/update', data),
|
||||
delete: (data: { strategyId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/whale-monitor-strategy/delete', data),
|
||||
triggers: (data: {
|
||||
strategyId: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: string
|
||||
startDate?: number
|
||||
endDate?: number
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<{ list: import('../types').WhaleMonitorTriggerDto[]; total: number }>>('/whale-monitor-strategy/triggers', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 订单管理 API
|
||||
*/
|
||||
|
||||
@@ -1309,3 +1309,71 @@ export interface TemplateVariablesResponse {
|
||||
categories: TemplateVariableCategory[] // 分类列表
|
||||
variables: TemplateVariable[] // 变量列表
|
||||
}
|
||||
|
||||
// ==================== 大单监听策略 ====================
|
||||
|
||||
export type WhaleMonitorMarketType = 'game' | 'season'
|
||||
|
||||
export interface WhaleMonitorMarketItem {
|
||||
conditionId: string
|
||||
title: string
|
||||
slug?: string
|
||||
category?: string
|
||||
volume?: string
|
||||
outcomes?: string
|
||||
/** 体育对阵/赛事名,如 Spurs vs. Thunder */
|
||||
eventTitle?: string
|
||||
/** game=单场赛事,season=长期/赛季市场 */
|
||||
marketType?: WhaleMonitorMarketType
|
||||
image?: string
|
||||
icon?: string
|
||||
eventImage?: string
|
||||
}
|
||||
|
||||
export interface WhaleMonitorFormDraft {
|
||||
formValues: Record<string, unknown>
|
||||
selectedMarkets: WhaleMonitorMarketItem[]
|
||||
editingStrategyId?: number
|
||||
}
|
||||
|
||||
export interface WhaleMonitorMarketSelectLocationState {
|
||||
selectedMarkets?: WhaleMonitorMarketItem[]
|
||||
}
|
||||
|
||||
export interface WhaleMonitorStrategyListLocationState {
|
||||
selectedMarkets?: WhaleMonitorMarketItem[]
|
||||
}
|
||||
|
||||
export interface WhaleMonitorStrategyDto {
|
||||
id: number
|
||||
accountId: number
|
||||
name?: string
|
||||
conditionIds: string[]
|
||||
windowSeconds: number
|
||||
thresholdAmount: string
|
||||
orderAmount: string
|
||||
minPrice: string
|
||||
maxPrice: string
|
||||
cooldownSeconds: number
|
||||
enabled: boolean
|
||||
lastTriggerAt?: number
|
||||
triggerCount: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface WhaleMonitorTriggerDto {
|
||||
id: number
|
||||
strategyId: number
|
||||
conditionId: string
|
||||
tokenId: string
|
||||
side: string
|
||||
triggerVolume: string
|
||||
orderPrice: string
|
||||
orderSize: string
|
||||
orderAmount: string
|
||||
orderId?: string
|
||||
status: string
|
||||
failReason?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user