Files
DinQuant/quantdinger_vue/src/views/trading-assistant/index.vue
T

4724 lines
171 KiB
Vue
Raw Normal View History

2025-12-29 03:06:49 +08:00
<template>
<div class="trading-assistant" :class="{ 'theme-dark': isDarkTheme }">
<a-row :gutter="24" class="strategy-layout">
<!-- 左侧策略列表 -->
<a-col
:xs="24"
:sm="24"
:md="10"
:lg="8"
:xl="8"
class="strategy-list-col">
<a-card :bordered="false" class="strategy-list-card">
<div slot="title" class="card-title">
<span>{{ $t('trading-assistant.strategyList') }}</span>
<a-button type="primary" size="small" @click="handleCreateStrategy">
<a-icon type="plus" />
{{ $t('trading-assistant.createStrategy') }}
</a-button>
</div>
<a-spin :spinning="loading">
<a-empty v-if="!loading && strategies.length === 0" :description="$t('trading-assistant.noStrategy')" />
<div v-else class="strategy-grouped-list">
<!-- 策略组列表 -->
<div
v-for="group in groupedStrategies.groups"
:key="group.id"
class="strategy-group"
>
<!-- 策略组头部 -->
<div class="strategy-group-header" @click="toggleGroup(group.id)">
<div class="group-header-left">
<a-icon :type="collapsedGroups[group.id] ? 'right' : 'down'" class="collapse-icon" />
<a-icon type="folder" class="group-icon" />
<span class="group-name">{{ group.baseName }}</span>
<a-tag size="small" color="blue">{{ group.strategies.length }} {{ $t('trading-assistant.symbolCount') }}</a-tag>
</div>
<div class="group-header-right" @click.stop>
<span v-if="group.runningCount > 0" class="group-status running">
{{ group.runningCount }} {{ $t('trading-assistant.status.running') }}
</span>
<span v-if="group.stoppedCount > 0" class="group-status stopped">
{{ group.stoppedCount }} {{ $t('trading-assistant.status.stopped') }}
</span>
<a-dropdown :getPopupContainer="getDropdownContainer" :trigger="['click']">
<a-menu slot="overlay" @click="({ key }) => handleGroupMenuClick(key, group)">
<a-menu-item key="startAll">
<a-icon type="play-circle" />
{{ $t('trading-assistant.startAll') }}
</a-menu-item>
<a-menu-item key="stopAll">
<a-icon type="pause-circle" />
{{ $t('trading-assistant.stopAll') }}
</a-menu-item>
<a-menu-divider />
<a-menu-item key="deleteAll" class="danger-item">
<a-icon type="delete" />
{{ $t('trading-assistant.deleteAll') }}
</a-menu-item>
</a-menu>
<a-button type="link" icon="more" size="small" />
</a-dropdown>
</div>
</div>
<!-- 策略组内的策略列表可折叠 -->
<div v-show="!collapsedGroups[group.id]" class="strategy-group-content">
<div
v-for="item in group.strategies"
:key="item.id"
:class="['strategy-list-item', { active: selectedStrategy && selectedStrategy.id === item.id }]"
@click="handleSelectStrategy(item)"
>
<div class="strategy-item-content">
<div class="strategy-item-header">
<div class="strategy-name-wrapper">
<span class="info-item" v-if="item.trading_config && item.trading_config.symbol">
<a-icon type="dollar" />
{{ item.trading_config.symbol }}
</span>
<span
class="status-label"
:class="[
item.status ? `status-${item.status}` : '',
{ 'status-stopped': item.status === 'stopped' }
]"
>
{{ getStatusText(item.status) }}
</span>
</div>
</div>
</div>
<div class="strategy-item-actions" @click.stop>
<a-dropdown :getPopupContainer="getDropdownContainer" :trigger="['click']">
<a-menu slot="overlay" @click="({ key }) => handleMenuClick(key, item)">
<a-menu-item v-if="item.status === 'stopped'" key="start">
<a-icon type="play-circle" />
{{ $t('trading-assistant.startStrategy') }}
</a-menu-item>
<a-menu-item v-if="item.status === 'running'" key="stop">
<a-icon type="pause-circle" />
{{ $t('trading-assistant.stopStrategy') }}
</a-menu-item>
<a-menu-divider />
<a-menu-item key="edit">
<a-icon type="edit" />
{{ $t('trading-assistant.editStrategy') }}
</a-menu-item>
<a-menu-divider />
<a-menu-item key="delete" class="danger-item">
<a-icon type="delete" />
{{ $t('trading-assistant.deleteStrategy') }}
</a-menu-item>
</a-menu>
<a-button type="link" icon="more" size="small" />
</a-dropdown>
</div>
</div>
</div>
</div>
<!-- 未分组的策略列表 -->
<div
v-for="item in groupedStrategies.ungrouped"
:key="item.id"
2025-12-29 03:06:49 +08:00
:class="['strategy-list-item', { active: selectedStrategy && selectedStrategy.id === item.id }]"
@click="handleSelectStrategy(item)"
>
<div class="strategy-item-content">
<div class="strategy-item-header">
<div class="strategy-name-wrapper">
<a-tag
v-if="item.exchange_config && item.exchange_config.exchange_id"
:color="getExchangeTagColor(item.exchange_config.exchange_id)"
size="small"
class="exchange-tag"
2025-12-29 03:06:49 +08:00
>
<a-icon type="bank" style="margin-right: 4px;" />
{{ getExchangeDisplayName(item.exchange_config.exchange_id) }}
</a-tag>
<span class="strategy-name">{{ item.strategy_name }}</span>
<a-tag
v-if="item.strategy_type === 'PromptBasedStrategy'"
color="purple"
size="small"
class="strategy-type-tag"
>
<a-icon type="robot" style="margin-right: 2px;" />
AI
</a-tag>
2025-12-29 03:06:49 +08:00
</div>
</div>
<div class="strategy-item-info">
<span class="info-item" v-if="item.trading_config && item.trading_config.symbol">
<a-icon type="dollar" />
{{ item.trading_config.symbol }}
</span>
<span
class="status-label"
:class="[
item.status ? `status-${item.status}` : '',
{ 'status-stopped': item.status === 'stopped' }
]"
>
{{ getStatusText(item.status) }}
</span>
</div>
</div>
<div class="strategy-item-actions" @click.stop>
<a-dropdown :getPopupContainer="getDropdownContainer" :trigger="['click']">
2025-12-29 03:06:49 +08:00
<a-menu slot="overlay" @click="({ key }) => handleMenuClick(key, item)">
<a-menu-item v-if="item.status === 'stopped'" key="start">
<a-icon type="play-circle" />
{{ $t('trading-assistant.startStrategy') }}
</a-menu-item>
<a-menu-item v-if="item.status === 'running'" key="stop">
<a-icon type="pause-circle" />
{{ $t('trading-assistant.stopStrategy') }}
</a-menu-item>
<a-menu-divider />
<a-menu-item key="edit">
<a-icon type="edit" />
{{ $t('trading-assistant.editStrategy') }}
</a-menu-item>
<a-menu-divider />
<a-menu-item key="delete" class="danger-item">
<a-icon type="delete" />
{{ $t('trading-assistant.deleteStrategy') }}
</a-menu-item>
</a-menu>
<a-button type="link" icon="more" size="small" />
</a-dropdown>
</div>
</div>
</div>
2025-12-29 03:06:49 +08:00
</a-spin>
</a-card>
</a-col>
<!-- 右侧策略详情和交易记录 -->
<a-col
:xs="24"
:sm="24"
:md="14"
:lg="16"
:xl="16"
class="strategy-detail-col">
<div v-if="!selectedStrategy" class="empty-detail">
<a-empty :description="$t('trading-assistant.selectStrategy')" />
</div>
<div v-else class="strategy-detail-panel">
<!-- 策略头部信息 -->
<a-card :bordered="false" class="strategy-header-card">
<div class="strategy-header">
<div class="header-left">
2025-12-29 19:05:17 +08:00
<div class="strategy-title-row">
<h3 class="strategy-title">{{ selectedStrategy.strategy_name }}</h3>
<div class="status-badge" :class="[`status-${selectedStrategy.status}`]">
<span class="status-dot"></span>
2025-12-29 03:06:49 +08:00
{{ getStatusText(selectedStrategy.status) }}
2025-12-29 19:05:17 +08:00
</div>
</div>
<!-- 关键数据卡片 -->
<div class="key-stats-grid">
<div class="stat-card" v-if="selectedStrategy.initial_capital || (selectedStrategy.trading_config && selectedStrategy.trading_config.initial_capital)">
<div class="stat-icon investment">
<a-icon type="wallet" />
</div>
<div class="stat-content">
<div class="stat-label">{{ $t('trading-assistant.detail.totalInvestment') }}</div>
<div class="stat-value">${{ ((selectedStrategy.initial_capital || selectedStrategy.trading_config?.initial_capital) || 0).toLocaleString() }}</div>
</div>
</div>
<div class="stat-card" v-if="currentEquity !== null">
<div class="stat-icon equity">
<a-icon type="fund" />
</div>
<div class="stat-content">
<div class="stat-label">{{ $t('trading-assistant.detail.currentEquity') }}</div>
<div class="stat-value" :class="getEquityColorClass">{{ formatCurrency(currentEquity) }}</div>
</div>
</div>
<div class="stat-card pnl-card" v-if="totalPnl !== null" :class="{ 'profit': totalPnl > 0, 'loss': totalPnl < 0 }">
<div class="stat-icon pnl">
<a-icon :type="totalPnl >= 0 ? 'rise' : 'fall'" />
</div>
<div class="stat-content">
<div class="stat-label">{{ $t('trading-assistant.detail.totalPnl') }}</div>
<div class="stat-value" :class="getPnlColorClass">
{{ formatPnl(totalPnl) }}
<span class="pnl-percent">({{ formatPnlPercent(totalPnlPercent) }})</span>
</div>
</div>
</div>
</div>
<!-- 策略详情标签 -->
<div class="strategy-tags">
<div class="tag-item" v-if="selectedStrategy.trading_config">
<a-icon type="stock" />
<span>{{ selectedStrategy.trading_config.symbol }}</span>
</div>
<div class="tag-item" v-if="selectedStrategy.indicator_config && selectedStrategy.indicator_config.indicator_name">
2025-12-29 03:06:49 +08:00
<a-icon type="line-chart" />
2025-12-29 19:05:17 +08:00
<span>{{ selectedStrategy.indicator_config.indicator_name }}</span>
</div>
<div class="tag-item" v-if="selectedStrategy.trading_config">
<a-icon type="thunderbolt" />
<span>{{ selectedStrategy.trading_config.leverage || 1 }}x</span>
</div>
<div class="tag-item" v-if="selectedStrategy.trading_config && selectedStrategy.trading_config.trade_direction">
2025-12-29 03:06:49 +08:00
<a-icon type="swap" />
2025-12-29 19:05:17 +08:00
<span>{{ getTradeDirectionText(selectedStrategy.trading_config.trade_direction) }}</span>
</div>
<div class="tag-item" v-if="selectedStrategy.trading_config && selectedStrategy.trading_config.timeframe">
2025-12-29 03:06:49 +08:00
<a-icon type="clock-circle" />
2025-12-29 19:05:17 +08:00
<span>{{ selectedStrategy.trading_config.timeframe }}</span>
</div>
2025-12-29 03:06:49 +08:00
</div>
</div>
<div class="header-right">
<a-button
v-if="selectedStrategy.status === 'stopped'"
type="primary"
size="large"
2025-12-29 19:05:17 +08:00
class="action-btn start-btn"
2025-12-29 03:06:49 +08:00
@click="handleStartStrategy(selectedStrategy.id)"
>
<a-icon type="play-circle" />
{{ $t('trading-assistant.startStrategy') }}
</a-button>
<a-button
v-if="selectedStrategy.status === 'running'"
type="danger"
size="large"
2025-12-29 19:05:17 +08:00
class="action-btn stop-btn"
2025-12-29 03:06:49 +08:00
@click="handleStopStrategy(selectedStrategy.id)"
>
<a-icon type="pause-circle" />
{{ $t('trading-assistant.stopStrategy') }}
</a-button>
</div>
</div>
</a-card>
<!-- 策略详情标签页 -->
<a-card :bordered="false" class="strategy-content-card">
<a-tabs defaultActiveKey="positions">
<a-tab-pane key="positions" :tab="$t('trading-assistant.tabs.positions')">
<position-records
:strategy-id="selectedStrategy.id"
:market-type="(selectedStrategy.trading_config && selectedStrategy.trading_config.market_type) || 'swap'"
:leverage="(selectedStrategy.trading_config && selectedStrategy.trading_config.leverage) || 1"
:loading="loadingRecords"
/>
</a-tab-pane>
<a-tab-pane key="trades" :tab="$t('trading-assistant.tabs.tradingRecords')">
<trading-records
:strategy-id="selectedStrategy.id"
:loading="loadingRecords"
/>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</a-col>
</a-row>
<!-- 创建/编辑策略弹窗 - 合并版本 -->
<a-modal
:visible="showFormModal"
:title="editingStrategy ? $t('trading-assistant.editStrategy') : $t('trading-assistant.createStrategy')"
:width="isMobile ? '95%' : 1100"
:confirmLoading="saving"
@ok="handleSubmit"
@cancel="handleCloseModal"
:maskClosable="false"
:wrapClassName="isMobile ? 'mobile-modal' : ''"
:bodyStyle="{ maxHeight: '70vh', overflowY: 'auto' }"
>
<a-spin :spinning="loadingIndicators">
<a-steps :current="currentStep" class="steps-container">
<a-step :title="$t('trading-assistant.form.step1')" />
<a-step :title="$t('trading-assistant.form.step2Params')" />
<a-step :title="$t('trading-assistant.form.step3Signal')" />
</a-steps>
<div class="form-container">
<!-- 步骤1: 指标策略-选择技术指标 / AI策略-基础配置 -->
<div v-show="currentStep === 0" class="step-content">
<!-- 指标策略选择技术指标 -->
<div v-if="strategyType === 'indicator'">
<a-form :form="form" layout="vertical">
<a-form-item :label="$t('trading-assistant.form.indicator')">
<a-select
v-decorator="['indicator_id', { rules: [{ required: true, message: $t('trading-assistant.validation.indicatorRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.selectIndicator')"
show-search
:filter-option="filterIndicatorOption"
@focus="handleIndicatorSelectFocus"
@change="handleIndicatorChange"
:loading="loadingIndicators"
:getPopupContainer="(triggerNode) => triggerNode.parentNode"
>
<a-select-option
v-for="indicator in availableIndicators"
:key="String(indicator.id)"
:value="String(indicator.id)"
>
<div class="indicator-option">
<span class="indicator-name">{{ indicator.name }}</span>
<a-tag v-if="indicator.type" size="small" :color="getIndicatorTypeColor(indicator.type)">
{{ getIndicatorTypeName(indicator.type) }}
</a-tag>
</div>
</a-select-option>
</a-select>
<div class="form-item-hint">
{{ $t('trading-assistant.form.indicatorHint') }}
</div>
</a-form-item>
<a-form-item v-if="selectedIndicator" :label="$t('trading-assistant.form.indicatorDescription')">
<div class="indicator-description">
{{ selectedIndicator.description || $t('trading-assistant.form.noDescription') }}
</div>
</a-form-item>
<a-divider />
<a-form-item :label="$t('trading-assistant.form.strategyName')">
<a-input
v-decorator="['strategy_name', { rules: [{ required: true, message: $t('trading-assistant.validation.strategyNameRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputStrategyName')"
/>
</a-form-item>
<a-form-item :label="isEditMode ? $t('trading-assistant.form.symbol') : $t('trading-assistant.form.symbols')">
<!-- 编辑模式单选 -->
2025-12-29 03:06:49 +08:00
<a-select
v-if="isEditMode"
2025-12-29 03:06:49 +08:00
v-decorator="['symbol', { rules: [{ required: true, message: $t('trading-assistant.validation.symbolRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.selectSymbol')"
show-search
:filter-option="filterWatchlistOption"
:loading="loadingWatchlist"
@change="handleWatchlistSymbolChange"
:getPopupContainer="(triggerNode) => triggerNode.parentNode"
>
<a-select-option
v-for="item in watchlist"
:key="`${item.market}:${item.symbol}`"
:value="`${item.market}:${item.symbol}`"
>
<div class="symbol-option">
<a-tag :color="getMarketColor(item.market)" style="margin-right: 8px; margin-bottom: 0;">
{{ item.market }}
</a-tag>
<span class="symbol-name">{{ item.symbol }}</span>
<span v-if="item.name" class="symbol-name-extra">{{ item.name }}</span>
</div>
</a-select-option>
</a-select>
<!-- 创建模式多选 -->
<a-select
v-else
v-model="selectedSymbols"
mode="multiple"
:placeholder="$t('trading-assistant.placeholders.selectSymbols')"
show-search
:filter-option="filterWatchlistOption"
:loading="loadingWatchlist"
@change="handleMultiSymbolChange"
:getPopupContainer="(triggerNode) => triggerNode.parentNode"
:maxTagCount="3"
>
<a-select-option
v-for="item in watchlist"
:key="`${item.market}:${item.symbol}`"
:value="`${item.market}:${item.symbol}`"
>
<div class="symbol-option">
<a-tag :color="getMarketColor(item.market)" style="margin-right: 8px; margin-bottom: 0;">
{{ item.market }}
</a-tag>
<span class="symbol-name">{{ item.symbol }}</span>
<span v-if="item.name" class="symbol-name-extra">{{ item.name }}</span>
</div>
</a-select-option>
</a-select>
2025-12-29 03:06:49 +08:00
<div class="form-item-hint">
{{ isEditMode ? $t('trading-assistant.form.symbolHintCrypto') : $t('trading-assistant.form.symbolsHint') }}
2025-12-29 03:06:49 +08:00
</div>
</a-form-item>
<a-row :gutter="16">
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="$t('trading-assistant.form.initialCapital')">
<a-input-number
v-decorator="['initial_capital', { rules: [{ required: true, message: $t('trading-assistant.validation.initialCapitalRequired') }], initialValue: 1000 }]"
:min="10"
:step="100"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="$t('trading-assistant.form.marketType')">
<a-radio-group
v-decorator="['market_type', { initialValue: 'swap' }]"
@change="handleMarketTypeChange"
>
<a-radio value="swap">{{ $t('trading-assistant.form.marketTypeFutures') }}</a-radio>
<a-radio value="spot">{{ $t('trading-assistant.form.marketTypeSpot') }}</a-radio>
</a-radio-group>
<div class="form-item-hint">
{{ $t('trading-assistant.form.marketTypeHint') }}
</div>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="`${$t('trading-assistant.form.leverage')} (x)`">
<a-input-number
v-decorator="['leverage', { initialValue: 1, rules: [{ required: true, message: $t('trading-assistant.validation.leverageRequired') }] }]"
:min="1"
:max="form.getFieldValue('market_type') === 'spot' ? 1 : 125"
:step="1"
style="width: 100%"
:disabled="form.getFieldValue('market_type') === 'spot'"
/>
<div class="form-item-hint">
<span v-if="form.getFieldValue('market_type') === 'spot'">
{{ $t('trading-assistant.form.spotLeverageFixed') }}
</span>
<span v-else>
{{ $t('trading-assistant.form.leverageHint') }}
</span>
</div>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="$t('trading-assistant.form.tradeDirection')">
<a-radio-group
v-decorator="['trade_direction', { initialValue: 'long' }]"
:disabled="form.getFieldValue('market_type') === 'spot'"
>
<a-radio value="long">{{ $t('trading-assistant.form.tradeDirectionLong') }}</a-radio>
<a-radio value="short" :disabled="form.getFieldValue('market_type') === 'spot'">
{{ $t('trading-assistant.form.tradeDirectionShort') }}
</a-radio>
<a-radio value="both" :disabled="form.getFieldValue('market_type') === 'spot'">
{{ $t('trading-assistant.form.tradeDirectionBoth') }}
</a-radio>
</a-radio-group>
<div v-if="form.getFieldValue('market_type') === 'spot'" class="form-item-hint" style="color: #ff9800;">
{{ $t('trading-assistant.form.spotOnlyLongHint') }}
</div>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="$t('trading-assistant.form.klinePeriod')">
<a-select
v-decorator="['timeframe', { initialValue: '1H', rules: [{ required: true }] }]"
:placeholder="$t('trading-assistant.placeholders.selectKlinePeriod')"
:getPopupContainer="(triggerNode) => triggerNode.parentNode"
>
<a-select-option value="1m">{{ $t('trading-assistant.form.timeframe1m') }}</a-select-option>
<a-select-option value="5m">{{ $t('trading-assistant.form.timeframe5m') }}</a-select-option>
<a-select-option value="15m">{{ $t('trading-assistant.form.timeframe15m') }}</a-select-option>
<a-select-option value="30m">{{ $t('trading-assistant.form.timeframe30m') }}</a-select-option>
<a-select-option value="1H">{{ $t('trading-assistant.form.timeframe1H') }}</a-select-option>
<a-select-option value="4H">{{ $t('trading-assistant.form.timeframe4H') }}</a-select-option>
<a-select-option value="1D">{{ $t('trading-assistant.form.timeframe1D') }}</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="24" :md="12" :lg="12"></a-col>
</a-row>
</a-form>
</div>
<!-- Local mode: indicator strategy only (no AI strategy) -->
</div>
<!-- Step 2: params (backtest-like / trading params) -->
<div v-show="currentStep === 1" class="step-content">
<!-- 指标策略策略参数 -->
<div v-if="strategyType === 'indicator'">
<a-form :form="form" layout="vertical">
<!-- Backtest-like configuration (aligned with indicator-analysis BacktestModal) -->
<a-collapse v-model="backtestCollapseKeys" :bordered="false" style="background: #fafafa;">
<a-collapse-panel key="risk" :header="$t('dashboard.indicator.backtest.panel.risk')">
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.stopLossPct')">
<a-input-number
v-decorator="['stop_loss_pct', { initialValue: 0 }]"
:min="0"
:max="100"
:step="0.01"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.takeProfitPct')">
<a-input-number
v-decorator="['take_profit_pct', { initialValue: 0 }]"
:min="0"
:max="1000"
:step="0.01"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trailingEnabled')">
<a-switch
v-decorator="['trailing_enabled', { valuePropName: 'checked', initialValue: false }]"
@change="onTrailingToggle"
/>
</a-form-item>
</a-col>
<a-col :span="12"></a-col>
</a-row>
<template v-if="trailingEnabledUi">
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trailingStopPct')">
<a-input-number
v-decorator="['trailing_stop_pct', { initialValue: 0 }]"
:min="0"
:max="100"
:step="0.01"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trailingActivationPct')">
<a-input-number
v-decorator="['trailing_activation_pct', { initialValue: 0 }]"
:min="0"
:max="1000"
:step="0.01"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
</a-row>
</template>
</a-collapse-panel>
<a-collapse-panel key="scale" :header="$t('dashboard.indicator.backtest.panel.scale')">
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendAddEnabled')">
<a-switch
v-decorator="['trend_add_enabled', { valuePropName: 'checked', initialValue: false }]"
@change="onTrendAddToggle"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.dcaAddEnabled')">
<a-switch
v-decorator="['dca_add_enabled', { valuePropName: 'checked', initialValue: false }]"
@change="onDcaAddToggle"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendAddStepPct')">
<a-input-number
v-decorator="['trend_add_step_pct', { initialValue: 0 }]"
:min="0"
:max="1000"
:step="0.01"
:precision="4"
style="width: 220px"
@change="onScaleParamsChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.dcaAddStepPct')">
<a-input-number
v-decorator="['dca_add_step_pct', { initialValue: 0 }]"
:min="0"
:max="1000"
:step="0.01"
:precision="4"
style="width: 220px"
@change="onScaleParamsChange"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendAddSizePct')">
<a-input-number
v-decorator="['trend_add_size_pct', { initialValue: 0 }]"
:min="0"
:max="100"
:step="0.1"
:precision="4"
style="width: 220px"
@change="onScaleParamsChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.dcaAddSizePct')">
<a-input-number
v-decorator="['dca_add_size_pct', { initialValue: 0 }]"
:min="0"
:max="100"
:step="0.1"
:precision="4"
style="width: 220px"
@change="onScaleParamsChange"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendAddMaxTimes')">
<a-input-number
v-decorator="['trend_add_max_times', { initialValue: 0 }]"
:min="0"
:max="50"
:step="1"
:precision="0"
style="width: 220px"
@change="onScaleParamsChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.dcaAddMaxTimes')">
<a-input-number
v-decorator="['dca_add_max_times', { initialValue: 0 }]"
:min="0"
:max="50"
:step="1"
:precision="0"
style="width: 220px"
@change="onScaleParamsChange"
/>
</a-form-item>
</a-col>
</a-row>
</a-collapse-panel>
<a-collapse-panel key="reduce" :header="$t('dashboard.indicator.backtest.panel.reduce')">
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendReduceEnabled')">
<a-switch v-decorator="['trend_reduce_enabled', { valuePropName: 'checked', initialValue: false }]" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.adverseReduceEnabled')">
<a-switch v-decorator="['adverse_reduce_enabled', { valuePropName: 'checked', initialValue: false }]" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendReduceStepPct')">
<a-input-number
v-decorator="['trend_reduce_step_pct', { initialValue: 0 }]"
:min="0"
:max="1000"
:step="0.01"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.adverseReduceStepPct')">
<a-input-number
v-decorator="['adverse_reduce_step_pct', { initialValue: 0 }]"
:min="0"
:max="1000"
:step="0.01"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendReduceSizePct')">
<a-input-number
v-decorator="['trend_reduce_size_pct', { initialValue: 0 }]"
:min="0"
:max="100"
:step="0.1"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.adverseReduceSizePct')">
<a-input-number
v-decorator="['adverse_reduce_size_pct', { initialValue: 0 }]"
:min="0"
:max="100"
:step="0.1"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendReduceMaxTimes')">
<a-input-number
v-decorator="['trend_reduce_max_times', { initialValue: 0 }]"
:min="0"
:max="50"
:step="1"
:precision="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.adverseReduceMaxTimes')">
<a-input-number
v-decorator="['adverse_reduce_max_times', { initialValue: 0 }]"
:min="0"
:max="50"
:step="1"
:precision="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</a-collapse-panel>
<a-collapse-panel key="position" :header="$t('dashboard.indicator.backtest.panel.position')">
<a-row :gutter="24">
<a-col :span="12">
<a-form-item
:label="$t('dashboard.indicator.backtest.field.entryPct')"
:help="$t('dashboard.indicator.backtest.hint.entryPctMax', { maxPct: Number(entryPctMaxUi || 0).toFixed(0) })"
>
<a-input-number
v-decorator="['entry_pct', { initialValue: 100 }]"
:min="0"
:max="entryPctMaxUi"
:step="0.1"
:precision="4"
style="width: 220px"
@change="onEntryPctChange"
/>
</a-form-item>
</a-col>
<a-col :span="12"></a-col>
</a-row>
</a-collapse-panel>
</a-collapse>
<!-- Indicator strategy: AI decision filter -->
<div class="ai-filter-box">
<div class="ai-filter-header">
<div class="ai-filter-title">
<a-icon type="robot" />
<span>{{ $t('trading-assistant.form.enableAiFilter') }}</span>
</div>
<a-switch
:checked="aiFilterEnabledUi"
@change="onAiFilterToggle"
/>
</div>
<div class="ai-filter-hint">{{ $t('trading-assistant.form.enableAiFilterHint') }}</div>
</div>
</a-form>
</div>
<!-- Local mode: indicator strategy only (no AI strategy) -->
</div>
<!-- Step 3: signal push & optional live trading (crypto only) -->
<div v-show="currentStep === 2" class="step-content">
<a-form :form="form" layout="vertical" autocomplete="off">
<a-form-item :label="$t('trading-assistant.form.executionMode')">
<a-radio-group
v-decorator="['execution_mode', { initialValue: 'signal' }]"
:disabled="!canUseLiveTrading"
2025-12-29 03:06:49 +08:00
@change="onExecutionModeChange"
>
<a-radio value="signal">{{ $t('trading-assistant.form.executionModeSignal') }}</a-radio>
<a-radio value="live" :disabled="!canUseLiveTrading">{{ $t('trading-assistant.form.executionModeLive') }}</a-radio>
2025-12-29 03:06:49 +08:00
</a-radio-group>
<div v-if="!canUseLiveTrading" class="form-item-hint" style="color: #ff9800;">
{{ $t('trading-assistant.form.liveTradingNotSupportedHint') }}
2025-12-29 03:06:49 +08:00
</div>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.notifyChannels')">
<a-checkbox-group
v-decorator="['notify_channels', { initialValue: ['browser'] }]"
@change="onNotifyChannelsChange"
>
<a-checkbox value="browser">{{ $t('trading-assistant.notify.browser') }}</a-checkbox>
<a-checkbox value="email">{{ $t('trading-assistant.notify.email') }}</a-checkbox>
<a-checkbox value="phone">{{ $t('trading-assistant.notify.phone') }}</a-checkbox>
<a-checkbox value="telegram">{{ $t('trading-assistant.notify.telegram') }}</a-checkbox>
<a-checkbox value="discord">{{ $t('trading-assistant.notify.discord') }}</a-checkbox>
<a-checkbox value="webhook">{{ $t('trading-assistant.notify.webhook') }}</a-checkbox>
</a-checkbox-group>
<div class="form-item-hint">{{ $t('trading-assistant.form.notifyChannelsHint') }}</div>
</a-form-item>
<a-form-item
v-if="notifyChannelsUi.includes('email')"
:label="$t('trading-assistant.form.notifyEmail')"
>
<a-input
v-decorator="['notify_email', { rules: [{ type: 'email', message: $t('trading-assistant.validation.emailInvalid') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputEmail')"
/>
</a-form-item>
<a-form-item
v-if="notifyChannelsUi.includes('phone')"
:label="$t('trading-assistant.form.notifyPhone')"
>
<a-input
v-decorator="['notify_phone']"
:placeholder="$t('trading-assistant.placeholders.inputPhone')"
/>
</a-form-item>
<a-form-item
v-if="notifyChannelsUi.includes('telegram')"
:label="$t('trading-assistant.form.notifyTelegram')"
>
<a-input
v-decorator="['notify_telegram']"
:placeholder="$t('trading-assistant.placeholders.inputTelegram')"
/>
</a-form-item>
<a-form-item
v-if="notifyChannelsUi.includes('discord')"
:label="$t('trading-assistant.form.notifyDiscord')"
>
<a-input
v-decorator="['notify_discord']"
:placeholder="$t('trading-assistant.placeholders.inputDiscord')"
/>
</a-form-item>
<a-form-item
v-if="notifyChannelsUi.includes('webhook')"
:label="$t('trading-assistant.form.notifyWebhook')"
>
<a-input
v-decorator="['notify_webhook']"
:placeholder="$t('trading-assistant.placeholders.inputWebhook')"
/>
</a-form-item>
<a-divider v-if="executionModeUi === 'live' && canUseLiveTrading" />
2025-12-29 03:06:49 +08:00
<!-- Live trading: exchange credentials -->
<div v-if="executionModeUi === 'live' && canUseLiveTrading">
2025-12-29 03:06:49 +08:00
<a-alert
type="info"
show-icon
style="margin-bottom: 12px;"
:message="$t('trading-assistant.form.liveTradingConfigTitle')"
:description="$t('trading-assistant.form.liveTradingConfigHint')"
/>
<!-- ========== Broker Configuration (US/HK Stocks) ========== -->
<template v-if="isIBKRMarket">
<a-form-item :label="$t('trading-assistant.form.broker')">
<a-select
v-decorator="['broker_id', {
initialValue: 'ibkr',
rules: [{ required: true, message: $t('trading-assistant.validation.brokerRequired') }]
}]"
:placeholder="$t('trading-assistant.placeholders.selectBroker')"
:getPopupContainer="getModalPopupContainer"
@change="handleBrokerSelectChange"
2025-12-29 03:06:49 +08:00
>
<a-select-option
v-for="broker in brokerOptions"
:key="broker.value"
:value="broker.value"
>
{{ broker.displayName }}
</a-select-option>
</a-select>
</a-form-item>
2025-12-29 03:06:49 +08:00
<!-- IBKR specific configuration -->
<template v-if="currentBrokerId === 'ibkr'">
<a-alert
type="info"
show-icon
style="margin-bottom: 16px;"
:message="$t('trading-assistant.form.ibkrConnectionTitle')"
:description="$t('trading-assistant.form.ibkrConnectionHint')"
/>
<a-form-item :label="$t('trading-assistant.form.ibkrHost')">
<a-input
v-decorator="['ibkr_host', { initialValue: '127.0.0.1' }]"
placeholder="127.0.0.1"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.ibkrPort')">
<a-input-number
v-decorator="['ibkr_port', { initialValue: 7497 }]"
placeholder="7497"
:min="1"
:max="65535"
style="width: 100%"
@change="handleApiConfigChange"
/>
<div class="form-item-hint">{{ $t('trading-assistant.form.ibkrPortHint') }}</div>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.ibkrClientId')">
<a-input-number
v-decorator="['ibkr_client_id', { initialValue: 1 }]"
placeholder="1"
:min="1"
:max="999"
style="width: 100%"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.ibkrAccount')">
<a-input
v-decorator="['ibkr_account', { initialValue: '' }]"
:placeholder="$t('trading-assistant.placeholders.ibkrAccount')"
@change="handleApiConfigChange"
/>
<div class="form-item-hint">{{ $t('trading-assistant.form.ibkrAccountHint') }}</div>
</a-form-item>
</template>
<!-- Future broker configurations can be added here -->
<!-- <template v-else-if="currentBrokerId === 'futu'">...</template> -->
</template>
2026-01-13 04:01:42 +08:00
<!-- ========== MT5/Forex Broker Configuration ========== -->
<template v-else-if="isMT5Market">
<a-form-item :label="$t('trading-assistant.form.forexBroker')">
<a-select
v-decorator="['forex_broker_id', {
initialValue: 'mt5',
rules: [{ required: true, message: $t('trading-assistant.validation.brokerRequired') }]
}]"
:placeholder="$t('trading-assistant.placeholders.selectBroker')"
:getPopupContainer="getModalPopupContainer"
@change="handleForexBrokerSelectChange"
>
<a-select-option
v-for="broker in forexBrokerOptions"
:key="broker.value"
:value="broker.value"
>
{{ broker.displayName }}
</a-select-option>
</a-select>
</a-form-item>
<!-- MT5 specific configuration -->
<template v-if="currentBrokerId === 'mt5'">
<a-alert
type="info"
show-icon
style="margin-bottom: 16px;"
:message="$t('trading-assistant.form.mt5ConnectionTitle')"
:description="$t('trading-assistant.form.mt5ConnectionHint')"
/>
<a-form-item :label="$t('trading-assistant.form.mt5Server')">
<a-input
v-decorator="['mt5_server', {
rules: [{ required: true, message: $t('trading-assistant.validation.mt5ServerRequired') }]
}]"
:placeholder="$t('trading-assistant.placeholders.mt5Server')"
@change="handleApiConfigChange"
/>
<div class="form-item-hint">{{ $t('trading-assistant.form.mt5ServerHint') }}</div>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.mt5Login')">
<a-input-number
v-decorator="['mt5_login', {
rules: [{ required: true, message: $t('trading-assistant.validation.mt5LoginRequired') }]
}]"
:placeholder="$t('trading-assistant.placeholders.mt5Login')"
:min="1"
style="width: 100%"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.mt5Password')">
<a-input-password
v-decorator="['mt5_password', {
rules: [{ required: true, message: $t('trading-assistant.validation.mt5PasswordRequired') }]
}]"
:placeholder="$t('trading-assistant.placeholders.mt5Password')"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.mt5TerminalPath')">
<a-input
v-decorator="['mt5_terminal_path']"
:placeholder="$t('trading-assistant.placeholders.mt5TerminalPath')"
@change="handleApiConfigChange"
/>
<div class="form-item-hint">{{ $t('trading-assistant.form.mt5TerminalPathHint') }}</div>
</a-form-item>
</template>
<!-- Future forex broker configurations can be added here -->
<!-- <template v-else-if="currentBrokerId === 'mt4'">...</template> -->
</template>
<!-- ========== Crypto Exchange Configuration ========== -->
<template v-else>
<a-form-item :label="$t('trading-assistant.form.savedCredential')">
<a-select
v-decorator="['credential_id', { getValueFromEvent: (val) => val || undefined }]"
:placeholder="$t('trading-assistant.placeholders.selectSavedCredential')"
allow-clear
show-search
option-filter-prop="children"
:loading="loadingExchangeCredentials"
@change="handleCredentialSelectChange"
2025-12-29 03:06:49 +08:00
>
<a-select-option
v-for="cred in exchangeCredentials"
:key="cred.id"
:value="cred.id"
>
{{ formatCredentialLabel(cred) }}
</a-select-option>
</a-select>
<div class="form-item-hint">{{ $t('trading-assistant.form.savedCredentialHint') }}</div>
</a-form-item>
2025-12-29 03:06:49 +08:00
<a-form-item :label="$t('trading-assistant.form.exchange')">
<a-select
v-decorator="['exchange_id', {
rules: [{ required: true, message: $t('trading-assistant.validation.exchangeRequired') }],
getValueFromEvent: (val) => val || undefined
}]"
:placeholder="$t('trading-assistant.placeholders.selectExchange')"
allow-clear
show-search
option-filter-prop="children"
@change="handleExchangeSelectChange"
>
<a-select-option
v-for="exchange in cryptoExchangeOptions"
:key="exchange.value"
:value="exchange.value"
>
{{ exchange.displayName }}
</a-select-option>
</a-select>
</a-form-item>
2025-12-29 03:06:49 +08:00
<a-form-item :label="$t('trading-assistant.form.apiKey')">
<a-input-password
v-decorator="['api_key', { rules: [{ required: true, message: $t('trading-assistant.validation.apiKeyRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputApiKey')"
autocomplete="new-password"
@change="handleApiConfigChange"
/>
</a-form-item>
2025-12-29 03:06:49 +08:00
<a-form-item :label="$t('trading-assistant.form.secretKey')">
<a-input-password
v-decorator="['secret_key', { rules: [{ required: true, message: $t('trading-assistant.validation.secretKeyRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputSecretKey')"
autocomplete="new-password"
@change="handleApiConfigChange"
/>
</a-form-item>
2025-12-29 03:06:49 +08:00
<a-form-item
v-if="needsPassphrase"
:label="$t('trading-assistant.form.passphrase')"
2025-12-29 03:06:49 +08:00
>
<a-input-password
v-decorator="['passphrase', { rules: [{ required: true, message: $t('trading-assistant.validation.passphraseRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputPassphrase')"
autocomplete="new-password"
@change="handleApiConfigChange"
/>
</a-form-item>
2025-12-29 03:06:49 +08:00
<a-form-item>
<a-checkbox
v-decorator="['save_credential', { valuePropName: 'checked', initialValue: false }]"
@change="onSaveCredentialChange"
>
{{ $t('trading-assistant.form.saveCredential') }}
</a-checkbox>
</a-form-item>
2025-12-29 03:06:49 +08:00
<a-form-item
v-if="saveCredentialUi"
:label="$t('trading-assistant.form.credentialName')"
>
<a-input
v-decorator="['credential_name']"
:placeholder="$t('trading-assistant.placeholders.inputCredentialName')"
/>
</a-form-item>
</template>
<!-- Test Connection Button (shared by both IBKR and Crypto) -->
2025-12-29 03:06:49 +08:00
<a-form-item>
<a-button
type="default"
:loading="testing"
@click="handleTestConnection"
block
>
<a-icon type="wallet" />
{{ $t('trading-assistant.form.testConnection') }}
</a-button>
<div v-if="testResult" class="test-result" :class="testResult.success ? 'success' : 'error'">
{{ testResult.message }}
</div>
</a-form-item>
</div>
</a-form>
</div>
</div>
</a-spin>
<template slot="footer">
<a-button @click="handleCloseModal">{{ $t('trading-assistant.form.cancel') }}</a-button>
<a-button
v-show="currentStep > 0"
@click="handlePrev"
>
{{ $t('trading-assistant.form.prev') }}
</a-button>
<a-button
v-show="currentStep < 2"
type="primary"
@click="handleNext"
:loading="saving"
>
{{ $t('trading-assistant.form.next') }}
</a-button>
<a-button
v-show="currentStep === 2"
type="primary"
@click="handleSubmit"
:loading="saving"
>
{{ editingStrategy ? $t('trading-assistant.form.confirmEdit') : $t('trading-assistant.form.confirmCreate') }}
</a-button>
</template>
</a-modal>
</div>
</template>
<script>
import { getStrategyList, startStrategy, stopStrategy, deleteStrategy, updateStrategy, testExchangeConnection, getStrategyEquityCurve, batchCreateStrategies, batchStartStrategies, batchStopStrategies, batchDeleteStrategies } from '@/api/strategy'
2025-12-29 03:06:49 +08:00
import { getWatchlist } from '@/api/market'
import { listExchangeCredentials, getExchangeCredential, createExchangeCredential } from '@/api/credentials'
import { baseMixin } from '@/store/app-mixin'
import request from '@/utils/request'
import TradingRecords from './components/TradingRecords.vue'
import PositionRecords from './components/PositionRecords.vue'
// 常见加密货币交易对
const CRYPTO_SYMBOLS = [
'BTC/USDT', 'ETH/USDT', 'BNB/USDT', 'SOL/USDT', 'ADA/USDT',
'XRP/USDT', 'DOGE/USDT', 'DOT/USDT', 'MATIC/USDT', 'AVAX/USDT',
'LINK/USDT', 'UNI/USDT', 'LTC/USDT', 'ATOM/USDT', 'ETC/USDT'
]
// Crypto exchange options
2025-12-29 03:06:49 +08:00
const EXCHANGE_OPTIONS = [
{ value: 'binance', labelKey: 'binance' },
{ value: 'okx', labelKey: 'okx' },
{ value: 'bitget', labelKey: 'bitget' },
{ value: 'bybit', labelKey: 'bybit' },
{ value: 'coinbaseexchange', labelKey: 'coinbaseexchange' },
{ value: 'kraken', labelKey: 'kraken' },
{ value: 'kucoin', labelKey: 'kucoin' },
{ value: 'gate', labelKey: 'gate' },
{ value: 'bitfinex', labelKey: 'bitfinex' }
]
// Traditional broker options (US/HK stocks) - extensible for future brokers
const BROKER_OPTIONS = [
{ value: 'ibkr', labelKey: 'ibkr', name: 'Interactive Brokers' }
// Future brokers can be added here:
// { value: 'td', labelKey: 'td', name: 'TD Ameritrade' },
// { value: 'schwab', labelKey: 'schwab', name: 'Charles Schwab' },
// { value: 'futu', labelKey: 'futu', name: 'Futu (富途)' },
// { value: 'tiger', labelKey: 'tiger', name: 'Tiger Brokers (老虎证券)' },
]
2026-01-13 04:01:42 +08:00
// Forex broker options
const FOREX_BROKER_OPTIONS = [
{ value: 'mt5', labelKey: 'mt5', name: 'MetaTrader 5' }
// Future forex brokers can be added here:
// { value: 'mt4', labelKey: 'mt4', name: 'MetaTrader 4' },
// { value: 'ctrader', labelKey: 'ctrader', name: 'cTrader' },
]
2025-12-29 03:06:49 +08:00
export default {
name: 'TradingAssistant',
mixins: [baseMixin],
components: {
TradingRecords,
PositionRecords
},
computed: {
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
needsPassphrase () {
// Exchanges that require passphrase
2025-12-29 03:06:49 +08:00
return ['okx', 'okex', 'coinbaseexchange', 'kucoin', 'bitget'].includes(this.currentExchangeId)
},
// Check if current market uses IBKR (US Stock / HK Stock)
isIBKRMarket () {
return ['USStock', 'HShare'].includes(this.selectedMarketCategory)
},
2026-01-13 04:01:42 +08:00
// Check if current market uses MT5 (Forex)
isMT5Market () {
return this.selectedMarketCategory === 'Forex'
},
// Check if current market uses any broker (not crypto exchange)
isBrokerMarket () {
return this.isIBKRMarket || this.isMT5Market
},
2025-12-29 03:06:49 +08:00
// 预处理交易所列表,包含显示名称,提升性能
formattedExchangeOptions () {
return EXCHANGE_OPTIONS.map(exchange => {
let label = ''
try {
if (exchange.labelKey) {
const translationKey = `trading-assistant.exchangeNames.${exchange.labelKey}`
const translated = this.$t(translationKey)
if (translated !== translationKey) {
label = translated
}
}
} catch (e) {
// 忽略翻译错误
}
if (!label) {
label = exchange.value.charAt(0).toUpperCase() + exchange.value.slice(1)
}
return {
...exchange,
displayName: label
}
})
},
totalPnl () {
if (this.currentEquity === null || !this.selectedStrategy || !this.selectedStrategy.initial_capital) {
return null
}
return this.currentEquity - (this.selectedStrategy.initial_capital || 0)
},
totalPnlPercent () {
if (this.totalPnl === null || !this.selectedStrategy || !this.selectedStrategy.initial_capital) {
return null
}
if (this.selectedStrategy.initial_capital === 0) return 0
return (this.totalPnl / this.selectedStrategy.initial_capital) * 100
},
getEquityColorClass () {
if (this.totalPnl === null) return ''
return this.totalPnl >= 0 ? 'text-success' : 'text-danger'
},
getPnlColorClass () {
if (this.totalPnl === null) return ''
return this.totalPnl >= 0 ? 'text-success' : 'text-danger'
},
isCryptoMarket () {
// IMPORTANT: do not rely on form.getFieldValue for reactivity (Ant Form is not reactive).
// Always depend on selectedMarketCategory to make UI reactive.
const cat = this.selectedMarketCategory || 'Crypto'
return String(cat).toLowerCase() === 'crypto'
},
2026-01-13 04:01:42 +08:00
// Check if selected market supports live trading (Crypto, USStock/HShare with IBKR, or Forex with MT5)
canUseLiveTrading () {
const cat = this.selectedMarketCategory || 'Crypto'
// Crypto always supports live trading via crypto exchanges
if (String(cat).toLowerCase() === 'crypto') {
return true
}
// USStock/HShare can use IBKR for live trading
if (['USStock', 'HShare'].includes(cat)) {
return true
}
2026-01-13 04:01:42 +08:00
// Forex can use MT5 for live trading
if (cat === 'Forex') {
return true
}
return false
},
// Check if current market + exchange combination supports live trading
isLiveTradingAvailable () {
const cat = this.selectedMarketCategory || 'Crypto'
const exchangeId = this.currentExchangeId || ''
// Crypto markets use crypto exchanges
if (String(cat).toLowerCase() === 'crypto') {
return ['binance', 'okx', 'bitget', 'bybit', 'coinbaseexchange', 'kraken', 'kucoin', 'gate', 'bitfinex'].includes(exchangeId)
}
// USStock/HShare use IBKR
if (['USStock', 'HShare'].includes(cat)) {
2026-01-13 04:01:42 +08:00
return this.currentBrokerId === 'ibkr'
}
// Forex uses MT5
if (cat === 'Forex') {
return this.currentBrokerId === 'mt5'
}
return false
},
// Broker options for US/HK stocks (with i18n support)
brokerOptions () {
return BROKER_OPTIONS.map(broker => {
let label = ''
try {
const translationKey = `trading-assistant.brokerNames.${broker.labelKey}`
const translated = this.$t(translationKey)
if (translated !== translationKey) {
label = translated
}
} catch (e) {}
if (!label) {
label = broker.name || broker.value.toUpperCase()
}
return {
...broker,
displayName: label
}
})
},
2026-01-13 04:01:42 +08:00
// Forex broker options (with i18n support)
forexBrokerOptions () {
return FOREX_BROKER_OPTIONS.map(broker => {
let label = ''
try {
const translationKey = `trading-assistant.brokerNames.${broker.labelKey}`
const translated = this.$t(translationKey)
if (translated !== translationKey) {
label = translated
}
} catch (e) {}
if (!label) {
label = broker.name || broker.value.toUpperCase()
}
return {
...broker,
displayName: label
}
})
},
// Crypto exchange options only
cryptoExchangeOptions () {
return EXCHANGE_OPTIONS.map(exchange => {
let label = ''
try {
if (exchange.labelKey) {
const translationKey = `trading-assistant.exchangeNames.${exchange.labelKey}`
const translated = this.$t(translationKey)
if (translated !== translationKey) {
label = translated
}
}
} catch (e) {}
if (!label) {
label = exchange.value.charAt(0).toUpperCase() + exchange.value.slice(1)
}
return {
...exchange,
displayName: label
}
})
},
// 策略分组显示
groupedStrategies () {
const groups = {}
const ungrouped = []
for (const s of this.strategies) {
const groupId = s.strategy_group_id
if (groupId && groupId.trim()) {
if (!groups[groupId]) {
groups[groupId] = {
id: groupId,
baseName: s.group_base_name || s.strategy_name.split('-')[0],
strategies: [],
// 统计信息
runningCount: 0,
stoppedCount: 0
}
}
groups[groupId].strategies.push(s)
if (s.status === 'running') {
groups[groupId].runningCount++
} else {
groups[groupId].stoppedCount++
}
} else {
ungrouped.push(s)
}
}
// 转换为数组,按创建时间排序
const groupList = Object.values(groups).sort((a, b) => {
const aTime = Math.max(...a.strategies.map(s => s.created_at || 0))
const bTime = Math.max(...b.strategies.map(s => s.created_at || 0))
return bTime - aTime
})
return { groups: groupList, ungrouped }
2025-12-29 03:06:49 +08:00
}
},
data () {
return {
loading: false,
loadingRecords: false,
strategies: [],
selectedStrategy: null,
showFormModal: false,
// Only indicator strategy in local mode
strategyType: 'indicator',
selectedMarketCategory: 'Crypto', // AShare / USStock / Crypto / Forex
currentStep: 0,
saving: false,
loadingIndicators: false,
availableIndicators: [],
selectedIndicator: null,
cryptoSymbols: CRYPTO_SYMBOLS,
// Watchlist symbols (same source as indicator-analysis page)
loadingWatchlist: false,
watchlist: [],
exchangeOptions: EXCHANGE_OPTIONS,
currentExchangeId: '',
currentBrokerId: 'ibkr',
2025-12-29 03:06:49 +08:00
testing: false,
testResult: null,
connectionTestResult: null,
indicatorsLoaded: false, // 标记指标是否已加载
editingStrategy: null, // 正在编辑的策略
currentEquity: null, // 当前净值
equityPollingTimer: null, // 净值轮询定时器
// Backtest-like UI state (same as indicator-analysis BacktestModal step1)
backtestCollapseKeys: ['risk'],
trailingEnabledUi: false,
entryPctMaxUi: 100,
aiFilterEnabledUi: false,
isEditMode: false, // 是否为编辑模式
supportedIPs: [], // 白名单IP列表
executionModeUi: 'signal',
notifyChannelsUi: ['browser'],
// Exchange credentials vault
loadingExchangeCredentials: false,
exchangeCredentials: [],
saveCredentialUi: false,
suppressApiClearOnce: false,
// 多币种选择(创建模式)
selectedSymbols: [],
// 策略组折叠状态
collapsedGroups: {}
2025-12-29 03:06:49 +08:00
// Market category is inferred from Step 1 watchlist symbol ("Market:SYMBOL").
}
},
beforeCreate () {
this.form = this.$form.createForm(this)
},
mounted () {
this.loadStrategies()
},
beforeDestroy () {
this.stopEquityPolling()
},
methods: {
async loadWatchlist () {
this.loadingWatchlist = true
try {
const res = await getWatchlist({ userid: 1 })
if (res && res.code === 1) {
this.watchlist = Array.isArray(res.data) ? res.data : []
} else {
this.watchlist = []
}
} catch (e) {
this.watchlist = []
} finally {
this.loadingWatchlist = false
}
},
filterWatchlistOption (input, option) {
const value = option.componentOptions?.propsData?.value || ''
return String(value).toLowerCase().includes(String(input || '').toLowerCase())
},
getMarketColor (market) {
const colors = {
USStock: 'green',
Crypto: 'purple',
Forex: 'gold',
2025-12-30 18:47:42 +08:00
Futures: 'cyan',
AShare: 'blue',
HShare: 'orange'
2025-12-29 03:06:49 +08:00
}
return colors[market] || 'default'
},
handleWatchlistSymbolChange (val) {
// val format: "Market:SYMBOL" (same as indicator-analysis page)
if (!val || typeof val !== 'string' || !val.includes(':')) {
return
}
const idx = val.indexOf(':')
const market = val.slice(0, idx)
// Keep selection reactive for Step 3 execution gating
this.selectedMarketCategory = market || 'Crypto'
2026-01-13 04:01:42 +08:00
// Auto-set broker ID based on market category
if (this.selectedMarketCategory === 'Forex') {
this.currentBrokerId = 'mt5'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ forex_broker_id: 'mt5' })
} catch (e) {}
} else if (['USStock', 'HShare'].includes(this.selectedMarketCategory)) {
this.currentBrokerId = 'ibkr'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ broker_id: 'ibkr' })
} catch (e) {}
}
// Markets without live trading support: force back to signal mode
2026-01-13 04:01:42 +08:00
// Crypto, USStock, HShare, Forex support live trading; others do not
const supportsLiveTrading = ['Crypto', 'USStock', 'HShare', 'Forex'].includes(this.selectedMarketCategory)
if (!supportsLiveTrading) {
2025-12-29 03:06:49 +08:00
this.executionModeUi = 'signal'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' })
} catch (e) {}
}
// Clear exchange selection when market changes (different markets use different exchanges)
this.currentExchangeId = ''
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ exchange_id: undefined })
} catch (e) {}
2025-12-29 03:06:49 +08:00
},
handleMultiSymbolChange (vals) {
// vals: array like ["Crypto:BTC/USDT", "Crypto:ETH/USDT"]
this.selectedSymbols = vals || []
// Update market type based on selected symbols
if (vals && vals.length > 0) {
const firstVal = vals[0]
if (typeof firstVal === 'string' && firstVal.includes(':')) {
const idx = firstVal.indexOf(':')
const market = firstVal.slice(0, idx)
this.selectedMarketCategory = market || 'Crypto'
}
}
2026-01-13 04:01:42 +08:00
// Auto-set broker ID based on market category
if (this.selectedMarketCategory === 'Forex') {
this.currentBrokerId = 'mt5'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ forex_broker_id: 'mt5' })
} catch (e) {}
} else if (['USStock', 'HShare'].includes(this.selectedMarketCategory)) {
this.currentBrokerId = 'ibkr'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ broker_id: 'ibkr' })
} catch (e) {}
}
// Markets without live trading support: force back to signal mode
2026-01-13 04:01:42 +08:00
const supportsLiveTrading = ['Crypto', 'USStock', 'HShare', 'Forex'].includes(this.selectedMarketCategory)
if (!supportsLiveTrading) {
this.executionModeUi = 'signal'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' })
} catch (e) {}
}
// Clear exchange selection when market changes
this.currentExchangeId = ''
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ exchange_id: undefined })
} catch (e) {}
},
2025-12-29 03:06:49 +08:00
async loadExchangeCredentials () {
this.loadingExchangeCredentials = true
try {
const res = await listExchangeCredentials({ user_id: 1 })
if (res && res.code === 1) {
this.exchangeCredentials = (res.data && res.data.items) || []
} else {
this.exchangeCredentials = []
this.$message.warning(res?.msg || this.$t('trading-assistant.messages.loadFailed'))
}
} catch (e) {
this.exchangeCredentials = []
this.$message.warning(this.$t('trading-assistant.exchange.testFailed'))
} finally {
this.loadingExchangeCredentials = false
}
},
formatCredentialLabel (cred) {
if (!cred) return ''
const name = (cred.name || '').trim()
const ex = cred.exchange_id || ''
const hint = cred.api_key_hint || ''
return name ? `${ex.toUpperCase()} - ${name} (${hint})` : `${ex.toUpperCase()} (${hint})`
},
async handleCredentialSelectChange (credentialId) {
// Selecting a saved credential auto-fills exchange fields
if (!credentialId) {
return
}
// First try to find in local list to avoid API call delay/failure
const localCred = this.exchangeCredentials.find(c => String(c.id) === String(credentialId))
// Function to apply config
const applyConfig = async (cfg) => {
if (!cfg) return
// 1. Update exchange ID to ensure correct fields (like passphrase) are rendered
this.currentExchangeId = cfg.exchange_id || ''
// 2. Wait for UI to update (render passphrase field if needed)
await this.$nextTick()
// 3. Set form values
// Prevent handleExchangeSelectChange() from clearing API fields when we programmatically set exchange_id.
this.suppressApiClearOnce = true
this.form.setFieldsValue({
exchange_id: cfg.exchange_id,
api_key: cfg.api_key,
secret_key: cfg.secret_key,
passphrase: cfg.passphrase || ''
})
this.testResult = null
this.connectionTestResult = null
}
try {
// Try to fetch full config including secrets
const res = await getExchangeCredential(credentialId, { user_id: 1 })
if (res && res.code === 1 && res.data && res.data.config) {
await applyConfig(res.data.config)
} else if (localCred) {
// Fallback to local data if API fails or returns no config
// Note: Local list usually doesn't have secrets, but just in case
await applyConfig(localCred)
}
} catch (e) {
// Fallback to local data on error
if (localCred) {
await applyConfig(localCred)
}
}
},
onSaveCredentialChange (e) {
const checked = !!(e && e.target && e.target.checked)
this.saveCredentialUi = checked
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ save_credential: checked })
} catch (err) {}
},
onExecutionModeChange (e) {
const v = e && e.target ? e.target.value : e
this.executionModeUi = v || 'signal'
// If market doesn't support live trading, force signal mode
if (!this.canUseLiveTrading && this.executionModeUi !== 'signal') {
2025-12-29 03:06:49 +08:00
this.executionModeUi = 'signal'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' })
} catch (err) {}
}
},
onNotifyChannelsChange (vals) {
this.notifyChannelsUi = Array.isArray(vals) ? vals : []
},
formatCurrency (value) {
if (value === null || value === undefined) return '-'
return '$' + value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
},
formatPnl (value) {
if (value === null || value === undefined) return '-'
const prefix = value >= 0 ? '+' : ''
return prefix + '$' + Math.abs(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
},
formatPnlPercent (value) {
if (value === null || value === undefined) return '-'
const prefix = value >= 0 ? '+' : ''
return prefix + Math.abs(value).toFixed(2) + '%'
},
async loadStrategies () {
this.loading = true
try {
const res = await getStrategyList()
if (res.code === 1) {
// 显示所有策略(包括指标策略和AI策略)
const allStrategies = res.data.strategies || []
this.strategies = allStrategies
// 如果有选中的策略,更新它
if (this.selectedStrategy) {
const updated = this.strategies.find(s => s.id === this.selectedStrategy.id)
if (updated) {
this.selectedStrategy = updated
} else {
// 如果选中的策略被过滤掉了,清空选中状态
this.selectedStrategy = null
}
}
} else {
this.$message.error(res.msg || this.$t('trading-assistant.messages.loadFailed'))
}
} catch (error) {
this.$message.error(this.$t('trading-assistant.messages.loadFailed'))
} finally {
this.loading = false
}
},
handleCreateStrategy () {
// Local mode: open indicator strategy modal directly
this.isEditMode = false
this.editingStrategy = null
this.strategyType = 'indicator'
this.currentStep = 0
this.currentExchangeId = ''
this.currentBrokerId = 'ibkr'
2025-12-29 03:06:49 +08:00
this.selectedIndicator = null
this.testResult = null
this.connectionTestResult = null
this.executionModeUi = 'signal'
this.notifyChannelsUi = ['browser']
this.saveCredentialUi = false
this.backtestCollapseKeys = ['risk']
this.trailingEnabledUi = false
this.entryPctMaxUi = 100
this.aiFilterEnabledUi = false
this.selectedMarketCategory = 'Crypto'
this.selectedSymbols = []
2025-12-29 03:06:49 +08:00
this.form.resetFields()
this.form.setFieldsValue({
execution_mode: 'signal',
notify_channels: ['browser'],
save_credential: false
})
this.showFormModal = true
this.$nextTick(() => {
this.loadWatchlist()
this.loadIndicators()
this.loadExchangeCredentials()
})
},
handleEditStrategy (strategy) {
// 如果策略正在运行,提示用户先停止
if (strategy.status === 'running') {
this.$message.warning(this.$t('trading-assistant.messages.runningWarning'))
return
}
// Local mode: indicator strategy only
this.strategyType = 'indicator'
this.isEditMode = true
this.editingStrategy = strategy
this.currentStep = 0
this.currentExchangeId = ''
this.selectedIndicator = null
this.testResult = null
this.connectionTestResult = null
this.form.resetFields()
this.backtestCollapseKeys = ['risk']
this.trailingEnabledUi = false
this.entryPctMaxUi = 100
this.aiFilterEnabledUi = false
// IMPORTANT:
// Ensure modal is visible BEFORE filling form values, otherwise some fields are not registered yet
// (especially Step 2/3 fields) and setFieldsValue may silently drop values.
this.showFormModal = true
// Delay loading to ensure modal/form items are mounted.
this.$nextTick(async () => {
// Keep data sources in sync (same as create flow)
this.loadWatchlist()
this.loadIndicators()
this.loadExchangeCredentials()
await this.loadStrategyDataToForm(strategy)
})
},
async loadStrategyDataToForm (strategy) {
// 先加载指标列表(如果需要)
if (!this.indicatorsLoaded) {
await this.loadIndicators()
}
// 使用 nextTick 确保表单已初始化
await this.$nextTick()
// Market / execution / notification defaults (backward compatible)
this.selectedMarketCategory = strategy.market_category || 'Crypto'
const executionMode = strategy.execution_mode || 'signal'
this.executionModeUi = executionMode
const notifyChannels = (strategy.notification_config && strategy.notification_config.channels) || ['browser']
this.notifyChannelsUi = Array.isArray(notifyChannels) ? notifyChannels : ['browser']
// Initialize trading config UI states first
let trailingEnabled = false
// Initialize AI filter state
let aiFilterEnabled = false
if (strategy.trading_config) {
const tc = strategy.trading_config || {}
const trailingObj = (tc.trailing && typeof tc.trailing === 'object') ? tc.trailing : null
trailingEnabled = (tc.trailing_enabled !== undefined) ? !!tc.trailing_enabled : !!(trailingObj && trailingObj.enabled)
// Check AI filter - handle various truthy values
const aiVal = tc.enable_ai_filter
aiFilterEnabled = aiVal === true || aiVal === 'true' || aiVal === 1 || aiVal === '1'
// Determine which collapse panels to open based on config to ensure fields are rendered
const scaleObj = (tc.scale && typeof tc.scale === 'object') ? tc.scale : null
const posObj = (tc.position && typeof tc.position === 'object') ? tc.position : null
const panels = new Set(['risk']) // Always keep risk panel open or as default
// Check Scale panel content
if (
tc.trend_add_enabled || tc.dca_add_enabled ||
(scaleObj && (scaleObj.trendAdd?.enabled || scaleObj.dcaAdd?.enabled))
) {
panels.add('scale')
}
// Check Reduce panel content
if (
tc.trend_reduce_enabled || tc.adverse_reduce_enabled ||
(scaleObj && (scaleObj.trendReduce?.enabled || scaleObj.adverseReduce?.enabled))
) {
panels.add('reduce')
}
// Check Position panel content - open if entry_pct is customized or if we just want to show it
// Considering "Entry sizing" parameter missing issue, better to expand it if data exists
if (tc.entry_pct !== undefined || (posObj && posObj.entryPct !== undefined)) {
panels.add('position')
}
this.backtestCollapseKeys = Array.from(panels)
}
this.trailingEnabledUi = trailingEnabled
this.aiFilterEnabledUi = aiFilterEnabled
// Wait for DOM update to ensure v-if fields (trailing) and Collapse panels are rendered
await this.$nextTick()
// First, set the "switch" fields that might control v-if visibility of other fields
// For ai_filter, it relies on aiFilterEnabledUi which is updated above, so form value is secondary for UI display
// But we still need to set form value for submission
this.form.setFieldsValue({
enable_ai_filter: aiFilterEnabled
})
// Wait for form value to update and v-if to re-render (for ai_filter)
await this.$nextTick()
this.form.setFieldsValue({
execution_mode: this.executionModeUi,
notify_channels: this.notifyChannelsUi,
notify_email: strategy.notification_config?.targets?.email || '',
notify_phone: strategy.notification_config?.targets?.phone || '',
notify_telegram: strategy.notification_config?.targets?.telegram || '',
notify_discord: strategy.notification_config?.targets?.discord || '',
notify_webhook: strategy.notification_config?.targets?.webhook || ''
})
// 加载指标数据
if (strategy.indicator_config && strategy.indicator_config.indicator_id) {
// 查找对应的指标,确保ID类型一致(处理 string vs number 问题)
const targetId = strategy.indicator_config.indicator_id
// 使用字符串比较处理类型不匹配问题(string vs number
const indicator = this.availableIndicators.find(ind => {
return String(ind.id) === String(targetId)
})
if (indicator) {
// 找到匹配的指标,使用指标对象中的ID(确保类型一致)
const finalId = String(indicator.id)
this.form.setFieldsValue({
indicator_id: finalId
})
this.handleIndicatorChange(finalId)
} else {
// 如果找不到,仍然设置值,但可能显示为ID
this.form.setFieldsValue({
indicator_id: String(targetId)
})
}
}
// Load exchange/broker configuration
2025-12-29 03:06:49 +08:00
if (strategy.exchange_config) {
const exchangeId = strategy.exchange_config.exchange_id || ''
2025-12-29 03:06:49 +08:00
const isLive = this.executionModeUi === 'live'
2026-01-13 04:01:42 +08:00
const supportsLiveTrading = ['Crypto', 'USStock', 'HShare', 'Forex'].includes(this.selectedMarketCategory)
const isBrokerMarket = ['USStock', 'HShare'].includes(this.selectedMarketCategory)
2026-01-13 04:01:42 +08:00
const isForexMarket = this.selectedMarketCategory === 'Forex'
2025-12-29 03:06:49 +08:00
if (isLive && supportsLiveTrading) {
if (isBrokerMarket) {
// Broker configuration (US/HK stocks)
this.currentBrokerId = exchangeId || 'ibkr'
this.form.setFieldsValue({
broker_id: exchangeId || 'ibkr',
ibkr_host: strategy.exchange_config.ibkr_host || '127.0.0.1',
ibkr_port: strategy.exchange_config.ibkr_port || 7497,
ibkr_client_id: strategy.exchange_config.ibkr_client_id || 1,
ibkr_account: strategy.exchange_config.ibkr_account || ''
})
2026-01-13 04:01:42 +08:00
} else if (isForexMarket) {
// MT5 configuration (Forex)
this.currentBrokerId = exchangeId || 'mt5'
this.form.setFieldsValue({
forex_broker_id: exchangeId || 'mt5',
mt5_server: strategy.exchange_config.mt5_server || '',
mt5_login: strategy.exchange_config.mt5_login || '',
mt5_password: strategy.exchange_config.mt5_password || '',
mt5_terminal_path: strategy.exchange_config.mt5_terminal_path || ''
})
} else {
// Crypto exchange configuration
this.currentExchangeId = exchangeId
this.suppressApiClearOnce = true
this.form.setFieldsValue({
exchange_id: exchangeId,
credential_id: strategy.exchange_config.credential_id || undefined,
api_key: strategy.exchange_config.api_key || '',
secret_key: strategy.exchange_config.secret_key || '',
passphrase: strategy.exchange_config.passphrase || ''
})
2025-12-29 03:06:49 +08:00
// If a vault credential is selected, auto-fill secrets from vault
const credId = strategy.exchange_config.credential_id
if (credId) {
await this.handleCredentialSelectChange(credId)
}
2025-12-29 03:06:49 +08:00
}
}
// Update UI state
2026-01-13 04:01:42 +08:00
if (isBrokerMarket || isForexMarket) {
this.currentBrokerId = exchangeId || (isForexMarket ? 'mt5' : 'ibkr')
} else {
this.currentExchangeId = exchangeId
2025-12-29 03:06:49 +08:00
}
}
// 加载交易配置
if (strategy.trading_config) {
const tc = strategy.trading_config || {}
const trailingObj = (tc.trailing && typeof tc.trailing === 'object') ? tc.trailing : null
const scaleObj = (tc.scale && typeof tc.scale === 'object') ? tc.scale : null
const posObj = (tc.position && typeof tc.position === 'object') ? tc.position : null
// Backward compatible: nested configs from indicator-analysis backtest modal
const trendAddObj = scaleObj && scaleObj.trendAdd ? scaleObj.trendAdd : null
const dcaAddObj = scaleObj && scaleObj.dcaAdd ? scaleObj.dcaAdd : null
const trendReduceObj = scaleObj && scaleObj.trendReduce ? scaleObj.trendReduce : null
const adverseReduceObj = scaleObj && scaleObj.adverseReduce ? scaleObj.adverseReduce : null
// Backward compatible: show symbol as "Market:SYMBOL" for watchlist dropdown
const rawSymbol = tc.symbol
const symbolValue = (typeof rawSymbol === 'string' && rawSymbol.includes(':'))
? rawSymbol
: `${this.selectedMarketCategory}:${rawSymbol}`
this.form.setFieldsValue({
strategy_name: strategy.strategy_name,
symbol: symbolValue,
initial_capital: tc.initial_capital,
leverage: tc.leverage,
trade_direction: tc.trade_direction || 'long',
timeframe: tc.timeframe || '1H',
market_type: (tc.market_type === 'futures' ? 'swap' : (tc.market_type || 'swap')),
take_profit_pct: tc.take_profit_pct || 0,
stop_loss_pct: tc.stop_loss_pct || 0,
// Trailing: support both flat fields and nested trailing object
trailing_enabled: trailingEnabled,
trailing_stop_pct: (tc.trailing_stop_pct !== undefined) ? (tc.trailing_stop_pct || 0) : (trailingObj ? (trailingObj.pct || 0) : 0),
trailing_activation_pct: (tc.trailing_activation_pct !== undefined) ? (tc.trailing_activation_pct || 0) : (trailingObj ? (trailingObj.activationPct || 0) : 0),
// Scale / reduce: support both flat fields and nested objects
trend_add_enabled: (tc.trend_add_enabled !== undefined) ? !!tc.trend_add_enabled : !!(trendAddObj && trendAddObj.enabled),
trend_add_step_pct: (tc.trend_add_step_pct !== undefined) ? (tc.trend_add_step_pct || 0) : (trendAddObj ? (trendAddObj.stepPct || 0) : 0),
trend_add_size_pct: (tc.trend_add_size_pct !== undefined) ? (tc.trend_add_size_pct || 0) : (trendAddObj ? (trendAddObj.sizePct || 0) : 0),
trend_add_max_times: (tc.trend_add_max_times !== undefined) ? (tc.trend_add_max_times || 0) : (trendAddObj ? (trendAddObj.maxTimes || 0) : 0),
dca_add_enabled: (tc.dca_add_enabled !== undefined) ? !!tc.dca_add_enabled : !!(dcaAddObj && dcaAddObj.enabled),
dca_add_step_pct: (tc.dca_add_step_pct !== undefined) ? (tc.dca_add_step_pct || 0) : (dcaAddObj ? (dcaAddObj.stepPct || 0) : 0),
dca_add_size_pct: (tc.dca_add_size_pct !== undefined) ? (tc.dca_add_size_pct || 0) : (dcaAddObj ? (dcaAddObj.sizePct || 0) : 0),
dca_add_max_times: (tc.dca_add_max_times !== undefined) ? (tc.dca_add_max_times || 0) : (dcaAddObj ? (dcaAddObj.maxTimes || 0) : 0),
trend_reduce_enabled: (tc.trend_reduce_enabled !== undefined) ? !!tc.trend_reduce_enabled : !!(trendReduceObj && trendReduceObj.enabled),
trend_reduce_step_pct: (tc.trend_reduce_step_pct !== undefined) ? (tc.trend_reduce_step_pct || 0) : (trendReduceObj ? (trendReduceObj.stepPct || 0) : 0),
trend_reduce_size_pct: (tc.trend_reduce_size_pct !== undefined) ? (tc.trend_reduce_size_pct || 0) : (trendReduceObj ? (trendReduceObj.sizePct || 0) : 0),
trend_reduce_max_times: (tc.trend_reduce_max_times !== undefined) ? (tc.trend_reduce_max_times || 0) : (trendReduceObj ? (trendReduceObj.maxTimes || 0) : 0),
adverse_reduce_enabled: (tc.adverse_reduce_enabled !== undefined) ? !!tc.adverse_reduce_enabled : !!(adverseReduceObj && adverseReduceObj.enabled),
adverse_reduce_step_pct: (tc.adverse_reduce_step_pct !== undefined) ? (tc.adverse_reduce_step_pct || 0) : (adverseReduceObj ? (adverseReduceObj.stepPct || 0) : 0),
adverse_reduce_size_pct: (tc.adverse_reduce_size_pct !== undefined) ? (tc.adverse_reduce_size_pct || 0) : (adverseReduceObj ? (adverseReduceObj.sizePct || 0) : 0),
adverse_reduce_max_times: (tc.adverse_reduce_max_times !== undefined) ? (tc.adverse_reduce_max_times || 0) : (adverseReduceObj ? (adverseReduceObj.maxTimes || 0) : 0),
entry_pct: (tc.entry_pct === 0 || tc.entry_pct) ? tc.entry_pct : (posObj && posObj.entryPct ? posObj.entryPct : 100),
2025-12-29 19:05:17 +08:00
// AI智能决策过滤
2025-12-29 03:06:49 +08:00
enable_ai_filter: aiFilterEnabled
})
this.$nextTick(() => {
this.recalcEntryPctMaxUi()
this.normalizeEntryPct()
})
}
},
handleSelectStrategy (strategy) {
this.selectedStrategy = strategy
this.currentEquity = null // 重置当前净值
this.loadStrategyDetails()
this.startEquityPolling() // 开始轮询净值
},
async loadStrategyDetails () {
if (!this.selectedStrategy) {
return Promise.resolve()
}
// 加载净值数据
try {
const res = await getStrategyEquityCurve(this.selectedStrategy.id)
if (res.code === 1 && res.data) {
// Local backend returns an array curve: [{ time, equity }, ...]
if (Array.isArray(res.data) && res.data.length > 0) {
const last = res.data[res.data.length - 1]
this.currentEquity = last.equity
} else {
const base = this.selectedStrategy.trading_config?.initial_capital || this.selectedStrategy.initial_capital
this.currentEquity = base || null
}
}
} catch (error) {
}
},
startEquityPolling () {
this.stopEquityPolling()
if (!this.selectedStrategy) return
// 初始加载一次
this.loadStrategyDetails()
// 每30秒轮询一次
this.equityPollingTimer = setInterval(() => {
this.loadStrategyDetails()
}, 30000)
},
stopEquityPolling () {
if (this.equityPollingTimer) {
clearInterval(this.equityPollingTimer)
this.equityPollingTimer = null
}
},
handleCloseModal () {
this.showFormModal = false
this.editingStrategy = null
this.isEditMode = false
this.strategyType = 'indicator'
this.currentStep = 0
this.currentExchangeId = ''
this.selectedIndicator = null
this.testResult = null
this.connectionTestResult = null
this.indicatorsLoaded = false
this.availableIndicators = []
this.backtestCollapseKeys = ['risk']
this.trailingEnabledUi = false
this.entryPctMaxUi = 100
this.aiFilterEnabledUi = false
this.form.resetFields()
},
handleRefresh () {
this.loadStrategies()
this.showFormModal = false
},
handleMenuClick (key, strategy) {
switch (key) {
case 'start':
this.handleStartStrategy(strategy.id)
break
case 'stop':
this.handleStopStrategy(strategy.id)
break
case 'edit':
this.handleEditStrategy(strategy)
break
case 'delete':
this.handleDeleteStrategy(strategy)
break
}
},
toggleGroup (groupId) {
this.$set(this.collapsedGroups, groupId, !this.collapsedGroups[groupId])
},
async handleGroupMenuClick (key, group) {
const strategyIds = group.strategies.map(s => s.id)
switch (key) {
case 'startAll':
await this.handleBatchStartStrategies(strategyIds, group.baseName)
break
case 'stopAll':
await this.handleBatchStopStrategies(strategyIds, group.baseName)
break
case 'deleteAll':
await this.handleBatchDeleteStrategies(strategyIds, group.baseName)
break
}
},
async handleBatchStartStrategies (strategyIds, groupName) {
try {
const res = await batchStartStrategies({ strategy_ids: strategyIds })
if (res.code === 1) {
const count = res.data?.success_ids?.length || strategyIds.length
this.$message.success(this.$t('trading-assistant.messages.batchStartSuccess', { count }))
this.loadStrategies()
} else {
this.$message.error(res.msg || this.$t('trading-assistant.messages.batchStartFailed'))
}
} catch (error) {
this.$message.error(this.$t('trading-assistant.messages.batchStartFailed'))
}
},
async handleBatchStopStrategies (strategyIds, groupName) {
try {
const res = await batchStopStrategies({ strategy_ids: strategyIds })
if (res.code === 1) {
const count = res.data?.success_ids?.length || strategyIds.length
this.$message.success(this.$t('trading-assistant.messages.batchStopSuccess', { count }))
this.loadStrategies()
} else {
this.$message.error(res.msg || this.$t('trading-assistant.messages.batchStopFailed'))
}
} catch (error) {
this.$message.error(this.$t('trading-assistant.messages.batchStopFailed'))
}
},
async handleBatchDeleteStrategies (strategyIds, groupName) {
const confirmText = this.$t('trading-assistant.messages.batchDeleteConfirm', {
count: strategyIds.length,
name: groupName
})
this.$confirm({
title: this.$t('trading-assistant.deleteAll'),
content: confirmText,
okText: this.$t('trading-assistant.deleteAll'),
okType: 'danger',
cancelText: this.$t('trading-assistant.form.cancel'),
onOk: async () => {
try {
const res = await batchDeleteStrategies({ strategy_ids: strategyIds })
if (res.code === 1) {
const count = res.data?.success_ids?.length || strategyIds.length
this.$message.success(this.$t('trading-assistant.messages.batchDeleteSuccess', { count }))
// 如果删除的策略包含当前选中的策略,清空选中状态
if (this.selectedStrategy && strategyIds.includes(this.selectedStrategy.id)) {
this.selectedStrategy = null
this.stopEquityPolling()
}
this.loadStrategies()
} else {
this.$message.error(res.msg || this.$t('trading-assistant.messages.batchDeleteFailed'))
}
} catch (error) {
this.$message.error(this.$t('trading-assistant.messages.batchDeleteFailed'))
}
}
})
},
2025-12-29 03:06:49 +08:00
async handleStartStrategy (id) {
try {
const res = await startStrategy(id)
if (res.code === 1) {
this.$message.success(this.$t('trading-assistant.messages.startSuccess'))
this.loadStrategies()
// 更新选中的策略状态
if (this.selectedStrategy && this.selectedStrategy.id === id) {
this.selectedStrategy.status = 'running'
}
} else {
this.$message.error(res.msg || this.$t('trading-assistant.messages.startFailed'))
}
} catch (error) {
this.$message.error(this.$t('trading-assistant.messages.startFailed'))
}
},
async handleStopStrategy (id) {
try {
const res = await stopStrategy(id)
if (res.code === 1) {
this.$message.success(this.$t('trading-assistant.messages.stopSuccess'))
this.loadStrategies()
// 更新选中的策略状态
if (this.selectedStrategy && this.selectedStrategy.id === id) {
this.selectedStrategy.status = 'stopped'
}
} else {
this.$message.error(res.msg || this.$t('trading-assistant.messages.stopFailed'))
}
} catch (error) {
this.$message.error(this.$t('trading-assistant.messages.stopFailed'))
}
},
handleDeleteStrategy (strategy) {
const confirmText = this.$t('trading-assistant.messages.deleteConfirmWithName', {
name: strategy.strategy_name
})
this.$confirm({
title: this.$t('trading-assistant.deleteStrategy'),
content: confirmText,
okText: this.$t('trading-assistant.deleteStrategy'),
okType: 'danger',
cancelText: this.$t('trading-assistant.form.cancel'),
onOk: async () => {
try {
const res = await deleteStrategy(strategy.id)
if (res.code === 1) {
this.$message.success(this.$t('trading-assistant.messages.deleteSuccess'))
if (this.selectedStrategy && this.selectedStrategy.id === strategy.id) {
this.selectedStrategy = null
this.stopEquityPolling() // 停止轮询
}
this.loadStrategies()
} else {
this.$message.error(res.msg || this.$t('trading-assistant.messages.deleteFailed'))
}
} catch (error) {
this.$message.error(this.$t('trading-assistant.messages.deleteFailed'))
}
}
})
},
getStatusColor (status) {
const colors = {
running: 'green',
stopped: 'default',
error: 'red'
}
return colors[status] || 'default'
},
getStatusText (status) {
return this.$t(`trading-assistant.status.${status}`) || status
},
getStrategyTypeText (type) {
return this.$t(`trading-assistant.strategyType.${type}`) || type
},
getTradeDirectionText (direction) {
if (!direction) return ''
const directionMap = {
long: this.$t('trading-assistant.form.tradeDirectionLong') || '做多',
short: this.$t('trading-assistant.form.tradeDirectionShort') || '做空',
both: this.$t('trading-assistant.form.tradeDirectionBoth') || '双向'
}
return directionMap[direction] || direction
},
// 指标相关方法
async handleIndicatorSelectFocus () {
// 懒加载:只在用户点击选择框时才加载指标
if (!this.indicatorsLoaded && !this.loadingIndicators) {
await this.loadIndicators()
}
},
async loadIndicators () {
if (this.loadingIndicators) {
// 如果正在加载,等待加载完成
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (!this.loadingIndicators) {
clearInterval(checkInterval)
resolve()
}
}, 100)
})
}
if (this.indicatorsLoaded) {
return Promise.resolve()
}
// 获取用户ID
const userInfo = this.$store.getters.userInfo || {}
const userId = userInfo.id || 1
this.loadingIndicators = true
try {
// 使用和 indicator-analysis 页面相同的接口
const res = await request({
url: '/api/indicator/getIndicators',
method: 'post',
data: {
userid: userId
}
})
if (res.code === 1 && res.data) {
// 将所有指标(包括选购的和自己创建的)合并到一个数组
const indicators = res.data.map(item => ({
id: item.id,
name: item.name,
description: item.description,
type: item.indicator_type || item.indicatorType || 'python',
code: item.code,
is_buy: item.is_buy,
source: item.is_buy === 1 ? 'bought' : 'custom'
}))
this.availableIndicators = indicators
this.indicatorsLoaded = true
} else {
this.availableIndicators = []
this.$message.warning(res.msg || this.$t('trading-assistant.messages.loadIndicatorsFailed'))
}
} catch (error) {
this.availableIndicators = []
this.$message.warning(this.$t('trading-assistant.messages.loadIndicatorsFailed'))
} finally {
this.loadingIndicators = false
}
},
handleIndicatorChange (indicatorId) {
const idStr = String(indicatorId)
this.selectedIndicator = this.availableIndicators.find(ind => String(ind.id) === idStr)
},
handleMarketTypeChange (e) {
const marketType = e.target.value
// 如果切换到现货,自动设置交易方向为做多,杠杆为1
if (marketType === 'spot') {
this.form.setFieldsValue({
trade_direction: 'long',
leverage: 1
})
}
},
// --- Backtest-like UI helpers (Ant Form getFieldValue is not reactive) ---
recalcEntryPctMaxUi () {
if (!this.form) {
this.entryPctMaxUi = 100
return
}
const trendOn = !!this.form.getFieldValue('trend_add_enabled')
const dcaOn = !!this.form.getFieldValue('dca_add_enabled')
const trendTimes = Number(this.form.getFieldValue('trend_add_max_times') || 0)
const dcaTimes = Number(this.form.getFieldValue('dca_add_max_times') || 0)
const trendSizePct = Number(this.form.getFieldValue('trend_add_size_pct') || 0)
const dcaSizePct = Number(this.form.getFieldValue('dca_add_size_pct') || 0)
const reservePct = (trendOn ? trendTimes * trendSizePct : 0) + (dcaOn ? dcaTimes * dcaSizePct : 0)
const maxEntryPct = Math.max(0, Math.min(100, 100 - reservePct))
this.entryPctMaxUi = maxEntryPct
},
normalizeEntryPct () {
if (!this.form) return
const current = Number(this.form.getFieldValue('entry_pct') || 0)
const max = Number(this.entryPctMaxUi || 100)
if (current > max) {
this.form.setFieldsValue({ entry_pct: max })
}
},
onTrendAddToggle (checked) {
if (!this.form) return
// Mutual exclusion to avoid double scale-in on the same candle.
if (checked) {
this.form.setFieldsValue({ dca_add_enabled: false })
}
this.$nextTick(() => {
this.recalcEntryPctMaxUi()
this.normalizeEntryPct()
})
},
onDcaAddToggle (checked) {
if (!this.form) return
if (checked) {
this.form.setFieldsValue({ trend_add_enabled: false })
}
this.$nextTick(() => {
this.recalcEntryPctMaxUi()
this.normalizeEntryPct()
})
},
onScaleParamsChange () {
this.$nextTick(() => {
this.recalcEntryPctMaxUi()
this.normalizeEntryPct()
})
},
onEntryPctChange () {
this.$nextTick(() => this.normalizeEntryPct())
},
onTrailingToggle (checked) {
if (!this.form) return
this.trailingEnabledUi = !!checked
// Only show fields when enabled; also clear values when disabled.
if (!checked) {
this.form.setFieldsValue({ trailing_stop_pct: 0, trailing_activation_pct: 0 })
}
},
onAiFilterToggle (checked) {
this.aiFilterEnabledUi = !!checked
// Ensure rc-form value is always in sync even if decorator event binding gets overridden.
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ enable_ai_filter: !!checked })
} catch (e) {}
},
filterIndicatorOption (input, option) {
const text = option.componentOptions.children[0].children[0].text
return text.toLowerCase().indexOf(input.toLowerCase()) >= 0
},
filterSymbolOption (input, option) {
return option.componentOptions.children[0].text.toLowerCase().indexOf(input.toLowerCase()) >= 0
},
getIndicatorTypeColor (type) {
const colors = {
trend: 'blue',
momentum: 'green',
volatility: 'orange',
volume: 'purple',
custom: 'default'
}
return colors[type] || 'default'
},
getIndicatorTypeName (type) {
return this.$t(`trading-assistant.indicatorType.${type}`) || type
},
// 交易所相关方法
getExchangeName (exchange) {
if (exchange.labelKey) {
const translationKey = `trading-assistant.exchangeNames.${exchange.labelKey}`
const translated = this.$t(translationKey)
// 如果翻译不存在,返回键值本身,否则返回翻译后的名称
if (translated === translationKey) {
// 翻译不存在,返回交易所ID的首字母大写
return exchange.value.charAt(0).toUpperCase() + exchange.value.slice(1)
}
return translated
}
return exchange.label || exchange.value
},
getExchangeDisplayName (exchangeId) {
if (!exchangeId) return ''
// 查找对应的交易所选项
const exchange = this.exchangeOptions.find(ex => ex.value === exchangeId)
if (exchange) {
return this.getExchangeName(exchange)
}
// 如果找不到,返回格式化的交易所ID
return exchangeId.charAt(0).toUpperCase() + exchangeId.slice(1)
},
getExchangeTagColor (exchangeId) {
// 为不同交易所设置不同的标签颜色
const colorMap = {
binance: 'gold',
okx: 'blue',
coinbaseexchange: 'cyan',
kraken: 'purple',
bitfinex: 'geekblue',
huobi: 'orange',
gate: 'green',
mexc: 'lime',
kucoin: 'volcano',
bybit: 'red',
bitget: 'magenta',
bitmex: 'red',
deribit: 'blue',
phemex: 'cyan',
bitmart: 'geekblue',
bitstamp: 'purple',
bittrex: 'orange',
poloniex: 'green',
gemini: 'lime',
cryptocom: 'volcano',
blockchaincom: 'magenta',
bitflyer: 'red',
upbit: 'blue',
bithumb: 'cyan',
coinone: 'purple',
zb: 'geekblue',
lbank: 'orange',
bibox: 'green',
bigone: 'lime',
bitrue: 'volcano',
coinex: 'magenta',
ftx: 'red',
ftxus: 'blue',
binanceus: 'gold',
binancecoinm: 'gold',
binanceusdm: 'gold',
ibkr: 'green'
2025-12-29 03:06:49 +08:00
}
return colorMap[exchangeId] || 'default'
},
handleApiConfigChange () {
// 当API配置字段变化时,清空测试结果,需要重新测试
this.testResult = null
this.connectionTestResult = null
},
getModalPopupContainer () {
// Return document.body for Select dropdown to avoid modal scroll issues
return window.document.body
},
handleBrokerSelectChange (value) {
this.currentBrokerId = value || 'ibkr'
this.testResult = null
this.connectionTestResult = null
},
2026-01-13 04:01:42 +08:00
handleForexBrokerSelectChange (value) {
this.currentBrokerId = value || 'mt5'
this.testResult = null
this.connectionTestResult = null
},
2025-12-29 03:06:49 +08:00
handleExchangeSelectChange (value) {
this.currentExchangeId = value || ''
this.testResult = null
this.connectionTestResult = null
// If exchange_id is set programmatically (e.g. by selecting a saved credential),
// don't clear the api fields we just filled.
if (this.suppressApiClearOnce) {
this.suppressApiClearOnce = false
return
}
// Clear API fields when exchange changes, as we rely on "Saved credential"
this.$nextTick(() => {
const fieldsToClear = {
api_key: undefined,
secret_key: undefined,
passphrase: undefined
}
setTimeout(() => {
this.form.setFieldsValue(fieldsToClear)
}, 100)
})
},
getPlaceholder (fieldType) {
const placeholders = {
okx: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key',
passphrase: '请输入Passphrase(创建API时设置)'
},
okex: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key',
passphrase: '请输入Passphrase(创建API时设置)'
},
binance: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key'
},
coinbaseexchange: {
api_key: '请输入API Key(或Key Name',
secret_key: '请输入API Secret(或Private Key',
passphrase: '请输入PassphraseLegacy Pro API需要)'
},
kucoin: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key',
passphrase: '请输入Passphrase'
},
gate: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key'
},
mexc: {
api_key: '请输入Access Key',
secret_key: '请输入Secret Key'
},
kraken: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key'
},
bitfinex: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key'
},
bybit: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key'
},
bitget: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key',
passphrase: '请输入PassphraseLegacy Pro API需要)'
}
}
const exchangePlaceholders = placeholders[this.currentExchangeId] || {}
if (exchangePlaceholders[fieldType]) {
return exchangePlaceholders[fieldType]
}
// 默认占位符使用多语言
const fieldLabels = {
'api_key': this.$t('trading-assistant.placeholders.inputApiKey'),
'secret_key': this.$t('trading-assistant.placeholders.inputSecretKey'),
'passphrase': this.$t('trading-assistant.placeholders.inputPassphrase')
}
return fieldLabels[fieldType] || this.$t('trading-assistant.placeholders.inputApiKey')
},
async handleTestConnection () {
this.testResult = null
this.testing = true
try {
// IBKR uses different connection test (host/port instead of api_key/secret)
if (this.isIBKRMarket) {
const values = this.form.getFieldsValue(['ibkr_host', 'ibkr_port', 'ibkr_client_id', 'ibkr_account'])
const host = values.ibkr_host || '127.0.0.1'
const port = values.ibkr_port || 7497
const clientId = values.ibkr_client_id || 1
const account = values.ibkr_account || ''
try {
// Call IBKR connect API
const res = await this.$http.post('/api/ibkr/connect', {
host: host,
port: parseInt(port),
clientId: parseInt(clientId),
account: account
})
if (res.data && res.data.success) {
this.testResult = {
success: true,
message: this.$t('trading-assistant.exchange.ibkrConnectionSuccess')
}
this.$message.success(this.$t('trading-assistant.exchange.ibkrConnectionSuccess'))
} else {
this.testResult = {
success: false,
message: res.data?.error || this.$t('trading-assistant.exchange.ibkrConnectionFailed')
}
this.$message.error(this.testResult.message)
}
} catch (error) {
this.testResult = {
success: false,
message: error.response?.data?.error || error.message || this.$t('trading-assistant.exchange.ibkrConnectionFailed')
}
this.$message.error(this.testResult.message)
} finally {
this.testing = false
}
return
}
2026-01-13 04:01:42 +08:00
// MT5 uses different connection test (server/login/password)
if (this.isMT5Market) {
const values = this.form.getFieldsValue(['mt5_server', 'mt5_login', 'mt5_password', 'mt5_terminal_path'])
const server = values.mt5_server || ''
const login = values.mt5_login || ''
const password = values.mt5_password || ''
const terminal_path = values.mt5_terminal_path || ''
if (!server || !login || !password) {
this.testResult = {
success: false,
message: this.$t('trading-assistant.exchange.fillComplete')
}
this.$message.error(this.testResult.message)
this.testing = false
return
}
try {
// Call MT5 connect API
const res = await this.$http.post('/api/mt5/connect', {
server: server,
login: parseInt(login),
password: password,
terminal_path: terminal_path
})
if (res.data && res.data.success) {
this.testResult = {
success: true,
message: this.$t('trading-assistant.exchange.mt5ConnectionSuccess')
}
this.$message.success(this.$t('trading-assistant.exchange.mt5ConnectionSuccess'))
} else {
this.testResult = {
success: false,
message: res.data?.error || this.$t('trading-assistant.exchange.mt5ConnectionFailed')
}
this.$message.error(this.testResult.message)
}
} catch (error) {
this.testResult = {
success: false,
message: error.response?.data?.error || error.message || this.$t('trading-assistant.exchange.mt5ConnectionFailed')
}
this.$message.error(this.testResult.message)
} finally {
this.testing = false
}
return
}
// Crypto exchanges: validate api_key/secret_key fields
2025-12-29 03:06:49 +08:00
const fieldsToValidate = ['exchange_id', 'api_key', 'secret_key']
if (this.needsPassphrase) {
fieldsToValidate.push('passphrase')
}
this.form.validateFields(fieldsToValidate, async (err, values) => {
if (err) {
this.testing = false
this.$message.error(this.$t('trading-assistant.exchange.fillComplete'))
return
}
try {
const marketType = (this.form.getFieldValue('market_type') || 'swap')
const exchangeConfig = {
exchange_id: values.exchange_id,
api_key: values.api_key,
secret_key: values.secret_key,
market_type: String(marketType || 'swap')
}
if (this.needsPassphrase && values.passphrase) {
exchangeConfig.passphrase = values.passphrase
}
const res = await testExchangeConnection(exchangeConfig)
this.testResult = {
success: res.code === 1,
message: res.msg || (res.code === 1 ? this.$t('trading-assistant.exchange.connectionSuccess') : this.$t('trading-assistant.exchange.connectionFailed'))
}
this.connectionTestResult = null
if (this.testResult.success) {
this.$message.success(this.$t('trading-assistant.exchange.connectionSuccess'))
} else {
this.$message.error(this.testResult.message || this.$t('trading-assistant.exchange.testFailed'))
}
} catch (error) {
this.testResult = {
success: false,
message: error.message || this.$t('trading-assistant.exchange.testFailed')
}
this.$message.error(this.testResult.message)
} finally {
this.testing = false
}
})
} catch (error) {
this.testResult = {
success: false,
message: this.$t('trading-assistant.exchange.testFailed')
}
this.$message.error(this.$t('trading-assistant.exchange.testFailed'))
this.testing = false
}
},
// 表单步骤控制
handleNext () {
if (this.currentStep === 0) {
// Step 1: basic config (strategy name/symbol/capital/leverage/market type/timeframe)
// 编辑模式验证 symbol,创建模式验证 selectedSymbols(多选)
2025-12-29 03:06:49 +08:00
const fieldsToValidate = [
'indicator_id',
'strategy_name',
'initial_capital',
'market_type',
'leverage',
'trade_direction',
'timeframe'
]
// 编辑模式需要验证 symbol 字段
if (this.isEditMode) {
fieldsToValidate.push('symbol')
}
2025-12-29 03:06:49 +08:00
this.form.validateFields(fieldsToValidate, (err, values) => {
if (err) return
// 创建模式:验证多币种选择
if (!this.isEditMode) {
if (!this.selectedSymbols || this.selectedSymbols.length === 0) {
this.$message.warning(this.$t('trading-assistant.validation.symbolsRequired'))
return
}
}
2025-12-29 03:06:49 +08:00
// Enforce spot limitations
try {
const marketType = (values && values.market_type) || this.form.getFieldValue('market_type')
if (marketType === 'spot') {
this.form.setFieldsValue({ leverage: 1, trade_direction: 'long' })
}
} catch (e) {}
// Init backtest-like UI states for Step 2 (Ant Form is not reactive).
this.backtestCollapseKeys = ['risk']
try {
this.trailingEnabledUi = !!this.form.getFieldValue('trailing_enabled')
} catch (e) {
this.trailingEnabledUi = false
}
this.$nextTick(() => {
this.recalcEntryPctMaxUi()
this.normalizeEntryPct()
})
this.currentStep++
})
} else if (this.currentStep === 1) {
// Step 2: backtest-like configs are optional; proceed directly.
// Sync UI states from form (Ant Form values are not always reactive)
try {
const execMode = this.form.getFieldValue('execution_mode') || 'signal'
this.executionModeUi = execMode
const chans = this.form.getFieldValue('notify_channels') || ['browser']
this.notifyChannelsUi = Array.isArray(chans) ? chans : ['browser']
} catch (e) {}
this.currentStep++
}
},
handlePrev () {
if (this.currentStep > 0) {
this.currentStep--
}
},
async handleSubmit () {
this.form.validateFields(async (err, values) => {
if (!err) {
try {
this.saving = true
const isLive = this.canUseLiveTrading && values.execution_mode === 'live'
2025-12-29 03:06:49 +08:00
if (isLive) {
const testResult = this.testResult
if (!testResult) {
this.$message.warning(this.$t('trading-assistant.validation.testConnectionRequired'))
this.saving = false
return
}
if (!testResult.success) {
this.$message.warning(this.$t('trading-assistant.validation.testConnectionFailed'))
this.saving = false
return
}
}
const notificationConfig = {
channels: values.notify_channels || [],
targets: {
email: values.notify_email || '',
phone: values.notify_phone || '',
telegram: values.notify_telegram || '',
discord: values.notify_discord || '',
webhook: values.notify_webhook || ''
}
}
if (!notificationConfig.channels || notificationConfig.channels.length === 0) {
this.$message.warning(this.$t('trading-assistant.validation.notifyChannelRequired'))
this.saving = false
return
}
// Indicator strategy submit logic (local mode)
const indicatorIdStr = String(values.indicator_id)
const indicator = this.availableIndicators.find(ind => String(ind.id) === indicatorIdStr)
if (!indicator) {
this.$message.error(this.$t('trading-assistant.validation.indicatorRequired'))
this.saving = false
return
}
// AI filter values: source of truth is the reactive UI state to avoid rc-form edge cases.
const enableAiFilter = !!this.aiFilterEnabledUi
const marketType = (values.market_type === 'futures' ? 'swap' : (values.market_type || 'swap'))
let leverage = values.leverage || 1
let tradeDirection = values.trade_direction || 'long'
if (marketType === 'spot') {
leverage = 1
tradeDirection = 'long'
this.$message.info(this.$t('trading-assistant.messages.spotLimitations'))
} else {
if (leverage < 1) leverage = 1
if (leverage > 125) leverage = 125
}
// 构建基础 payload
const basePayload = {
2025-12-29 03:06:49 +08:00
strategy_name: values.strategy_name,
market_category: this.selectedMarketCategory || 'Crypto',
2025-12-29 03:06:49 +08:00
execution_mode: values.execution_mode || 'signal',
notification_config: notificationConfig,
indicator_config: {
indicator_id: indicator.id,
indicator_name: indicator.name,
indicator_code: indicator.code || ''
},
exchange_config: isLive ? (this.isIBKRMarket ? {
// Broker configuration (US/HK stocks)
exchange_id: values.broker_id || this.currentBrokerId || 'ibkr',
// IBKR specific fields
ibkr_host: values.ibkr_host || '127.0.0.1',
ibkr_port: values.ibkr_port || 7497,
ibkr_client_id: values.ibkr_client_id || 1,
ibkr_account: values.ibkr_account || ''
2026-01-13 04:01:42 +08:00
} : this.isMT5Market ? {
// MT5/Forex broker configuration
exchange_id: values.forex_broker_id || this.currentBrokerId || 'mt5',
// MT5 specific fields
mt5_server: values.mt5_server || '',
mt5_login: values.mt5_login || '',
mt5_password: values.mt5_password || '',
mt5_terminal_path: values.mt5_terminal_path || ''
} : {
// Crypto exchange configuration
2025-12-29 03:06:49 +08:00
exchange_id: values.exchange_id,
credential_id: values.credential_id,
api_key: values.api_key,
secret_key: values.secret_key,
passphrase: this.needsPassphrase ? values.passphrase : undefined
}) : undefined,
2025-12-29 03:06:49 +08:00
trading_config: {
initial_capital: values.initial_capital,
leverage: leverage,
trade_direction: tradeDirection,
timeframe: values.timeframe,
market_type: marketType,
// Order execution settings moved to backend env config (ORDER_MODE, MAKER_WAIT_SEC, MAKER_OFFSET_BPS)
2025-12-29 03:06:49 +08:00
margin_mode: 'cross',
signal_mode: 'confirmed',
// Backtest-like configs
take_profit_pct: values.take_profit_pct || 0,
stop_loss_pct: values.stop_loss_pct || 0,
trailing_enabled: !!values.trailing_enabled,
trailing_stop_pct: values.trailing_stop_pct || 0,
trailing_activation_pct: values.trailing_activation_pct || 0,
trend_add_enabled: !!values.trend_add_enabled,
trend_add_step_pct: values.trend_add_step_pct || 0,
trend_add_size_pct: values.trend_add_size_pct || 0,
trend_add_max_times: values.trend_add_max_times || 0,
dca_add_enabled: !!values.dca_add_enabled,
dca_add_step_pct: values.dca_add_step_pct || 0,
dca_add_size_pct: values.dca_add_size_pct || 0,
dca_add_max_times: values.dca_add_max_times || 0,
trend_reduce_enabled: !!values.trend_reduce_enabled,
trend_reduce_step_pct: values.trend_reduce_step_pct || 0,
trend_reduce_size_pct: values.trend_reduce_size_pct || 0,
trend_reduce_max_times: values.trend_reduce_max_times || 0,
adverse_reduce_enabled: !!values.adverse_reduce_enabled,
adverse_reduce_step_pct: values.adverse_reduce_step_pct || 0,
adverse_reduce_size_pct: values.adverse_reduce_size_pct || 0,
adverse_reduce_max_times: values.adverse_reduce_max_times || 0,
entry_pct: (values.entry_pct === 0 || values.entry_pct) ? values.entry_pct : 100,
commission: values.commission || 0,
slippage: values.slippage || 0,
// AI智能决策过滤
2025-12-29 19:05:17 +08:00
enable_ai_filter: enableAiFilter
2025-12-29 03:06:49 +08:00
}
}
let res
if (this.editingStrategy) {
// 编辑模式:更新单个策略
let parsedSymbol = values.symbol
if (typeof parsedSymbol === 'string' && parsedSymbol.includes(':')) {
const idx = parsedSymbol.indexOf(':')
basePayload.market_category = parsedSymbol.slice(0, idx) || basePayload.market_category
parsedSymbol = parsedSymbol.slice(idx + 1)
}
basePayload.trading_config.symbol = parsedSymbol
res = await updateStrategy(this.editingStrategy.id, basePayload)
2025-12-29 03:06:49 +08:00
} else {
// 创建模式:批量创建策略
basePayload.user_id = 1
basePayload.strategy_type = 'IndicatorStrategy'
basePayload.symbols = this.selectedSymbols // 多币种数组
res = await batchCreateStrategies(basePayload)
2025-12-29 03:06:49 +08:00
}
if (res.code === 1) {
if (this.isEditMode) {
this.$message.success(this.$t('trading-assistant.messages.updateSuccess'))
} else {
const totalCreated = res.data?.total_created || this.selectedSymbols.length
this.$message.success(this.$t('trading-assistant.messages.batchCreateSuccess', { count: totalCreated }))
}
2026-01-13 04:01:42 +08:00
// Save credential to vault (crypto exchanges only, IBKR/MT5 don't need this)
if (isLive && values.save_credential && !this.isIBKRMarket && !this.isMT5Market) {
2025-12-29 03:06:49 +08:00
try {
await createExchangeCredential({
user_id: 1,
name: values.credential_name || '',
exchange_id: values.exchange_id,
api_key: values.api_key,
secret_key: values.secret_key,
passphrase: values.passphrase || ''
})
this.loadExchangeCredentials()
} catch (e) {
// Silent fail: vault is optional.
}
}
this.handleRefresh()
} else {
this.$message.error(res.msg || (this.isEditMode ? this.$t('trading-assistant.messages.updateFailed') : this.$t('trading-assistant.messages.createFailed')))
}
} catch (error) {
this.$message.error(this.isEditMode ? this.$t('trading-assistant.messages.updateFailed') : this.$t('trading-assistant.messages.createFailed'))
} finally {
this.saving = false
}
}
})
},
getFormValues () {
return new Promise((resolve, reject) => {
this.form.validateFields((err, values) => {
if (err) {
reject(err)
} else {
resolve(values)
}
})
})
},
getDropdownContainer (triggerNode) {
2026-01-11 21:15:26 +08:00
// 始终将下拉菜单挂载到body,避免被父容器裁剪
return document.body
2025-12-29 03:06:49 +08:00
}
}
}
</script>
<style lang="less" scoped>
2025-12-29 19:05:17 +08:00
// 主色调变量
@primary-color: #1890ff;
@primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@success-color: #0ecb81;
@danger-color: #f6465d;
@warning-color: #f0b90b;
@card-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
@card-shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.12);
@border-radius-lg: 16px;
@border-radius-md: 12px;
@border-radius-sm: 8px;
2025-12-29 03:06:49 +08:00
.trading-assistant {
padding: 0px;
height: calc(100vh - 120px);
2025-12-29 19:05:17 +08:00
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
2025-12-29 03:06:49 +08:00
.strategy-layout {
height: calc(100vh - 120px);
}
// 移动端适配
@media (max-width: 768px) {
min-height: auto;
margin: -24px;
.strategy-layout {
height: auto;
min-height: calc(100vh - 120px);
}
.strategy-list-col {
margin-bottom: 12px;
height: auto;
max-height: 50vh;
.strategy-list-card {
height: auto;
max-height: 50vh;
.card-title {
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
span {
font-size: 14px;
font-weight: 600;
}
.ant-btn {
font-size: 12px;
padding: 0 10px;
height: 28px;
line-height: 28px;
}
}
/deep/ .ant-card-body {
max-height: calc(50vh - 60px);
overflow-y: auto;
padding: 8px;
-webkit-overflow-scrolling: touch;
}
/deep/ .ant-card-head {
padding: 0;
min-height: 48px;
}
.strategy-list-item {
padding: 12px 8px;
margin-bottom: 4px;
border-radius: 8px;
/deep/ .ant-list-item-meta {
width: 100%;
}
/deep/ .ant-list-item-action {
margin-left: 8px;
}
.strategy-item-header {
width: 100%;
.strategy-name-wrapper {
width: 100%;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
flex-wrap: wrap;
.strategy-type-tag {
font-size: 10px;
padding: 2px 6px;
line-height: 1.4;
margin: 0;
}
.exchange-tag {
font-size: 10px;
padding: 2px 6px;
line-height: 1.4;
margin: 0;
.anticon {
font-size: 10px;
margin-right: 2px;
}
}
.strategy-name {
font-size: 14px;
font-weight: 600;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
/deep/ .ant-list-item-meta-description {
.strategy-item-info {
display: flex !important;
flex-direction: row;
align-items: center;
gap: 6px;
margin-top: 4px;
font-size: 11px;
flex-wrap: wrap;
.info-item {
display: flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
font-size: 11px;
.anticon {
font-size: 11px;
}
}
.status-label {
font-size: 10px;
padding: 2px 6px;
line-height: 1.4;
}
}
}
}
}
}
.strategy-detail-col {
height: auto;
min-height: calc(50vh - 60px);
.strategy-detail-panel {
gap: 12px;
.strategy-header-card {
/deep/ .ant-card-body {
padding: 16px 12px;
}
/deep/ .ant-card-head {
padding: 0;
}
.strategy-header {
flex-direction: column;
gap: 12px;
.header-left {
width: 100%;
.strategy-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
line-height: 1.4;
word-break: break-word;
}
.strategy-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-items: start;
/deep/ .ant-tag {
grid-column: 1 / -1;
justify-self: start;
margin: 0;
font-size: 11px;
padding: 4px 8px;
}
.meta-item {
display: flex;
align-items: flex-start;
gap: 4px;
font-size: 12px;
line-height: 1.5;
word-break: break-word;
.anticon {
font-size: 12px;
flex-shrink: 0;
margin-top: 2px;
}
// 确保文本可换行
& > span {
word-break: break-word;
line-height: 1.5;
flex: 1;
}
// 对于包含数值的span
span:not(.anticon) {
display: inline;
word-break: break-word;
overflow-wrap: break-word;
}
}
// 如果参数过多,使用单列布局
@media (max-width: 480px) {
grid-template-columns: 1fr;
/deep/ .ant-tag {
grid-column: 1;
}
}
}
}
.header-right {
width: 100%;
display: flex;
justify-content: flex-start;
gap: 8px;
flex-wrap: wrap;
.ant-btn {
flex: 1;
min-width: 100px;
font-size: 13px;
padding: 0 12px;
height: 36px;
line-height: 36px;
.anticon {
margin-right: 4px;
}
}
}
}
}
.strategy-content-card {
/deep/ .ant-card-body {
padding: 12px 8px;
overflow-x: hidden;
}
/deep/ .ant-card-head {
padding: 0 8px;
}
/deep/ .ant-tabs {
.ant-tabs-nav {
padding: 0 4px;
.ant-tabs-tab {
font-size: 13px;
padding: 8px 10px;
margin: 0 2px;
white-space: nowrap;
}
}
.ant-tabs-content {
padding-top: 12px;
overflow-x: hidden;
.ant-tabs-tabpane {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
}
}
}
}
}
// 超小屏幕适配
@media (max-width: 480px) {
.strategy-list-col {
max-height: 45vh;
.strategy-list-card {
max-height: 45vh;
/deep/ .ant-card-body {
max-height: calc(45vh - 60px);
}
}
}
.strategy-detail-col {
.strategy-detail-panel {
.strategy-header-card {
.strategy-header {
.header-left {
.strategy-meta {
grid-template-columns: 1fr;
gap: 6px;
.meta-item {
font-size: 11px;
line-height: 1.6;
}
}
}
.header-right {
.ant-btn {
width: 100%;
flex: none;
}
}
}
}
}
}
}
.strategy-list-col {
height: 100%;
.strategy-list-card {
height: 100%;
display: flex;
flex-direction: column;
2025-12-29 19:05:17 +08:00
border-radius: @border-radius-lg;
box-shadow: @card-shadow;
border: none;
overflow: hidden;
transition: box-shadow 0.3s ease;
&:hover {
box-shadow: @card-shadow-hover;
}
2025-12-29 03:06:49 +08:00
.card-title {
display: flex;
justify-content: space-between;
align-items: center;
2025-12-29 19:05:17 +08:00
span {
font-size: 16px;
font-weight: 700;
background: linear-gradient(135deg, #1e3a5f 0%, #2d5a87 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.ant-btn-primary {
border-radius: @border-radius-sm;
background: linear-gradient(135deg, @primary-color 0%, #40a9ff 100%);
border: none;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.35);
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.45);
}
}
2025-12-29 03:06:49 +08:00
}
/deep/ .ant-card-body {
flex: 1;
overflow-y: auto;
background: #fff;
2025-12-29 19:05:17 +08:00
padding: 12px;
2025-12-29 03:06:49 +08:00
}
/deep/ .ant-card-head {
2025-12-29 19:05:17 +08:00
background: linear-gradient(180deg, #fff 0%, #fafbfc 100%);
border-bottom: 1px solid #f0f0f0;
2025-12-29 03:06:49 +08:00
}
// 策略分组列表
.strategy-grouped-list {
.strategy-group {
margin-bottom: 12px;
background: #fff;
border-radius: @border-radius-md;
border: 1px solid #e8ecf1;
overflow: hidden;
.strategy-group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: linear-gradient(135deg, #e8f4fd 0%, #e3f0fc 100%);
}
.group-header-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
.collapse-icon {
font-size: 12px;
color: #8c8c8c;
transition: transform 0.2s ease;
}
.group-icon {
font-size: 16px;
color: @primary-color;
}
.group-name {
font-weight: 600;
font-size: 14px;
color: #1e3a5f;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.group-header-right {
display: flex;
align-items: center;
gap: 8px;
.group-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
&.running {
background: rgba(14, 203, 129, 0.1);
color: @success-color;
}
&.stopped {
background: rgba(246, 70, 93, 0.1);
color: @danger-color;
}
}
}
}
.strategy-group-content {
padding: 4px 8px 8px;
.strategy-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
margin-bottom: 4px;
margin-left: 20px;
border-left: 2px solid #e8ecf1;
background: #fafbfc;
border-radius: 0 @border-radius-sm @border-radius-sm 0;
&:last-child {
margin-bottom: 0;
}
&:hover {
background: #f0f7ff;
border-left-color: @primary-color;
}
&.active {
background: #e6f4ff;
border-left-color: @primary-color;
border-left-width: 3px;
}
.strategy-item-content {
flex: 1;
min-width: 0;
}
.strategy-item-actions {
flex-shrink: 0;
}
}
}
}
}
2025-12-29 03:06:49 +08:00
.strategy-list-item {
cursor: pointer;
2025-12-29 19:05:17 +08:00
padding: 14px 16px;
border-radius: @border-radius-md;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
2025-12-29 03:06:49 +08:00
position: relative;
2025-12-29 19:05:17 +08:00
margin-bottom: 8px;
background: #fafbfc;
border: 1px solid transparent;
2025-12-29 03:06:49 +08:00
&:hover {
2025-12-29 19:05:17 +08:00
background: linear-gradient(135deg, #f0f7ff 0%, #f5f9ff 100%);
border-color: rgba(24, 144, 255, 0.2);
transform: translateX(4px);
box-shadow: 0 2px 12px rgba(24, 144, 255, 0.1);
2025-12-29 03:06:49 +08:00
}
&.active {
2025-12-29 19:05:17 +08:00
background: linear-gradient(135deg, #e6f4ff 0%, #f0f9ff 100%);
border-color: @primary-color;
border-left: 4px solid @primary-color;
box-shadow: 0 4px 16px rgba(24, 144, 255, 0.15);
2025-12-29 03:06:49 +08:00
}
// 移动端优化点击区域
@media (max-width: 768px) {
padding: 12px 8px;
margin: 0 4px 4px 4px;
&.active {
border-left-width: 2px;
}
}
.strategy-item-header {
display: flex;
justify-content: space-between;
align-items: center;
.strategy-name-wrapper {
display: flex;
align-items: center;
2025-12-29 19:05:17 +08:00
gap: 10px;
2025-12-29 03:06:49 +08:00
flex: 1;
2025-12-29 19:05:17 +08:00
min-width: 0;
2025-12-29 03:06:49 +08:00
.strategy-name {
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
2025-12-29 19:05:17 +08:00
color: #1e3a5f;
transition: color 0.2s ease;
2025-12-29 03:06:49 +08:00
}
2025-12-29 19:05:17 +08:00
.exchange-tag {
flex-shrink: 0;
display: inline-flex;
align-items: center;
font-size: 11px;
line-height: 1.5;
padding: 2px 8px;
border-radius: 6px;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
border: 1px solid rgba(102, 126, 234, 0.2);
color: #667eea;
transition: all 0.2s ease;
.anticon {
2025-12-29 03:06:49 +08:00
font-size: 11px;
}
}
2025-12-29 19:05:17 +08:00
.strategy-type-tag {
flex-shrink: 0;
display: inline-flex;
align-items: center;
font-size: 10px;
line-height: 1.5;
margin-left: 4px;
padding: 2px 8px;
border-radius: 6px;
background: linear-gradient(135deg, rgba(156, 39, 176, 0.1) 0%, rgba(103, 58, 183, 0.1) 100%);
border: 1px solid rgba(156, 39, 176, 0.2);
.anticon {
font-size: 10px;
margin-right: 3px;
}
}
}
2025-12-29 03:06:49 +08:00
/deep/ .status-stopped {
2025-12-29 19:05:17 +08:00
color: @danger-color !important;
border-color: @danger-color !important;
2025-12-29 03:06:49 +08:00
}
}
/deep/ .ant-list-item-meta-description {
max-width: calc(100% - 20px); // 留出空间给右侧操作按钮和选中边框
overflow: hidden;
.strategy-item-info {
display: flex !important;
gap: 12px; // 减小间距
margin-top: 8px;
font-size: 12px;
color: var(--text-color-secondary, #8c8c8c);
align-items: center;
flex-wrap: nowrap; // 禁止换行
max-width: 100%;
overflow: hidden;
.status-label {
display: inline-flex;
align-items: center;
2025-12-29 19:05:17 +08:00
gap: 6px;
padding: 4px 12px;
border-radius: 16px;
font-size: 11px;
2025-12-29 03:06:49 +08:00
font-weight: 600;
line-height: 1;
border: 1px solid transparent;
flex-shrink: 0;
2025-12-29 19:05:17 +08:00
background: linear-gradient(135deg, #f0f2f5 0%, #e8eaed 100%);
2025-12-29 03:06:49 +08:00
color: #595959;
2025-12-29 19:05:17 +08:00
transition: all 0.2s ease;
&::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
2025-12-29 03:06:49 +08:00
}
.status-running {
2025-12-29 19:05:17 +08:00
background: linear-gradient(135deg, rgba(14, 203, 129, 0.15) 0%, rgba(14, 203, 129, 0.08) 100%);
color: @success-color;
border-color: rgba(14, 203, 129, 0.3);
box-shadow: 0 2px 8px rgba(14, 203, 129, 0.2);
&::before {
animation: statusPulse 2s infinite;
box-shadow: 0 0 8px @success-color;
}
2025-12-29 03:06:49 +08:00
}
.status-stopped {
2025-12-29 19:05:17 +08:00
background: linear-gradient(135deg, rgba(246, 70, 93, 0.15) 0%, rgba(246, 70, 93, 0.08) 100%);
color: @danger-color;
border-color: rgba(246, 70, 93, 0.3);
2025-12-29 03:06:49 +08:00
}
.status-error {
2025-12-29 19:05:17 +08:00
background: linear-gradient(135deg, rgba(255, 77, 79, 0.15) 0%, rgba(255, 77, 79, 0.08) 100%);
2025-12-29 03:06:49 +08:00
color: #ff4d4f;
2025-12-29 19:05:17 +08:00
border-color: rgba(255, 77, 79, 0.3);
}
@keyframes statusPulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
2025-12-29 03:06:49 +08:00
}
.info-item {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
}
}
.strategy-detail-col {
height: 100%;
display: flex;
flex-direction: column;
2025-12-29 19:05:17 +08:00
overflow-y: auto;
2025-12-29 03:06:49 +08:00
.empty-detail {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
2025-12-29 19:05:17 +08:00
background: linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.9) 100%);
border-radius: @border-radius-lg;
border: 2px dashed #e0e6ed;
transition: all 0.3s ease;
&:hover {
border-color: @primary-color;
background: linear-gradient(135deg, rgba(24,144,255,0.02) 0%, rgba(24,144,255,0.05) 100%);
}
/deep/ .ant-empty-image {
opacity: 0.6;
}
/deep/ .ant-empty-description {
color: #8c8c8c;
font-size: 14px;
}
2025-12-29 03:06:49 +08:00
}
.strategy-detail-panel {
display: flex;
flex-direction: column;
gap: 16px;
2025-12-29 19:05:17 +08:00
min-height: 100%;
2025-12-29 03:06:49 +08:00
.strategy-header-card {
2025-12-29 19:05:17 +08:00
flex-shrink: 0; // 防止头部被压缩
background: linear-gradient(135deg, #fff 0%, #f8fafc 100%);
border: none;
border-radius: @border-radius-lg;
box-shadow: @card-shadow;
transition: all 0.3s ease;
&:hover {
box-shadow: @card-shadow-hover;
}
2025-12-29 03:06:49 +08:00
/deep/ .ant-card-head {
2025-12-29 19:05:17 +08:00
background: transparent;
border-bottom: none;
2025-12-29 03:06:49 +08:00
}
/deep/ .ant-card-body {
2025-12-29 19:05:17 +08:00
background: transparent;
padding: 20px;
2025-12-29 03:06:49 +08:00
}
.strategy-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
2025-12-29 19:05:17 +08:00
gap: 24px;
2025-12-29 03:06:49 +08:00
.header-left {
flex: 1;
2025-12-29 19:05:17 +08:00
min-width: 0;
2025-12-29 03:06:49 +08:00
2025-12-29 19:05:17 +08:00
.strategy-title-row {
2025-12-29 03:06:49 +08:00
display: flex;
align-items: center;
gap: 12px;
2025-12-29 19:05:17 +08:00
margin-bottom: 16px;
2025-12-29 03:06:49 +08:00
flex-wrap: wrap;
2025-12-29 19:05:17 +08:00
.strategy-title {
font-size: 20px;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, #1e3a5f 0%, #2d5a87 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
transition: all 0.3s ease;
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 2s infinite;
}
&.status-running {
background: linear-gradient(135deg, rgba(14, 203, 129, 0.15) 0%, rgba(14, 203, 129, 0.08) 100%);
color: @success-color;
border: 1px solid rgba(14, 203, 129, 0.3);
.status-dot {
background: @success-color;
box-shadow: 0 0 12px @success-color;
}
}
&.status-stopped {
background: linear-gradient(135deg, rgba(246, 70, 93, 0.15) 0%, rgba(246, 70, 93, 0.08) 100%);
color: @danger-color;
border: 1px solid rgba(246, 70, 93, 0.3);
.status-dot {
background: @danger-color;
animation: none;
}
}
&.status-error {
background: linear-gradient(135deg, rgba(255, 77, 79, 0.15) 0%, rgba(255, 77, 79, 0.08) 100%);
color: #ff4d4f;
border: 1px solid rgba(255, 77, 79, 0.3);
.status-dot {
background: #ff4d4f;
animation: none;
}
}
}
}
// 关键数据卡片网格
.key-stats-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
.stat-card {
2025-12-29 03:06:49 +08:00
display: flex;
2025-12-29 19:05:17 +08:00
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #fff;
border-radius: @border-radius-sm;
border: 1px solid #f0f0f0;
transition: all 0.2s ease;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.stat-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
&.investment {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
&.equity {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
color: #fff;
}
&.pnl {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: #fff;
}
}
.stat-content {
flex: 1;
min-width: 0;
.stat-label {
font-size: 11px;
color: #8c8c8c;
margin-bottom: 2px;
}
.stat-value {
font-size: 15px;
font-weight: 700;
color: #1e3a5f;
line-height: 1.2;
.pnl-percent {
font-size: 12px;
font-weight: 500;
opacity: 0.8;
}
}
}
&.pnl-card {
&.profit .stat-content .stat-value {
color: @success-color;
}
&.loss .stat-content .stat-value {
color: @danger-color;
}
}
}
}
// 策略标签
.strategy-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
.tag-item {
display: inline-flex;
align-items: center;
2025-12-29 03:06:49 +08:00
gap: 4px;
2025-12-29 19:05:17 +08:00
padding: 4px 10px;
background: #f5f7fa;
border-radius: 14px;
font-size: 12px;
color: #5a6872;
border: 1px solid #e8ecf1;
2025-12-29 03:06:49 +08:00
.anticon {
2025-12-29 19:05:17 +08:00
font-size: 12px;
color: @primary-color;
2025-12-29 03:06:49 +08:00
}
}
}
}
2025-12-29 19:05:17 +08:00
.header-right {
flex-shrink: 0;
.action-btn {
min-width: 120px;
height: 38px;
border-radius: @border-radius-sm;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s ease;
.anticon {
font-size: 16px;
}
&.start-btn {
background: linear-gradient(135deg, @success-color 0%, #26d87d 100%);
border: none;
box-shadow: 0 2px 8px rgba(14, 203, 129, 0.3);
&:hover {
box-shadow: 0 4px 12px rgba(14, 203, 129, 0.4);
}
}
&.stop-btn {
background: linear-gradient(135deg, @danger-color 0%, #ff6b7a 100%);
border: none;
box-shadow: 0 2px 8px rgba(246, 70, 93, 0.3);
&:hover {
box-shadow: 0 4px 12px rgba(246, 70, 93, 0.4);
}
}
}
}
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
2025-12-29 03:06:49 +08:00
}
}
.strategy-content-card {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
2025-12-29 19:05:17 +08:00
border: none;
border-radius: @border-radius-lg;
box-shadow: @card-shadow;
transition: all 0.3s ease;
min-height: 400px; // 确保有足够的最小高度
&:hover {
box-shadow: @card-shadow-hover;
}
2025-12-29 03:06:49 +08:00
/deep/ .ant-card-head {
2025-12-29 19:05:17 +08:00
background: linear-gradient(180deg, #fafbfc 0%, #fff 100%);
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
2025-12-29 03:06:49 +08:00
}
/deep/ .ant-card-body {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px;
background: #fff;
.ant-tabs {
flex: 1;
display: flex;
flex-direction: column;
2025-12-29 19:05:17 +08:00
.ant-tabs-bar {
flex-shrink: 0;
}
2025-12-29 03:06:49 +08:00
.ant-tabs-content {
flex: 1;
}
.ant-tabs-tabpane {
2025-12-29 19:05:17 +08:00
.trading-records,
.position-records {
2025-12-29 03:06:49 +08:00
width: 100%;
}
}
}
}
}
}
}
&.theme-dark {
2025-12-29 19:05:17 +08:00
background: linear-gradient(180deg, #0d1117 0%, #161b22 100%);
2025-12-29 03:06:49 +08:00
color: var(--dark-text-color, #fff);
// 左侧策略列表卡片
.strategy-list-col {
.strategy-list-card {
2025-12-29 19:05:17 +08:00
background: linear-gradient(180deg, #1e222d 0%, #1a1e28 100%);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
.card-title span {
background: linear-gradient(135deg, #e0e6ed 0%, #c5ccd6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
2025-12-29 03:06:49 +08:00
/deep/ .ant-card-head {
2025-12-29 19:05:17 +08:00
background: linear-gradient(180deg, #252a36 0%, #1e222d 100%);
border-bottom-color: rgba(255, 255, 255, 0.06);
2025-12-29 03:06:49 +08:00
.ant-card-head-title {
color: #d1d4dc;
}
}
/deep/ .ant-card-body {
2025-12-29 19:05:17 +08:00
background: transparent;
2025-12-29 03:06:49 +08:00
}
/deep/ .ant-empty-description {
color: #868993;
}
/deep/ .ant-list {
.ant-list-item {
2025-12-29 19:05:17 +08:00
border-bottom-color: rgba(255, 255, 255, 0.06);
2025-12-29 03:06:49 +08:00
.ant-list-item-meta-title {
color: #d1d4dc;
}
.ant-list-item-meta-description {
color: #868993;
}
}
}
}
.strategy-list-item {
2025-12-29 19:05:17 +08:00
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
2025-12-29 03:06:49 +08:00
&:hover {
2025-12-29 19:05:17 +08:00
background: linear-gradient(135deg, rgba(24, 144, 255, 0.08) 0%, rgba(24, 144, 255, 0.04) 100%);
border-color: rgba(24, 144, 255, 0.2);
2025-12-29 03:06:49 +08:00
}
&.active {
2025-12-29 19:05:17 +08:00
background: linear-gradient(135deg, rgba(24, 144, 255, 0.12) 0%, rgba(24, 144, 255, 0.06) 100%);
border-color: @primary-color;
box-shadow: 0 4px 20px rgba(24, 144, 255, 0.2);
2025-12-29 03:06:49 +08:00
}
.strategy-name {
2025-12-29 19:05:17 +08:00
color: #e0e6ed;
2025-12-29 03:06:49 +08:00
}
.strategy-item-info {
color: #868993;
}
}
}
// 右侧策略详情卡片
.strategy-detail-col {
.strategy-header-card {
2025-12-29 19:05:17 +08:00
background: linear-gradient(135deg, #1e222d 0%, #1a1e28 100%);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
2025-12-29 03:06:49 +08:00
/deep/ .ant-card-head {
2025-12-29 19:05:17 +08:00
background: transparent;
border-bottom: none;
2025-12-29 03:06:49 +08:00
}
/deep/ .ant-card-body {
2025-12-29 19:05:17 +08:00
background: transparent;
2025-12-29 03:06:49 +08:00
}
2025-12-29 19:05:17 +08:00
.strategy-title-row {
.strategy-title {
background: linear-gradient(135deg, #e0e6ed 0%, #c5ccd6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
2025-12-29 03:06:49 +08:00
}
2025-12-29 19:05:17 +08:00
.key-stats-grid {
.stat-card {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.06);
&:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.1);
}
.stat-content {
.stat-label {
color: #868993;
}
.stat-value {
color: #e0e6ed;
}
}
}
}
.strategy-tags {
.tag-item {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
color: #a0a8b3;
&:hover {
background: rgba(24, 144, 255, 0.1);
border-color: rgba(24, 144, 255, 0.3);
}
2025-12-29 03:06:49 +08:00
}
}
}
.strategy-content-card {
2025-12-29 19:05:17 +08:00
background: linear-gradient(180deg, #1e222d 0%, #1a1e28 100%);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
2025-12-29 03:06:49 +08:00
/deep/ .ant-card-head {
2025-12-29 19:05:17 +08:00
background: rgba(255, 255, 255, 0.02);
border-bottom-color: rgba(255, 255, 255, 0.06);
2025-12-29 03:06:49 +08:00
.ant-card-head-title {
color: #d1d4dc;
}
}
/deep/ .ant-card-body {
2025-12-29 19:05:17 +08:00
background: transparent;
2025-12-29 03:06:49 +08:00
}
/deep/ .ant-tabs {
.ant-tabs-nav {
.ant-tabs-tab {
color: #868993;
&:hover {
color: #d1d4dc;
}
&.ant-tabs-tab-active {
.ant-tabs-tab-btn {
color: #1890ff;
}
}
}
.ant-tabs-ink-bar {
2025-12-29 19:05:17 +08:00
background: linear-gradient(90deg, @primary-color 0%, #40a9ff 100%);
2025-12-29 03:06:49 +08:00
}
}
.ant-tabs-content {
color: #d1d4dc;
}
}
/deep/ .ant-empty-description {
color: #868993;
}
}
}
// 空状态
.empty-detail {
/deep/ .ant-empty-description {
color: #868993;
}
}
}
}
/* AI filter box (Step 2) */
.ai-filter-box {
margin-top: 12px;
padding: 14px 14px 12px;
border: 1px solid #e8e8e8;
border-radius: 10px;
background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%);
}
.ai-filter-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ai-filter-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #262626;
}
.ai-filter-title .anticon {
color: #1890ff;
}
.ai-filter-hint {
margin-top: 6px;
font-size: 12px;
line-height: 1.5;
color: #8c8c8c;
}
.symbol-option {
display: flex;
align-items: center;
}
.symbol-name {
font-weight: 600;
color: #8f8d8d;
margin-right: 8px;
}
.symbol-name-extra {
font-size: 12px;
color: #999;
margin-left: 4px;
}
// 弹窗样式
.steps-container {
margin-bottom: 24px;
@media (max-width: 768px) {
margin-bottom: 16px;
/deep/ .ant-steps-item-title {
font-size: 12px;
}
/deep/ .ant-steps-item-icon {
width: 24px;
height: 24px;
line-height: 24px;
font-size: 12px;
}
}
}
.form-container {
min-height: 400px;
padding: 24px 0;
.step-content {
animation: fadeIn 0.3s;
}
@media (max-width: 768px) {
min-height: 300px;
padding: 16px 0;
/deep/ .ant-form-item-label {
padding-bottom: 4px;
label {
font-size: 13px;
}
}
/deep/ .ant-input,
/deep/ .ant-input-number,
/deep/ .ant-select {
font-size: 14px;
}
/deep/ .ant-radio-group {
display: flex;
flex-direction: column;
gap: 8px;
.ant-radio-wrapper {
font-size: 13px;
}
}
}
@media (max-width: 480px) {
min-height: 250px;
padding: 12px 0;
/deep/ .ant-form-item-label label {
font-size: 12px;
}
/deep/ .ant-input,
/deep/ .ant-input-number,
/deep/ .ant-select {
font-size: 13px;
}
}
}
.strategy-type-selector {
padding: 16px 0;
.market-category-selector {
margin-bottom: 16px;
.selector-label {
font-weight: 600;
margin-bottom: 8px;
color: #262626;
}
/deep/ .ant-radio-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.strategy-type-card {
cursor: pointer;
transition: all 0.3s;
height: 100%;
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.2);
}
&.selected {
border-color: #1890ff;
background-color: #e6f7ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.3);
}
.strategy-type-content {
text-align: center;
padding: 16px;
.strategy-type-icon {
font-size: 48px;
color: #1890ff;
margin-bottom: 16px;
}
h3 {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: #1f1f1f;
}
p {
font-size: 14px;
color: #8c8c8c;
margin: 0;
line-height: 1.6;
}
}
}
}
.indicator-option {
display: flex;
justify-content: space-between;
align-items: center;
.indicator-name {
flex: 1;
}
}
.indicator-description {
padding: 12px;
background-color: var(--bg-color-secondary, #f5f5f5);
border-radius: 4px;
color: var(--text-color, #666);
font-size: 14px;
line-height: 1.6;
}
.form-item-hint {
margin-top: 4px;
font-size: 12px;
color: var(--text-color-secondary, #8c8c8c);
}
.test-result {
margin-top: 8px;
padding: 8px;
border-radius: 4px;
font-size: 14px;
&.success {
background-color: #f6ffed;
border: 1px solid #b7eb8f;
color: #52c41a;
}
}
.ip-whitelist-tip {
margin-top: 12px;
padding: 12px;
background-color: #e6f7ff;
border: 1px solid #91d5ff;
border-radius: 4px;
font-size: 13px;
color: #1890ff;
line-height: 1.6;
.anticon {
margin-right: 6px;
color: #1890ff;
}
.ip-list {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
.ant-tag {
margin: 0;
font-family: 'Courier New', monospace;
font-size: 12px;
}
}
&.error {
background-color: #fff2f0;
border: 1px solid #ffccc7;
color: #ff4d4f;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/deep/ .danger-item {
color: #ff4d4f;
}
// 移动端弹窗样式
/deep/ .mobile-modal {
.ant-modal {
top: 20px;
padding-bottom: 0;
}
.ant-modal-content {
max-height: calc(100vh - 40px);
display: flex;
flex-direction: column;
}
.ant-modal-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 16px;
}
.ant-modal-footer {
padding: 12px 16px;
border-top: 1px solid #e8e8e8;
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
.ant-btn {
font-size: 13px;
padding: 0 12px;
height: 32px;
}
}
}
</style>