feat(尾盘策略): 触发记录分页与时间筛选、策略列表与表单优化

触发记录:
- 成功/失败分 Tab,失败展示失败原因
- 分页与按天时间范围筛选(日历,结束日不超过今天)
- 后端 startDate/endDate、Repository 时间范围查询
- 弹窗美化:时间范围卡片、空状态、分页总数;移除订单 ID 列
- 查看触发记录按钮文案改为「订单」

策略列表:
- 状态列恢复为 Switch,PC 端放在第一列
- 新增账户列;移除最近触发列
- 文案换行(wordBreak/whiteSpace);时间区间 PC 换行、移动端单行
- 时间区间列宽缩小;移动端 Switch 在底部操作区
- 编辑时账户不可修改;策略名称 placeholder 改为「选填,留空将自动生成」
- 多语言:account、viewTriggers、strategyNamePlaceholder 等

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WrBug
2026-02-15 01:41:53 +08:00
parent a64d1edbf4
commit 0f50939625
8 changed files with 463 additions and 165 deletions
@@ -93,12 +93,16 @@ data class CryptoTailStrategyDeleteRequest(
/**
* 触发记录列表请求
* @param startDate 开始日期(当天 00:00:00.000 的时间戳毫秒),为 null 表示不限制
* @param endDate 结束日期(当天 23:59:59.999 的时间戳毫秒),为 null 表示不限制
*/
data class CryptoTailStrategyTriggerListRequest(
val strategyId: Long = 0L,
val page: Int = 1,
val pageSize: Int = 20,
val status: String? = null
val status: String? = null,
val startDate: Long? = null,
val endDate: Long? = null
)
/**
@@ -15,6 +15,11 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategy
fun findAllByStrategyIdAndStatusOrderByCreatedAtDesc(strategyId: Long, status: String, pageable: Pageable): Page<CryptoTailStrategyTrigger>
fun countByStrategyIdAndStatus(strategyId: Long, status: String): Long
fun findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, startInclusive: Long, endInclusive: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
fun findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, status: String, startInclusive: Long, endInclusive: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
fun countByStrategyIdAndCreatedAtBetween(strategyId: Long, startInclusive: Long, endInclusive: Long): Long
fun countByStrategyIdAndStatusAndCreatedAtBetween(strategyId: Long, status: String, startInclusive: Long, endInclusive: Long): Long
/** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */
fun findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
@@ -199,16 +199,33 @@ class CryptoTailStrategyService(
fun getTriggerRecords(request: CryptoTailStrategyTriggerListRequest): Result<CryptoTailStrategyTriggerListResponse> {
return try {
val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100))
val pageResult = if (request.status != null && request.status.isNotBlank()) {
triggerRepository.findAllByStrategyIdAndStatusOrderByCreatedAtDesc(request.strategyId, request.status, page)
} else {
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(request.strategyId, page)
val startTs = request.startDate ?: 0L
val endTs = request.endDate ?: Long.MAX_VALUE
val useTimeRange = request.startDate != null || request.endDate != null
val pageResult = when {
useTimeRange && request.status != null && request.status.isNotBlank() ->
triggerRepository.findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(
request.strategyId, request.status, startTs, endTs, page
)
useTimeRange ->
triggerRepository.findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(
request.strategyId, startTs, endTs, page
)
request.status != null && request.status.isNotBlank() ->
triggerRepository.findAllByStrategyIdAndStatusOrderByCreatedAtDesc(request.strategyId, request.status, page)
else ->
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(request.strategyId, page)
}
val list = pageResult.content.map { triggerToDto(it) }
val total = if (request.status != null && request.status.isNotBlank()) {
triggerRepository.countByStrategyIdAndStatus(request.strategyId, request.status)
} else {
pageResult.totalElements
val total = when {
useTimeRange && request.status != null && request.status.isNotBlank() ->
triggerRepository.countByStrategyIdAndStatusAndCreatedAtBetween(request.strategyId, request.status, startTs, endTs)
useTimeRange ->
triggerRepository.countByStrategyIdAndCreatedAtBetween(request.strategyId, startTs, endTs)
request.status != null && request.status.isNotBlank() ->
triggerRepository.countByStrategyIdAndStatus(request.strategyId, request.status)
else ->
pageResult.totalElements
}
Result.success(CryptoTailStrategyTriggerListResponse(list = list, total = total))
} catch (e: Exception) {
+13 -4
View File
@@ -1412,13 +1412,13 @@
"walletTip": "Use a dedicated wallet for tail strategy. Do not use it for manual trading or copy trading to avoid balance/position issues.",
"addStrategy": "Add Strategy",
"strategyName": "Strategy Name",
"account": "Account",
"market": "Market",
"timeWindow": "Time Window",
"priceRange": "Price Range",
"amountMode": "Amount Mode",
"ratio": "Ratio",
"fixed": "Fixed",
"recentTrigger": "Last Trigger",
"totalRealizedPnl": "Total PnL",
"winRate": "Win Rate",
"actions": "Actions",
@@ -1426,14 +1426,14 @@
"enable": "Enable",
"disable": "Disable",
"delete": "Delete",
"viewTriggers": "Trigger Records",
"viewTriggers": "Orders",
"deleteConfirm": "Delete this strategy?",
"fetchFailed": "Failed to fetch list"
},
"form": {
"walletTip": "Use a dedicated wallet to avoid balance or order issues.",
"strategyName": "Strategy Name",
"strategyNamePlaceholder": "Auto",
"strategyNamePlaceholder": "Optional, leave blank to auto-generate",
"selectAccount": "Select Account",
"selectMarket": "Select Market",
"timeWindowStart": "Window Start",
@@ -1467,6 +1467,11 @@
},
"triggerRecords": {
"title": "Trigger Records",
"timeRange": "Time Range",
"startDate": "Start Date",
"endDate": "End Date",
"successTab": "Success",
"failTab": "Failed",
"triggerTime": "Time",
"market": "Market",
"direction": "Direction",
@@ -1478,8 +1483,12 @@
"status": "Status",
"success": "Success",
"fail": "Fail",
"failReason": "Failure Reason",
"realizedPnl": "PnL",
"resolved": "Settled"
"resolved": "Settled",
"emptySuccess": "No success records",
"emptyFail": "No failed records",
"totalCount": "{count} record(s)"
}
}
}
+13 -4
View File
@@ -1411,13 +1411,13 @@
"walletTip": "请使用单独的钱包运行尾盘策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。",
"addStrategy": "新增策略",
"strategyName": "策略名称",
"account": "账户",
"market": "关联市场",
"timeWindow": "时间区间",
"priceRange": "价格区间",
"amountMode": "投入方式",
"ratio": "比例",
"fixed": "固定金额",
"recentTrigger": "最近触发",
"totalRealizedPnl": "总收益",
"winRate": "胜率",
"actions": "操作",
@@ -1425,14 +1425,14 @@
"enable": "启用",
"disable": "停用",
"delete": "删除",
"viewTriggers": "查看触发记录",
"viewTriggers": "订单",
"deleteConfirm": "确定删除该策略?",
"fetchFailed": "获取列表失败"
},
"form": {
"walletTip": "建议使用专用钱包,避免手动操作等导致余额或下单异常。",
"strategyName": "策略名称",
"strategyNamePlaceholder": "自动生成",
"strategyNamePlaceholder": "选填,留空将自动生成",
"selectAccount": "选择账户",
"selectMarket": "选择市场",
"timeWindowStart": "区间开始",
@@ -1466,6 +1466,11 @@
},
"triggerRecords": {
"title": "触发记录",
"timeRange": "时间范围",
"startDate": "开始日期",
"endDate": "结束日期",
"successTab": "成功",
"failTab": "失败",
"triggerTime": "触发时间",
"market": "市场",
"direction": "方向",
@@ -1477,8 +1482,12 @@
"status": "状态",
"success": "成功",
"fail": "失败",
"failReason": "失败原因",
"realizedPnl": "收益",
"resolved": "已结算"
"resolved": "已结算",
"emptySuccess": "暂无成功记录",
"emptyFail": "暂无失败记录",
"totalCount": "共 {count} 条"
}
}
}
+13 -4
View File
@@ -1412,13 +1412,13 @@
"walletTip": "請使用單獨的錢包運行尾盤策略,避免該錢包用於手動交易、跟單等其他操作,否則可能導致餘額或倉位變化,造成策略執行異常。",
"addStrategy": "新增策略",
"strategyName": "策略名稱",
"account": "賬戶",
"market": "關聯市場",
"timeWindow": "時間區間",
"priceRange": "價格區間",
"amountMode": "投入方式",
"ratio": "比例",
"fixed": "固定金額",
"recentTrigger": "最近觸發",
"totalRealizedPnl": "總收益",
"winRate": "勝率",
"actions": "操作",
@@ -1426,14 +1426,14 @@
"enable": "啟用",
"disable": "停用",
"delete": "刪除",
"viewTriggers": "查看觸發記錄",
"viewTriggers": "訂單",
"deleteConfirm": "確定刪除該策略?",
"fetchFailed": "獲取列表失敗"
},
"form": {
"walletTip": "建議使用專用錢包,避免手動操作等導致餘額或下單異常。",
"strategyName": "策略名稱",
"strategyNamePlaceholder": "自動生成",
"strategyNamePlaceholder": "選填,留空將自動生成",
"selectAccount": "選擇賬戶",
"selectMarket": "選擇市場",
"timeWindowStart": "區間開始",
@@ -1467,6 +1467,11 @@
},
"triggerRecords": {
"title": "觸發記錄",
"timeRange": "時間範圍",
"startDate": "開始日期",
"endDate": "結束日期",
"successTab": "成功",
"failTab": "失敗",
"triggerTime": "觸發時間",
"market": "市場",
"direction": "方向",
@@ -1478,8 +1483,12 @@
"status": "狀態",
"success": "成功",
"fail": "失敗",
"failReason": "失敗原因",
"realizedPnl": "收益",
"resolved": "已結算"
"resolved": "已結算",
"emptySuccess": "暫無成功記錄",
"emptyFail": "暫無失敗記錄",
"totalCount": "共 {count} 條"
}
}
}
+381 -143
View File
@@ -17,9 +17,16 @@ import {
InputNumber,
Radio,
Spin,
Tooltip
Tooltip,
Tabs,
DatePicker,
Empty,
Typography,
Divider
} from 'antd'
import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined } from '@ant-design/icons'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
@@ -41,9 +48,13 @@ const CryptoTailStrategyList: React.FC = () => {
const [editingId, setEditingId] = useState<number | null>(null)
const [marketOptions, setMarketOptions] = useState<CryptoTailMarketOptionDto[]>([])
const [triggersModalOpen, setTriggersModalOpen] = useState(false)
const [, setTriggersStrategyId] = useState<number | null>(null)
const [triggersStrategyId, setTriggersStrategyId] = useState<number | null>(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<CryptoTailStrategyTriggerDto[]>([])
const [, setTriggersTotal] = useState(0)
const [triggersTotal, setTriggersTotal] = useState(0)
const [triggersLoading, setTriggersLoading] = useState(false)
const [form] = Form.useForm()
@@ -269,12 +280,30 @@ const CryptoTailStrategyList: React.FC = () => {
}
}
const openTriggers = async (strategyId: number) => {
setTriggersStrategyId(strategyId)
setTriggersModalOpen(true)
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: 1, pageSize: 50 })
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)
@@ -284,18 +313,52 @@ const CryptoTailStrategyList: React.FC = () => {
}
}
const formatTimeWindow = (startSec: number, endSec: number): string => {
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
return `${sm} ${t('cryptoTailStrategy.form.minute')} ${ss} ${t('cryptoTailStrategy.form.second')} ~ ${em} ${t('cryptoTailStrategy.form.minute')} ${es} ${t('cryptoTailStrategy.form.second')}`
}
const formatLastTrigger = (ts?: number) => {
if (ts == null) return '-'
const d = new Date(ts)
return d.toLocaleString()
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 => {
@@ -314,79 +377,110 @@ const CryptoTailStrategyList: React.FC = () => {
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) => (
<Switch
checked={enabled}
onChange={() => handleToggle(record)}
size="small"
/>
)
},
{
title: t('cryptoTailStrategy.list.strategyName'),
dataIndex: 'name',
key: 'name',
width: isMobile ? 100 : 140,
render: (name: string | undefined, r: CryptoTailStrategyDto) => name || (r.marketTitle ?? r.marketSlugPrefix) || '-'
width: isMobile ? 100 : 160,
render: (name: string | undefined, r: CryptoTailStrategyDto) => (
<Typography.Text strong style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
{name || (r.marketTitle ?? r.marketSlugPrefix) || '-'}
</Typography.Text>
)
},
{
title: t('cryptoTailStrategy.list.account'),
dataIndex: 'accountId',
key: 'accountId',
width: isMobile ? 90 : 120,
render: (_: unknown, r: CryptoTailStrategyDto) => (
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
{getAccountLabel(r.accountId)}
</Typography.Text>
)
},
{
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 ?? '-'
render: (_: unknown, r: CryptoTailStrategyDto) => (
<Typography.Text style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
{marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-'}
</Typography.Text>
)
},
{
title: t('cryptoTailStrategy.list.timeWindow'),
key: 'timeWindow',
width: isMobile ? 140 : 180,
render: (_: unknown, r: CryptoTailStrategyDto) => formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds)
width: isMobile ? 100 : 120,
render: (_: unknown, r: CryptoTailStrategyDto) => (
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'pre-line' }}>
{formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds)}
</Typography.Text>
)
},
{
title: t('cryptoTailStrategy.list.priceRange'),
key: 'priceRange',
width: isMobile ? 90 : 120,
render: (_: unknown, r: CryptoTailStrategyDto) => formatPriceRange(r.minPrice, r.maxPrice)
render: (_: unknown, r: CryptoTailStrategyDto) => (
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
{formatPriceRange(r.minPrice, r.maxPrice)}
</Typography.Text>
)
},
{
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('common.status'),
dataIndex: 'enabled',
key: 'enabled',
width: 90,
render: (enabled: boolean, record: CryptoTailStrategyDto) => (
<Switch
checked={enabled}
onChange={() => handleToggle(record)}
checkedChildren={t('cryptoTailStrategy.list.enable')}
unCheckedChildren={t('cryptoTailStrategy.list.disable')}
/>
render: (_: unknown, r: CryptoTailStrategyDto) => (
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
{(r.amountMode?.toUpperCase() ?? '') === 'RATIO'
? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(r.amountValue, 2) || '0'}%`
: `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)} USDC`}
</Typography.Text>
)
},
{
title: t('cryptoTailStrategy.list.recentTrigger'),
dataIndex: 'lastTriggerAt',
key: 'lastTriggerAt',
width: isMobile ? 100 : 160,
render: (ts: number | undefined) => formatLastTrigger(ts)
},
{
title: t('cryptoTailStrategy.list.totalRealizedPnl'),
key: 'totalRealizedPnl',
width: isMobile ? 90 : 110,
width: isMobile ? 90 : 120,
render: (_: unknown, r: CryptoTailStrategyDto) => {
const text = r.totalRealizedPnl != null ? `${formatUSDC(r.totalRealizedPnl)} USDC` : '-'
const color = pnlColor(r.totalRealizedPnl)
return color ? <span style={{ color }}>{text}</span> : text
return color ? (
<Typography.Text style={{ color, fontWeight: 500 }}>{text}</Typography.Text>
) : (
<Typography.Text type="secondary">{text}</Typography.Text>
)
}
},
{
title: t('cryptoTailStrategy.list.winRate'),
key: 'winRate',
width: isMobile ? 70 : 80,
width: isMobile ? 70 : 90,
render: (_: unknown, r: CryptoTailStrategyDto) =>
r.winRate != null ? `${(Number(r.winRate) * 100).toFixed(1)}%` : '-'
r.winRate != null ? (
<Tag color="blue">{(Number(r.winRate) * 100).toFixed(1)}%</Tag>
) : (
<Typography.Text type="secondary">-</Typography.Text>
)
},
{
title: t('cryptoTailStrategy.list.actions'),
@@ -504,45 +598,71 @@ const CryptoTailStrategyList: React.FC = () => {
</div>
<Spin spinning={loading}>
{isMobile ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{list.map((item) => (
<Card key={item.id} size="small">
<div style={{ marginBottom: 8 }}>
<strong>{item.name || item.marketSlugPrefix || '-'}</strong>
<Card
key={item.id}
size="small"
styles={{ body: { padding: 16 } }}
style={{ borderLeft: `3px solid ${item.enabled ? 'var(--ant-colorSuccess)' : 'var(--ant-colorBorder)'}` }}
>
<Typography.Text strong style={{ fontSize: 15, wordBreak: 'break-word', whiteSpace: 'normal', display: 'block', marginBottom: 8 }}>
{item.name || (item.marketTitle ?? item.marketSlugPrefix) || '-'}
</Typography.Text>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 4, fontSize: 13, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{t('cryptoTailStrategy.list.account')}: {getAccountLabel(item.accountId)}
</Typography.Text>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8, fontSize: 13, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{marketOptions.find((m) => m.slug === item.marketSlugPrefix)?.title ?? item.marketSlugPrefix ?? '-'}
</Typography.Text>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 16px', marginBottom: 12 }}>
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{t('cryptoTailStrategy.list.timeWindow')}
</Typography.Text>
<Typography.Text style={{ fontSize: 12 }}>{formatTimeWindow(item.windowStartSeconds, item.windowEndSeconds, false)}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{t('cryptoTailStrategy.list.priceRange')}
</Typography.Text>
<Typography.Text style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>{formatPriceRange(item.minPrice, item.maxPrice)}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{t('cryptoTailStrategy.list.amountMode')}
</Typography.Text>
<Typography.Text style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{(item.amountMode?.toUpperCase() ?? '') === 'RATIO'
? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(item.amountValue, 2) || '0'}%`
: `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(item.amountValue)} USDC`}
</Typography.Text>
</div>
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
{t('cryptoTailStrategy.list.timeWindow')}: {formatTimeWindow(item.windowStartSeconds, item.windowEndSeconds)}
</div>
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
{t('cryptoTailStrategy.list.priceRange')}: {formatPriceRange(item.minPrice, item.maxPrice)}
</div>
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
{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`}
</div>
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
{t('cryptoTailStrategy.list.totalRealizedPnl')}:{' '}
<Divider style={{ margin: '10px 0' }} />
<Space wrap style={{ marginBottom: 12 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('cryptoTailStrategy.list.totalRealizedPnl')}:{' '}
</Typography.Text>
{item.totalRealizedPnl != null ? (
<span style={{ color: pnlColor(item.totalRealizedPnl) ?? '#666' }}>
<Typography.Text style={{ color: pnlColor(item.totalRealizedPnl) ?? undefined, fontWeight: 500, fontSize: 12 }}>
{formatUSDC(item.totalRealizedPnl)} USDC
</span>
</Typography.Text>
) : (
'-'
<Typography.Text type="secondary" style={{ fontSize: 12 }}>-</Typography.Text>
)}
{item.winRate != null ? ` · ${t('cryptoTailStrategy.list.winRate')}: ${(Number(item.winRate) * 100).toFixed(1)}%` : ''}
</div>
<Space>
{item.winRate != null && (
<>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>·</Typography.Text>
<Tag color="blue" style={{ margin: 0 }}>{t('cryptoTailStrategy.list.winRate')} {(Number(item.winRate) * 100).toFixed(1)}%</Tag>
</>
)}
</Space>
<Divider style={{ margin: '10px 0' }} />
<Space wrap size="small">
<Switch
checked={item.enabled}
onChange={() => handleToggle(item)}
size="small"
/>
<Button type="link" size="small" onClick={() => openEditModal(item)}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(item)}>
{t('cryptoTailStrategy.list.edit')}
</Button>
<Button type="link" size="small" onClick={() => openTriggers(item.id)}>
<Button type="link" size="small" icon={<UnorderedListOutlined />} onClick={() => openTriggers(item.id)}>
{t('cryptoTailStrategy.list.viewTriggers')}
</Button>
<Popconfirm
@@ -608,6 +728,7 @@ const CryptoTailStrategyList: React.FC = () => {
<Select
placeholder={t('cryptoTailStrategy.form.selectAccount')}
options={accounts.map((a) => ({ label: a.accountName || `#${a.id}`, value: a.id }))}
disabled={!!editingId}
/>
</Form.Item>
<Form.Item name="name" label={t('cryptoTailStrategy.form.strategyName')}>
@@ -741,76 +862,193 @@ const CryptoTailStrategyList: React.FC = () => {
</Modal>
<Modal
title={t('cryptoTailStrategy.triggerRecords.title')}
title={
<Space>
<UnorderedListOutlined style={{ fontSize: 18, color: 'var(--ant-colorPrimary)' }} />
<span>{t('cryptoTailStrategy.triggerRecords.title')}</span>
</Space>
}
open={triggersModalOpen}
onCancel={() => setTriggersModalOpen(false)}
footer={null}
width={Math.min(800, window.innerWidth - 48)}
width={Math.min(900, window.innerWidth - 48)}
styles={{ body: { paddingTop: 16 } }}
>
<Spin spinning={triggersLoading}>
<Table
rowKey="id"
size="small"
dataSource={triggers}
columns={[
{
title: t('cryptoTailStrategy.triggerRecords.triggerTime'),
dataIndex: 'createdAt',
key: 'createdAt',
render: (ts: number) => new Date(ts).toLocaleString()
},
{
title: t('cryptoTailStrategy.triggerRecords.direction'),
dataIndex: 'outcomeIndex',
key: 'outcomeIndex',
render: (i: number) => (i === 0 ? t('cryptoTailStrategy.triggerRecords.up') : t('cryptoTailStrategy.triggerRecords.down'))
},
{
title: t('cryptoTailStrategy.triggerRecords.triggerPrice'),
dataIndex: 'triggerPrice',
key: 'triggerPrice',
render: (v: string) => (formatNumber(v, 2) || '-')
},
{
title: t('cryptoTailStrategy.triggerRecords.amount'),
dataIndex: 'amountUsdc',
key: 'amountUsdc',
render: (v: string) => `${formatUSDC(v)} USDC`
},
{
title: t('cryptoTailStrategy.triggerRecords.orderId'),
dataIndex: 'orderId',
key: 'orderId',
ellipsis: true
},
{
title: t('cryptoTailStrategy.triggerRecords.status'),
dataIndex: 'status',
key: 'status',
render: (s: string) => (
<Tag color={s === 'success' ? 'green' : 'red'}>
{s === 'success' ? t('cryptoTailStrategy.triggerRecords.success') : t('cryptoTailStrategy.triggerRecords.fail')}
</Tag>
)
},
{
title: t('cryptoTailStrategy.triggerRecords.realizedPnl'),
dataIndex: 'realizedPnl',
key: 'realizedPnl',
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 ? <span style={{ color }}>{text}</span> : text
}
}
]}
pagination={false}
scroll={{ x: 600 }}
/>
</Spin>
<Card size="small" style={{ marginBottom: 16, background: 'var(--ant-colorFillQuaternary)' }}>
<Space wrap align="center" size="middle">
<Space align="center">
<CalendarOutlined style={{ color: 'var(--ant-colorTextSecondary)' }} />
<Typography.Text type="secondary">{t('cryptoTailStrategy.triggerRecords.timeRange')}</Typography.Text>
</Space>
<DatePicker.RangePicker
value={triggerDateRange}
onChange={onTriggerDateRangeChange}
format="YYYY-MM-DD"
placeholder={[t('cryptoTailStrategy.triggerRecords.startDate'), t('cryptoTailStrategy.triggerRecords.endDate')]}
allowClear
disabledDate={disabledTriggerEndDate}
style={{ minWidth: 240 }}
/>
</Space>
</Card>
<Tabs
activeKey={triggerTab}
onChange={onTriggerTabChange}
items={[
{
key: 'success',
label: t('cryptoTailStrategy.triggerRecords.successTab'),
children: (
<Spin spinning={triggersLoading}>
<Table
rowKey="id"
size="small"
dataSource={triggerTab === 'success' ? triggers : []}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('cryptoTailStrategy.triggerRecords.emptySuccess')} />
}}
columns={[
{
title: t('cryptoTailStrategy.triggerRecords.triggerTime'),
dataIndex: 'createdAt',
key: 'createdAt',
width: 172,
render: (ts: number) => <Typography.Text>{new Date(ts).toLocaleString()}</Typography.Text>
},
{
title: t('cryptoTailStrategy.triggerRecords.direction'),
dataIndex: 'outcomeIndex',
key: 'outcomeIndex',
width: 80,
align: 'center',
render: (i: number) =>
i === 0 ? (
<Tag icon={<ArrowUpOutlined />} color="green">{t('cryptoTailStrategy.triggerRecords.up')}</Tag>
) : (
<Tag icon={<ArrowDownOutlined />} color="volcano">{t('cryptoTailStrategy.triggerRecords.down')}</Tag>
)
},
{
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 ? <Typography.Text style={{ color, fontWeight: 500 }}>{text}</Typography.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 }}
/>
</Spin>
)
},
{
key: 'fail',
label: t('cryptoTailStrategy.triggerRecords.failTab'),
children: (
<Spin spinning={triggersLoading}>
<Table
rowKey="id"
size="small"
dataSource={triggerTab === 'fail' ? triggers : []}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('cryptoTailStrategy.triggerRecords.emptyFail')} />
}}
columns={[
{
title: t('cryptoTailStrategy.triggerRecords.triggerTime'),
dataIndex: 'createdAt',
key: 'createdAt',
width: 172,
render: (ts: number) => <Typography.Text>{new Date(ts).toLocaleString()}</Typography.Text>
},
{
title: t('cryptoTailStrategy.triggerRecords.direction'),
dataIndex: 'outcomeIndex',
key: 'outcomeIndex',
width: 80,
align: 'center',
render: (i: number) =>
i === 0 ? (
<Tag icon={<ArrowUpOutlined />} color="green">{t('cryptoTailStrategy.triggerRecords.up')}</Tag>
) : (
<Tag icon={<ArrowDownOutlined />} color="volcano">{t('cryptoTailStrategy.triggerRecords.down')}</Tag>
)
},
{
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 ? (
<Tooltip title={text}>
<Typography.Text ellipsis style={{ maxWidth: 280 }}>{text}</Typography.Text>
</Tooltip>
) : (
<Typography.Text type={text === '-' ? 'secondary' : undefined}>{text}</Typography.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 }}
/>
</Spin>
)
}
]}
/>
</Modal>
</div>
)
+8 -1
View File
@@ -468,7 +468,14 @@ export const apiService = {
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/update', data),
delete: (data: { strategyId: number }) =>
apiClient.post<ApiResponse<void>>('/crypto-tail-strategy/delete', data),
triggers: (data: { strategyId: number; page?: number; pageSize?: number; status?: string }) =>
triggers: (data: {
strategyId: number
page?: number
pageSize?: number
status?: string
startDate?: number
endDate?: number
}) =>
apiClient.post<ApiResponse<{ list: import('../types').CryptoTailStrategyTriggerDto[]; total: number }>>('/crypto-tail-strategy/triggers', data),
marketOptions: () =>
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}),