feat: 收益曲线图表布局优化
1. 新增净值指标区域(最新净值 + 涨跌幅) 2. 移除Y轴标签,图表全宽展示 3. 曲线使用贝塞尔平滑 + 渐变填充 4. 统计指标改为四宫格布局
This commit is contained in:
parent
86a3f57c4a
commit
37a4d8376c
@ -85,12 +85,27 @@
|
||||
|
||||
<!-- 收益曲线 -->
|
||||
<view v-else class="nav-chart-wrapper">
|
||||
<!-- 净值指标 -->
|
||||
<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>
|
||||
|
||||
<!-- 统计指标 -->
|
||||
<view v-if="navStatistics" class="nav-stats">
|
||||
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user