import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Modal, Alert, Form, Input, InputNumber, Radio, Spin, Tooltip, Tabs, DatePicker, Empty, Typography, Divider } from 'antd' import type { Dayjs } from 'dayjs' import dayjs from 'dayjs' import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { useMediaQuery } from 'react-responsive' import { apiService } from '../services/api' import { useAccountStore } from '../store/accountStore' import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto } from '../types' import { formatUSDC, formatNumber } from '../utils' import { getVersionInfo } from '../utils/version' const CryptoTailStrategyList: React.FC = () => { const { t, i18n } = useTranslation() const navigate = useNavigate() const isMobile = useMediaQuery({ maxWidth: 768 }) const { accounts, fetchAccounts } = useAccountStore() const [list, setList] = useState([]) const [loading, setLoading] = useState(false) const [filters, setFilters] = useState<{ accountId?: number; enabled?: boolean }>({}) const [systemConfig, setSystemConfig] = useState<{ builderApiKeyConfigured?: boolean; autoRedeemEnabled?: boolean } | null>(null) const [redeemModalOpen, setRedeemModalOpen] = useState(false) const [formModalOpen, setFormModalOpen] = useState(false) const [editingId, setEditingId] = useState(null) const [marketOptions, setMarketOptions] = useState([]) const [triggersModalOpen, setTriggersModalOpen] = useState(false) const [triggersStrategyId, setTriggersStrategyId] = useState(null) const [triggerTab, setTriggerTab] = useState<'success' | 'fail'>('success') const [triggerDateRange, setTriggerDateRange] = useState<[Dayjs | null, Dayjs | null]>([null, null]) const [triggerPage, setTriggerPage] = useState(1) const [triggerPageSize, setTriggerPageSize] = useState(20) const [triggers, setTriggers] = useState([]) const [triggersTotal, setTriggersTotal] = useState(0) const [triggersLoading, setTriggersLoading] = useState(false) const [form] = Form.useForm() /** 币安 API 健康状态:仅保留「不可用」的项,用于强提醒 */ const [binanceUnhealthy, setBinanceUnhealthy] = useState>([]) const [binanceCheckLoading, setBinanceCheckLoading] = useState(false) const BINANCE_API_NAMES = ['币安 API', '币安 WebSocket'] const fetchBinanceApiStatus = async () => { setBinanceCheckLoading(true) try { const res = await apiService.proxyConfig.checkApiHealth() if (res.data.code === 0 && res.data.data?.apis) { const unhealthy = res.data.data.apis.filter( (api) => BINANCE_API_NAMES.includes(api.name) && api.status !== 'success' ) setBinanceUnhealthy(unhealthy.map((api) => ({ name: api.name, message: api.message }))) } else { setBinanceUnhealthy([]) } } catch { setBinanceUnhealthy([{ name: '币安 API', message: '' }, { name: '币安 WebSocket', message: '' }]) } finally { setBinanceCheckLoading(false) } } useEffect(() => { fetchAccounts() fetchSystemConfig() fetchMarketOptions() }, []) useEffect(() => { fetchBinanceApiStatus() }, []) useEffect(() => { fetchList() }, [filters]) const fetchSystemConfig = async () => { try { const res = await apiService.systemConfig.get() if (res.data.code === 0 && res.data.data) { setSystemConfig(res.data.data) } } catch { setSystemConfig(null) } } const fetchMarketOptions = async () => { try { const res = await apiService.cryptoTailStrategy.marketOptions() if (res.data.code === 0 && res.data.data) { setMarketOptions(res.data.data) } } catch { setMarketOptions([]) } } const fetchList = async () => { setLoading(true) try { const res = await apiService.cryptoTailStrategy.list(filters) if (res.data.code === 0 && res.data.data) { setList(res.data.data.list ?? []) } else { message.error(res.data.msg || t('cryptoTailStrategy.list.fetchFailed')) } } catch (e) { message.error((e as Error).message || t('cryptoTailStrategy.list.fetchFailed')) } finally { setLoading(false) } } const openAddModal = () => { const needApiKey = !systemConfig?.builderApiKeyConfigured const needAutoRedeem = !systemConfig?.autoRedeemEnabled if (needApiKey || needAutoRedeem) { setRedeemModalOpen(true) return } setEditingId(null) form.resetFields() form.setFieldsValue({ enabled: true, amountMode: 'RATIO', maxPrice: '1', spreadMode: 'AUTO', spreadDirection: 'MIN', windowStartMinutes: 0, windowStartSeconds: 0 }) setFormModalOpen(true) } const openEditModal = (record: CryptoTailStrategyDto) => { setEditingId(record.id) form.setFieldsValue({ accountId: record.accountId, name: record.name, marketSlugPrefix: record.marketSlugPrefix, windowStartMinutes: Math.floor(record.windowStartSeconds / 60), windowStartSeconds: record.windowStartSeconds % 60, windowEndMinutes: Math.floor(record.windowEndSeconds / 60), windowEndSeconds: record.windowEndSeconds % 60, minPrice: record.minPrice, maxPrice: record.maxPrice, amountMode: record.amountMode, amountValue: record.amountValue, spreadMode: record.spreadMode ?? 'AUTO', spreadValue: record.spreadValue ?? undefined, spreadDirection: record.spreadDirection ?? 'MIN', enabled: record.enabled }) setFormModalOpen(true) } const handleFormSubmit = async () => { try { const v = await form.validateFields() // 新建与编辑均按当前选择的市场 slug 取周期,编辑时无 Form.Item 的 intervalSeconds 不会在 v 中 const interval = marketOptions.find((m) => m.slug === v.marketSlugPrefix)?.intervalSeconds ?? 300 const windowStartSeconds = (v.windowStartMinutes ?? 0) * 60 + (v.windowStartSeconds ?? 0) const windowEndSeconds = (v.windowEndMinutes ?? 0) * 60 + (v.windowEndSeconds ?? 0) if (windowStartSeconds > windowEndSeconds) { message.error(t('cryptoTailStrategy.form.timeWindowStartLEEnd')) return } const maxWindow = interval if (windowEndSeconds > maxWindow) { message.error(t('cryptoTailStrategy.form.timeWindowExceed')) return } const payload = { accountId: v.accountId as number, name: v.name as string | undefined, marketSlugPrefix: v.marketSlugPrefix as string, intervalSeconds: interval, windowStartSeconds, windowEndSeconds, minPrice: String(v.minPrice ?? 0), maxPrice: v.maxPrice != null ? String(v.maxPrice) : undefined, amountMode: v.amountMode as string, amountValue: String(v.amountValue ?? 0), spreadMode: (v.spreadMode as string) || 'AUTO', spreadValue: v.spreadMode === 'FIXED' && v.spreadValue != null ? String(v.spreadValue) : (v.spreadMode === 'AUTO' && v.spreadValue != null ? String(v.spreadValue) : undefined), spreadDirection: v.spreadDirection as string || 'MIN', enabled: v.enabled !== false } if (editingId) { const res = await apiService.cryptoTailStrategy.update({ strategyId: editingId, name: payload.name, windowStartSeconds: payload.windowStartSeconds, windowEndSeconds: payload.windowEndSeconds, minPrice: payload.minPrice, maxPrice: payload.maxPrice, amountMode: payload.amountMode, amountValue: payload.amountValue, spreadMode: payload.spreadMode, spreadValue: payload.spreadValue, spreadDirection: payload.spreadDirection, enabled: payload.enabled }) if (res.data.code === 0) { message.success(t('common.success')) setFormModalOpen(false) fetchList() } else { message.error(res.data.msg || t('common.failed')) } } else { const res = await apiService.cryptoTailStrategy.create({ ...payload, spreadValue: payload.spreadMode === 'FIXED' ? payload.spreadValue : undefined }) if (res.data.code === 0) { message.success(t('common.success')) setFormModalOpen(false) fetchList() } else { message.error(res.data.msg || t('common.failed')) } } } catch (e) { if ((e as { errorFields?: unknown[] })?.errorFields) { return } message.error((e as Error).message) } } const handleToggle = async (record: CryptoTailStrategyDto) => { try { const res = await apiService.cryptoTailStrategy.update({ strategyId: record.id, enabled: !record.enabled }) if (res.data.code === 0) { message.success(record.enabled ? t('cryptoTailStrategy.list.disable') : t('cryptoTailStrategy.list.enable')) fetchList() } else { message.error(res.data.msg) } } catch (e) { message.error((e as Error).message) } } const handleDelete = async (strategyId: number) => { try { const res = await apiService.cryptoTailStrategy.delete({ strategyId }) if (res.data.code === 0) { message.success(t('common.success')) fetchList() } else { message.error(res.data.msg) } } catch (e) { message.error((e as Error).message) } } type TriggerLoadOpts = { page?: number; pageSize?: number; dateRange?: [Dayjs | null, Dayjs | null] } const loadTriggerRecords = async ( strategyId: number, status: 'success' | 'fail', opts?: TriggerLoadOpts ) => { const page = opts?.page ?? triggerPage const pageSize = opts?.pageSize ?? triggerPageSize const range = opts?.dateRange ?? triggerDateRange const startDate = range[0] != null ? range[0].startOf('day').valueOf() : undefined const endDate = range[1] != null ? range[1].endOf('day').valueOf() : undefined setTriggersLoading(true) try { const res = await apiService.cryptoTailStrategy.triggers({ strategyId, page, pageSize, status, startDate, endDate }) if (res.data.code === 0 && res.data.data) { setTriggers(res.data.data.list ?? []) setTriggersTotal(res.data.data.total ?? 0) } } finally { setTriggersLoading(false) } } const openTriggers = async (strategyId: number) => { setTriggersStrategyId(strategyId) setTriggerTab('success') setTriggerDateRange([null, null]) setTriggerPage(1) setTriggerPageSize(20) setTriggersModalOpen(true) await loadTriggerRecords(strategyId, 'success', { page: 1, pageSize: 20, dateRange: [null, null] }) } const onTriggerTabChange = (key: string) => { const next = key === 'success' ? 'success' : 'fail' setTriggerTab(next) setTriggers([]) setTriggerPage(1) if (triggersStrategyId != null) { loadTriggerRecords(triggersStrategyId, next, { page: 1 }) } } const onTriggerDateRangeChange = (dates: [Dayjs | null, Dayjs | null] | null) => { const next = dates ?? [null, null] setTriggerDateRange(next) setTriggerPage(1) if (triggersStrategyId != null) { loadTriggerRecords(triggersStrategyId, triggerTab, { page: 1, dateRange: next }) } } const onTriggerPageChange = (page: number, pageSize: number) => { setTriggerPage(page) setTriggerPageSize(pageSize) if (triggersStrategyId != null) { loadTriggerRecords(triggersStrategyId, triggerTab, { page, pageSize }) } } const disabledTriggerEndDate = (current: Dayjs) => current && current.isAfter(dayjs(), 'day') const formatTimeWindow = (startSec: number, endSec: number, wrap = true): string => { const sm = Math.floor(startSec / 60) const ss = startSec % 60 const em = Math.floor(endSec / 60) const es = endSec % 60 const sep = wrap ? '\n~ ' : ' ~ ' return `${sm} ${t('cryptoTailStrategy.form.minute')} ${ss} ${t('cryptoTailStrategy.form.second')}${sep}${em} ${t('cryptoTailStrategy.form.minute')} ${es} ${t('cryptoTailStrategy.form.second')}` } const formatPriceRange = (minPrice: string, maxPrice: string): string => { const min = formatNumber(minPrice, 2) const max = formatNumber(maxPrice, 2) if (min === '' || max === '') return '-' return `${min} ~ ${max}` } const pnlColor = (value: string | number | null | undefined): string | undefined => { if (value == null || value === '') return undefined const num = typeof value === 'string' ? Number(value) : value if (Number.isNaN(num)) return undefined if (num > 0) return '#52c41a' if (num < 0) return '#ff4d4f' return undefined } const getAccountLabel = (accountId: number) => accounts.find((a) => a.id === accountId)?.accountName || `#${accountId}` const columns = [ { title: t('common.status'), dataIndex: 'enabled', key: 'enabled', width: 80, render: (enabled: boolean, record: CryptoTailStrategyDto) => ( handleToggle(record)} size="small" /> ) }, { title: t('cryptoTailStrategy.list.strategyName'), dataIndex: 'name', key: 'name', width: isMobile ? 100 : 160, render: (name: string | undefined, r: CryptoTailStrategyDto) => ( {name || (r.marketTitle ?? r.marketSlugPrefix) || '-'} ) }, { title: t('cryptoTailStrategy.list.account'), dataIndex: 'accountId', key: 'accountId', width: isMobile ? 90 : 120, render: (_: unknown, r: CryptoTailStrategyDto) => ( {getAccountLabel(r.accountId)} ) }, { title: t('cryptoTailStrategy.list.market'), key: 'market', width: isMobile ? 120 : 200, render: (_: unknown, r: CryptoTailStrategyDto) => ( {marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-'} ) }, { title: t('cryptoTailStrategy.list.timeWindow'), key: 'timeWindow', width: isMobile ? 100 : 120, render: (_: unknown, r: CryptoTailStrategyDto) => ( {formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds)} ) }, { title: t('cryptoTailStrategy.list.priceRange'), key: 'priceRange', width: isMobile ? 90 : 120, render: (_: unknown, r: CryptoTailStrategyDto) => ( {formatPriceRange(r.minPrice, r.maxPrice)} ) }, { title: t('cryptoTailStrategy.list.amountMode'), key: 'amountMode', width: isMobile ? 90 : 120, render: (_: unknown, r: CryptoTailStrategyDto) => ( {(r.amountMode?.toUpperCase() ?? '') === 'RATIO' ? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(r.amountValue, 2) || '0'}%` : `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)} USDC`} ) }, { title: t('cryptoTailStrategy.list.totalRealizedPnl'), key: 'totalRealizedPnl', width: isMobile ? 90 : 120, render: (_: unknown, r: CryptoTailStrategyDto) => { const text = r.totalRealizedPnl != null ? `${formatUSDC(r.totalRealizedPnl)} USDC` : '-' const color = pnlColor(r.totalRealizedPnl) return color ? ( {text} ) : ( {text} ) } }, { title: t('cryptoTailStrategy.list.winRate'), key: 'winRate', width: isMobile ? 70 : 90, render: (_: unknown, r: CryptoTailStrategyDto) => r.winRate != null ? ( {(Number(r.winRate) * 100).toFixed(1)}% ) : ( - ) }, { title: t('cryptoTailStrategy.list.actions'), key: 'actions', width: isMobile ? 120 : 200, fixed: 'right' as const, render: (_: unknown, record: CryptoTailStrategyDto) => ( handleDelete(record.id)} okText={t('common.confirm')} cancelText={t('common.cancel')} > ) } ] const selectedMarket = Form.useWatch('marketSlugPrefix', form) const intervalSeconds = marketOptions.find((m) => m.slug === selectedMarket)?.intervalSeconds ?? 300 const maxMinutes = Math.floor(intervalSeconds / 60) // 新建时:选择市场后,区间开始默认 0分0秒,区间结束默认 x分0秒(x=周期) useEffect(() => { if (!formModalOpen || editingId != null || !selectedMarket) return const intervalMin = Math.floor(intervalSeconds / 60) form.setFieldsValue({ windowStartMinutes: 0, windowStartSeconds: 0, windowEndMinutes: intervalMin, windowEndSeconds: 0 }) }, [formModalOpen, editingId, selectedMarket, intervalSeconds]) const getGuideUrl = () => { const { githubRepoUrl } = getVersionInfo() const lang = i18n.language === 'zh-CN' || i18n.language === 'zh-TW' ? 'zh' : 'en' return `${githubRepoUrl}/blob/main/docs/crypto-tail-strategy/${lang}/crypto-tail-strategy-user-guide.md` } return (

{t('cryptoTailStrategy.list.title')}

{binanceUnhealthy.length > 0 && list.some((s) => s.enabled) && ( } message={t('cryptoTailStrategy.binanceApiAlert.title')} description={

{t('cryptoTailStrategy.binanceApiAlert.description')}

    {binanceUnhealthy.map((item, i) => (
  • {item.name} {item.message ? `: ${item.message}` : ''}
  • ))}
} style={{ marginBottom: 16 }} /> )}
setFilters((f) => ({ ...f, enabled: en }))} value={filters.enabled} options={[ { label: t('common.enabled'), value: true }, { label: t('common.disabled'), value: false } ]} />
{isMobile ? (
{list.map((item) => ( {item.name || (item.marketTitle ?? item.marketSlugPrefix) || '-'} {t('cryptoTailStrategy.list.account')}: {getAccountLabel(item.accountId)} {marketOptions.find((m) => m.slug === item.marketSlugPrefix)?.title ?? item.marketSlugPrefix ?? '-'}
{t('cryptoTailStrategy.list.timeWindow')} {formatTimeWindow(item.windowStartSeconds, item.windowEndSeconds, false)} {t('cryptoTailStrategy.list.priceRange')} {formatPriceRange(item.minPrice, item.maxPrice)} {t('cryptoTailStrategy.list.amountMode')} {(item.amountMode?.toUpperCase() ?? '') === 'RATIO' ? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(item.amountValue, 2) || '0'}%` : `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(item.amountValue)} USDC`}
{t('cryptoTailStrategy.list.totalRealizedPnl')}:{' '} {item.totalRealizedPnl != null ? ( {formatUSDC(item.totalRealizedPnl)} USDC ) : ( - )} {item.winRate != null && ( <> · {t('cryptoTailStrategy.list.winRate')} {(Number(item.winRate) * 100).toFixed(1)}% )} handleToggle(item)} size="small" /> handleDelete(item.id)} okText={t('common.confirm')} cancelText={t('common.cancel')} >
))}
) : ( )} setRedeemModalOpen(false)} footer={[ , ]} >

{t('cryptoTailStrategy.redeemRequiredModal.description')}

setFormModalOpen(false)} onOk={handleFormSubmit} width={isMobile ? '100%' : 520} destroyOnClose >
({ label: `${i}`, value: i }))} /> {t('cryptoTailStrategy.form.minute')} ({ label: `${i}`, value: i }))} /> {t('cryptoTailStrategy.form.minute')}
}} columns={[ { title: t('cryptoTailStrategy.triggerRecords.triggerTime'), dataIndex: 'createdAt', key: 'createdAt', width: 172, render: (ts: number) => {new Date(ts).toLocaleString()} }, { title: t('cryptoTailStrategy.triggerRecords.direction'), dataIndex: 'outcomeIndex', key: 'outcomeIndex', width: 80, align: 'center', render: (i: number) => i === 0 ? ( {t('cryptoTailStrategy.triggerRecords.up')} ) : ( {t('cryptoTailStrategy.triggerRecords.down')} ) }, { title: t('cryptoTailStrategy.triggerRecords.triggerPrice'), dataIndex: 'triggerPrice', key: 'triggerPrice', width: 100, render: (v: string) => (formatNumber(v, 2) || '-') }, { title: t('cryptoTailStrategy.triggerRecords.amount'), dataIndex: 'amountUsdc', key: 'amountUsdc', width: 110, render: (v: string) => `${formatUSDC(v)} USDC` }, { title: t('cryptoTailStrategy.triggerRecords.realizedPnl'), dataIndex: 'realizedPnl', key: 'realizedPnl', width: 100, render: (v: string | undefined, r: CryptoTailStrategyTriggerDto) => { if (v == null || v === '') return r.resolved ? formatUSDC('0') : '-' const num = Number(v) const formatted = formatUSDC(String(Math.abs(num))) const text = num >= 0 ? `+${formatted}` : `-${formatted}` const color = pnlColor(v) return color ? {text} : text } } ]} pagination={{ current: triggerPage, pageSize: triggerPageSize, total: triggersTotal, showSizeChanger: true, showTotal: (total) => t('cryptoTailStrategy.triggerRecords.totalCount').replace('{count}', String(total)), onChange: onTriggerPageChange }} scroll={{ x: 540 }} /> ) }, { key: 'fail', label: t('cryptoTailStrategy.triggerRecords.failTab'), children: (
}} columns={[ { title: t('cryptoTailStrategy.triggerRecords.triggerTime'), dataIndex: 'createdAt', key: 'createdAt', width: 172, render: (ts: number) => {new Date(ts).toLocaleString()} }, { title: t('cryptoTailStrategy.triggerRecords.direction'), dataIndex: 'outcomeIndex', key: 'outcomeIndex', width: 80, align: 'center', render: (i: number) => i === 0 ? ( {t('cryptoTailStrategy.triggerRecords.up')} ) : ( {t('cryptoTailStrategy.triggerRecords.down')} ) }, { title: t('cryptoTailStrategy.triggerRecords.triggerPrice'), dataIndex: 'triggerPrice', key: 'triggerPrice', width: 100, render: (v: string) => (formatNumber(v, 2) || '-') }, { title: t('cryptoTailStrategy.triggerRecords.amount'), dataIndex: 'amountUsdc', key: 'amountUsdc', width: 110, render: (v: string) => `${formatUSDC(v)} USDC` }, { title: t('cryptoTailStrategy.triggerRecords.failReason'), dataIndex: 'failReason', key: 'failReason', ellipsis: true, render: (v: string | null | undefined) => { const text = v ?? '-' return text.length > 40 ? ( {text} ) : ( {text} ) } } ]} pagination={{ current: triggerPage, pageSize: triggerPageSize, total: triggersTotal, showSizeChanger: true, showTotal: (total) => t('cryptoTailStrategy.triggerRecords.totalCount').replace('{count}', String(total)), onChange: onTriggerPageChange }} scroll={{ x: 540 }} /> ) } ]} /> ) } export default CryptoTailStrategyList