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