feat: 收益曲线图表布局优化

1. 新增净值指标区域(最新净值 + 涨跌幅)
2. 移除Y轴标签,图表全宽展示
3. 曲线使用贝塞尔平滑 + 渐变填充
4. 统计指标改为四宫格布局
This commit is contained in:
claw_bot 2026-03-16 02:21:43 +00:00
parent 86a3f57c4a
commit 37a4d8376c

View File

@ -85,11 +85,26 @@
<!-- 收益曲线 -->
<view v-else class="nav-chart-wrapper">
<canvas
canvas-id="navChart"
id="navChart"
class="nav-canvas"
></canvas>
<!-- 净值指标 -->
<view class="nav-indicator">
<view class="indicator-main">
<text class="indicator-label">最新净值</text>
<text class="indicator-value">{{ (navHistory[navHistory.length - 1]?.nav || 1).toFixed(4) }}</text>
</view>
<view class="indicator-change" :class="navStatistics?.totalReturn >= 0 ? 'positive' : 'negative'">
<text class="change-arrow">{{ navStatistics?.totalReturn >= 0 ? '↑' : '↓' }}</text>
<text class="change-value">{{ Math.abs(navStatistics?.totalReturn || 0).toFixed(2) }}%</text>
</view>
</view>
<!-- 图表区域 -->
<view class="chart-area">
<canvas
canvas-id="navChart"
id="navChart"
class="nav-canvas"
></canvas>
</view>
</view>
<!-- 统计指标 -->
@ -102,7 +117,7 @@
</view>
<view class="stat-item">
<text class="stat-label">最大回撤</text>
<text class="stat-val text-green">{{ navStatistics.maxDrawdown.toFixed(2) }}%</text>
<text class="stat-val text-green">-{{ navStatistics.maxDrawdown.toFixed(2) }}%</text>
</view>
<view class="stat-item">
<text class="stat-label">夏普比率</text>
@ -494,10 +509,10 @@ const drawNavChart = () => {
nextTick(() => {
const ctx = uni.createCanvasContext('navChart', this);
// (rpx -> px)
const width = 320;
const height = 200;
const padding = { top: 20, right: 20, bottom: 30, left: 45 };
// (rpx -> px) -
const width = 350;
const height = 180;
const padding = { top: 10, right: 10, bottom: 25, left: 10 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
@ -511,39 +526,23 @@ const drawNavChart = () => {
const totalReturn = navStatistics.value?.totalReturn || 0;
const isPositive = totalReturn >= 0;
const lineColor = isPositive ? '#059669' : '#DC2626';
const fillColor = isPositive ? 'rgba(5, 150, 105, 0.15)' : 'rgba(220, 38, 38, 0.15)';
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);
// 线
ctx.setStrokeStyle('#E5E7EB');
ctx.setLineWidth(0.5);
for (let i = 0; i <= 4; i++) {
const y = padding.top + (chartHeight / 4) * i;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
}
// Y
ctx.setFillStyle('#9CA3AF');
ctx.setFontSize(10);
ctx.setTextAlign('right');
for (let i = 0; i <= 4; i++) {
const y = padding.top + (chartHeight / 4) * i;
const val = maxVal - (range / 4) * i;
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);
@ -552,16 +551,29 @@ const drawNavChart = () => {
ctx.lineTo(points[points.length - 1].x, padding.top + chartHeight);
ctx.lineTo(points[0].x, padding.top + chartHeight);
ctx.closePath();
ctx.setFillStyle(fillColor);
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 ctx.lineTo(p.x, p.y);
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();
@ -575,7 +587,7 @@ const drawNavChart = () => {
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 - 8);
ctx.fillText(dateStr, x, height - 5);
}
ctx.draw();
@ -1354,7 +1366,90 @@ const deletePortfolio = async () => {
background-color: #FFFFFF;
border-radius: 20rpx;
padding: 24rpx;
min-height: 400rpx;
min-height: 300rpx;
}
.nav-chart-wrapper {
display: flex;
flex-direction: column;
}
/* 净值指标 */
.nav-indicator {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16rpx 0 24rpx;
border-bottom: 1rpx solid #F3F4F6;
margin-bottom: 16rpx;
}
.indicator-main {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.indicator-label {
font-size: 24rpx;
color: #9CA3AF;
}
.indicator-value {
font-size: 44rpx;
font-weight: 700;
font-family: 'DIN Alternate', sans-serif;
color: #1F2937;
}
.indicator-change {
display: flex;
align-items: center;
gap: 4rpx;
padding: 8rpx 16rpx;
border-radius: 8rpx;
margin-top: 8rpx;
}
.indicator-change.positive {
background-color: #ECFDF5;
}
.indicator-change.negative {
background-color: #FEF2F2;
}
.change-arrow {
font-size: 28rpx;
font-weight: 600;
}
.indicator-change.positive .change-arrow,
.indicator-change.positive .change-value {
color: #059669;
}
.indicator-change.negative .change-arrow,
.indicator-change.negative .change-value {
color: #DC2626;
}
.change-value {
font-size: 28rpx;
font-weight: 600;
font-family: 'DIN Alternate', sans-serif;
}
/* 图表区域 */
.chart-area {
width: 100%;
height: 360rpx;
position: relative;
}
.nav-canvas {
width: 100%;
height: 100%;
}
.nav-loading,
@ -1363,7 +1458,7 @@ const deletePortfolio = async () => {
flex-direction: column;
align-items: center;
justify-content: center;
height: 400rpx;
height: 300rpx;
gap: 16rpx;
}
@ -1426,21 +1521,23 @@ const deletePortfolio = async () => {
}
.nav-stats {
display: flex;
justify-content: space-between;
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid #E5E7EB;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #F3F4F6;
}
.nav-stats .stat-item {
text-align: center;
padding: 12rpx 0;
}
.nav-stats .stat-label {
display: block;
font-size: 22rpx;
color: #6B7280;
color: #9CA3AF;
margin-bottom: 8rpx;
}
@ -1448,14 +1545,15 @@ const deletePortfolio = async () => {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #111827;
font-family: 'DIN Alternate', sans-serif;
color: #1F2937;
}
.nav-stats .text-red {
color: #EF4444;
color: #059669;
}
.nav-stats .text-green {
color: #10B981;
color: #DC2626;
}
</style>