Files
DinQuant/quantdinger_vue/src/views/portfolio/index.vue
T

3070 lines
90 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="portfolio-container" :class="{ 'theme-dark': isDarkTheme }">
<!-- 资产总览 - 第一行核心数据 -->
<div class="summary-section">
<div class="summary-card total-value">
<div class="card-icon">
<a-icon type="wallet" />
</div>
<div class="card-content">
<div class="card-label">{{ $t('portfolio.summary.totalValue') }}</div>
<div class="card-value">
<span class="currency">$</span>
<span class="amount">{{ formatNumber(summary.total_market_value) }}</span>
</div>
<div class="card-sub" v-if="summary.today_change !== undefined">
<span :class="summary.today_change >= 0 ? 'positive' : 'negative'">
{{ $t('portfolio.summary.today') }}: {{ summary.today_change >= 0 ? '+' : '' }}${{ formatNumber(summary.today_change) }}
</span>
</div>
</div>
</div>
<div class="summary-card">
<div class="card-icon cost">
<a-icon type="dollar" />
</div>
<div class="card-content">
<div class="card-label">{{ $t('portfolio.summary.totalCost') }}</div>
<div class="card-value">${{ formatNumber(summary.total_cost) }}</div>
</div>
</div>
<div class="summary-card">
<div class="card-icon" :class="summary.total_pnl >= 0 ? 'profit' : 'loss'">
<a-icon :type="summary.total_pnl >= 0 ? 'rise' : 'fall'" />
</div>
<div class="card-content">
<div class="card-label">{{ $t('portfolio.summary.totalPnl') }}</div>
<div class="card-value" :class="summary.total_pnl >= 0 ? 'positive' : 'negative'">
{{ summary.total_pnl >= 0 ? '+' : '' }}${{ formatNumber(summary.total_pnl) }}
<span class="percent">({{ summary.total_pnl_percent >= 0 ? '+' : '' }}{{ summary.total_pnl_percent }}%)</span>
</div>
</div>
</div>
<div class="summary-card">
<div class="card-icon positions">
<a-icon type="fund" />
</div>
<div class="card-content">
<div class="card-label">{{ $t('portfolio.summary.positionCount') }}</div>
<div class="card-value">
{{ summary.position_count }}
<span class="position-detail" v-if="profitLossStats.profit > 0 || profitLossStats.loss > 0">
(<span class="positive">{{ profitLossStats.profit }}</span>/<span class="negative">{{ profitLossStats.loss }}</span>)
</span>
</div>
<div class="card-sub">
{{ $t('portfolio.summary.profitLossRatio') }}
</div>
</div>
</div>
</div>
<!-- 资产总览 - 第二行详细统计 -->
<div class="summary-section secondary">
<div class="summary-card mini">
<div class="card-icon today" :class="summary.today_pnl >= 0 ? 'profit' : 'loss'">
<a-icon type="stock" />
</div>
<div class="card-content">
<div class="card-label">{{ $t('portfolio.summary.todayPnl') }}</div>
<div class="card-value" :class="summary.today_pnl >= 0 ? 'positive' : 'negative'">
{{ summary.today_pnl >= 0 ? '+' : '' }}${{ formatNumber(summary.today_pnl || 0) }}
</div>
</div>
</div>
<div class="summary-card mini">
<div class="card-icon best">
<a-icon type="trophy" />
</div>
<div class="card-content">
<div class="card-label">{{ $t('portfolio.summary.bestPerformer') }}</div>
<div class="card-value small positive" v-if="bestPerformer">
{{ bestPerformer.symbol }} +{{ bestPerformer.pnl_percent }}%
</div>
<div class="card-value small" v-else>-</div>
</div>
</div>
<div class="summary-card mini">
<div class="card-icon worst">
<a-icon type="warning" />
</div>
<div class="card-content">
<div class="card-label">{{ $t('portfolio.summary.worstPerformer') }}</div>
<div class="card-value small negative" v-if="worstPerformer">
{{ worstPerformer.symbol }} {{ worstPerformer.pnl_percent }}%
</div>
<div class="card-value small" v-else>-</div>
</div>
</div>
<div class="summary-card mini sync-card">
<div class="card-icon sync" :class="{ 'syncing': isSyncing }">
<a-icon type="sync" :spin="isSyncing" />
</div>
<div class="card-content">
<div class="card-label">{{ $t('portfolio.summary.priceSync') }}</div>
<div class="card-value small">
{{ lastSyncTime ? formatSyncTime(lastSyncTime) : '-' }}
</div>
<div class="card-sub">
{{ $t('portfolio.summary.syncInterval') }}: 30s
<a-button type="link" size="small" @click="refreshPrices" :loading="isSyncing" style="padding: 0; margin-left: 8px;">
<a-icon type="reload" v-if="!isSyncing" />
</a-button>
</div>
</div>
</div>
</div>
<!-- 主内容区域 -->
<div class="main-content">
<!-- 持仓列表 -->
<div class="positions-section">
<div class="section-header">
<h3>
<a-icon type="stock" />
<span>{{ $t('portfolio.positions.title') }}</span>
</h3>
<div class="header-actions">
<!-- 视图切换 -->
<a-radio-group v-model="viewMode" size="small" style="margin-right: 12px;">
<a-radio-button value="grid">
<a-icon type="appstore" />
</a-radio-button>
<a-radio-button value="group">
<a-icon type="folder" />
</a-radio-button>
</a-radio-group>
<!-- 分组筛选仅网格视图时显示 -->
<a-select
v-if="viewMode === 'grid'"
v-model="selectedGroup"
:placeholder="$t('portfolio.groups.all')"
style="width: 150px; margin-right: 12px;"
allow-clear
@change="filterByGroup"
>
<a-select-option value="">{{ $t('portfolio.groups.all') }}</a-select-option>
<a-select-option value="__ungrouped__">{{ $t('portfolio.groups.ungrouped') }}</a-select-option>
<a-select-option v-for="g in groups" :key="g.name" :value="g.name">
{{ g.name }} ({{ g.count }})
</a-select-option>
</a-select>
<a-button type="primary" @click="showAddPositionModal = true">
<a-icon type="plus" />
{{ $t('portfolio.positions.add') }}
</a-button>
</div>
</div>
<a-spin :spinning="loadingPositions">
<div class="positions-list">
<div v-if="positions.length === 0" class="empty-state">
<a-empty :description="$t('portfolio.positions.empty')">
<a-button type="primary" @click="showAddPositionModal = true">
<a-icon type="plus" />
{{ $t('portfolio.positions.addFirst') }}
</a-button>
</a-empty>
</div>
<!-- 网格视图 -->
<div v-else-if="viewMode === 'grid'" class="position-grid">
<div
v-for="pos in filteredPositions"
:key="pos.id"
class="position-card"
:class="{ 'profit': pos.pnl >= 0, 'loss': pos.pnl < 0 }"
>
<div class="position-header">
<div class="symbol-info">
<a-tag :color="getMarketColor(pos.market)" size="small">{{ getMarketName(pos.market) }}</a-tag>
<span class="symbol">{{ pos.symbol }}</span>
<span class="name">{{ pos.name }}</span>
<a-tag v-if="pos.group_name" size="small" color="blue" style="margin-left: 4px;">
<a-icon type="folder" /> {{ pos.group_name }}
</a-tag>
</div>
<div class="position-actions">
<a-tooltip :title="hasAlertForPosition(pos.id) ? $t('portfolio.alerts.editAlert') : $t('portfolio.alerts.addAlert')">
<a-button
type="link"
size="small"
@click="showAddAlertForPosition(pos)"
:class="{ 'has-alert': hasAlertForPosition(pos.id) }"
>
<a-icon type="bell" :theme="hasAlertForPosition(pos.id) ? 'filled' : 'outlined'" />
</a-button>
</a-tooltip>
<a-button type="link" size="small" @click="editPosition(pos)">
<a-icon type="edit" />
</a-button>
<a-popconfirm
:title="$t('portfolio.positions.deleteConfirm')"
@confirm="deletePosition(pos.id)"
>
<a-button type="link" size="small" class="delete-btn">
<a-icon type="delete" />
</a-button>
</a-popconfirm>
</div>
</div>
<div class="position-body">
<div class="price-row">
<div class="current-price">
<span class="label">{{ $t('portfolio.positions.currentPrice') }}</span>
<span class="value">{{ getCurrencySymbol(pos.market) }}{{ formatPrice(pos.current_price) }}</span>
<span class="change" :class="pos.price_change >= 0 ? 'up' : 'down'">
{{ pos.price_change >= 0 ? '▲' : '▼' }}{{ Math.abs(pos.price_change_percent).toFixed(2) }}%
</span>
</div>
<div class="entry-price">
<span class="label">{{ $t('portfolio.positions.entryPrice') }}</span>
<span class="value">{{ getCurrencySymbol(pos.market) }}{{ formatPrice(pos.entry_price) }}</span>
</div>
</div>
<div class="quantity-row">
<div class="item">
<span class="label">{{ $t('portfolio.positions.quantity') }}</span>
<span class="value">{{ formatNumber(pos.quantity, 4) }}</span>
</div>
<div class="item">
<span class="label">{{ $t('portfolio.positions.side') }}</span>
<a-tag :color="pos.side === 'long' ? 'green' : 'red'" size="small">
{{ pos.side === 'long' ? $t('portfolio.positions.long') : $t('portfolio.positions.short') }}
</a-tag>
</div>
<div class="item">
<span class="label">{{ $t('portfolio.positions.marketValue') }}</span>
<span class="value">{{ getCurrencySymbol(pos.market) }}{{ formatNumber(pos.market_value) }}</span>
</div>
</div>
</div>
<div class="position-footer">
<div class="pnl">
<span class="label">{{ $t('portfolio.positions.pnl') }}</span>
<span class="value" :class="pos.pnl >= 0 ? 'positive' : 'negative'">
{{ pos.pnl >= 0 ? '+' : '' }}{{ getCurrencySymbol(pos.market) }}{{ formatNumber(pos.pnl) }}
<span class="percent">({{ pos.pnl_percent >= 0 ? '+' : '' }}{{ pos.pnl_percent }}%)</span>
</span>
</div>
</div>
</div>
</div>
<!-- 分组折叠视图 -->
<div v-else class="position-collapse-view">
<a-collapse v-model="activeGroups" :bordered="false">
<!-- 未分组 -->
<a-collapse-panel v-if="ungroupedPositions.length > 0" key="__ungrouped__" class="group-panel">
<template slot="header">
<div class="group-header">
<span class="group-name">
<a-icon type="inbox" style="margin-right: 8px;" />
{{ $t('portfolio.groups.ungrouped') }}
</span>
<span class="group-stats">
<span class="count">{{ ungroupedPositions.length }} {{ $t('portfolio.positions.items') }}</span>
<span class="group-pnl" :class="getGroupPnl(ungroupedPositions) >= 0 ? 'positive' : 'negative'">
{{ getGroupPnl(ungroupedPositions) >= 0 ? '+' : '' }}${{ formatNumber(getGroupPnl(ungroupedPositions)) }}
</span>
</span>
</div>
</template>
<div class="position-grid compact">
<div
v-for="pos in ungroupedPositions"
:key="pos.id"
class="position-card compact"
:class="{ 'profit': pos.pnl >= 0, 'loss': pos.pnl < 0 }"
>
<div class="position-header">
<div class="symbol-info">
<a-tag :color="getMarketColor(pos.market)" size="small">{{ getMarketName(pos.market) }}</a-tag>
<span class="symbol">{{ pos.symbol }}</span>
<span class="name">{{ pos.name }}</span>
</div>
<div class="position-actions">
<a-button type="link" size="small" @click="editPosition(pos)">
<a-icon type="edit" />
</a-button>
<a-popconfirm :title="$t('portfolio.positions.deleteConfirm')" @confirm="deletePosition(pos.id)">
<a-button type="link" size="small" class="delete-btn"><a-icon type="delete" /></a-button>
</a-popconfirm>
</div>
</div>
<div class="position-compact-body">
<div class="compact-item">
<span class="value">{{ getCurrencySymbol(pos.market) }}{{ formatPrice(pos.current_price) }}</span>
<span class="change" :class="pos.price_change >= 0 ? 'up' : 'down'">
{{ pos.price_change >= 0 ? '▲' : '▼' }}{{ Math.abs(pos.price_change_percent).toFixed(2) }}%
</span>
</div>
<div class="compact-item">
<span class="label">{{ $t('portfolio.positions.quantity') }}:</span>
<span class="value">{{ formatNumber(pos.quantity, 4) }}</span>
</div>
<div class="compact-item pnl" :class="pos.pnl >= 0 ? 'positive' : 'negative'">
{{ pos.pnl >= 0 ? '+' : '' }}{{ getCurrencySymbol(pos.market) }}{{ formatNumber(pos.pnl) }}
<span class="percent">({{ pos.pnl_percent >= 0 ? '+' : '' }}{{ pos.pnl_percent }}%)</span>
</div>
</div>
</div>
</div>
</a-collapse-panel>
<!-- 各分组 -->
<a-collapse-panel v-for="group in groupsWithPositions" :key="group.name" class="group-panel">
<template slot="header">
<div class="group-header">
<span class="group-name">
<a-icon type="folder" style="margin-right: 8px; color: #1890ff;" />
{{ group.name }}
</span>
<span class="group-stats">
<span class="count">{{ group.positions.length }} {{ $t('portfolio.positions.items') }}</span>
<span class="group-pnl" :class="getGroupPnl(group.positions) >= 0 ? 'positive' : 'negative'">
{{ getGroupPnl(group.positions) >= 0 ? '+' : '' }}${{ formatNumber(getGroupPnl(group.positions)) }}
</span>
</span>
</div>
</template>
<div class="position-grid compact">
<div
v-for="pos in group.positions"
:key="pos.id"
class="position-card compact"
:class="{ 'profit': pos.pnl >= 0, 'loss': pos.pnl < 0 }"
>
<div class="position-header">
<div class="symbol-info">
<a-tag :color="getMarketColor(pos.market)" size="small">{{ getMarketName(pos.market) }}</a-tag>
<span class="symbol">{{ pos.symbol }}</span>
<span class="name">{{ pos.name }}</span>
</div>
<div class="position-actions">
<a-button type="link" size="small" @click="editPosition(pos)">
<a-icon type="edit" />
</a-button>
<a-popconfirm :title="$t('portfolio.positions.deleteConfirm')" @confirm="deletePosition(pos.id)">
<a-button type="link" size="small" class="delete-btn"><a-icon type="delete" /></a-button>
</a-popconfirm>
</div>
</div>
<div class="position-compact-body">
<div class="compact-item">
<span class="value">{{ getCurrencySymbol(pos.market) }}{{ formatPrice(pos.current_price) }}</span>
<span class="change" :class="pos.price_change >= 0 ? 'up' : 'down'">
{{ pos.price_change >= 0 ? '▲' : '▼' }}{{ Math.abs(pos.price_change_percent).toFixed(2) }}%
</span>
</div>
<div class="compact-item">
<span class="label">{{ $t('portfolio.positions.quantity') }}:</span>
<span class="value">{{ formatNumber(pos.quantity, 4) }}</span>
</div>
<div class="compact-item pnl" :class="pos.pnl >= 0 ? 'positive' : 'negative'">
{{ pos.pnl >= 0 ? '+' : '' }}{{ getCurrencySymbol(pos.market) }}{{ formatNumber(pos.pnl) }}
<span class="percent">({{ pos.pnl_percent >= 0 ? '+' : '' }}{{ pos.pnl_percent }}%)</span>
</div>
</div>
</div>
</div>
</a-collapse-panel>
</a-collapse>
</div>
</div>
</a-spin>
</div>
<!-- 监控任务 -->
<div class="monitors-section">
<div class="section-header">
<h3>
<a-icon type="eye" />
<span>{{ $t('portfolio.monitors.title') }}</span>
</h3>
<a-button type="primary" @click="showAddMonitorModal = true">
<a-icon type="plus" />
{{ $t('portfolio.monitors.add') }}
</a-button>
</div>
<a-spin :spinning="loadingMonitors">
<div class="monitors-list">
<div v-if="monitors.length === 0" class="empty-state small">
<a-empty :description="$t('portfolio.monitors.empty')" :image="simpleImage">
<a-button type="primary" size="small" @click="showAddMonitorModal = true">
<a-icon type="plus" />
{{ $t('portfolio.monitors.addFirst') }}
</a-button>
</a-empty>
</div>
<div v-else>
<div v-for="monitor in monitors" :key="monitor.id" class="monitor-card">
<div class="monitor-header">
<div class="monitor-name">
<a-icon type="robot" />
<span>{{ monitor.name }}</span>
</div>
<a-switch
:checked="monitor.is_active"
@change="toggleMonitor(monitor.id, $event)"
size="small"
/>
</div>
<div class="monitor-body">
<div class="monitor-info">
<span class="label">{{ $t('portfolio.monitors.interval') }}:</span>
<span class="value">{{ getIntervalText(monitor.config.interval_minutes) }}</span>
</div>
<div class="monitor-info">
<span class="label">{{ $t('portfolio.form.monitorScope') }}:</span>
<span class="value">
<template v-if="getMonitorPositionIds(monitor).length > 0">
<a-tooltip>
<template slot="title">
<div v-for="posId in getMonitorPositionIds(monitor)" :key="posId">
{{ getPositionNameById(posId) }}
</div>
</template>
<span class="scope-selected">
{{ $t('portfolio.form.selectedCount', { count: getMonitorPositionIds(monitor).length, total: positions.length }) }}
</span>
</a-tooltip>
</template>
<span v-else class="scope-all">{{ $t('portfolio.form.allPositions') }}</span>
</span>
</div>
<div class="monitor-info" v-if="monitor.last_run_at">
<span class="label">{{ $t('portfolio.monitors.lastRun') }}:</span>
<span class="value">{{ formatTime(monitor.last_run_at) }}</span>
</div>
<div class="monitor-info" v-if="monitor.next_run_at && monitor.is_active">
<span class="label">{{ $t('portfolio.monitors.nextRun') }}:</span>
<span class="value">{{ formatTime(monitor.next_run_at) }}</span>
</div>
<div class="monitor-channels" v-if="monitor.notification_config.channels">
<span class="label">{{ $t('portfolio.monitors.channels') }}:</span>
<a-tag v-for="ch in monitor.notification_config.channels" :key="ch" size="small">
{{ ch }}
</a-tag>
</div>
</div>
<div class="monitor-actions">
<a-button type="link" size="small" @click="runMonitorNow(monitor.id)" :loading="runningMonitor === monitor.id">
<a-icon type="play-circle" />
{{ $t('portfolio.monitors.runNow') }}
</a-button>
<a-button type="link" size="small" @click="editMonitor(monitor)">
<a-icon type="edit" />
</a-button>
<a-popconfirm
:title="$t('portfolio.monitors.deleteConfirm')"
@confirm="deleteMonitor(monitor.id)"
>
<a-button type="link" size="small" class="delete-btn">
<a-icon type="delete" />
</a-button>
</a-popconfirm>
</div>
</div>
</div>
</div>
</a-spin>
</div>
</div>
<!-- 添加/编辑持仓弹窗 -->
<a-modal
:title="editingPosition ? $t('portfolio.modal.editPosition') : $t('portfolio.modal.addPosition')"
:visible="showAddPositionModal"
@ok="handleSavePosition"
@cancel="closePositionModal"
:confirmLoading="savingPosition"
width="600px"
>
<a-form :form="positionForm" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<!-- 市场类型 -->
<a-form-item :label="$t('portfolio.form.market')">
<a-select
v-decorator="['market', { rules: [{ required: true, message: $t('portfolio.form.marketRequired') }] }]"
:placeholder="$t('portfolio.form.selectMarket')"
@change="handleMarketChange"
:disabled="!!editingPosition"
>
<a-select-option v-for="mt in marketTypes" :key="mt.value" :value="mt.value">
{{ $t(mt.i18nKey) }}
</a-select-option>
</a-select>
</a-form-item>
<!-- 标的搜索/选择 -->
<a-form-item :label="$t('portfolio.form.symbol')">
<a-select
v-decorator="['symbol', { rules: [{ required: true, message: $t('portfolio.form.symbolRequired') }] }]"
show-search
:placeholder="$t('portfolio.form.searchSymbol')"
:default-active-first-option="false"
:show-arrow="false"
:filter-option="false"
:not-found-content="null"
@search="handleSymbolSearch"
@change="handleSymbolSelect"
:disabled="!!editingPosition"
style="width: 100%"
>
<!-- 搜索结果选项 -->
<a-select-option v-for="item in symbolSearchResults" :key="item.symbol" :value="item.symbol">
<div class="symbol-option">
<strong>{{ item.symbol }}</strong>
<span class="symbol-name">{{ item.name }}</span>
</div>
</a-select-option>
<!-- 手动输入选项当搜索无结果且有输入时显示 -->
<a-select-option
v-if="symbolSearchKeyword && symbolSearchResults.length === 0"
:key="'__manual__' + symbolSearchKeyword.toUpperCase()"
:value="symbolSearchKeyword.toUpperCase()"
>
<div class="symbol-option manual-input">
<a-icon type="edit" style="margin-right: 6px; color: #1890ff;" />
<span>{{ $t('portfolio.form.useAsSymbol') }} </span>
<strong style="color: #1890ff;">{{ symbolSearchKeyword.toUpperCase() }}</strong>
<span> {{ $t('portfolio.form.asSymbolCode') }}</span>
</div>
</a-select-option>
</a-select>
<div class="symbol-hint" style="font-size: 12px; color: #999; margin-top: 4px;">
{{ $t('portfolio.form.symbolHint') }}
</div>
</a-form-item>
<!-- 方向 -->
<a-form-item :label="$t('portfolio.form.side')">
<a-radio-group v-decorator="['side', { initialValue: 'long' }]" :disabled="!!editingPosition">
<a-radio-button value="long">{{ $t('portfolio.positions.long') }}</a-radio-button>
<a-radio-button value="short">{{ $t('portfolio.positions.short') }}</a-radio-button>
</a-radio-group>
</a-form-item>
<!-- 数量 -->
<a-form-item :label="$t('portfolio.form.quantity')">
<a-input-number
v-decorator="['quantity', { rules: [{ required: true, message: $t('portfolio.form.quantityRequired') }] }]"
:min="0.00000001"
:step="1"
style="width: 100%"
:placeholder="$t('portfolio.form.enterQuantity')"
/>
</a-form-item>
<!-- 买入价 -->
<a-form-item :label="$t('portfolio.form.entryPrice')">
<a-input-number
v-decorator="['entry_price', { rules: [{ required: true, message: $t('portfolio.form.entryPriceRequired') }] }]"
:min="0.00000001"
:step="0.01"
style="width: 100%"
:placeholder="$t('portfolio.form.enterEntryPrice')"
/>
</a-form-item>
<!-- 备注 -->
<a-form-item :label="$t('portfolio.form.notes')">
<a-textarea
v-decorator="['notes']"
:rows="2"
:placeholder="$t('portfolio.form.enterNotes')"
/>
</a-form-item>
<!-- 分组 -->
<a-form-item :label="$t('portfolio.form.group')">
<a-auto-complete
v-decorator="['group_name']"
:placeholder="$t('portfolio.form.enterGroup')"
:dataSource="groupNames"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 添加/编辑预警弹窗 -->
<a-modal
:title="editingAlert ? $t('portfolio.modal.editAlert') : $t('portfolio.modal.addAlert')"
:visible="showAddAlertModal"
@cancel="closeAlertModal"
width="560px"
>
<!-- 自定义 Footer -->
<template slot="footer">
<div class="alert-modal-footer">
<a-button
v-if="editingAlert"
type="danger"
ghost
:loading="deletingAlert"
@click="confirmDeleteAlert"
>
<a-icon type="delete" />
{{ $t('portfolio.alerts.delete') }}
</a-button>
<span v-else></span>
<div class="footer-right">
<a-button @click="closeAlertModal">{{ $t('common.cancel') }}</a-button>
<a-button type="primary" :loading="savingAlert" @click="handleSaveAlert">
{{ $t('common.save') }}
</a-button>
</div>
</div>
</template>
<a-form :form="alertForm" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<!-- 标的信息 -->
<a-form-item :label="$t('portfolio.form.symbol')">
<div class="alert-symbol-info">
<a-input
v-decorator="['symbol']"
disabled
class="symbol-input"
/>
<div class="current-price-info" v-if="alertPosition">
<span class="label">{{ $t('portfolio.alerts.currentPrice') }}:</span>
<span class="price">${{ formatNumber(alertPosition.current_price || alertPosition.entry_price) }}</span>
</div>
</div>
</a-form-item>
<!-- 预警类型 -->
<a-form-item :label="$t('portfolio.alerts.alertType')">
<a-select
v-decorator="['alert_type', { initialValue: 'price_above', rules: [{ required: true }] }]"
>
<a-select-option value="price_above">
<a-icon type="rise" style="color: #52c41a; margin-right: 6px;" />
{{ $t('portfolio.alerts.priceAbove') }}
</a-select-option>
<a-select-option value="price_below">
<a-icon type="fall" style="color: #f5222d; margin-right: 6px;" />
{{ $t('portfolio.alerts.priceBelow') }}
</a-select-option>
<a-select-option value="pnl_above">
<a-icon type="dollar" style="color: #52c41a; margin-right: 6px;" />
{{ $t('portfolio.alerts.pnlAbove') }}
</a-select-option>
<a-select-option value="pnl_below">
<a-icon type="dollar" style="color: #f5222d; margin-right: 6px;" />
{{ $t('portfolio.alerts.pnlBelow') }}
</a-select-option>
</a-select>
</a-form-item>
<!-- 阈值 -->
<a-form-item :label="$t('portfolio.alerts.threshold')">
<div class="threshold-input-wrapper">
<a-input-number
v-decorator="['threshold', { rules: [{ required: true, message: $t('portfolio.alerts.thresholdRequired') }] }]"
:step="alertTypeIsPrice ? 0.01 : 1"
style="width: 100%"
:placeholder="alertTypeIsPrice ? $t('portfolio.alerts.enterPrice') : $t('portfolio.alerts.enterPercent')"
:precision="alertTypeIsPrice ? 4 : 2"
/>
<span class="alert-unit" v-if="!alertTypeIsPrice">%</span>
<span class="alert-unit" v-else>$</span>
</div>
<div class="threshold-hint" v-if="alertPosition && alertTypeIsPrice">
{{ $t('portfolio.alerts.currentPriceHint') }}: ${{ formatNumber(alertPosition.current_price || alertPosition.entry_price) }}
</div>
</a-form-item>
<!-- 重复提醒 -->
<a-form-item :label="$t('portfolio.alerts.repeatInterval')">
<a-select
v-decorator="['repeat_interval', { initialValue: 0 }]"
>
<a-select-option :value="0">{{ $t('portfolio.alerts.noRepeat') }}</a-select-option>
<a-select-option :value="5">{{ $t('portfolio.alerts.every5min') }}</a-select-option>
<a-select-option :value="15">{{ $t('portfolio.alerts.every15min') }}</a-select-option>
<a-select-option :value="30">{{ $t('portfolio.alerts.every30min') }}</a-select-option>
<a-select-option :value="60">{{ $t('portfolio.alerts.every1hour') }}</a-select-option>
<a-select-option :value="240">{{ $t('portfolio.alerts.every4hours') }}</a-select-option>
<a-select-option :value="1440">{{ $t('portfolio.alerts.onceDaily') }}</a-select-option>
</a-select>
</a-form-item>
<!-- 通知渠道 - 使用 v-model 直接绑定 -->
<a-form-item :label="$t('portfolio.form.notifyChannels')">
<a-checkbox-group v-model="alertChannels">
<a-checkbox value="browser">
<a-icon type="bell" />
{{ $t('portfolio.form.browser') }}
</a-checkbox>
<a-checkbox value="telegram">
<a-icon type="message" />
Telegram
</a-checkbox>
<a-checkbox value="email">
<a-icon type="mail" />
{{ $t('portfolio.form.email') }}
</a-checkbox>
</a-checkbox-group>
</a-form-item>
<!-- Telegram Chat ID -->
<a-form-item
v-show="alertChannels.includes('telegram')"
:label="$t('portfolio.form.telegramChatId')"
>
<a-input
v-model="alertTelegramChatId"
:placeholder="$t('portfolio.form.enterTelegramChatId')"
>
<a-icon slot="prefix" type="message" />
</a-input>
</a-form-item>
<!-- Email -->
<a-form-item
v-show="alertChannels.includes('email')"
:label="$t('portfolio.form.emailAddress')"
>
<a-input
v-model="alertEmail"
:placeholder="$t('portfolio.form.enterEmail')"
>
<a-icon slot="prefix" type="mail" />
</a-input>
</a-form-item>
<!-- 启用状态 -->
<a-form-item :label="$t('portfolio.alerts.enabled')">
<a-switch
v-decorator="['is_active', { initialValue: true, valuePropName: 'checked' }]"
/>
<span class="switch-label">{{ $t('portfolio.alerts.enabledDesc') }}</span>
</a-form-item>
<!-- 备注 -->
<a-form-item :label="$t('portfolio.form.notes')">
<a-textarea
v-decorator="['notes']"
:placeholder="$t('portfolio.form.enterNotes')"
:autoSize="{ minRows: 2, maxRows: 4 }"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 添加/编辑监控弹窗 -->
<a-modal
:title="editingMonitor ? $t('portfolio.modal.editMonitor') : $t('portfolio.modal.addMonitor')"
:visible="showAddMonitorModal"
@ok="handleSaveMonitor"
@cancel="closeMonitorModal"
:confirmLoading="savingMonitor"
width="600px"
>
<a-form :form="monitorForm" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<!-- 监控名称 -->
<a-form-item :label="$t('portfolio.form.monitorName')">
<a-input
v-decorator="['name', { rules: [{ required: true, message: $t('portfolio.form.monitorNameRequired') }] }]"
:placeholder="$t('portfolio.form.enterMonitorName')"
/>
</a-form-item>
<!-- 执行间隔 -->
<a-form-item :label="$t('portfolio.form.interval')">
<a-select
v-decorator="['interval_minutes', { initialValue: 60, rules: [{ required: true }] }]"
>
<a-select-option :value="5">5 {{ $t('portfolio.form.minutes') }}</a-select-option>
<a-select-option :value="10">10 {{ $t('portfolio.form.minutes') }}</a-select-option>
<a-select-option :value="30">30 {{ $t('portfolio.form.minutes') }}</a-select-option>
<a-select-option :value="60">1 {{ $t('portfolio.form.hour') }}</a-select-option>
<a-select-option :value="120">2 {{ $t('portfolio.form.hours') }}</a-select-option>
<a-select-option :value="240">4 {{ $t('portfolio.form.hours') }}</a-select-option>
<a-select-option :value="480">8 {{ $t('portfolio.form.hours') }}</a-select-option>
<a-select-option :value="1440">24 {{ $t('portfolio.form.hours') }}</a-select-option>
</a-select>
</a-form-item>
<!-- 通知渠道 - 使用 v-model 直接绑定 -->
<a-form-item :label="$t('portfolio.form.notifyChannels')">
<a-checkbox-group v-model="monitorChannels">
<a-checkbox value="browser">{{ $t('portfolio.form.browser') }}</a-checkbox>
<a-checkbox value="telegram">Telegram</a-checkbox>
<a-checkbox value="email">{{ $t('portfolio.form.email') }}</a-checkbox>
</a-checkbox-group>
</a-form-item>
<!-- Telegram Chat ID -->
<a-form-item :label="$t('portfolio.form.telegramChatId')" v-show="monitorChannels.includes('telegram')">
<a-input
v-model="monitorTelegramChatId"
:placeholder="$t('portfolio.form.enterTelegramChatId')"
/>
</a-form-item>
<!-- Email -->
<a-form-item :label="$t('portfolio.form.emailAddress')" v-show="monitorChannels.includes('email')">
<a-input
v-model="monitorEmail"
:placeholder="$t('portfolio.form.enterEmail')"
/>
</a-form-item>
<!-- 监控范围 -->
<a-form-item :label="$t('portfolio.form.monitorScope')">
<a-radio-group v-model="monitorScope" @change="handleMonitorScopeChange">
<a-radio value="all">{{ $t('portfolio.form.allPositions') }}</a-radio>
<a-radio value="selected">{{ $t('portfolio.form.selectedPositions') }}</a-radio>
</a-radio-group>
</a-form-item>
<!-- 选择持仓 -->
<a-form-item
:label="$t('portfolio.form.selectPositions')"
v-if="monitorScope === 'selected'"
>
<a-checkbox-group
v-model="selectedMonitorPositions"
class="position-checkbox-group"
>
<div
v-for="pos in positions"
:key="pos.id"
class="position-checkbox-item"
>
<a-checkbox :value="pos.id">
<span class="position-checkbox-label">
<a-tag :color="getMarketColor(pos.market)" size="small">{{ pos.market }}</a-tag>
<span class="symbol">{{ pos.symbol }}</span>
<span class="name">{{ pos.name }}</span>
<span :class="['pnl', pos.pnl >= 0 ? 'positive' : 'negative']">
{{ pos.pnl >= 0 ? '+' : '' }}{{ formatNumber(pos.pnl_percent) }}%
</span>
</span>
</a-checkbox>
</div>
</a-checkbox-group>
<div class="position-select-actions">
<a-button type="link" size="small" @click="selectAllPositions">
{{ $t('portfolio.form.selectAll') }}
</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" @click="deselectAllPositions">
{{ $t('portfolio.form.deselectAll') }}
</a-button>
<span class="selected-count">
{{ $t('portfolio.form.selectedCount', { count: selectedMonitorPositions.length, total: positions.length }) }}
</span>
</div>
</a-form-item>
<!-- 自定义提示 -->
<a-form-item :label="$t('portfolio.form.customPrompt')">
<a-textarea
v-decorator="['prompt']"
:rows="3"
:placeholder="$t('portfolio.form.customPromptPlaceholder')"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script>
import { mapState } from 'vuex'
import { Empty } from 'ant-design-vue'
import {
getPositions, addPosition, updatePosition, deletePosition as deletePositionApi,
getPortfolioSummary,
getMonitors, addMonitor, updateMonitor, deleteMonitor as deleteMonitorApi, runMonitor,
getAlerts, addAlert, updateAlert, deleteAlert as deleteAlertApi,
getGroups,
searchSymbols, getMarketTypes
} from '@/api/portfolio'
export default {
name: 'Portfolio',
data () {
return {
simpleImage: Empty.PRESENTED_IMAGE_SIMPLE,
// Summary
summary: {
total_cost: 0,
total_market_value: 0,
total_pnl: 0,
total_pnl_percent: 0,
position_count: 0,
market_distribution: [],
today_pnl: 0,
today_change: 0
},
// Positions
positions: [],
loadingPositions: false,
showAddPositionModal: false,
savingPosition: false,
editingPosition: null,
positionForm: null,
// Monitors
monitors: [],
loadingMonitors: false,
showAddMonitorModal: false,
savingMonitor: false,
editingMonitor: null,
monitorForm: null,
runningMonitor: null,
// Market types
marketTypes: [],
// Symbol search
symbolSearchResults: [],
searchTimer: null,
selectedSymbolName: '',
symbolSearchKeyword: '', // 当前搜索关键词,用于手动输入
// Price refresh
priceRefreshTimer: null,
lastSyncTime: null, // 最后同步时间
isSyncing: false, // 是否正在同步
// Groups
groups: [],
selectedGroup: '',
// View mode
viewMode: 'grid', // 'grid' or 'group'
activeGroups: [], // 折叠面板展开的分组
// Alerts
alerts: [],
loadingAlerts: false,
showAddAlertModal: false,
savingAlert: false,
deletingAlert: false,
editingAlert: null,
alertForm: null,
alertPosition: null,
// Alert channels (for reactive display)
alertChannels: ['browser'],
// Alert notification targets
alertTelegramChatId: '',
alertEmail: '',
// Monitor channels (for reactive display)
monitorChannels: ['browser'],
// Monitor notification targets
monitorTelegramChatId: '',
monitorEmail: '',
// Monitor scope (all or selected positions)
monitorScope: 'all',
// Selected positions for monitoring
selectedMonitorPositions: []
}
},
computed: {
...mapState({
navTheme: state => state.app.theme
}),
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
selectedChannels () {
return this.monitorChannels || []
},
selectedAlertChannels () {
return this.alertChannels || []
},
filteredPositions () {
if (!this.selectedGroup) {
return this.positions
}
if (this.selectedGroup === '__ungrouped__') {
return this.positions.filter(p => !p.group_name)
}
return this.positions.filter(p => p.group_name === this.selectedGroup)
},
groupNames () {
return this.groups.map(g => g.name)
},
alertTypeIsPrice () {
if (!this.alertForm) return true
const alertType = this.alertForm.getFieldValue('alert_type')
return alertType && alertType.startsWith('price_')
},
// 盈利/亏损持仓统计
profitLossStats () {
const profit = this.positions.filter(p => p.pnl >= 0).length
const loss = this.positions.filter(p => p.pnl < 0).length
return { profit, loss }
},
// 最佳表现持仓
bestPerformer () {
if (this.positions.length === 0) return null
return this.positions.reduce((best, pos) => {
if (!best || (pos.pnl_percent || 0) > (best.pnl_percent || 0)) return pos
return best
}, null)
},
// 最差表现持仓
worstPerformer () {
if (this.positions.length === 0) return null
return this.positions.reduce((worst, pos) => {
if (!worst || (pos.pnl_percent || 0) < (worst.pnl_percent || 0)) return pos
return worst
}, null)
},
// 未分组持仓
ungroupedPositions () {
return this.positions.filter(p => !p.group_name)
},
// 按分组整理的持仓
groupsWithPositions () {
const groupMap = {}
this.positions.forEach(pos => {
if (pos.group_name) {
if (!groupMap[pos.group_name]) {
groupMap[pos.group_name] = {
name: pos.group_name,
positions: []
}
}
groupMap[pos.group_name].positions.push(pos)
}
})
return Object.values(groupMap).sort((a, b) => a.name.localeCompare(b.name))
}
},
created () {
this.positionForm = this.$form.createForm(this, { name: 'position_form' })
this.monitorForm = this.$form.createForm(this, { name: 'monitor_form' })
this.alertForm = this.$form.createForm(this, { name: 'alert_form' })
this.loadMarketTypes()
this.loadData()
},
mounted () {
// Refresh prices every 30 seconds
this.priceRefreshTimer = setInterval(() => {
this.refreshPrices()
}, 30000)
},
beforeDestroy () {
if (this.priceRefreshTimer) {
clearInterval(this.priceRefreshTimer)
}
if (this.searchTimer) {
clearTimeout(this.searchTimer)
}
},
methods: {
async loadData () {
await Promise.all([
this.loadPositions(),
this.loadSummary(),
this.loadMonitors(),
this.loadGroups(),
this.loadAlerts()
])
this.lastSyncTime = new Date()
},
filterByGroup () {
// Filter is handled by computed property
},
// 刷新价格
async refreshPrices () {
if (this.isSyncing) return
this.isSyncing = true
try {
await Promise.all([
this.loadPositions(),
this.loadSummary()
])
this.lastSyncTime = new Date()
} finally {
this.isSyncing = false
}
},
// 格式化同步时间
formatSyncTime (time) {
if (!time) return '-'
const now = new Date()
const diff = Math.floor((now - time) / 1000)
if (diff < 60) return this.$t('portfolio.summary.justNow')
if (diff < 3600) return `${Math.floor(diff / 60)} ${this.$t('portfolio.form.minutes')}${this.$t('portfolio.summary.ago')}`
return time.toLocaleTimeString()
},
// 计算分组总盈亏
getGroupPnl (positions) {
return positions.reduce((sum, pos) => sum + (pos.pnl || 0), 0)
},
async loadGroups () {
try {
const res = await getGroups()
if (res && res.code === 1 && res.data) {
this.groups = res.data.groups || []
}
} catch (e) {
console.error('Failed to load groups:', e)
}
},
async loadAlerts () {
this.loadingAlerts = true
try {
const res = await getAlerts()
if (res && res.code === 1) {
this.alerts = res.data || []
}
} catch (e) {
console.error('Failed to load alerts:', e)
} finally {
this.loadingAlerts = false
}
},
async loadMarketTypes () {
try {
const res = await getMarketTypes()
if (res && res.code === 1 && res.data) {
this.marketTypes = res.data.map(item => ({
value: item.value,
i18nKey: item.i18nKey || `dashboard.analysis.market.${item.value}`
}))
}
} catch (e) {
this.marketTypes = [
{ value: 'USStock', i18nKey: 'dashboard.analysis.market.USStock' },
{ value: 'Crypto', i18nKey: 'dashboard.analysis.market.Crypto' },
{ value: 'AShare', i18nKey: 'dashboard.analysis.market.AShare' },
{ value: 'HShare', i18nKey: 'dashboard.analysis.market.HShare' },
{ value: 'Forex', i18nKey: 'dashboard.analysis.market.Forex' },
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' }
]
}
},
async loadPositions () {
this.loadingPositions = true
try {
const res = await getPositions()
if (res && res.code === 1) {
this.positions = res.data || []
}
} catch (e) {
this.$message.error(this.$t('portfolio.message.loadFailed'))
} finally {
this.loadingPositions = false
}
},
async loadSummary () {
try {
const res = await getPortfolioSummary()
if (res && res.code === 1) {
this.summary = res.data || this.summary
}
} catch (e) {
console.error('Failed to load summary:', e)
}
},
async loadMonitors () {
this.loadingMonitors = true
try {
const res = await getMonitors()
if (res && res.code === 1) {
this.monitors = res.data || []
}
} catch (e) {
console.error('Failed to load monitors:', e)
} finally {
this.loadingMonitors = false
}
},
// Position methods
handleMarketChange (value) {
this.symbolSearchResults = []
this.symbolSearchKeyword = ''
this.positionForm.setFieldsValue({ symbol: '' })
},
handleSymbolSearch (value) {
// 保存搜索关键词,用于手动输入功能
this.symbolSearchKeyword = value || ''
if (this.searchTimer) {
clearTimeout(this.searchTimer)
}
if (!value || value.length < 1) {
this.symbolSearchResults = []
return
}
this.searchTimer = setTimeout(async () => {
const market = this.positionForm.getFieldValue('market')
if (!market) return
try {
const res = await searchSymbols({ market, keyword: value, limit: 10 })
if (res && res.code === 1) {
this.symbolSearchResults = res.data || []
} else {
// 搜索无结果,清空列表但保留关键词供手动输入
this.symbolSearchResults = []
}
} catch (e) {
// 搜索失败,也允许手动输入
this.symbolSearchResults = []
}
}, 300)
},
handleSymbolSelect (value, option) {
// 先从搜索结果中查找
const item = this.symbolSearchResults.find(s => s.symbol === value)
if (item) {
this.selectedSymbolName = item.name
} else {
// 手动输入的情况,名称留空,后端会尝试获取
this.selectedSymbolName = ''
}
// 清空搜索关键词
this.symbolSearchKeyword = ''
},
editPosition (pos) {
this.editingPosition = pos
this.showAddPositionModal = true
this.$nextTick(() => {
this.positionForm.setFieldsValue({
market: pos.market,
symbol: pos.symbol,
side: pos.side,
quantity: pos.quantity,
entry_price: pos.entry_price,
notes: pos.notes,
group_name: pos.group_name || ''
})
})
},
async handleSavePosition () {
this.positionForm.validateFields(async (err, values) => {
if (err) return
this.savingPosition = true
try {
const data = {
market: values.market,
symbol: values.symbol.toUpperCase(),
side: values.side,
quantity: values.quantity,
entry_price: values.entry_price,
notes: values.notes || '',
name: this.selectedSymbolName || '',
group_name: values.group_name || ''
}
let res
if (this.editingPosition) {
res = await updatePosition(this.editingPosition.id, data)
} else {
res = await addPosition(data)
}
if (res && res.code === 1) {
this.$message.success(this.$t('portfolio.message.saveSuccess'))
this.closePositionModal()
this.loadData()
} else {
this.$message.error(res?.msg || this.$t('portfolio.message.saveFailed'))
}
} catch (e) {
this.$message.error(this.$t('portfolio.message.saveFailed'))
} finally {
this.savingPosition = false
}
})
},
async deletePosition (id) {
try {
const res = await deletePositionApi(id)
if (res && res.code === 1) {
this.$message.success(this.$t('portfolio.message.deleteSuccess'))
this.loadData()
} else {
this.$message.error(res?.msg || this.$t('portfolio.message.deleteFailed'))
}
} catch (e) {
this.$message.error(this.$t('portfolio.message.deleteFailed'))
}
},
closePositionModal () {
this.showAddPositionModal = false
this.editingPosition = null
this.positionForm.resetFields()
this.symbolSearchResults = []
this.selectedSymbolName = ''
this.symbolSearchKeyword = ''
},
// Alert methods
handleAlertChannelsChange (channels) {
this.alertChannels = channels || []
},
showAddAlertForPosition (pos) {
// 检查是否已存在该持仓的 Alert,如果存在则编辑
const existingAlert = this.alerts.find(a => a.position_id === pos.id)
if (existingAlert) {
// 编辑已存在的 Alert
this.editAlert(existingAlert)
return
}
// 创建新的 Alert
this.editingAlert = null
this.alertPosition = pos
this.alertChannels = ['browser']
this.alertTelegramChatId = ''
this.alertEmail = ''
this.showAddAlertModal = true
this.$nextTick(() => {
this.alertForm.setFieldsValue({
symbol: `${pos.market}/${pos.symbol}`,
alert_type: 'price_above',
threshold: pos.current_price || pos.entry_price,
repeat_interval: 0,
is_active: true,
notes: ''
})
})
},
editAlert (alert) {
this.editingAlert = alert
// 找到对应的持仓
this.alertPosition = this.positions.find(p => p.id === alert.position_id) || {
market: alert.market,
symbol: alert.symbol,
current_price: 0,
entry_price: 0
}
// Set channels and targets directly with v-model binding
this.alertChannels = [...(alert.notification_config?.channels || ['browser'])]
this.alertTelegramChatId = alert.notification_config?.targets?.telegram || ''
this.alertEmail = alert.notification_config?.targets?.email || ''
// Show modal
this.showAddAlertModal = true
// Set form values for fields still using v-decorator
this.$nextTick(() => {
if (this.alertForm) {
this.alertForm.setFieldsValue({
symbol: `${alert.market}/${alert.symbol}`,
alert_type: alert.alert_type || 'price_above',
threshold: alert.threshold || 0,
repeat_interval: alert.repeat_interval || 0,
is_active: alert.is_active !== false,
notes: alert.notes || ''
})
}
})
},
async handleSaveAlert () {
this.alertForm.validateFields(async (err, values) => {
if (err) return
this.savingAlert = true
try {
// 构建通知目标 - 使用 v-model 绑定的值
const targets = {}
if (this.alertChannels.includes('telegram') && this.alertTelegramChatId) {
targets.telegram = this.alertTelegramChatId.trim()
}
if (this.alertChannels.includes('email') && this.alertEmail) {
targets.email = this.alertEmail.trim()
}
const data = {
position_id: this.alertPosition?.id,
market: this.alertPosition?.market,
symbol: this.alertPosition?.symbol,
alert_type: values.alert_type,
threshold: values.threshold,
notification_config: {
// 使用 v-model 绑定的值
channels: this.alertChannels.length > 0 ? this.alertChannels : ['browser'],
targets: targets,
language: this.$store.getters.lang || 'en-US' // 保存当前语言
},
is_active: values.is_active !== false,
repeat_interval: values.repeat_interval || 0,
notes: values.notes || ''
}
let res
if (this.editingAlert) {
res = await updateAlert(this.editingAlert.id, data)
} else {
res = await addAlert(data)
}
if (res && res.code === 1) {
this.$message.success(this.$t('portfolio.message.saveSuccess'))
this.closeAlertModal()
this.loadAlerts()
} else {
this.$message.error(res?.msg || this.$t('portfolio.message.saveFailed'))
}
} catch (e) {
this.$message.error(this.$t('portfolio.message.saveFailed'))
} finally {
this.savingAlert = false
}
})
},
async deleteAlert (id) {
try {
const res = await deleteAlertApi(id)
if (res && res.code === 1) {
this.$message.success(this.$t('portfolio.message.deleteSuccess'))
this.loadAlerts()
} else {
this.$message.error(res?.msg || this.$t('portfolio.message.deleteFailed'))
}
} catch (e) {
this.$message.error(this.$t('portfolio.message.deleteFailed'))
}
},
closeAlertModal () {
this.showAddAlertModal = false
this.editingAlert = null
this.alertPosition = null
this.alertChannels = ['browser']
this.alertTelegramChatId = ''
this.alertEmail = ''
this.alertForm.resetFields()
},
confirmDeleteAlert () {
if (!this.editingAlert) return
const self = this
this.$confirm({
title: this.$t('portfolio.alerts.deleteConfirm'),
okText: this.$t('common.confirm'),
okType: 'danger',
cancelText: this.$t('common.cancel'),
async onOk () {
await self.handleDeleteAlert()
}
})
},
async handleDeleteAlert () {
if (!this.editingAlert) return
this.deletingAlert = true
try {
const res = await deleteAlertApi(this.editingAlert.id)
if (res && res.code === 1) {
this.$message.success(this.$t('portfolio.message.deleteSuccess'))
this.closeAlertModal()
await this.loadAlerts()
} else {
this.$message.error(res?.msg || this.$t('portfolio.message.deleteFailed'))
}
} catch (e) {
console.error('Delete alert error:', e)
this.$message.error(this.$t('portfolio.message.deleteFailed'))
} finally {
this.deletingAlert = false
}
},
// Monitor methods
handleMonitorChannelsChange (channels) {
this.monitorChannels = channels || []
},
editMonitor (monitor) {
this.editingMonitor = monitor
// Set channels directly with v-model binding
this.monitorChannels = [...(monitor.notification_config?.channels || ['browser'])]
this.monitorTelegramChatId = monitor.notification_config?.targets?.telegram || ''
this.monitorEmail = monitor.notification_config?.targets?.email || ''
// Update monitor scope and selected positions
let positionIds = []
if (monitor.position_ids) {
if (typeof monitor.position_ids === 'string') {
try {
positionIds = JSON.parse(monitor.position_ids) || []
} catch (e) {
positionIds = []
}
} else if (Array.isArray(monitor.position_ids)) {
positionIds = monitor.position_ids
}
}
this.monitorScope = positionIds.length > 0 ? 'selected' : 'all'
this.selectedMonitorPositions = positionIds
// Show modal
this.showAddMonitorModal = true
// Set form values for fields still using v-decorator
this.$nextTick(() => {
if (this.monitorForm) {
this.monitorForm.setFieldsValue({
name: monitor.name,
interval_minutes: monitor.config?.interval_minutes || 60,
prompt: monitor.config?.prompt || ''
})
}
})
},
handleMonitorScopeChange (e) {
this.monitorScope = e.target.value
if (e.target.value === 'all') {
this.selectedMonitorPositions = []
}
},
selectAllPositions () {
this.selectedMonitorPositions = this.positions.map(p => p.id)
},
deselectAllPositions () {
this.selectedMonitorPositions = []
},
async handleSaveMonitor () {
this.monitorForm.validateFields(async (err, values) => {
if (err) return
// Validate: if selected scope but no positions selected
if (this.monitorScope === 'selected' && this.selectedMonitorPositions.length === 0) {
this.$message.warning(this.$t('portfolio.form.pleaseSelectPositions'))
return
}
this.savingMonitor = true
try {
const data = {
name: values.name,
monitor_type: 'ai',
// position_ids as a separate field for backend
position_ids: this.monitorScope === 'selected' ? this.selectedMonitorPositions : [],
config: {
interval_minutes: values.interval_minutes,
prompt: values.prompt || '',
language: this.$store.getters.lang || 'en-US'
},
notification_config: {
// Use v-model bound values instead of form values
channels: this.monitorChannels.length > 0 ? this.monitorChannels : ['browser'],
targets: {
telegram: this.monitorTelegramChatId || '',
email: this.monitorEmail || ''
}
},
is_active: true
}
let res
if (this.editingMonitor) {
res = await updateMonitor(this.editingMonitor.id, data)
} else {
res = await addMonitor(data)
}
if (res && res.code === 1) {
this.$message.success(this.$t('portfolio.message.saveSuccess'))
this.closeMonitorModal()
this.loadMonitors()
} else {
this.$message.error(res?.msg || this.$t('portfolio.message.saveFailed'))
}
} catch (e) {
this.$message.error(this.$t('portfolio.message.saveFailed'))
} finally {
this.savingMonitor = false
}
})
},
async toggleMonitor (id, active) {
try {
const res = await updateMonitor(id, { is_active: active })
if (res && res.code === 1) {
this.$message.success(active ? this.$t('portfolio.message.monitorEnabled') : this.$t('portfolio.message.monitorDisabled'))
this.loadMonitors()
}
} catch (e) {
this.$message.error(this.$t('portfolio.message.updateFailed'))
}
},
async runMonitorNow (id) {
this.runningMonitor = id
try {
// 传递当前语言给后端
const currentLang = this.$store.getters.lang || 'en-US'
const res = await runMonitor(id, { language: currentLang })
if (res && res.code === 1) {
if (res.data?.success) {
this.$message.success(this.$t('portfolio.message.monitorRunSuccess'))
// Show analysis result in a modal or notification
if (res.data.analysis) {
this.$notification.open({
message: this.$t('portfolio.monitors.analysisResult'),
description: res.data.analysis.substring(0, 500) + (res.data.analysis.length > 500 ? '...' : ''),
duration: 0
})
}
} else {
this.$message.error(res.data?.error || this.$t('portfolio.message.monitorRunFailed'))
}
this.loadMonitors()
}
} catch (e) {
this.$message.error(this.$t('portfolio.message.monitorRunFailed'))
} finally {
this.runningMonitor = null
}
},
async deleteMonitor (id) {
try {
const res = await deleteMonitorApi(id)
if (res && res.code === 1) {
this.$message.success(this.$t('portfolio.message.deleteSuccess'))
this.loadMonitors()
} else {
this.$message.error(res?.msg || this.$t('portfolio.message.deleteFailed'))
}
} catch (e) {
this.$message.error(this.$t('portfolio.message.deleteFailed'))
}
},
closeMonitorModal () {
this.showAddMonitorModal = false
this.editingMonitor = null
this.monitorForm.resetFields()
this.monitorChannels = ['browser'] // Reset to default
this.monitorTelegramChatId = '' // Reset telegram
this.monitorEmail = '' // Reset email
this.monitorScope = 'all' // Reset monitor scope
this.selectedMonitorPositions = [] // Reset selected positions
},
// Helper to get position IDs from monitor
getMonitorPositionIds (monitor) {
if (!monitor.position_ids) return []
if (typeof monitor.position_ids === 'string') {
try {
return JSON.parse(monitor.position_ids) || []
} catch (e) {
return []
}
}
return Array.isArray(monitor.position_ids) ? monitor.position_ids : []
},
// Helper to get position name by ID
getPositionNameById (posId) {
const pos = this.positions.find(p => p.id === posId)
return pos ? `${pos.symbol} (${pos.name || pos.market})` : `#${posId}`
},
// Helpers
hasAlertForPosition (positionId) {
return this.alerts.some(a => a.position_id === positionId)
},
getAlertForPosition (positionId) {
return this.alerts.find(a => a.position_id === positionId)
},
getMarketColor (market) {
const colors = {
'USStock': 'green',
'Crypto': 'purple',
'Forex': 'gold',
'Futures': 'cyan',
'AShare': 'blue',
'HShare': 'orange'
}
return colors[market] || 'default'
},
getMarketName (market) {
return this.$t(`dashboard.analysis.market.${market}`) || market
},
getCurrencySymbol (market) {
const dollarMarkets = ['USStock', 'Crypto', 'Forex', 'Futures']
return dollarMarkets.includes(market) ? '$' : '¥'
},
formatNumber (num, digits = 2) {
if (num === undefined || num === null) return '0.00'
return Number(num).toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits })
},
formatPrice (price) {
if (!price) return '0'
if (price >= 1000) return this.formatNumber(price, 2)
if (price >= 1) return this.formatNumber(price, 4)
return this.formatNumber(price, 6)
},
formatTime (timestamp) {
if (!timestamp) return '-'
const d = new Date(timestamp * 1000)
return d.toLocaleString()
},
getIntervalText (minutes) {
if (!minutes) return '-'
if (minutes < 60) return `${minutes} ${this.$t('portfolio.form.minutes')}`
const hours = minutes / 60
return `${hours} ${this.$t('portfolio.form.hours')}`
}
}
}
</script>
<style lang="less" scoped>
@import '~ant-design-vue/es/style/themes/default.less';
@green: #10b981;
@red: #ef4444;
@blue: #3b82f6;
@purple: #8b5cf6;
.portfolio-container {
padding: 20px;
background: #f5f5f5;
min-height: calc(100vh - 120px);
&.theme-dark {
background: #131722;
.summary-card {
background: #1e222d;
border-color: #363c4e;
.card-label { color: #868993; }
.card-value { color: #d1d4dc; }
.card-sub { color: #868993; }
// 暗色模式总市值卡片
&.total-value {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
border: 1px solid rgba(59, 130, 246, 0.3);
.card-icon { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
.card-label { color: rgba(255, 255, 255, 0.7); }
.card-value { color: #fff; }
.card-sub {
color: rgba(255, 255, 255, 0.6);
.positive { color: #34d399; }
.negative { color: #f87171; }
}
}
&.mini {
.card-label { color: #868993; }
.card-value { color: #d1d4dc; }
}
}
.positions-section, .monitors-section {
background: #1e222d;
border-color: #363c4e;
.section-header {
border-color: #363c4e;
h3 { color: #d1d4dc; }
}
}
.position-card {
background: #2a2e39;
border-color: #363c4e;
.position-header {
border-color: #363c4e;
.symbol { color: #d1d4dc; }
.name { color: #868993; }
}
.position-body {
.label { color: #868993; }
.value { color: #d1d4dc; }
.price-row {
.current-price, .entry-price {
.label { color: #868993; }
.value { color: #d1d4dc; }
}
}
.quantity-row {
.item {
.label { color: #868993; }
.value { color: #d1d4dc; }
}
}
}
.position-footer {
background: #252930;
.pnl {
.label { color: #868993; }
}
}
// 紧凑卡片暗色模式
&.compact {
.position-compact-body {
.compact-item {
.label { color: #868993; }
.value { color: #d1d4dc; }
}
}
}
}
.monitor-card {
background: #2a2e39;
border-color: #363c4e;
.monitor-header {
.monitor-name { color: #d1d4dc; }
}
.monitor-body {
.label { color: #868993; }
.value { color: #d1d4dc; }
}
.monitor-actions {
border-color: #363c4e;
}
}
// 折叠视图暗色模式
.position-collapse-view {
::v-deep .ant-collapse {
.ant-collapse-item {
background: #2a2e39;
}
.ant-collapse-header {
background: #1e222d;
border-color: #363c4e;
color: #d1d4dc;
}
.ant-collapse-content-box {
background: #2a2e39;
}
}
.group-header {
.group-name { color: #d1d4dc; }
.group-stats .count { color: #868993; }
}
}
}
}
.summary-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 16px;
&.secondary {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
margin-bottom: 20px;
}
}
.summary-card {
background: #fff;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
border: 1px solid #e8e8e8;
transition: all 0.3s;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
// 总市值卡片 - 鲜艳渐变风格
&.total-value {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%);
border: none;
position: relative;
overflow: hidden;
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3);
&::before {
content: '';
position: absolute;
top: -30%;
right: -20%;
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.2) 0%, transparent 60%);
}
&::after {
content: '';
position: absolute;
bottom: -30%;
left: -10%;
width: 150px;
height: 150px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
}
&:hover {
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4);
transform: translateY(-2px);
}
.card-icon {
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
.card-content {
position: relative;
z-index: 1;
.card-label {
color: rgba(255, 255, 255, 0.9) !important;
font-weight: 500;
}
.card-value {
color: #fff !important;
font-size: 24px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.currency {
color: rgba(255, 255, 255, 0.9);
font-size: 18px;
}
.amount {
color: #fff;
}
}
.card-sub {
color: rgba(255, 255, 255, 0.85) !important;
font-size: 12px;
margin-top: 4px;
.positive { color: #bbf7d0 !important; }
.negative { color: #fecaca !important; }
}
}
}
// 迷你卡片
&.mini {
padding: 14px 16px;
.card-icon {
width: 36px;
height: 36px;
font-size: 18px;
}
.card-value {
font-size: 16px;
&.small {
font-size: 14px;
}
}
}
// 同步状态卡片
&.sync-card {
.card-sub {
display: flex;
align-items: center;
}
}
.card-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
background: rgba(59, 130, 246, 0.1);
color: @blue;
&.cost { background: rgba(139, 92, 246, 0.1); color: @purple; }
&.profit { background: rgba(16, 185, 129, 0.1); color: @green; }
&.loss { background: rgba(239, 68, 68, 0.1); color: @red; }
&.positions { background: rgba(6, 182, 212, 0.1); color: #06b6d4; }
&.today { background: rgba(251, 191, 36, 0.1); color: #f59e0b; }
&.best { background: rgba(16, 185, 129, 0.1); color: @green; }
&.worst { background: rgba(239, 68, 68, 0.1); color: @red; }
&.sync {
background: rgba(59, 130, 246, 0.1);
color: @blue;
&.syncing { color: #1890ff; }
}
}
.card-content {
flex: 1;
min-width: 0;
.card-label {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 4px;
}
.card-sub {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.card-value {
font-size: 20px;
font-weight: 700;
color: #262626;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.currency { font-size: 16px; margin-right: 2px; }
.percent { font-size: 14px; margin-left: 8px; }
.position-detail {
font-size: 14px;
font-weight: 500;
margin-left: 4px;
}
}
}
}
.main-content {
display: grid;
grid-template-columns: 1fr 360px;
gap: 20px;
@media (max-width: 1200px) {
grid-template-columns: 1fr;
}
}
.positions-section, .monitors-section {
background: #fff;
border-radius: 12px;
border: 1px solid #e8e8e8;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
h3 {
font-size: 16px;
font-weight: 600;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
color: #262626;
.anticon { color: @blue; }
}
}
.positions-list {
padding: 16px;
}
.empty-state {
padding: 40px 20px;
text-align: center;
&.small {
padding: 20px;
}
}
.position-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.position-card {
background: #fafafa;
border-radius: 12px;
border: 1px solid #e8e8e8;
overflow: hidden;
transition: all 0.3s;
&:hover {
border-color: @blue;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&.profit {
border-left: 3px solid @green;
}
&.loss {
border-left: 3px solid @red;
}
.position-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
.symbol-info {
display: flex;
align-items: center;
gap: 8px;
.symbol {
font-size: 16px;
font-weight: 600;
color: #262626;
}
.name {
font-size: 12px;
color: #8c8c8c;
}
}
.position-actions {
.delete-btn { color: @red; }
.has-alert {
color: #faad14 !important;
.anticon {
color: #faad14;
}
}
}
}
.position-body {
padding: 12px 16px;
.price-row {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
.current-price, .entry-price {
.label {
display: block;
font-size: 11px;
color: #8c8c8c;
margin-bottom: 2px;
}
.value {
font-size: 16px;
font-weight: 600;
color: #262626;
}
.change {
font-size: 12px;
margin-left: 8px;
&.up { color: @green; }
&.down { color: @red; }
}
}
}
.quantity-row {
display: flex;
gap: 16px;
.item {
.label {
display: block;
font-size: 11px;
color: #8c8c8c;
margin-bottom: 2px;
}
.value {
font-size: 14px;
color: #262626;
}
}
}
}
.position-footer {
padding: 12px 16px;
background: #f5f5f5;
.pnl {
display: flex;
align-items: center;
justify-content: space-between;
.label {
font-size: 12px;
color: #8c8c8c;
}
.value {
font-size: 18px;
font-weight: 700;
.percent {
font-size: 14px;
margin-left: 8px;
}
}
}
}
}
.monitors-list {
padding: 16px;
}
.monitor-card {
background: #fafafa;
border-radius: 8px;
border: 1px solid #e8e8e8;
padding: 12px 16px;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.monitor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.monitor-name {
font-size: 14px;
font-weight: 600;
color: #262626;
display: flex;
align-items: center;
gap: 8px;
.anticon { color: @blue; }
}
}
.monitor-body {
.monitor-info {
font-size: 12px;
margin-bottom: 4px;
.label { color: #8c8c8c; }
.value { color: #262626; margin-left: 4px; }
}
.monitor-channels {
margin-top: 8px;
font-size: 12px;
.label { color: #8c8c8c; margin-right: 8px; }
}
.scope-selected {
color: #1890ff;
cursor: help;
border-bottom: 1px dashed #1890ff;
}
.scope-all {
color: #52c41a;
}
}
.monitor-actions {
display: flex;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
.delete-btn { color: @red; }
}
}
.positive { color: @green !important; }
.negative { color: @red !important; }
.symbol-option {
display: flex;
align-items: center;
gap: 8px;
.symbol-name {
color: #8c8c8c;
font-size: 12px;
}
}
// 折叠视图样式
.position-collapse-view {
::v-deep .ant-collapse {
background: transparent;
border: none;
.ant-collapse-item {
border: none;
margin-bottom: 12px;
background: #fafafa;
border-radius: 8px;
overflow: hidden;
&:last-child {
margin-bottom: 0;
}
}
.ant-collapse-header {
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
.ant-collapse-arrow {
left: auto;
right: 16px;
}
}
.ant-collapse-content {
border: none;
.ant-collapse-content-box {
padding: 12px;
background: #fafafa;
}
}
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
width: calc(100% - 30px);
.group-name {
font-weight: 600;
font-size: 14px;
color: #262626;
display: flex;
align-items: center;
}
.group-stats {
display: flex;
align-items: center;
gap: 16px;
.count {
font-size: 12px;
color: #8c8c8c;
}
.group-pnl {
font-size: 14px;
font-weight: 600;
}
}
}
}
// 紧凑型持仓卡片
.position-grid.compact {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.position-card.compact {
padding: 12px;
.position-header {
padding: 0 0 8px 0;
border-bottom: none;
.symbol-info {
.symbol {
font-size: 14px;
}
.name {
font-size: 11px;
}
}
}
.position-compact-body {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
.compact-item {
.label {
font-size: 11px;
color: #8c8c8c;
margin-right: 4px;
}
.value {
font-size: 14px;
font-weight: 600;
color: #262626;
}
.change {
font-size: 12px;
margin-left: 6px;
&.up { color: @green; }
&.down { color: @red; }
}
&.pnl {
font-size: 14px;
font-weight: 700;
.percent {
font-size: 12px;
margin-left: 4px;
}
}
}
}
}
// 预警弹窗样式
.alert-symbol-info {
.symbol-input {
margin-bottom: 8px;
}
.current-price-info {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%);
border-radius: 6px;
font-size: 13px;
.label {
color: #595959;
}
.price {
font-weight: 600;
color: #1890ff;
font-size: 14px;
}
}
}
.threshold-input-wrapper {
position: relative;
display: flex;
align-items: center;
.alert-unit {
position: absolute;
right: 12px;
color: #8c8c8c;
font-size: 14px;
pointer-events: none;
}
}
.threshold-hint {
margin-top: 4px;
font-size: 12px;
color: #8c8c8c;
}
.switch-label {
margin-left: 12px;
color: #8c8c8c;
font-size: 13px;
}
// 预警弹窗底部按钮
.alert-modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
.footer-right {
display: flex;
gap: 8px;
}
}
// 持仓选择器样式
.position-checkbox-group {
display: flex;
flex-direction: column;
max-height: 250px;
overflow-y: auto;
border: 1px solid #e8e8e8;
border-radius: 6px;
padding: 8px;
background: #fafafa;
.position-checkbox-item {
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 4px;
transition: background 0.2s;
&:hover {
background: rgba(24, 144, 255, 0.05);
}
&:last-child {
margin-bottom: 0;
}
.ant-checkbox-wrapper {
width: 100%;
}
}
}
.position-checkbox-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
.symbol {
font-weight: 600;
color: #262626;
min-width: 60px;
}
.name {
color: #8c8c8c;
font-size: 12px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px;
}
.pnl {
font-weight: 500;
font-size: 12px;
&.positive { color: @green; }
&.negative { color: @red; }
}
}
.position-select-actions {
margin-top: 8px;
display: flex;
align-items: center;
.ant-btn-link {
padding: 0 4px;
height: auto;
font-size: 12px;
}
.selected-count {
margin-left: auto;
font-size: 12px;
color: #8c8c8c;
}
}
// 暗黑主题下的预警弹窗
&.theme-dark {
.position-checkbox-group {
background: #2a2e39;
border-color: #363c4e;
}
.position-checkbox-item:hover {
background: rgba(24, 144, 255, 0.1);
}
.position-checkbox-label {
.symbol { color: #d1d4dc; }
.name { color: #868993; }
}
.position-select-actions {
.selected-count { color: #868993; }
}
.alert-symbol-info {
.current-price-info {
background: linear-gradient(135deg, rgba(24, 144, 255, 0.15) 0%, rgba(114, 46, 209, 0.1) 100%);
.label {
color: rgba(255, 255, 255, 0.65);
}
.price {
color: #40a9ff;
}
}
}
.threshold-hint {
color: rgba(255, 255, 255, 0.45);
}
.switch-label {
color: rgba(255, 255, 255, 0.45);
}
}
// ==================== 移动端响应式适配 ====================
// 平板端 (768px - 1024px)
@media screen and (max-width: 1024px) {
.portfolio-container {
padding: 16px;
}
.summary-section {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
&.secondary {
grid-template-columns: repeat(2, 1fr);
}
}
.main-content {
grid-template-columns: 1fr;
gap: 16px;
.monitors-section {
order: 2;
}
}
.position-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
}
// 手机端 (< 768px)
@media screen and (max-width: 768px) {
.portfolio-container {
padding: 12px;
}
// 概览卡片 - 手机端
.summary-section {
grid-template-columns: 1fr 1fr;
gap: 10px;
&.secondary {
grid-template-columns: 1fr 1fr;
gap: 8px;
}
}
.summary-card {
padding: 14px;
border-radius: 10px;
gap: 10px;
// 总市值卡片手机端占满两列
&.total-value {
grid-column: span 2;
padding: 16px;
.card-content {
.card-value {
font-size: 22px;
.currency {
font-size: 16px;
}
}
}
}
&.mini {
padding: 10px 12px;
.card-icon {
width: 32px;
height: 32px;
font-size: 16px;
}
.card-value {
font-size: 14px;
&.small {
font-size: 12px;
}
}
.card-label {
font-size: 11px;
}
}
.card-icon {
width: 40px;
height: 40px;
font-size: 20px;
border-radius: 10px;
}
.card-content {
.card-label {
font-size: 11px;
}
.card-value {
font-size: 16px;
.percent {
font-size: 11px;
}
.position-detail {
font-size: 11px;
}
}
.card-sub {
font-size: 10px;
}
}
}
// 主内容区域
.main-content {
grid-template-columns: 1fr;
gap: 12px;
}
// 持仓区域
.positions-section {
padding: 12px;
border-radius: 10px;
.section-header {
padding-bottom: 10px;
flex-wrap: wrap;
gap: 8px;
h3 {
font-size: 15px;
width: 100%;
}
.header-actions {
width: 100%;
justify-content: space-between;
.view-toggle {
.ant-btn {
padding: 4px 8px;
font-size: 12px;
}
}
.group-filter {
.ant-select {
width: 120px !important;
font-size: 12px;
}
}
}
}
}
// 持仓网格 - 手机端单列
.position-grid {
grid-template-columns: 1fr;
gap: 10px;
&.compact {
grid-template-columns: 1fr;
}
}
// 持仓卡片 - 手机端优化
.position-card {
border-radius: 10px;
.position-header {
padding: 12px;
.symbol-info {
.symbol {
font-size: 15px;
}
.name {
font-size: 12px;
}
}
.position-actions {
.ant-btn {
padding: 2px 6px;
font-size: 12px;
}
}
}
.position-body {
padding: 10px 12px;
.price-row {
flex-direction: column;
gap: 8px;
.current-price, .entry-price {
.value {
font-size: 14px;
}
}
}
.quantity-row {
.item {
.value {
font-size: 13px;
}
}
}
}
.position-footer {
padding: 10px 12px;
.pnl {
.value {
font-size: 16px;
}
.percent {
font-size: 12px;
}
}
}
// 紧凑卡片手机端
&.compact {
.position-compact-body {
flex-direction: column;
align-items: flex-start;
gap: 6px;
.compact-item {
width: 100%;
display: flex;
justify-content: space-between;
.value {
font-size: 13px;
}
}
}
}
}
// 监控区域 - 手机端
.monitors-section {
padding: 12px;
border-radius: 10px;
.section-header {
padding-bottom: 10px;
h3 {
font-size: 15px;
}
}
}
.monitor-card {
padding: 12px;
border-radius: 8px;
.monitor-header {
.monitor-name {
font-size: 14px;
.anticon {
font-size: 16px;
}
}
}
.monitor-body {
.monitor-info {
font-size: 12px;
}
}
.monitor-actions {
padding-top: 10px;
flex-wrap: wrap;
gap: 4px;
.ant-btn {
padding: 2px 8px;
font-size: 12px;
}
}
}
// 折叠视图手机端
.position-collapse-view {
::v-deep .ant-collapse {
.ant-collapse-item {
margin-bottom: 8px;
border-radius: 8px;
}
.ant-collapse-header {
padding: 10px 12px;
font-size: 14px;
}
.ant-collapse-content-box {
padding: 8px;
}
}
.group-header {
.group-name {
font-size: 14px;
}
.group-stats {
font-size: 11px;
gap: 6px;
flex-wrap: wrap;
}
}
}
// 空状态
.empty-state {
padding: 30px 16px;
&.small {
padding: 20px 12px;
}
}
}
// 超小屏幕 (< 480px)
@media screen and (max-width: 480px) {
.portfolio-container {
padding: 8px;
}
.summary-section {
gap: 8px;
&.secondary {
// 超小屏幕下第二行改为滚动
display: flex;
overflow-x: auto;
padding-bottom: 8px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
.summary-card.mini {
flex-shrink: 0;
min-width: 140px;
}
}
}
.summary-card {
padding: 12px;
gap: 8px;
&.total-value {
padding: 14px;
.card-content {
.card-value {
font-size: 20px;
}
}
}
.card-icon {
width: 36px;
height: 36px;
font-size: 18px;
}
.card-content {
.card-value {
font-size: 15px;
}
}
}
.positions-section, .monitors-section {
padding: 10px;
.section-header {
h3 {
font-size: 14px;
}
}
}
.position-card {
.position-header {
padding: 10px;
.symbol-info {
.symbol { font-size: 14px; }
.name { font-size: 11px; }
}
}
.position-body {
padding: 8px 10px;
}
.position-footer {
padding: 8px 10px;
.pnl {
.value { font-size: 15px; }
.percent { font-size: 11px; }
}
}
}
.monitor-card {
padding: 10px;
.monitor-header {
.monitor-name {
font-size: 13px;
}
}
}
}
</style>