AssetManager.UniApp/pages/detail/detail.vue
claw_bot 23641d2629 fix: 优化交易记录时间格式
- 今天的交易只显示时间 (16:15)
- 非今天的交易显示月-日 时间 (03-24 16:15)
- 缩短时间字符串,避免布局变形
2026-03-24 08:31:51 +00:00

1851 lines
54 KiB
Vue
Executable File
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>
<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">组合总额</text>
</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>
<text class="section-count" v-if="logTotal > 0">共 {{ logTotal }} 条</text>
</view>
<!-- 空状态 -->
<view v-if="logs.length === 0 && !loading" class="empty-state-sm">
<uni-icons type="list" size="48" color="#D1D5DB"></uni-icons>
<text class="empty-text-sm">暂无交易记录</text>
</view>
<view v-else class="timeline-box">
<view class="timeline-item" v-for="(log, k) in logs" :key="k">
<view class="tl-left">
<text class="tl-datetime">{{ formatLogTime(log.date, 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">
<view class="flex-col">
<text class="tl-title">{{ log.title || (log.type === 'buy' ? '买入' : '卖出') }} {{ log.stockCode || '' }}</text>
<text class="tl-shares">{{ log.remark || '' }}</text>
</view>
<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>
</view>
</view>
<!-- 加载更多提示 -->
<view class="load-more" v-if="logs.length > 0">
<text class="load-more-text" v-if="logLoading">加载中...</text>
<text class="load-more-text" v-else-if="!logHasMore">没有更多了</text>
<text class="load-more-text" v-else>上拉加载更多</text>
</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 lang="ts">
import { ref, onMounted, getCurrentInstance, nextTick } from 'vue';
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app';
import { api } from '@/utils/api';
import type { PositionItem, TransactionItem, NavHistoryItem, NavStatistics } from '@/types';
const { proxy } = getCurrentInstance()!;
const uToastRef = ref();
const loading = ref<boolean>(true);
const getCurrencySymbol = (currency?: string): string => {
const symbols: Record<string, string> = { 'CNY': '¥', 'USD': '$', 'HKD': 'HK$' };
return symbols[currency || 'CNY'] || '¥';
};
const formatLogTime = (date?: string, time?: string): string => {
if (!date) return '';
const today = new Date();
const todayStr = today.toISOString().split('T')[0];
const isToday = date === todayStr;
const datePart = isToday ? '' : date.slice(5);
const timePart = time ? time.slice(0, 5) : '';
if (isToday) {
return timePart || '今天';
}
return datePart + (timePart ? ' ' + timePart : '');
};
const portfolioId = ref<string>('');
const portfolioData = ref<any>({
id: '',
name: '',
currency: 'CNY',
status: '',
portfolioValue: 0,
totalReturn: 0,
todayProfit: 0,
todayProfitCurrency: 'CNY',
historicalChange: 0,
dailyVolatility: 0,
strategy: null
});
const positions = ref<PositionItem[]>([]);
const logs = ref<TransactionItem[]>([]);
// 交易记录分页
const logPage = ref<number>(0);
const logPageSize = ref<number>(10);
const logTotal = ref<number>(0);
const logHasMore = ref<boolean>(true);
const logLoading = ref<boolean>(false);
const navLoading = ref<boolean>(false);
const navPeriod = ref<string>('30d');
const navHistory = ref<NavHistoryItem[]>([]);
const navStatistics = ref<NavStatistics | null>(null);
const backfillLoading = ref<boolean>(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: 45 };
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);
// 绘制 Y 轴刻度
ctx.setFillStyle('#D1D5DB');
ctx.setStrokeStyle('#F3F4F6');
ctx.setLineWidth(1);
ctx.setFontSize(9);
ctx.setTextAlign('right');
const yTicks = 5;
for (let i = 0; i <= yTicks; i++) {
const y = padding.top + (chartHeight / yTicks) * i;
const val = maxVal - (range / yTicks) * i;
// 刻度线
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
// 刻度标签
ctx.setFillStyle('#9CA3AF');
ctx.fillText(val.toFixed(2), padding.left - 5, y + 3);
}
// 计算点坐标
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<boolean>(false);
const transactionType = ref<'buy' | 'sell'>('buy');
const getCurrentDate = (): string => {
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<any>({
stockCode: '',
amount: '',
price: '',
currency: '',
transactionDate: getCurrentDate(),
dateTimestamp: Date.now(),
remark: ''
});
const maxSellAmount = ref<number>(0);
// 编辑弹窗相关
const showEditModal = ref<boolean>(false);
const editForm = ref<any>({
name: '',
strategyId: null,
strategyName: '',
status: '运行中'
});
const strategyOptions = ref<any[]>([{ id: null, name: '不绑定策略' }]);
const statusOptions = ref<any[]>([
{ 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<any[]>([]);
let searchTimer: ReturnType<typeof setTimeout> | null = 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 (loadMore: boolean = false) => {
try {
if (!portfolioId.value) return;
if (loadMore && logLoading.value) return;
if (loadMore && !logHasMore.value) return;
logLoading.value = true;
const offset = loadMore ? (logPage.value + 1) * logPageSize.value : 0;
const response = await api.assets.getTransactions({
portfolioId: portfolioId.value,
limit: logPageSize.value,
offset: offset
});
if (response.code === 200) {
const items = response.data.items || [];
if (loadMore) {
logs.value = [...logs.value, ...items];
logPage.value++;
} else {
logs.value = items;
logPage.value = 0;
}
logTotal.value = response.data.total || 0;
logHasMore.value = logs.value.length < logTotal.value;
}
} catch (error) {
console.error('获取交易记录失败:', error);
proxy?.$refs.uToastRef?.show({
type: 'error',
message: '加载交易记录失败',
icon: 'error'
});
} finally {
logLoading.value = false;
}
};
// 获取净值历史
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;
});
// 下拉刷新
onPullDownRefresh(async () => {
await Promise.all([
fetchPortfolioData(),
fetchTransactions(),
fetchNavHistory()
]);
uni.stopPullDownRefresh();
});
// 上拉加载更多交易记录
onReachBottom(async () => {
if (logHasMore.value && !logLoading.value) {
await fetchTransactions(true);
}
});
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-datetime { font-size: 22rpx; color: #9CA3AF; white-space: nowrap; }
.tl-shares { 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;
}
/* 空状态小 */
.empty-state-sm {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 40rpx;
background-color: #FFFFFF;
border-radius: 24rpx;
}
.empty-text-sm {
font-size: 26rpx;
color: #9CA3AF;
margin-top: 20rpx;
}
/* 加载更多 */
.load-more {
display: flex;
justify-content: center;
padding: 32rpx 0;
}
.load-more-text {
font-size: 24rpx;
color: #9CA3AF;
}
/* 标题栏附加信息 */
.section-count {
font-size: 24rpx;
color: #9CA3AF;
margin-left: 16rpx;
}
</style>