1732 lines
51 KiB
Vue
Executable File
1732 lines
51 KiB
Vue
Executable File
<template>
|
||
<view class="page-container">
|
||
<!-- uView Toast 组件 -->
|
||
<u-toast ref="uToastRef" />
|
||
|
||
<!-- 骨架屏:资产卡片 -->
|
||
<view class="header-section" v-if="loading">
|
||
<view class="skeleton-card">
|
||
<view class="skeleton-row">
|
||
<view class="skeleton-text skeleton-label"></view>
|
||
<view class="skeleton-text skeleton-badge"></view>
|
||
</view>
|
||
<view class="skeleton-row">
|
||
<view class="skeleton-text skeleton-big"></view>
|
||
</view>
|
||
<view class="skeleton-row skeleton-bottom">
|
||
<view class="skeleton-text skeleton-stat"></view>
|
||
<view class="skeleton-text skeleton-stat"></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 真实内容:资产卡片 -->
|
||
<view class="header-section" v-else>
|
||
<view class="asset-card">
|
||
<view class="card-watermark">
|
||
<uni-icons type="vip-filled" size="120" color="rgba(255,255,255,0.05)"></uni-icons>
|
||
</view>
|
||
|
||
<view class="card-top">
|
||
<text class="label-text">组合总额 (NV)</text>
|
||
<view class="status-badge">
|
||
<text class="status-text">账本追踪中</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="card-main">
|
||
<text class="currency">{{ getCurrencySymbol(portfolioData.currency) }}</text>
|
||
<text class="big-number">{{ (portfolioData.portfolioValue || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</text>
|
||
</view>
|
||
|
||
<view class="card-bottom">
|
||
<view class="stat-item">
|
||
<text class="stat-label">总盈亏</text>
|
||
<text class="stat-val" :class="(portfolioData.totalReturn || 0) >= 0 ? 'text-red' : 'text-green'">
|
||
{{ (portfolioData.totalReturn || 0) >= 0 ? '+' : '' }}{{ getCurrencySymbol(portfolioData.currency) }}{{ (portfolioData.totalReturn || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
|
||
<text style="font-size: 22rpx; opacity: 0.8; margin-left: 8rpx;">({{ (portfolioData.historicalChange || 0) >= 0 ? '+' : '' }}{{ (portfolioData.historicalChange || 0).toFixed(2) }}%)</text>
|
||
</text>
|
||
</view>
|
||
<view class="stat-item align-right">
|
||
<text class="stat-label">当日盈亏</text>
|
||
<text class="stat-val" :class="(portfolioData.todayProfit || 0) >= 0 ? 'text-red' : 'text-green'">
|
||
{{ (portfolioData.todayProfit || 0) >= 0 ? '+' : '' }}{{ getCurrencySymbol(portfolioData.todayProfitCurrency || portfolioData.currency) }}{{ (portfolioData.todayProfit || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 收益曲线 -->
|
||
<view class="section-container">
|
||
<view class="section-header">
|
||
<text class="section-title">收益曲线</text>
|
||
<view class="period-tabs">
|
||
<text :class="['period-tab', {active: navPeriod === '7d'}]" @click="changeNavPeriod('7d')">7天</text>
|
||
<text :class="['period-tab', {active: navPeriod === '30d'}]" @click="changeNavPeriod('30d')">30天</text>
|
||
<text :class="['period-tab', {active: navPeriod === 'all'}]" @click="changeNavPeriod('all')">全部</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="nav-chart-container">
|
||
<!-- 加载中 -->
|
||
<view v-if="navLoading" class="nav-loading">
|
||
<text class="loading-text">加载中...</text>
|
||
</view>
|
||
|
||
<!-- 无数据 -->
|
||
<view v-else-if="!navHistory || navHistory.length === 0" class="nav-empty">
|
||
<text class="empty-text">暂无收益数据</text>
|
||
<text class="empty-hint">回填历史净值后可生成收益曲线</text>
|
||
<button class="backfill-btn" @click="handleBackfillNav" :disabled="backfillLoading">
|
||
{{ backfillLoading ? '回填中...' : '生成收益曲线' }}
|
||
</button>
|
||
</view>
|
||
|
||
<!-- 收益曲线 -->
|
||
<view v-else class="nav-chart-wrapper">
|
||
<!-- 净值指标 -->
|
||
<view class="nav-indicator">
|
||
<text class="indicator-label">最新净值</text>
|
||
<text class="indicator-value">{{ (navHistory[navHistory.length - 1]?.nav || 1).toFixed(4) }}</text>
|
||
</view>
|
||
|
||
<!-- 图表区域 -->
|
||
<view class="chart-area">
|
||
<canvas
|
||
canvas-id="navChart"
|
||
id="navChart"
|
||
class="nav-canvas"
|
||
></canvas>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 统计指标 -->
|
||
<view v-if="navStatistics" class="nav-stats">
|
||
<view class="stat-item">
|
||
<text class="stat-label">总收益</text>
|
||
<text class="stat-val" :class="navStatistics.totalReturn >= 0 ? 'text-red' : 'text-green'">
|
||
{{ navStatistics.totalReturn >= 0 ? '+' : '' }}{{ navStatistics.totalReturn.toFixed(2) }}%
|
||
</text>
|
||
</view>
|
||
<view class="stat-item">
|
||
<text class="stat-label">最大回撤</text>
|
||
<text class="stat-val text-green">-{{ navStatistics.maxDrawdown.toFixed(2) }}%</text>
|
||
</view>
|
||
<view class="stat-item">
|
||
<text class="stat-label">夏普比率</text>
|
||
<text class="stat-val">{{ navStatistics.sharpeRatio.toFixed(2) }}</text>
|
||
</view>
|
||
<view class="stat-item">
|
||
<text class="stat-label">波动率</text>
|
||
<text class="stat-val">{{ navStatistics.volatility.toFixed(2) }}%</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="section-container pb-0">
|
||
<view class="section-header">
|
||
<text class="section-title">当前逻辑模型</text>
|
||
<view class="flex-row items-center gap-2">
|
||
<view class="edit-btn" @click="openEditModal">
|
||
<uni-icons type="compose" size="14" color="#064E3B"></uni-icons>
|
||
<text class="edit-text">编辑</text>
|
||
</view>
|
||
<view class="flex-row items-center" @click="goStrategyConfig" v-if="portfolioData.logicModel">
|
||
<text class="section-sub text-brand">参数配置</text>
|
||
<uni-icons type="right" size="12" color="#064E3B"></uni-icons>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<u-card
|
||
v-if="portfolioData.logicModel"
|
||
:border="false"
|
||
:shadow="false"
|
||
:show-head="false"
|
||
:show-foot="false"
|
||
class="strategy-info-card"
|
||
>
|
||
<template #body>
|
||
<view class="st-left">
|
||
<view class="st-icon-box bg-green-100">
|
||
<text class="st-icon-text text-green">{{ portfolioData.logicModel?.charAt(0) || 'S' }}</text>
|
||
</view>
|
||
<view class="flex-col gap-1">
|
||
<text class="st-name">{{ portfolioData.logicModel || '未设置策略' }}</text>
|
||
<view class="flex-row gap-2">
|
||
<text class="st-tag" v-if="portfolioData.logicModelDescription">{{ portfolioData.logicModelDescription }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="st-right">
|
||
<view class="flex-row items-center gap-1">
|
||
<view class="status-dot pulsing"></view>
|
||
<text class="st-status-text">{{ portfolioData.logicModelStatus || '监控中' }}</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
</u-card>
|
||
|
||
<!-- 未绑定策略提示 -->
|
||
<u-card
|
||
v-else
|
||
:border="false"
|
||
:shadow="false"
|
||
:show-head="false"
|
||
:show-foot="false"
|
||
class="strategy-info-card"
|
||
>
|
||
<template #body>
|
||
<view class="st-left">
|
||
<view class="st-icon-box bg-gray-100">
|
||
<text class="st-icon-text text-gray">—</text>
|
||
</view>
|
||
<view class="flex-col gap-1">
|
||
<text class="st-name">未绑定策略</text>
|
||
<text class="st-tag">点击编辑可绑定策略</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
</u-card>
|
||
</view>
|
||
|
||
<view class="section-container">
|
||
<view class="section-header">
|
||
<text class="section-title">当前记录项 ({{ portfolioData.totalItems || positions.length }})</text>
|
||
<text class="section-sub">占比 {{ portfolioData.totalRatio || 100 }}%</text>
|
||
</view>
|
||
|
||
<view class="position-list">
|
||
<u-card
|
||
v-for="(item, index) in positions"
|
||
:key="item.id || index"
|
||
:border="false"
|
||
:shadow="false"
|
||
:show-head="false"
|
||
:show-foot="false"
|
||
class="position-card"
|
||
>
|
||
<template #body>
|
||
<view class="pos-top">
|
||
<view class="flex-row items-center gap-2">
|
||
<view class="stock-icon" :class="index % 2 === 0 ? 'bg-blue-100 text-blue' : 'bg-orange-100 text-orange'">
|
||
<text class="icon-char">{{ item.stockName?.charAt(0) || item.stockCode?.charAt(0) || 'S' }}</text>
|
||
</view>
|
||
<view class="flex-col flex-1">
|
||
<!-- 第一行:股票名 + 持仓市值 -->
|
||
<view class="flex-row justify-between items-center">
|
||
<text class="stock-name">{{ item.stockName || item.stockCode }}</text>
|
||
<text class="market-val">{{ getCurrencySymbol(portfolioData.currency) }}{{ (item.totalValue || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</text>
|
||
</view>
|
||
<!-- 第二行:代码+数量 + 现价 -->
|
||
<view class="flex-row justify-between items-center mt-1">
|
||
<text class="stock-code">{{ item.stockCode }} · {{ item.amount }}份</text>
|
||
<text class="price-text">{{ getCurrencySymbol(portfolioData.currency) }}{{ (item.currentPrice || 0).toLocaleString('zh-CN', { minimumFractionDigits: 3, maximumFractionDigits: 3 }) }}</text>
|
||
</view>
|
||
<!-- 第三行:占比 + 成本价 -->
|
||
<view class="flex-row justify-between items-center mt-1">
|
||
<text class="weight-tag">比例 {{ (item.ratio || 0).toFixed(1) }}%</text>
|
||
<text class="cost-text">{{ getCurrencySymbol(portfolioData.currency) }}{{ (item.averagePrice || 0).toLocaleString('zh-CN', { minimumFractionDigits: 3, maximumFractionDigits: 3 }) }}</text>
|
||
</view>
|
||
<!-- 第四行:(空) + 当日盈亏 -->
|
||
<view class="flex-row justify-between items-center mt-1">
|
||
<text></text>
|
||
<text class="pnl-val" :class="(item.changeAmount || 0) >= 0 ? 'text-red' : 'text-green'">
|
||
当日 {{ (item.changeAmount || 0) >= 0 ? '+' : '' }}{{ getCurrencySymbol(portfolioData.currency) }}{{ (item.changeAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
|
||
</text>
|
||
</view>
|
||
<!-- 第五行:(空) + 持仓盈亏 -->
|
||
<view class="flex-row justify-between items-center mt-1">
|
||
<text></text>
|
||
<text class="pnl-val" :class="(item.profit || 0) >= 0 ? 'text-red' : 'text-green'">
|
||
持仓 {{ (item.profit || 0) >= 0 ? '+' : '' }}{{ getCurrencySymbol(portfolioData.currency) }}{{ (item.profit || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
|
||
<text class="pnl-rate"> ({{ (item.profitRate || 0) >= 0 ? '+' : '' }}{{ (item.profitRate || 0).toFixed(2) }}%)</text>
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
</u-card>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="section-container">
|
||
<view class="section-header">
|
||
<text class="section-title">最近交易记录</text>
|
||
</view>
|
||
|
||
<view class="timeline-box">
|
||
<view class="timeline-item" v-for="(log, k) in logs" :key="k">
|
||
<view class="tl-left">
|
||
<text class="tl-date">{{ log.date }}</text>
|
||
<text class="tl-time">{{ log.time }}</text>
|
||
</view>
|
||
|
||
<view class="tl-line">
|
||
<view class="tl-dot" :class="log.type === 'buy' ? 'bg-red' : 'bg-green'"></view>
|
||
<view class="tl-dash" v-if="k !== logs.length - 1"></view>
|
||
</view>
|
||
|
||
<view class="tl-right">
|
||
<view class="flex-row justify-between items-center w-full">
|
||
<text class="tl-title">{{ log.title }} {{ log.stockCode || '' }}</text>
|
||
<text class="tl-amount" :class="log.type === 'buy' ? 'text-red' : 'text-green'">
|
||
{{ log.type === 'buy' ? '+' : '-' }}{{ getCurrencySymbol(portfolioData.currency) }}{{ (log.amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
|
||
</text>
|
||
</view>
|
||
<text class="tl-desc mt-1">{{ log.type === 'buy' ? '买入' : '卖出' }} 操作</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="action-section fixed-bottom">
|
||
<u-button
|
||
class="btn-delete"
|
||
@click="deletePortfolio"
|
||
:customStyle="{
|
||
backgroundColor: '#FEF2F2',
|
||
color: '#DC2626',
|
||
fontWeight: '600',
|
||
borderRadius: '20rpx',
|
||
height: '80rpx',
|
||
fontSize: '28rpx',
|
||
width: '80rpx',
|
||
border: 'none',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center'
|
||
}"
|
||
>
|
||
<u-icon name="trash" size="20" color="#DC2626"></u-icon>
|
||
</u-button>
|
||
<u-button
|
||
class="btn-buy"
|
||
@click="handleBuy"
|
||
:customStyle="{
|
||
backgroundColor: '#064E3B',
|
||
color: '#FFFFFF',
|
||
fontWeight: '600',
|
||
borderRadius: '20rpx',
|
||
height: '80rpx',
|
||
fontSize: '28rpx',
|
||
flex: '1',
|
||
border: 'none',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: '8rpx'
|
||
}"
|
||
>
|
||
<u-icon name="download" size="18" color="#FFFFFF"></u-icon>
|
||
<text>增加</text>
|
||
</u-button>
|
||
<u-button
|
||
class="btn-sell"
|
||
@click="handleSell"
|
||
:customStyle="{
|
||
backgroundColor: '#D1FAE5',
|
||
color: '#064E3B',
|
||
fontWeight: '600',
|
||
borderRadius: '20rpx',
|
||
height: '80rpx',
|
||
fontSize: '28rpx',
|
||
flex: '1',
|
||
border: 'none',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: '8rpx'
|
||
}"
|
||
>
|
||
<u-icon name="upload" size="18" color="#064E3B"></u-icon>
|
||
<text>减少</text>
|
||
</u-button>
|
||
</view>
|
||
|
||
<!-- 交易表单弹窗 -->
|
||
<view v-if="showTransactionForm" class="transaction-modal" @click="showTransactionForm = false">
|
||
<view class="modal-content" @click.stop>
|
||
<view class="modal-header">
|
||
<text class="modal-title">{{ transactionType === 'buy' ? '增加' : '减少' }}</text>
|
||
<view class="close-btn" @click="showTransactionForm = false">
|
||
<uni-icons type="close" size="20" color="#6B7280"></uni-icons>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="form-content">
|
||
<view class="form-item">
|
||
<text class="form-label">{{ transactionType === 'sell' ? '选择持仓' : '股票代码' }}</text>
|
||
<view class="relative">
|
||
<input
|
||
v-model="transactionForm.stockCode"
|
||
class="stock-input"
|
||
:placeholder="transactionType === 'sell' ? '点击选择要卖出的持仓' : '请输入股票代码搜索'"
|
||
:disabled="transactionType === 'sell'"
|
||
@input="onStockInput"
|
||
@click="handleStockInputClick"
|
||
/>
|
||
<!-- 搜索下拉列表 -->
|
||
<view class="search-dropdown" v-if="searchResults.length > 0">
|
||
<view
|
||
class="dropdown-item"
|
||
v-for="(result, idx) in searchResults"
|
||
:key="idx"
|
||
@click="selectStock(result)"
|
||
>
|
||
<view class="item-left">
|
||
<text class="item-ticker">{{ result.ticker || result.stockCode }}</text>
|
||
<text class="item-name">{{ result.name || result.stockName }}</text>
|
||
</view>
|
||
<view class="item-right">
|
||
<text class="item-type" v-if="result.assetType">{{ result.assetType }}</text>
|
||
<text class="item-exchange">{{ result.exchange || '' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="form-item">
|
||
<text class="form-label">数量{{ transactionType === 'sell' && maxSellAmount > 0 ? ` (最多可卖 ${maxSellAmount} 份)` : '' }}</text>
|
||
<input
|
||
v-model="transactionForm.amount"
|
||
type="number"
|
||
class="form-input"
|
||
:placeholder="transactionType === 'sell' && maxSellAmount > 0 ? `请输入数量,不超过 ${maxSellAmount}` : '请输入数量'"
|
||
/>
|
||
</view>
|
||
|
||
<view class="form-item">
|
||
<text class="form-label">价格</text>
|
||
<input
|
||
v-model="transactionForm.price"
|
||
type="digit"
|
||
class="form-input"
|
||
placeholder="请输入价格"
|
||
/>
|
||
</view>
|
||
|
||
<view class="form-item">
|
||
<text class="form-label">交易时间</text>
|
||
<picker mode="date" :value="transactionForm.transactionDate" @change="onDateChange">
|
||
<view class="form-select">
|
||
<text>{{ transactionForm.transactionDate || '请选择日期' }}</text>
|
||
<uni-icons type="bottom" size="14" color="#9CA3AF"></uni-icons>
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<view class="form-item">
|
||
<text class="form-label">备注</text>
|
||
<input
|
||
v-model="transactionForm.remark"
|
||
placeholder="请输入备注(可选)"
|
||
class="native-input-remark"
|
||
/>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="modal-footer">
|
||
<u-button
|
||
class="btn-cancel"
|
||
@click="showTransactionForm = false"
|
||
:customStyle="{
|
||
backgroundColor: '#FFFFFF',
|
||
color: '#6B7280',
|
||
fontWeight: '600',
|
||
borderRadius: '16rpx',
|
||
height: '80rpx',
|
||
fontSize: '28rpx',
|
||
flex: '1',
|
||
border: '2rpx solid #E5E7EB'
|
||
}"
|
||
>
|
||
取消
|
||
</u-button>
|
||
<u-button
|
||
class="btn-confirm"
|
||
@click="submitTransaction"
|
||
:customStyle="{
|
||
backgroundColor: '#064E3B',
|
||
color: '#FFFFFF',
|
||
fontWeight: '600',
|
||
borderRadius: '16rpx',
|
||
height: '80rpx',
|
||
fontSize: '28rpx',
|
||
flex: '1',
|
||
border: 'none'
|
||
}"
|
||
>
|
||
确认
|
||
</u-button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 编辑组合弹窗 -->
|
||
<view v-if="showEditModal" class="transaction-modal" @click="showEditModal = false">
|
||
<view class="modal-content" @click.stop>
|
||
<view class="modal-header">
|
||
<text class="modal-title">编辑组合</text>
|
||
<view class="close-btn" @click="showEditModal = false">
|
||
<uni-icons type="close" size="20" color="#6B7280"></uni-icons>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="form-content">
|
||
<view class="form-item">
|
||
<text class="form-label">组合名称</text>
|
||
<input
|
||
v-model="editForm.name"
|
||
class="form-input"
|
||
placeholder="请输入组合名称"
|
||
/>
|
||
</view>
|
||
|
||
<view class="form-item">
|
||
<text class="form-label">绑定策略</text>
|
||
<picker :range="strategyOptions" range-key="name" @change="onStrategyChange">
|
||
<view class="form-select">
|
||
<text>{{ editForm.strategyName || '不绑定策略' }}</text>
|
||
<uni-icons type="bottom" size="14" color="#9CA3AF"></uni-icons>
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<view class="form-item">
|
||
<text class="form-label">状态</text>
|
||
<picker :range="statusOptions" range-key="label" @change="onStatusChange">
|
||
<view class="form-select">
|
||
<text>{{ statusOptions.find(s => s.value === editForm.status)?.label || '运行中' }}</text>
|
||
<uni-icons type="bottom" size="14" color="#9CA3AF"></uni-icons>
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="modal-footer">
|
||
<u-button
|
||
class="btn-cancel"
|
||
@click="showEditModal = false"
|
||
:customStyle="{
|
||
backgroundColor: '#FFFFFF',
|
||
color: '#6B7280',
|
||
fontWeight: '600',
|
||
borderRadius: '16rpx',
|
||
height: '80rpx',
|
||
fontSize: '28rpx',
|
||
flex: '1',
|
||
border: '2rpx solid #E5E7EB'
|
||
}"
|
||
>
|
||
取消
|
||
</u-button>
|
||
<u-button
|
||
class="btn-confirm"
|
||
@click="submitEdit"
|
||
:customStyle="{
|
||
backgroundColor: '#064E3B',
|
||
color: '#FFFFFF',
|
||
fontWeight: '600',
|
||
borderRadius: '16rpx',
|
||
height: '80rpx',
|
||
fontSize: '28rpx',
|
||
flex: '1',
|
||
border: 'none'
|
||
}"
|
||
>
|
||
保存
|
||
</u-button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, getCurrentInstance, nextTick } from 'vue';
|
||
import { api } from '../../utils/api';
|
||
|
||
// 获取 u-toast 实例
|
||
const { proxy } = getCurrentInstance();
|
||
const uToastRef = ref();
|
||
|
||
// 加载状态
|
||
const loading = ref(true);
|
||
|
||
// 获取货币符号
|
||
const getCurrencySymbol = (currency) => {
|
||
const symbols = {
|
||
'CNY': '¥',
|
||
'USD': '$',
|
||
'HKD': 'HK$'
|
||
};
|
||
return symbols[currency] || '¥';
|
||
};
|
||
|
||
const portfolioId = ref('');
|
||
const portfolioData = ref({
|
||
id: '',
|
||
name: '',
|
||
currency: 'CNY',
|
||
status: '',
|
||
portfolioValue: 0,
|
||
totalReturn: 0,
|
||
todayProfit: 0,
|
||
todayProfitCurrency: 'CNY',
|
||
historicalChange: 0,
|
||
dailyVolatility: 0,
|
||
logicModel: '',
|
||
logicModelStatus: '',
|
||
logicModelDescription: '',
|
||
totalItems: 0,
|
||
totalRatio: 100,
|
||
strategy: null
|
||
});
|
||
|
||
const positions = ref([]);
|
||
const logs = ref([]);
|
||
|
||
// 净值历史相关
|
||
const navLoading = ref(false);
|
||
const navPeriod = ref('30d');
|
||
const navHistory = ref([]);
|
||
const navStatistics = ref(null);
|
||
const backfillLoading = ref(false);
|
||
|
||
// 绘制收益曲线
|
||
const drawNavChart = () => {
|
||
const data = navHistory.value;
|
||
if (!data || data.length === 0) return;
|
||
|
||
nextTick(() => {
|
||
const ctx = uni.createCanvasContext('navChart', this);
|
||
|
||
// 画布尺寸 (rpx -> px) - 匹配 CSS 高度 320rpx ≈ 160px
|
||
const width = 350;
|
||
const height = 160;
|
||
const padding = { top: 10, right: 10, bottom: 25, left: 10 };
|
||
const chartWidth = width - padding.left - padding.right;
|
||
const chartHeight = height - padding.top - padding.bottom;
|
||
|
||
// 数据范围
|
||
const values = data.map(item => item.nav);
|
||
const minVal = Math.min(...values) * 0.98;
|
||
const maxVal = Math.max(...values) * 1.02;
|
||
const range = maxVal - minVal || 1;
|
||
|
||
// 判断收益正负,决定颜色
|
||
const totalReturn = navStatistics.value?.totalReturn || 0;
|
||
const isPositive = totalReturn >= 0;
|
||
const lineColor = isPositive ? '#059669' : '#DC2626';
|
||
const fillColorTop = isPositive ? 'rgba(5, 150, 105, 0.3)' : 'rgba(220, 38, 38, 0.3)';
|
||
const fillColorBottom = isPositive ? 'rgba(5, 150, 105, 0.02)' : 'rgba(220, 38, 38, 0.02)';
|
||
|
||
// 清空画布
|
||
ctx.clearRect(0, 0, width, height);
|
||
|
||
// 计算点坐标
|
||
const points = data.map((item, index) => ({
|
||
x: padding.left + (chartWidth / (data.length - 1 || 1)) * index,
|
||
y: padding.top + chartHeight - ((item.nav - minVal) / range) * chartHeight
|
||
}));
|
||
|
||
// 绘制渐变填充区域
|
||
const gradient = ctx.createLinearGradient(0, padding.top, 0, padding.top + chartHeight);
|
||
gradient.addColorStop(0, fillColorTop);
|
||
gradient.addColorStop(1, fillColorBottom);
|
||
|
||
ctx.beginPath();
|
||
points.forEach((p, i) => {
|
||
if (i === 0) ctx.moveTo(p.x, p.y);
|
||
else ctx.lineTo(p.x, p.y);
|
||
});
|
||
ctx.lineTo(points[points.length - 1].x, padding.top + chartHeight);
|
||
ctx.lineTo(points[0].x, padding.top + chartHeight);
|
||
ctx.closePath();
|
||
ctx.setFillStyle(gradient);
|
||
ctx.fill();
|
||
|
||
// 绘制平滑曲线
|
||
ctx.beginPath();
|
||
ctx.setStrokeStyle(lineColor);
|
||
ctx.setLineWidth(2);
|
||
ctx.setLineCap('round');
|
||
ctx.setLineJoin('round');
|
||
|
||
// 使用贝塞尔曲线平滑
|
||
points.forEach((p, i) => {
|
||
if (i === 0) {
|
||
ctx.moveTo(p.x, p.y);
|
||
} else {
|
||
// 二次贝塞尔曲线
|
||
const prev = points[i - 1];
|
||
const cpX = (prev.x + p.x) / 2;
|
||
ctx.quadraticCurveTo(prev.x, prev.y, cpX, (prev.y + p.y) / 2);
|
||
if (i === points.length - 1) {
|
||
ctx.quadraticCurveTo(cpX, (prev.y + p.y) / 2, p.x, p.y);
|
||
}
|
||
}
|
||
});
|
||
ctx.stroke();
|
||
|
||
// X轴日期标签
|
||
ctx.setFillStyle('#9CA3AF');
|
||
ctx.setFontSize(10);
|
||
ctx.setTextAlign('center');
|
||
const labelCount = Math.min(5, data.length);
|
||
const step = Math.floor(data.length / labelCount) || 1;
|
||
for (let i = 0; i < labelCount; i++) {
|
||
const idx = Math.min(i * step, data.length - 1);
|
||
const x = padding.left + (chartWidth / (data.length - 1 || 1)) * idx;
|
||
const dateStr = data[idx].date.split('-').slice(1).join('/');
|
||
ctx.fillText(dateStr, x, height - 5);
|
||
}
|
||
|
||
ctx.draw();
|
||
});
|
||
};
|
||
|
||
// 回填净值历史
|
||
const handleBackfillNav = async () => {
|
||
if (!portfolioId.value) return;
|
||
|
||
backfillLoading.value = true;
|
||
try {
|
||
const response = await api.assets.backfillNavHistory(portfolioId.value, true);
|
||
if (response.code === 200) {
|
||
uni.showToast({
|
||
title: `成功生成 ${response.data.recordsCreated} 条记录`,
|
||
icon: 'success'
|
||
});
|
||
// 重新获取净值历史
|
||
await fetchNavHistory();
|
||
}
|
||
} catch (error) {
|
||
console.error('回填净值失败:', error);
|
||
uni.showToast({
|
||
title: '生成失败,请重试',
|
||
icon: 'none'
|
||
});
|
||
} finally {
|
||
backfillLoading.value = false;
|
||
}
|
||
};
|
||
|
||
// 交易表单
|
||
const showTransactionForm = ref(false);
|
||
const transactionType = ref('buy'); // buy 或 sell
|
||
// 获取当前日期格式化为YYYY-MM-DD
|
||
const getCurrentDate = () => {
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||
const day = String(now.getDate()).padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
};
|
||
|
||
const transactionForm = ref({
|
||
stockCode: '',
|
||
amount: '',
|
||
price: '',
|
||
currency: '',
|
||
transactionDate: getCurrentDate(),
|
||
dateTimestamp: Date.now(),
|
||
remark: ''
|
||
});
|
||
|
||
const maxSellAmount = ref(0);
|
||
|
||
// 编辑弹窗相关
|
||
const showEditModal = ref(false);
|
||
const editForm = ref({
|
||
name: '',
|
||
strategyId: null,
|
||
strategyName: '',
|
||
status: '运行中'
|
||
});
|
||
const strategyOptions = ref([{ id: null, name: '不绑定策略' }]);
|
||
const statusOptions = ref([
|
||
{ label: '运行中', value: '运行中' },
|
||
{ label: '已暂停', value: '已暂停' },
|
||
{ label: '已清仓', value: '已清仓' }
|
||
]);
|
||
|
||
// 打开编辑弹窗
|
||
const openEditModal = async () => {
|
||
editForm.value = {
|
||
name: portfolioData.value.name || '',
|
||
strategyId: portfolioData.value.strategy?.id || null,
|
||
strategyName: portfolioData.value.strategy?.name || '不绑定策略',
|
||
status: portfolioData.value.status || '运行中'
|
||
};
|
||
|
||
// 获取策略列表
|
||
try {
|
||
const res = await api.strategies.getStrategies();
|
||
if (res.code === 200 && res.data) {
|
||
strategyOptions.value = [
|
||
{ id: null, name: '不绑定策略' },
|
||
...res.data.map(s => ({ id: s.id, name: s.name }))
|
||
];
|
||
}
|
||
} catch (e) {
|
||
console.error('获取策略列表失败:', e);
|
||
}
|
||
|
||
showEditModal.value = true;
|
||
};
|
||
|
||
const onStrategyChange = (e) => {
|
||
const idx = e.detail.value;
|
||
const selected = strategyOptions.value[idx];
|
||
editForm.value.strategyId = selected.id;
|
||
editForm.value.strategyName = selected.name;
|
||
};
|
||
|
||
const onStatusChange = (e) => {
|
||
const idx = e.detail.value;
|
||
editForm.value.status = statusOptions.value[idx].value;
|
||
};
|
||
|
||
const submitEdit = async () => {
|
||
if (!editForm.value.name?.trim()) {
|
||
proxy?.$refs.uToastRef?.show({
|
||
type: 'warning',
|
||
message: '请输入组合名称',
|
||
icon: 'warning'
|
||
});
|
||
return;
|
||
}
|
||
|
||
uni.showLoading({ title: '保存中...', mask: true });
|
||
|
||
try {
|
||
const response = await api.assets.updatePortfolio(portfolioId.value, {
|
||
name: editForm.value.name,
|
||
strategyId: editForm.value.strategyId,
|
||
status: editForm.value.status
|
||
});
|
||
|
||
if (response.code === 200) {
|
||
uni.hideLoading();
|
||
proxy?.$refs.uToastRef?.show({
|
||
type: 'success',
|
||
message: '保存成功',
|
||
icon: 'success'
|
||
});
|
||
showEditModal.value = false;
|
||
await fetchPortfolioData();
|
||
}
|
||
} catch (error) {
|
||
console.error('更新组合失败:', error);
|
||
uni.hideLoading();
|
||
proxy?.$refs.uToastRef?.show({
|
||
type: 'error',
|
||
message: '保存失败,请重试',
|
||
icon: 'error'
|
||
});
|
||
}
|
||
};
|
||
|
||
// 股票搜索相关
|
||
const searchResults = ref([]);
|
||
const searchTimer = ref(null);
|
||
|
||
const handleStockInputClick = () => {
|
||
if (transactionType.value === 'sell') {
|
||
searchResults.value = positions.value.map(pos => ({
|
||
ticker: pos.stockCode,
|
||
stockCode: pos.stockCode,
|
||
stockName: pos.stockName,
|
||
name: pos.stockName,
|
||
assetType: pos.assetType || 'Stock',
|
||
currency: pos.currency,
|
||
amount: pos.amount,
|
||
exchange: ''
|
||
}));
|
||
}
|
||
};
|
||
|
||
const onStockInput = (e) => {
|
||
const keyword = e.detail.value;
|
||
console.log('🔍 股票输入:', keyword);
|
||
searchStock(keyword);
|
||
};
|
||
|
||
const searchStock = async (keyword) => {
|
||
console.log('🔍 searchStock 调用:', keyword);
|
||
if (searchTimer.value) clearTimeout(searchTimer.value);
|
||
if (!keyword || keyword.length < 1) {
|
||
searchResults.value = [];
|
||
return;
|
||
}
|
||
|
||
searchTimer.value = setTimeout(async () => {
|
||
try {
|
||
console.log('📤 调用 api.ticker.search:', keyword);
|
||
const res = await api.ticker.search(keyword);
|
||
console.log('📥 搜索结果:', res);
|
||
if (res.code === 200) {
|
||
searchResults.value = res.data;
|
||
}
|
||
} catch (err) {
|
||
console.error('搜索股票失败:', err);
|
||
searchResults.value = [];
|
||
}
|
||
}, 300);
|
||
};
|
||
|
||
const selectStock = (result) => {
|
||
transactionForm.value.stockCode = result.ticker || result.Ticker || result.stockCode;
|
||
const currency = result.priceCurrency || result.currency;
|
||
if (currency) {
|
||
transactionForm.value.currency = currency;
|
||
}
|
||
if (transactionType.value === 'sell') {
|
||
const position = positions.value.find(pos => pos.stockCode === transactionForm.value.stockCode);
|
||
maxSellAmount.value = position ? position.amount : 0;
|
||
} else {
|
||
maxSellAmount.value = 0;
|
||
}
|
||
searchResults.value = [];
|
||
};
|
||
|
||
const fetchPortfolioData = async () => {
|
||
try {
|
||
const pages = getCurrentPages();
|
||
const currentPage = pages[pages.length - 1];
|
||
const id = currentPage.options?.id;
|
||
|
||
if (!id) {
|
||
console.error('缺少投资组合ID');
|
||
return;
|
||
}
|
||
|
||
portfolioId.value = id;
|
||
const response = await api.assets.getPortfolio(id);
|
||
|
||
if (response.code === 200) {
|
||
portfolioData.value = response.data;
|
||
positions.value = response.data.positions || [];
|
||
console.log('投资组合数据获取成功:', response.data);
|
||
}
|
||
} catch (error) {
|
||
console.error('获取投资组合数据失败:', error);
|
||
proxy?.$refs.uToastRef?.show({
|
||
type: 'error',
|
||
message: '加载失败,请重试',
|
||
icon: 'error'
|
||
});
|
||
}
|
||
};
|
||
|
||
const fetchTransactions = async () => {
|
||
try {
|
||
if (!portfolioId.value) return;
|
||
|
||
const response = await api.assets.getTransactions({
|
||
portfolioId: portfolioId.value,
|
||
limit: 10,
|
||
offset: 0
|
||
});
|
||
|
||
if (response.code === 200) {
|
||
logs.value = response.data.items || [];
|
||
console.log('交易记录获取成功:', response.data);
|
||
}
|
||
} catch (error) {
|
||
console.error('获取交易记录失败:', error);
|
||
proxy?.$refs.uToastRef?.show({
|
||
type: 'error',
|
||
message: '加载交易记录失败',
|
||
icon: 'error'
|
||
});
|
||
}
|
||
};
|
||
|
||
// 获取净值历史
|
||
const fetchNavHistory = async () => {
|
||
if (!portfolioId.value) return;
|
||
|
||
navLoading.value = true;
|
||
try {
|
||
const today = new Date();
|
||
let startDate;
|
||
|
||
if (navPeriod.value === '7d') {
|
||
startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||
} else if (navPeriod.value === '30d') {
|
||
startDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||
} else {
|
||
startDate = null; // 全部
|
||
}
|
||
|
||
const params = {};
|
||
if (startDate) {
|
||
params.startDate = startDate.toISOString().split('T')[0];
|
||
}
|
||
params.endDate = today.toISOString().split('T')[0];
|
||
|
||
const response = await api.assets.getNavHistory(portfolioId.value, params);
|
||
|
||
if (response.code === 200 && response.data) {
|
||
navHistory.value = response.data.navHistory || [];
|
||
navStatistics.value = response.data.statistics || null;
|
||
|
||
// 数据加载后绘制图表
|
||
if (navHistory.value.length > 0) {
|
||
drawNavChart();
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('获取净值历史失败:', error);
|
||
} finally {
|
||
navLoading.value = false;
|
||
}
|
||
};
|
||
|
||
// 切换时间周期
|
||
const changeNavPeriod = (period) => {
|
||
navPeriod.value = period;
|
||
fetchNavHistory();
|
||
};
|
||
|
||
onMounted(async () => {
|
||
loading.value = true;
|
||
await fetchPortfolioData();
|
||
await fetchTransactions();
|
||
await fetchNavHistory();
|
||
loading.value = false;
|
||
});
|
||
|
||
const goStrategyConfig = () => {
|
||
if (portfolioData.value.strategy?.id) {
|
||
uni.navigateTo({ url: `/pages/strategies/edit/edit?id=${portfolioData.value.strategy.id}` });
|
||
}
|
||
};
|
||
|
||
const handleBuy = () => {
|
||
transactionType.value = 'buy';
|
||
resetTransactionForm();
|
||
transactionForm.value.currency = portfolioData.value.currency;
|
||
showTransactionForm.value = true;
|
||
};
|
||
|
||
const handleSell = () => {
|
||
transactionType.value = 'sell';
|
||
resetTransactionForm();
|
||
transactionForm.value.currency = portfolioData.value.currency;
|
||
showTransactionForm.value = true;
|
||
};
|
||
|
||
const resetTransactionForm = () => {
|
||
transactionForm.value = {
|
||
stockCode: '',
|
||
amount: '',
|
||
price: '',
|
||
currency: 'CNY',
|
||
transactionDate: getCurrentDate(),
|
||
dateTimestamp: Date.now(),
|
||
remark: ''
|
||
};
|
||
searchResults.value = [];
|
||
maxSellAmount.value = 0;
|
||
};
|
||
|
||
const onDateChange = (e) => {
|
||
// 原生 picker 直接返回日期字符串 YYYY-MM-DD
|
||
transactionForm.value.transactionDate = e.detail.value;
|
||
transactionForm.value.dateTimestamp = new Date(e.detail.value).getTime();
|
||
};
|
||
|
||
const submitTransaction = async () => {
|
||
// 表单验证
|
||
if (!transactionForm.value.stockCode) {
|
||
proxy?.$refs.uToastRef?.show({
|
||
type: 'warning',
|
||
message: transactionType.value === 'sell' ? '请选择要卖出的持仓' : '请输入股票代码',
|
||
icon: 'warning'
|
||
});
|
||
return;
|
||
}
|
||
const amount = parseFloat(transactionForm.value.amount);
|
||
if (!amount || amount <= 0) {
|
||
proxy?.$refs.uToastRef?.show({
|
||
type: 'warning',
|
||
message: '请输入有效的数量',
|
||
icon: 'warning'
|
||
});
|
||
return;
|
||
}
|
||
// 卖出时校验数量不超过持仓
|
||
if (transactionType.value === 'sell' && amount > maxSellAmount.value) {
|
||
proxy?.$refs.uToastRef?.show({
|
||
type: 'warning',
|
||
message: `卖出数量不能超过持仓数量 ${maxSellAmount.value}`,
|
||
icon: 'warning'
|
||
});
|
||
return;
|
||
}
|
||
if (!transactionForm.value.price || parseFloat(transactionForm.value.price) <= 0) {
|
||
proxy?.$refs.uToastRef?.show({
|
||
type: 'warning',
|
||
message: '请输入有效的价格',
|
||
icon: 'warning'
|
||
});
|
||
return;
|
||
}
|
||
|
||
const transactionData = {
|
||
portfolioId: portfolioId.value,
|
||
type: transactionType.value,
|
||
stockCode: transactionForm.value.stockCode,
|
||
amount: parseFloat(transactionForm.value.amount),
|
||
price: parseFloat(transactionForm.value.price),
|
||
currency: transactionForm.value.currency,
|
||
transactionDate: transactionForm.value.transactionDate,
|
||
remark: transactionForm.value.remark
|
||
};
|
||
|
||
uni.showLoading({ title: '提交中...', mask: true });
|
||
|
||
try {
|
||
const response = await api.assets.createTransaction(transactionData);
|
||
if (response.code === 200) {
|
||
uni.hideLoading();
|
||
proxy?.$refs.uToastRef?.show({
|
||
type: 'success',
|
||
message: '交易提交成功',
|
||
icon: 'success'
|
||
});
|
||
showTransactionForm.value = false;
|
||
|
||
// 重新获取交易记录
|
||
await fetchTransactions();
|
||
// 重新获取投资组合数据
|
||
await fetchPortfolioData();
|
||
}
|
||
} catch (error) {
|
||
console.error('创建交易失败:', error);
|
||
uni.hideLoading();
|
||
proxy?.$refs.uToastRef?.show({
|
||
type: 'error',
|
||
message: '提交失败,请重试',
|
||
icon: 'error'
|
||
});
|
||
}
|
||
};
|
||
|
||
// 删除组合
|
||
const deletePortfolio = async () => {
|
||
uni.showModal({
|
||
title: '确认删除',
|
||
content: '删除后所有持仓和交易记录都会丢失,确定要删除这个组合吗?',
|
||
confirmText: '删除',
|
||
confirmColor: '#EF4444',
|
||
success: async (res) => {
|
||
if (res.confirm) {
|
||
uni.showLoading({ title: '删除中', mask: true });
|
||
try {
|
||
// 调用删除组合接口
|
||
const response = await uni.request({
|
||
url: `${import.meta.env.VITE_API_BASE_URL || 'https://localhost:7040/'}api/v1/portfolio/${portfolioId.value}`,
|
||
method: 'DELETE',
|
||
header: {
|
||
'Authorization': `Bearer ${uni.getStorageSync('token')}`
|
||
}
|
||
});
|
||
|
||
if (response.statusCode === 200) {
|
||
uni.hideLoading();
|
||
proxy?.$refs.uToastRef?.show({
|
||
type: 'success',
|
||
message: '删除成功',
|
||
icon: 'success'
|
||
});
|
||
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1500);
|
||
} else {
|
||
throw new Error('删除失败');
|
||
}
|
||
} catch (error) {
|
||
uni.hideLoading();
|
||
proxy?.$refs.uToastRef?.show({
|
||
type: 'error',
|
||
message: '删除失败,请重试',
|
||
icon: 'error'
|
||
});
|
||
}
|
||
}
|
||
}
|
||
});
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 基础设置 */
|
||
.page-container {
|
||
min-height: 100vh;
|
||
background-color: #F9FAFB;
|
||
padding-bottom: 180rpx;
|
||
}
|
||
|
||
/* 骨架屏样式 */
|
||
.skeleton-card {
|
||
background-color: #064E3B;
|
||
border-radius: 40rpx;
|
||
padding: 40rpx 48rpx;
|
||
min-height: 320rpx;
|
||
}
|
||
|
||
.skeleton-row {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.skeleton-bottom {
|
||
justify-content: space-between;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.skeleton-text {
|
||
background: linear-gradient(90deg, rgba(255,255,255,0.1) 25%, rgba(255,255,255,0.2) 37%, rgba(255,255,255,0.1) 50%);
|
||
background-size: 400% 100%;
|
||
animation: skeleton-loading 1.8s ease infinite;
|
||
border-radius: 8rpx;
|
||
}
|
||
|
||
.skeleton-label {
|
||
width: 160rpx;
|
||
height: 28rpx;
|
||
}
|
||
|
||
.skeleton-badge {
|
||
width: 120rpx;
|
||
height: 40rpx;
|
||
border-radius: 20rpx;
|
||
}
|
||
|
||
.skeleton-big {
|
||
width: 350rpx;
|
||
height: 68rpx;
|
||
}
|
||
|
||
.skeleton-stat {
|
||
width: 150rpx;
|
||
height: 36rpx;
|
||
}
|
||
|
||
@keyframes skeleton-loading {
|
||
0% { background-position: 100% 50% }
|
||
100% { background-position: 0 50% }
|
||
}
|
||
|
||
/* 工具类 */
|
||
.flex-row { display: flex; flex-direction: row; }
|
||
.flex-col { display: flex; flex-direction: column; }
|
||
.items-center { align-items: center; }
|
||
.justify-between { justify-content: space-between; }
|
||
.gap-1 { gap: 8rpx; }
|
||
.gap-2 { gap: 16rpx; }
|
||
.align-right { align-items: flex-end; }
|
||
.pb-0 { padding-bottom: 0 !important; }
|
||
.mt-1 { margin-top: 8rpx; }
|
||
.flex-1 { flex: 1; }
|
||
|
||
/* 颜色工具 */
|
||
.text-red { color: #EF4444; }
|
||
.text-green { color: #10B981; }
|
||
.text-brand { color: #064E3B; }
|
||
.bg-blue-100 { background-color: #EFF6FF; }
|
||
.text-blue { color: #2563EB; }
|
||
.bg-orange-100 { background-color: #FFF7ED; }
|
||
.text-orange { color: #EA580C; }
|
||
.bg-green-100 { background-color: #ECFDF5; }
|
||
.bg-red { background-color: #EF4444; }
|
||
.bg-green { background-color: #10B981; }
|
||
.bg-gray-100 { background-color: #F3F4F6; }
|
||
.text-gray { color: #9CA3AF; }
|
||
|
||
/* 编辑按钮 */
|
||
.edit-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4rpx;
|
||
padding: 8rpx 16rpx;
|
||
background-color: #D1FAE5;
|
||
border-radius: 12rpx;
|
||
}
|
||
|
||
.edit-text {
|
||
font-size: 24rpx;
|
||
color: #064E3B;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.header-section { padding: 20rpx 32rpx; }
|
||
.asset-card {
|
||
background-color: #064E3B;
|
||
border-radius: 40rpx;
|
||
padding: 40rpx;
|
||
position: relative;
|
||
overflow: hidden;
|
||
box-shadow: 0 10rpx 30rpx rgba(6, 78, 59, 0.25);
|
||
color: #fff;
|
||
}
|
||
.card-watermark { position: absolute; right: -20rpx; top: -20rpx; opacity: 0.1; transform: rotate(15deg); }
|
||
.card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
|
||
.label-text { font-size: 26rpx; opacity: 0.8; }
|
||
.status-badge { background-color: rgba(255,255,255,0.2); padding: 4rpx 16rpx; border-radius: 20rpx; }
|
||
.status-text { font-size: 22rpx; font-weight: 600; }
|
||
.card-main { display: flex; align-items: baseline; margin-bottom: 40rpx; }
|
||
.currency { font-size: 40rpx; font-weight: 700; margin-right: 8rpx; }
|
||
.big-number { font-size: 64rpx; font-weight: 800; font-family: 'DIN Alternate'; }
|
||
.card-bottom { display: flex; justify-content: space-between; }
|
||
.stat-item { display: flex; flex-direction: column; gap: 8rpx; }
|
||
.stat-label { font-size: 24rpx; opacity: 0.7; }
|
||
.stat-val { font-size: 32rpx; font-weight: 700; font-family: 'DIN Alternate'; }
|
||
|
||
/* 策略信息卡片 */
|
||
.strategy-info-card {
|
||
border-radius: 24rpx;
|
||
padding: 24rpx;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.st-left { display: flex; align-items: center; gap: 20rpx; }
|
||
.st-icon-box {
|
||
width: 80rpx; height: 80rpx;
|
||
border-radius: 20rpx;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.st-icon-text { font-size: 36rpx; font-weight: 800; }
|
||
.st-name { font-size: 28rpx; font-weight: 700; color: #1F2937; }
|
||
.st-tag {
|
||
font-size: 20rpx; color: #6B7280;
|
||
background-color: #F3F4F6; padding: 2rpx 10rpx; border-radius: 8rpx;
|
||
}
|
||
.st-status-text { font-size: 24rpx; font-weight: 600; color: #059669; }
|
||
.status-dot {
|
||
width: 12rpx; height: 12rpx; border-radius: 50%;
|
||
background-color: #10B981;
|
||
}
|
||
.pulsing { animation: pulse 2s infinite; }
|
||
@keyframes pulse {
|
||
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
|
||
70% { box-shadow: 0 0 0 10rpx rgba(16, 185, 129, 0); }
|
||
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
|
||
}
|
||
|
||
/* 通用容器 */
|
||
.section-container { padding: 20rpx 32rpx; }
|
||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24rpx; }
|
||
.section-title { font-size: 30rpx; font-weight: 800; color: #1F2937; border-left: 8rpx solid #064E3B; padding-left: 16rpx; line-height: 1; }
|
||
.section-sub { font-size: 24rpx; color: #9CA3AF; margin-right: 4rpx; }
|
||
|
||
/* 持仓卡片 */
|
||
.position-card {
|
||
border-radius: 24rpx;
|
||
padding: 32rpx;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
.stock-icon { width: 80rpx; height: 80rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; }
|
||
.icon-char { font-size: 32rpx; font-weight: 800; }
|
||
.stock-name { font-size: 30rpx; font-weight: 700; color: #1F2937; }
|
||
.stock-code { font-size: 24rpx; color: #9CA3AF; margin-top: 4rpx; }
|
||
.market-val { font-size: 30rpx; font-weight: 700; color: #1F2937; }
|
||
.price-text { font-size: 26rpx; color: #1F2937; }
|
||
.cost-text { font-size: 24rpx; color: #6B7280; }
|
||
.weight-tag { font-size: 22rpx; color: #6B7280; background-color: #F3F4F6; padding: 2rpx 8rpx; border-radius: 6rpx; }
|
||
.pnl-val { font-size: 26rpx; font-weight: 700; }
|
||
.pnl-rate { font-size: 22rpx; opacity: 0.9; }
|
||
|
||
/* 交易明细 */
|
||
.timeline-box { padding: 0 16rpx; }
|
||
.timeline-item { display: flex; margin-bottom: 0; min-height: 120rpx; }
|
||
.tl-left { width: 120rpx; text-align: right; padding-right: 24rpx; display: flex; flex-direction: column; }
|
||
.tl-date { font-size: 26rpx; font-weight: 600; color: #374151; }
|
||
.tl-time { font-size: 22rpx; color: #9CA3AF; margin-top: 4rpx; }
|
||
.tl-line { width: 40rpx; display: flex; flex-direction: column; align-items: center; position: relative; }
|
||
.tl-dot { width: 16rpx; height: 16rpx; border-radius: 50%; z-index: 2; margin-top: 10rpx; }
|
||
.tl-dash { width: 2rpx; flex: 1; background-color: #E5E7EB; margin-top: 8rpx; }
|
||
.tl-right { flex: 1; padding-left: 24rpx; padding-bottom: 40rpx; }
|
||
.tl-title { font-size: 28rpx; font-weight: 600; color: #1F2937; }
|
||
.tl-amount { font-size: 26rpx; font-weight: 700; }
|
||
.tl-desc { font-size: 24rpx; color: #6B7280; margin-top: 8rpx; }
|
||
.w-full { width: 100%; }
|
||
|
||
.fixed-bottom {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background-color: #FFFFFF;
|
||
display: flex;
|
||
gap: 24rpx;
|
||
padding: 20rpx 32rpx 50rpx 32rpx;
|
||
box-shadow: 0 -4rpx 16rpx rgba(0,0,0,0.05);
|
||
z-index: 999;
|
||
}
|
||
|
||
/* .btn-delete 样式已通过 u-button 的 customStyle 设置,此处保留空类避免冲突 */
|
||
.btn-delete {
|
||
/* 样式已内联设置 */
|
||
}
|
||
/* .btn-buy 样式已通过 u-button 的 customStyle 设置,此处保留空类避免冲突 */
|
||
.btn-buy {
|
||
/* 样式已内联设置 */
|
||
}
|
||
|
||
/* .btn-sell 样式已通过 u-button 的 customStyle 设置,此处保留空类避免冲突 */
|
||
.btn-sell {
|
||
/* 样式已内联设置 */
|
||
}
|
||
|
||
.transaction-modal {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.modal-content {
|
||
background-color: #fff;
|
||
border-radius: 24rpx 24rpx 0 0;
|
||
width: 100%;
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 32rpx;
|
||
border-bottom: 1rpx solid #E5E7EB;
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: #111827;
|
||
}
|
||
|
||
.close-btn {
|
||
width: 48rpx;
|
||
height: 48rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.form-content {
|
||
padding: 32rpx;
|
||
}
|
||
|
||
.form-item {
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.form-label {
|
||
display: block;
|
||
font-size: 24rpx;
|
||
font-weight: 500;
|
||
color: #374151;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.stock-input {
|
||
width: 100%;
|
||
height: 80rpx;
|
||
background-color: #F9FAFB;
|
||
border: 2rpx solid #E5E7EB;
|
||
border-radius: 16rpx;
|
||
padding: 0 20rpx;
|
||
font-size: 28rpx;
|
||
color: #1F2937;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.form-input {
|
||
width: 100%;
|
||
height: 80rpx;
|
||
background-color: #F9FAFB;
|
||
border: 2rpx solid #E5E7EB;
|
||
border-radius: 16rpx;
|
||
padding: 0 20rpx;
|
||
font-size: 28rpx;
|
||
color: #1F2937;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.form-select {
|
||
width: 100%;
|
||
height: 80rpx;
|
||
background-color: #F9FAFB;
|
||
border: 2rpx solid #E5E7EB;
|
||
border-radius: 16rpx;
|
||
padding: 0 20rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
font-size: 26rpx;
|
||
color: #1F2937;
|
||
}
|
||
|
||
.modal-footer {
|
||
padding: 32rpx;
|
||
border-top: 1rpx solid #E5E7EB;
|
||
display: flex;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
/* .btn-cancel 和 .btn-confirm 样式已通过 u-button 的 customStyle 设置,此处保留空类避免冲突 */
|
||
.btn-cancel,
|
||
.btn-confirm {
|
||
/* 样式已内联设置 */
|
||
}
|
||
|
||
.relative { position: relative; }
|
||
|
||
.search-dropdown {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 0;
|
||
right: 0;
|
||
background-color: #FFFFFF;
|
||
border: 1rpx solid #E5E7EB;
|
||
border-radius: 12rpx;
|
||
margin-top: 4rpx;
|
||
max-height: 300rpx;
|
||
overflow-y: auto;
|
||
z-index: 100;
|
||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.dropdown-item {
|
||
padding: 16rpx 20rpx;
|
||
border-bottom: 1rpx solid #F3F4F6;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.dropdown-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.dropdown-item:active {
|
||
background-color: #F3F4F6;
|
||
}
|
||
|
||
.item-left {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.item-ticker {
|
||
font-size: 26rpx;
|
||
font-weight: 600;
|
||
color: #1F2937;
|
||
}
|
||
|
||
.item-name {
|
||
font-size: 22rpx;
|
||
color: #6B7280;
|
||
}
|
||
|
||
.item-right {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.item-type {
|
||
font-size: 18rpx;
|
||
color: #064E3B;
|
||
background-color: #D1FAE5;
|
||
padding: 2rpx 8rpx;
|
||
border-radius: 4rpx;
|
||
}
|
||
|
||
.item-exchange {
|
||
font-size: 22rpx;
|
||
color: #9CA3AF;
|
||
}
|
||
|
||
/* 收益曲线样式 */
|
||
.nav-chart-container {
|
||
background-color: #FFFFFF;
|
||
border-radius: 20rpx;
|
||
padding: 24rpx;
|
||
min-height: 300rpx;
|
||
}
|
||
|
||
.nav-chart-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* 净值指标 */
|
||
.nav-indicator {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8rpx;
|
||
padding: 0 0 20rpx 0;
|
||
}
|
||
|
||
.indicator-label {
|
||
font-size: 24rpx;
|
||
color: #9CA3AF;
|
||
}
|
||
|
||
.indicator-value {
|
||
font-size: 48rpx;
|
||
font-weight: 700;
|
||
font-family: 'DIN Alternate', sans-serif;
|
||
color: #1F2937;
|
||
}
|
||
|
||
/* 图表区域 */
|
||
.chart-area {
|
||
width: 100%;
|
||
height: 400rpx;
|
||
position: relative;
|
||
}
|
||
|
||
.nav-canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.nav-loading,
|
||
.nav-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 300rpx;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.loading-text,
|
||
.empty-text {
|
||
font-size: 28rpx;
|
||
color: #9CA3AF;
|
||
}
|
||
|
||
.empty-hint {
|
||
font-size: 24rpx;
|
||
color: #D1D5DB;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.backfill-btn {
|
||
margin-top: 16rpx;
|
||
padding: 16rpx 32rpx;
|
||
font-size: 26rpx;
|
||
color: #FFFFFF;
|
||
background-color: #059669;
|
||
border-radius: 8rpx;
|
||
border: none;
|
||
}
|
||
|
||
.backfill-btn[disabled] {
|
||
background-color: #9CA3AF;
|
||
}
|
||
|
||
.nav-chart-wrapper {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 400rpx;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.nav-canvas {
|
||
width: 640rpx;
|
||
height: 400rpx;
|
||
}
|
||
|
||
.period-tabs {
|
||
display: flex;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.period-tab {
|
||
font-size: 24rpx;
|
||
color: #6B7280;
|
||
padding: 8rpx 20rpx;
|
||
border-radius: 20rpx;
|
||
background-color: #F3F4F6;
|
||
}
|
||
|
||
.period-tab.active {
|
||
color: #FFFFFF;
|
||
background-color: #064E3B;
|
||
}
|
||
|
||
.nav-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 16rpx;
|
||
margin-top: 24rpx;
|
||
}
|
||
|
||
.nav-stats .stat-item {
|
||
text-align: center;
|
||
padding: 12rpx 0;
|
||
}
|
||
|
||
.nav-stats .stat-label {
|
||
display: block;
|
||
font-size: 20rpx;
|
||
color: #9CA3AF;
|
||
margin-bottom: 6rpx;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.nav-stats .stat-val {
|
||
display: block;
|
||
font-size: 26rpx;
|
||
font-weight: 600;
|
||
font-family: 'DIN Alternate', sans-serif;
|
||
color: #1F2937;
|
||
}
|
||
|
||
.nav-stats .text-red {
|
||
color: #059669;
|
||
}
|
||
|
||
.nav-stats .text-green {
|
||
color: #DC2626;
|
||
}
|
||
|
||
/* 原生 input 样式 */
|
||
.native-input-remark {
|
||
background-color: #F9FAFB;
|
||
border-radius: 16rpx;
|
||
height: 80rpx;
|
||
padding: 0 20rpx;
|
||
border: 2rpx solid #E5E7EB;
|
||
font-size: 28rpx;
|
||
color: #1F2937;
|
||
}
|
||
</style> |