3858 lines
135 KiB
Vue
3858 lines
135 KiB
Vue
<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')" />
|
||
<a-list
|
||
v-else
|
||
:data-source="strategies"
|
||
size="small"
|
||
>
|
||
<a-list-item
|
||
slot="renderItem"
|
||
slot-scope="item"
|
||
:class="['strategy-list-item', { active: selectedStrategy && selectedStrategy.id === item.id }]"
|
||
@click="handleSelectStrategy(item)"
|
||
>
|
||
<a-list-item-meta>
|
||
<template slot="title">
|
||
<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"
|
||
>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template slot="description">
|
||
<div class="strategy-item-info">
|
||
<!-- <span class="info-item">
|
||
<a-icon type="line-chart" />
|
||
{{ getStrategyTypeText(item.strategy_type) }}
|
||
</span> -->
|
||
<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>
|
||
</template>
|
||
</a-list-item-meta>
|
||
<template slot="actions">
|
||
<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>
|
||
</template>
|
||
</a-list-item>
|
||
</a-list>
|
||
</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">
|
||
<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>
|
||
{{ getStatusText(selectedStrategy.status) }}
|
||
</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">
|
||
<a-icon type="line-chart" />
|
||
<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">
|
||
<a-icon type="swap" />
|
||
<span>{{ getTradeDirectionText(selectedStrategy.trading_config.trade_direction) }}</span>
|
||
</div>
|
||
<div class="tag-item" v-if="selectedStrategy.trading_config && selectedStrategy.trading_config.timeframe">
|
||
<a-icon type="clock-circle" />
|
||
<span>{{ selectedStrategy.trading_config.timeframe }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="header-right">
|
||
<a-button
|
||
v-if="selectedStrategy.status === 'stopped'"
|
||
type="primary"
|
||
size="large"
|
||
class="action-btn start-btn"
|
||
@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"
|
||
class="action-btn stop-btn"
|
||
@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="$t('trading-assistant.form.symbol')">
|
||
<a-select
|
||
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>
|
||
<div class="form-item-hint">
|
||
{{ $t('trading-assistant.form.symbolHintCrypto') }}
|
||
</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="!isCryptoMarket"
|
||
@change="onExecutionModeChange"
|
||
>
|
||
<a-radio value="signal">{{ $t('trading-assistant.form.executionModeSignal') }}</a-radio>
|
||
<a-radio value="live" :disabled="!isCryptoMarket">{{ $t('trading-assistant.form.executionModeLive') }}</a-radio>
|
||
</a-radio-group>
|
||
<div v-if="!isCryptoMarket" class="form-item-hint" style="color: #ff9800;">
|
||
{{ $t('trading-assistant.form.liveTradingCryptoOnlyHint') }}
|
||
</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' && isCryptoMarket" />
|
||
|
||
<!-- Live trading: exchange credentials (crypto only) -->
|
||
<div v-if="executionModeUi === 'live' && isCryptoMarket">
|
||
<a-alert
|
||
type="info"
|
||
show-icon
|
||
style="margin-bottom: 12px;"
|
||
:message="$t('trading-assistant.form.liveTradingConfigTitle')"
|
||
:description="$t('trading-assistant.form.liveTradingConfigHint')"
|
||
/>
|
||
|
||
<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"
|
||
>
|
||
<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>
|
||
|
||
<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 formattedExchangeOptions"
|
||
:key="exchange.value"
|
||
:value="exchange.value"
|
||
>
|
||
{{ exchange.displayName }}
|
||
</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<a-form-item
|
||
v-if="needsPassphrase"
|
||
:label="$t('trading-assistant.form.passphrase')"
|
||
>
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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, createStrategy, updateStrategy, testExchangeConnection, getStrategyEquityCurve } from '@/api/strategy'
|
||
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'
|
||
]
|
||
|
||
// 交易所选项(前端仅展示后端已适配/支持的交易所)
|
||
// 注意:这些标签在组件中会通过计算属性动态生成,以支持多语言
|
||
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' }
|
||
]
|
||
|
||
export default {
|
||
name: 'TradingAssistant',
|
||
mixins: [baseMixin],
|
||
components: {
|
||
TradingRecords,
|
||
PositionRecords
|
||
},
|
||
computed: {
|
||
isDarkTheme () {
|
||
return this.navTheme === 'dark' || this.navTheme === 'realdark'
|
||
},
|
||
needsPassphrase () {
|
||
// 需要 passphrase 的交易所
|
||
return ['okx', 'okex', 'coinbaseexchange', 'kucoin', 'bitget'].includes(this.currentExchangeId)
|
||
},
|
||
// 预处理交易所列表,包含显示名称,提升性能
|
||
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'
|
||
}
|
||
},
|
||
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: '',
|
||
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
|
||
// 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',
|
||
Futures: 'cyan',
|
||
AShare: 'blue',
|
||
HShare: 'orange'
|
||
}
|
||
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'
|
||
|
||
// Non-crypto markets cannot use live trading. Force back to signal to keep UI consistent.
|
||
if (this.selectedMarketCategory !== 'Crypto') {
|
||
this.executionModeUi = 'signal'
|
||
try {
|
||
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' })
|
||
} catch (e) {}
|
||
}
|
||
},
|
||
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 (!this.isCryptoMarket && this.executionModeUi !== 'signal') {
|
||
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.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.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)
|
||
})
|
||
}
|
||
}
|
||
|
||
// 加载交易所配置
|
||
if (strategy.exchange_config) {
|
||
this.currentExchangeId = strategy.exchange_config.exchange_id || ''
|
||
|
||
// Only set form fields if live trading is enabled (fields are rendered)
|
||
// Check executionModeUi and market category (via isCryptoMarket, but we need to ensure computed prop is updated or check directly)
|
||
const isLive = this.executionModeUi === 'live'
|
||
const isCrypto = this.selectedMarketCategory === 'Crypto'
|
||
|
||
if (isLive && isCrypto) {
|
||
// Prevent exchange change handler from clearing API fields while backfilling saved data.
|
||
this.suppressApiClearOnce = true
|
||
this.form.setFieldsValue({
|
||
exchange_id: strategy.exchange_config.exchange_id,
|
||
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 || ''
|
||
})
|
||
|
||
// If a vault credential is selected, auto-fill secrets from vault (strategy rows may not store secrets).
|
||
const credId = strategy.exchange_config.credential_id
|
||
if (credId) {
|
||
await this.handleCredentialSelectChange(credId)
|
||
}
|
||
}
|
||
|
||
// 设置当前交易所ID (UI state)
|
||
if (strategy.exchange_config?.exchange_id) {
|
||
this.currentExchangeId = strategy.exchange_config.exchange_id
|
||
}
|
||
}
|
||
|
||
// 加载交易配置
|
||
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),
|
||
// AI智能决策过滤
|
||
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
|
||
}
|
||
},
|
||
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'
|
||
}
|
||
return colorMap[exchangeId] || 'default'
|
||
},
|
||
handleApiConfigChange () {
|
||
// 当API配置字段变化时,清空测试结果,需要重新测试
|
||
this.testResult = null
|
||
this.connectionTestResult = null
|
||
},
|
||
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: '请输入Passphrase(Legacy 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: '请输入Passphrase(Legacy 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 {
|
||
// 构建需要验证的字段列表
|
||
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,
|
||
// IMPORTANT: let backend pick correct Binance endpoints (spot vs futures)
|
||
market_type: String(marketType || 'swap')
|
||
}
|
||
|
||
// 如果需要 passphrase,添加它
|
||
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)
|
||
const fieldsToValidate = [
|
||
'indicator_id',
|
||
'strategy_name',
|
||
'symbol',
|
||
'initial_capital',
|
||
'market_type',
|
||
'leverage',
|
||
'trade_direction',
|
||
'timeframe'
|
||
]
|
||
this.form.validateFields(fieldsToValidate, (err, values) => {
|
||
if (err) return
|
||
// 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.isCryptoMarket && values.execution_mode === 'live'
|
||
|
||
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
|
||
}
|
||
|
||
// Watchlist-style symbol value: "Market:SYMBOL"
|
||
let parsedMarketCategory = (values.market_category || this.selectedMarketCategory || 'Crypto')
|
||
let parsedSymbol = values.symbol
|
||
if (typeof parsedSymbol === 'string' && parsedSymbol.includes(':')) {
|
||
const idx = parsedSymbol.indexOf(':')
|
||
parsedMarketCategory = parsedSymbol.slice(0, idx) || parsedMarketCategory
|
||
parsedSymbol = parsedSymbol.slice(idx + 1)
|
||
}
|
||
|
||
const payload = {
|
||
strategy_name: values.strategy_name,
|
||
market_category: parsedMarketCategory,
|
||
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 ? {
|
||
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,
|
||
trading_config: {
|
||
symbol: parsedSymbol,
|
||
initial_capital: values.initial_capital,
|
||
leverage: leverage,
|
||
trade_direction: tradeDirection,
|
||
timeframe: values.timeframe,
|
||
market_type: marketType,
|
||
// Preset order params (advanced settings removed from UI)
|
||
order_mode: 'maker',
|
||
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智能决策过滤
|
||
enable_ai_filter: enableAiFilter
|
||
}
|
||
}
|
||
|
||
let res
|
||
if (this.editingStrategy) {
|
||
res = await updateStrategy(this.editingStrategy.id, payload)
|
||
} else {
|
||
payload.user_id = 1
|
||
payload.strategy_type = 'IndicatorStrategy'
|
||
res = await createStrategy(payload)
|
||
}
|
||
|
||
if (res.code === 1) {
|
||
this.$message.success(this.isEditMode ? this.$t('trading-assistant.messages.updateSuccess') : this.$t('trading-assistant.messages.createSuccess'))
|
||
if (isLive && values.save_credential) {
|
||
// Save credential to vault (best-effort)
|
||
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) {
|
||
// 移动端将下拉菜单挂载到body,避免被父容器裁剪
|
||
return this.isMobile ? document.body : triggerNode.parentNode
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
// 主色调变量
|
||
@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;
|
||
|
||
.trading-assistant {
|
||
padding: 0px;
|
||
height: calc(100vh - 120px);
|
||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||
|
||
.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;
|
||
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;
|
||
}
|
||
|
||
.card-title {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/deep/ .ant-card-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
background: #fff;
|
||
padding: 12px;
|
||
}
|
||
|
||
/deep/ .ant-card-head {
|
||
background: linear-gradient(180deg, #fff 0%, #fafbfc 100%);
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.strategy-list-item {
|
||
cursor: pointer;
|
||
padding: 14px 16px;
|
||
border-radius: @border-radius-md;
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
position: relative;
|
||
margin-bottom: 8px;
|
||
background: #fafbfc;
|
||
border: 1px solid transparent;
|
||
|
||
&:hover {
|
||
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);
|
||
}
|
||
|
||
&.active {
|
||
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);
|
||
}
|
||
|
||
// 移动端优化点击区域
|
||
@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;
|
||
gap: 10px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
|
||
.strategy-name {
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
flex-shrink: 0;
|
||
color: #1e3a5f;
|
||
transition: color 0.2s ease;
|
||
}
|
||
|
||
.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 {
|
||
font-size: 11px;
|
||
}
|
||
}
|
||
|
||
.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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/deep/ .status-stopped {
|
||
color: @danger-color !important;
|
||
border-color: @danger-color !important;
|
||
}
|
||
}
|
||
|
||
/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;
|
||
gap: 6px;
|
||
padding: 4px 12px;
|
||
border-radius: 16px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
border: 1px solid transparent;
|
||
flex-shrink: 0;
|
||
background: linear-gradient(135deg, #f0f2f5 0%, #e8eaed 100%);
|
||
color: #595959;
|
||
transition: all 0.2s ease;
|
||
|
||
&::before {
|
||
content: '';
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
}
|
||
}
|
||
|
||
.status-running {
|
||
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;
|
||
}
|
||
}
|
||
|
||
.status-stopped {
|
||
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);
|
||
}
|
||
|
||
.status-error {
|
||
background: linear-gradient(135deg, rgba(255, 77, 79, 0.15) 0%, rgba(255, 77, 79, 0.08) 100%);
|
||
color: #ff4d4f;
|
||
border-color: rgba(255, 77, 79, 0.3);
|
||
}
|
||
|
||
@keyframes statusPulse {
|
||
0%, 100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0.5;
|
||
}
|
||
}
|
||
|
||
.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;
|
||
overflow-y: auto;
|
||
|
||
.empty-detail {
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
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;
|
||
}
|
||
}
|
||
|
||
.strategy-detail-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
min-height: 100%;
|
||
|
||
.strategy-header-card {
|
||
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;
|
||
}
|
||
|
||
/deep/ .ant-card-head {
|
||
background: transparent;
|
||
border-bottom: none;
|
||
}
|
||
|
||
/deep/ .ant-card-body {
|
||
background: transparent;
|
||
padding: 20px;
|
||
}
|
||
|
||
.strategy-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 24px;
|
||
|
||
.header-left {
|
||
flex: 1;
|
||
min-width: 0;
|
||
|
||
.strategy-title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
flex-wrap: wrap;
|
||
|
||
.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 {
|
||
display: flex;
|
||
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;
|
||
gap: 4px;
|
||
padding: 4px 10px;
|
||
background: #f5f7fa;
|
||
border-radius: 14px;
|
||
font-size: 12px;
|
||
color: #5a6872;
|
||
border: 1px solid #e8ecf1;
|
||
|
||
.anticon {
|
||
font-size: 12px;
|
||
color: @primary-color;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.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);
|
||
}
|
||
}
|
||
|
||
.strategy-content-card {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #fff;
|
||
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;
|
||
}
|
||
|
||
/deep/ .ant-card-head {
|
||
background: linear-gradient(180deg, #fafbfc 0%, #fff 100%);
|
||
border-bottom: 1px solid #f0f0f0;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/deep/ .ant-card-body {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 16px;
|
||
background: #fff;
|
||
|
||
.ant-tabs {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.ant-tabs-bar {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.ant-tabs-content {
|
||
flex: 1;
|
||
}
|
||
|
||
.ant-tabs-tabpane {
|
||
.trading-records,
|
||
.position-records {
|
||
width: 100%;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
&.theme-dark {
|
||
background: linear-gradient(180deg, #0d1117 0%, #161b22 100%);
|
||
color: var(--dark-text-color, #fff);
|
||
|
||
// 左侧策略列表卡片
|
||
.strategy-list-col {
|
||
.strategy-list-card {
|
||
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;
|
||
}
|
||
|
||
/deep/ .ant-card-head {
|
||
background: linear-gradient(180deg, #252a36 0%, #1e222d 100%);
|
||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||
|
||
.ant-card-head-title {
|
||
color: #d1d4dc;
|
||
}
|
||
}
|
||
|
||
/deep/ .ant-card-body {
|
||
background: transparent;
|
||
}
|
||
|
||
/deep/ .ant-empty-description {
|
||
color: #868993;
|
||
}
|
||
|
||
/deep/ .ant-list {
|
||
.ant-list-item {
|
||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||
|
||
.ant-list-item-meta-title {
|
||
color: #d1d4dc;
|
||
}
|
||
|
||
.ant-list-item-meta-description {
|
||
color: #868993;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.strategy-list-item {
|
||
background: rgba(255, 255, 255, 0.02);
|
||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||
|
||
&:hover {
|
||
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);
|
||
}
|
||
|
||
&.active {
|
||
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);
|
||
}
|
||
|
||
.strategy-name {
|
||
color: #e0e6ed;
|
||
}
|
||
|
||
.strategy-item-info {
|
||
color: #868993;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 右侧策略详情卡片
|
||
.strategy-detail-col {
|
||
.strategy-header-card {
|
||
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);
|
||
|
||
/deep/ .ant-card-head {
|
||
background: transparent;
|
||
border-bottom: none;
|
||
}
|
||
|
||
/deep/ .ant-card-body {
|
||
background: transparent;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
}
|
||
|
||
.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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.strategy-content-card {
|
||
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);
|
||
|
||
/deep/ .ant-card-head {
|
||
background: rgba(255, 255, 255, 0.02);
|
||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||
|
||
.ant-card-head-title {
|
||
color: #d1d4dc;
|
||
}
|
||
}
|
||
|
||
/deep/ .ant-card-body {
|
||
background: transparent;
|
||
}
|
||
|
||
/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 {
|
||
background: linear-gradient(90deg, @primary-color 0%, #40a9ff 100%);
|
||
}
|
||
}
|
||
|
||
.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>
|