美化了可视化界面

This commit is contained in:
Sebastian
2026-01-15 22:04:20 +08:00
parent d43a4da719
commit b24015618a
9 changed files with 348 additions and 396 deletions

View File

@@ -33,19 +33,9 @@ def get_fund_detail(fund_code):
if not fund_code:
return jsonify({"error": "Fund code is required"}), 400
# 首先检查数据库是否有缓存
db = next(get_db())
cached_fund = db.query(FundDetail).filter(FundDetail.fund_code == fund_code).first()
if cached_fund:
# 返回缓存数据
try:
data = json.loads(cached_fund.data_json)
return jsonify(data)
except:
pass
# 从API获取数据
# 直接从API获取最新数据
detail_data = fund_api.get_fund_detail(fund_code)
basic_info = fund_api.get_fund_basic_info(fund_code)
@@ -53,7 +43,10 @@ def get_fund_detail(fund_code):
# 合并数据
detail_data['basic_info'] = basic_info
# 缓存到数据库或更新现有记录
# 检查数据库是否存在记录
cached_fund = db.query(FundDetail).filter(FundDetail.fund_code == fund_code).first()
# 更新或新增记录
if cached_fund:
cached_fund.data_json = json.dumps(detail_data, ensure_ascii=False)
cached_fund.net_worth_trend = json.dumps(detail_data.get('net_worth_trend', []), ensure_ascii=False)
@@ -67,11 +60,24 @@ def get_fund_detail(fund_code):
)
db.add(fund_detail)
db.commit()
try:
db.commit()
except Exception as e:
print(f"Error saving to database: {e}")
db.rollback()
return jsonify(detail_data)
else:
return jsonify({"error": "Fund not found"}), 404
# 如果API获取失败尝试从数据库获取缓存数据作为兜底
cached_fund = db.query(FundDetail).filter(FundDetail.fund_code == fund_code).first()
if cached_fund:
try:
data = json.loads(cached_fund.data_json)
return jsonify(data)
except:
pass
return jsonify({"error": "Fund not found"}), 404
@app.route('/api/fund/<fund_code>/basic', methods=['GET'])
def get_fund_basic(fund_code):

Binary file not shown.

View File

@@ -10,18 +10,20 @@
<table>
<thead>
<tr>
<th>资产类型</th>
<th v-for="(date, index) in categories" :key="index">{{ date }}</th>
<th style="min-width: 100px;">时间</th>
<th v-for="(serie, index) in series" :key="index" style="text-align: center; min-width: 100px;">
<div class="type-cell" style="justify-content: center;">
<span class="type-dot" :style="{ background: getColor(index) }"></span>
{{ serie.name }}
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(serie, index) in series" :key="index">
<td class="type-cell">
<span class="type-dot" :style="{ background: getColor(index) }"></span>
{{ serie.name }}
</td>
<td v-for="(value, idx) in serie.data" :key="idx" class="value-cell">
{{ formatValue(value, serie.name) }}
<tr v-for="(date, dateIndex) in categories" :key="dateIndex">
<td style="font-weight: bold;">{{ date }}</td>
<td v-for="(serie, index) in series" :key="index" class="value-cell">
{{ formatValue(serie.data[dateIndex], serie.name) }}
</td>
</tr>
</tbody>
@@ -86,6 +88,11 @@ export default {
data: serie.data,
itemStyle: {
color: getColor(index)
},
label: {
show: true,
position: 'inside',
formatter: (p) => p.value > 5 ? p.value + '%' : '' // Show label if wide enough
}
}))
@@ -94,13 +101,13 @@ export default {
const lineSeries = netAssetSerie ? [{
name: '净资产',
type: 'line',
yAxisIndex: 1,
yAxisIndex: 1, // 使用右侧Y轴
data: netAssetSerie.data,
itemStyle: {
color: '#ee6666'
},
lineStyle: {
width: 3
width: 3
},
symbol: 'circle',
symbolSize: 8
@@ -126,34 +133,34 @@ export default {
},
legend: {
data: series.value.map(s => s.name),
bottom: 0
bottom: 0,
type: 'scroll'
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '10%',
right: '5%',
bottom: '10%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: categories.value
data: categories.value,
boundaryGap: true
},
yAxis: [
{
type: 'value',
name: '占净值比(%)',
axisLabel: {
formatter: '{value}%'
{
type: 'value',
axisLabel: { formatter: '{value}%' },
splitLine: { show: true }
},
{
type: 'value',
name: '净资产(亿)',
position: 'right',
axisLabel: { formatter: '{value}' },
splitLine: { show: false }
}
},
{
type: 'value',
name: '净资产(亿)',
axisLabel: {
formatter: '{value}亿'
}
}
],
series: [...barSeries, ...lineSeries]
}

View File

@@ -235,11 +235,11 @@ export default {
}
.positive {
color: #52c41a;
color: #ff6b6b; /* 上涨显示红色 */
}
.negative {
color: #ff4d4f;
color: #2ed573; /* 下跌显示绿色 */
}
.loading {

View File

@@ -8,6 +8,13 @@
>
业绩走势
</div>
<div
class="tab-item"
:class="{ active: activeTab === 'comparison' }"
@click="switchTab('comparison')"
>
收益对比
</div>
<div
class="tab-item"
:class="{ active: activeTab === 'drawdown' }"
@@ -24,10 +31,9 @@
<br>
<span class="value" :class="getColor(fundChange)">{{ fundChange > 0 ? '+' : ''}}{{ fundChange }}%</span>
</div>
<!-- Placeholder for standard/benchmark if data exists -->
</div>
<div class="summary-info drawdown-info" v-if="activeTab === 'drawdown'">
<div class="summary-info drawdown-info" v-else-if="activeTab === 'drawdown'">
<div class="info-group">
<div class="legend-dot-row">
<span class="legend-line green"></span>
@@ -40,7 +46,17 @@
<span class="legend-box pink"></span>
<span class="label">最大回撤修复天数</span>
</div>
<div class="value-row">{{ maxDrawdownInfo.days ? maxDrawdownInfo.days + '天' : '--' }}</div>
<div class="value-row">{{ maxDrawdownInfo.days ? maxDrawdownInfo.days + '天' : '正在修复中...' }}</div>
</div>
</div>
<div class="summary-info comparison-info" v-else-if="activeTab === 'comparison'">
<div class="info-group" v-for="item in comparisonInfo" :key="item.name">
<div class="legend-dot-row">
<span class="legend-dot" :style="{ background: item.color, width: '12px', height: '3px' }"></span>
<span class="label" style="margin-left: 4px;">{{ item.name }}</span>
</div>
<!-- Optional: Add value at end of period? -->
</div>
</div>
@@ -63,7 +79,7 @@
</template>
<script>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
export default {
@@ -76,6 +92,10 @@ export default {
acWorthTrend: {
type: Array,
default: () => []
},
grandTotal: {
type: Array,
default: () => []
}
},
setup(props) {
@@ -99,7 +119,13 @@ export default {
const switchTab = (tab) => {
activeTab.value = tab
updateChart()
nextTick(() => {
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
initChart();
})
}
const getColor = (val) => {
@@ -110,120 +136,145 @@ export default {
// Computed properties for summary
const fundChange = ref('0.00')
const maxDrawdownInfo = ref({ val: '0.00', days: 0 })
const comparisonInfo = ref([])
const filterByDate = (data, range) => {
if (!data || data.length === 0) return []
const now = new Date()
let startDate = new Date(0)
if (range === '3m') {
startDate = new Date(now.setMonth(now.getMonth() - 3))
} else if (range === '6m') {
startDate = new Date(now.setMonth(now.getMonth() - 6))
} else if (range === '1y') {
startDate = new Date(now.setFullYear(now.getFullYear() - 1))
} else if (range === '3y') {
startDate = new Date(now.setFullYear(now.getFullYear() - 3))
}
return data.filter(item => item[0] >= startDate.getTime())
}
const processData = () => {
if (!props.netWorthTrend.length) return { netWorth: [], drawdownInfo: null }
const rawData = (props.netWorthTrend || []).map(item => [item.x, item.y]);
const filtered = filterByDate(rawData, selectedRange.value);
if (filtered.length === 0) return { netWorth: [], drawdownInfo: null }
const now = new Date()
let startDate = new Date(0)
const startVal = filtered[0][1]
const endVal = filtered[filtered.length - 1][1]
fundChange.value = startVal !== 0 ? ((endVal - startVal) / startVal * 100).toFixed(2) : '0.00'
if (selectedRange.value === '3m') {
startDate = new Date(now.setMonth(now.getMonth() - 3))
} else if (selectedRange.value === '6m') {
startDate = new Date(now.setMonth(now.getMonth() - 6))
} else if (selectedRange.value === '1y') {
startDate = new Date(now.setFullYear(now.getFullYear() - 1))
} else if (selectedRange.value === '3y') {
startDate = new Date(now.setFullYear(now.getFullYear() - 3))
}
// Prepare Percentage Data
const toPercent = (val) => startVal !== 0 ? parseFloat(((val - startVal) / startVal * 100).toFixed(2)) : 0
const percentTrend = filtered.map(item => [item[0], toPercent(item[1])])
// Filter Data
const filtered = props.netWorthTrend.filter(item => item.x >= startDate.getTime())
if (filtered.length === 0) return { netWorth: [], drawdownInfo: null }
// Calculate Max Drawdown & Recovery
let curMaxdd = 0;
let globalPeakIndex = 0;
let globalValleyIndex = 0;
let runningPeakValue = -Infinity;
let runningPeakIndex = 0;
for (let i = 0; i < filtered.length; i++) {
const val = filtered[i][1];
if (val > runningPeakValue) {
runningPeakValue = val;
runningPeakIndex = i;
}
const dd = (runningPeakValue - val) / runningPeakValue;
if (dd > curMaxdd) {
curMaxdd = dd;
globalPeakIndex = runningPeakIndex;
globalValleyIndex = i;
}
}
// Check Recovery
let recoveryIndex = -1;
const peakValRaw = filtered[globalPeakIndex][1];
for (let i = globalPeakIndex + 1; i < filtered.length; i++) {
if (filtered[i][1] >= peakValRaw) {
recoveryIndex = i;
break;
}
}
const peakDate = filtered[globalPeakIndex][0];
const valleyDate = filtered[globalValleyIndex][0];
const recoveryDate = recoveryIndex !== -1 ? filtered[recoveryIndex][0] : null;
const days = recoveryDate ? Math.ceil((recoveryDate - peakDate) / (1000 * 3600 * 24)) : null;
// Calculate Fund Change %
const startVal = filtered[0].y
const endVal = filtered[filtered.length - 1].y
fundChange.value = ((endVal - startVal) / startVal * 100).toFixed(2)
const ddInfo = {
val: (curMaxdd * 100).toFixed(2),
peakDate,
valleyDate,
recoveryDate,
days,
peakValue: toPercent(peakValRaw),
valleyValue: toPercent(filtered[globalValleyIndex][1]),
recoveryValue: recoveryIndex !== -1 ? toPercent(filtered[recoveryIndex][1]) : null
}
// Calculate Max Drawdown & Recovery
// Logic: Iterate to find the (Peak -> Valley) that gives Max Drawdown
// Then find recovery from that specific Peak
let curMaxdd = 0;
let globalPeakIndex = 0;
let globalValleyIndex = 0;
let runningPeakValue = -Infinity;
let runningPeakIndex = 0;
for (let i = 0; i < filtered.length; i++) {
const val = filtered[i].y;
if (val > runningPeakValue) {
runningPeakValue = val;
runningPeakIndex = i;
}
const dd = (runningPeakValue - val) / runningPeakValue;
if (dd > curMaxdd) {
curMaxdd = dd;
globalPeakIndex = runningPeakIndex;
globalValleyIndex = i;
}
}
// Check Recovery
let recoveryIndex = -1;
const peakVal = filtered[globalPeakIndex].y;
// Look for recovery AFTER the valley? Or AFTER the peak?
// "Recovery Period" usually starts from Drawdown start (Peak).
// Find first point > peakVal after peakIndex
for (let i = globalPeakIndex + 1; i < filtered.length; i++) {
if (filtered[i].y >= peakVal) {
recoveryIndex = i;
break;
}
}
const peakDate = filtered[globalPeakIndex].x;
const valleyDate = filtered[globalValleyIndex].x;
const recoveryDate = recoveryIndex !== -1 ? filtered[recoveryIndex].x : null;
const days = recoveryDate ? Math.ceil((recoveryDate - peakDate) / (1000 * 3600 * 24)) : null;
const ddInfo = {
val: (curMaxdd * 100).toFixed(2),
peakDate,
valleyDate,
recoveryDate,
days,
peakValue: peakVal,
valleyValue: filtered[globalValleyIndex].y,
recoveryValue: recoveryIndex !== -1 ? filtered[recoveryIndex].y : null
}
maxDrawdownInfo.value = ddInfo
return {
netWorth: filtered.map(item => [item.x, item.y]),
drawdownInfo: ddInfo
}
maxDrawdownInfo.value = ddInfo
// Also process Comparison Data just in case we need to filter for Comparison Tab?
// Usually comparison tab shows "All" or follows the range selector if enabled.
// User requirements usually imply comparison follows standard range or all.
// But the range selector is hidden for comparison in template: v-if="activeTab !== 'comparison'"
return {
netWorth: percentTrend,
drawdownInfo: ddInfo
}
}
const initChart = () => {
if (!chartEl.value) return
chartInstance = echarts.init(chartEl.value)
if (!chartInstance) {
chartInstance = echarts.init(chartEl.value)
}
updateChart()
}
const updateChart = () => {
if (!chartInstance) return
const { netWorth, drawdownInfo } = processData()
// Common Options
chartInstance.clear();
const option = {
grid: { left: '3%', right: '5%', bottom: '3%', top: '10%', containLabel: true },
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '5%', bottom: '10%', top: '15%', containLabel: true },
tooltip: {
trigger: 'axis',
formatter: function (params) {
let res = '<div>' + echarts.format.formatTime('yyyy-MM-dd', params[0].value[0]) + '</div>'
params.forEach(item => {
let val = item.value[1];
// If comparison, values are usually percents.
// If net worth, values are currency.
res += `<div>${item.marker} ${item.seriesName}: ${val}${activeTab.value === 'comparison' ? '%' : ''}</div>`
})
return res;
}
},
xAxis: { type: 'time', boundaryGap: false, axisLine: { show: false }, axisTick: { show: false } },
yAxis: { type: 'value', scale: true, splitLine: { lineStyle: { type: 'dashed' } } },
yAxis: {
type: 'value',
scale: true,
splitLine: { lineStyle: { type: 'dashed' } },
axisLabel: { formatter: '{value}%' }
},
series: []
}
if (activeTab.value === 'performance') {
const { netWorth } = processData()
option.series.push({
name: '本基金',
type: 'line',
@@ -238,81 +289,135 @@ export default {
])
}
})
} else {
// Drawdown View
const seriesData = {
name: '本基金',
type: 'line',
data: netWorth,
smooth: true,
symbol: 'none',
lineStyle: { width: 2, color: '#88aaff' }, // Lighter blue
markArea: {
itemStyle: { color: 'rgba(255, 230, 230, 0.6)' }, // Light Pink
data: []
},
markPoint: {
symbol: 'circle',
symbolSize: 8,
label: {
show: true,
position: 'top',
color: '#fff',
padding: [4, 8],
borderRadius: 4
},
data: []
option.tooltip.formatter = function (params) {
let res = '<div>' + echarts.format.formatTime('yyyy-MM-dd', params[0].value[0]) + '</div>'
params.forEach(item => {
res += `<div>${item.marker} ${item.seriesName}: ${item.value[1]}%</div>`
})
return res;
}
} else if (activeTab.value === 'comparison') {
const comparisonData = props.grandTotal || []
if (comparisonData.length > 0) {
const colors = ['#007bff', '#91cc75', '#fac858', '#ee6666', '#5470c6'];
// Update Legend Info
comparisonInfo.value = comparisonData.map((item, index) => ({
name: item.name,
color: colors[index % colors.length]
}))
const series = comparisonData.map((item, index) => {
const rawData = item.data || [];
const filteredData = filterByDate(rawData, selectedRange.value);
return {
name: item.name,
type: 'line',
data: filteredData,
smooth: true,
symbol: 'none',
lineStyle: {
width: item.name.includes('本基金') ? 3 : 1.5
},
itemStyle: {
color: colors[index % colors.length]
},
z: item.name.includes('本基金') ? 3 : 2
}
});
option.series = series
option.legend = { show: false } // Hide internal legend
// Adjust tooltip for comparison to show %
option.tooltip.formatter = function (params) {
let res = '<div>' + echarts.format.formatTime('yyyy-MM-dd', params[0].value[0]) + '</div>'
params.forEach(item => {
res += `<div>
<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${item.color};"></span>
${item.seriesName}: ${item.value[1]}%
</div>`
})
return res;
}
}
} else if (activeTab.value === 'drawdown') {
const { netWorth, drawdownInfo } = processData()
if (drawdownInfo && drawdownInfo.peakDate) {
const endDate = drawdownInfo.recoveryDate || netWorth[netWorth.length - 1][0];
if (netWorth.length > 0) {
const seriesData = {
name: '本基金',
type: 'line',
data: netWorth,
smooth: true,
symbol: 'none',
lineStyle: { width: 2, color: '#88aaff' },
markArea: {
itemStyle: { color: 'rgba(255, 230, 230, 0.6)' },
data: []
},
markPoint: {
symbol: 'circle',
symbolSize: 8,
label: {
show: true,
color: '#fff',
padding: [4, 8],
borderRadius: 4
},
data: []
}
}
// Mark Area: Peak to Recovery (or End)
seriesData.markArea.data.push([
{ xAxis: drawdownInfo.peakDate },
{ xAxis: endDate }
]);
const points = [];
// 1. Tag at Valley: "Max Drawdown X%"
points.push({
xAxis: drawdownInfo.valleyDate,
yAxis: drawdownInfo.valleyValue,
itemStyle: { color: '#00bfa5' }, // Green dot
label: {
offset: [0, 15],
formatter: `最大回撤${drawdownInfo.val}%`,
backgroundColor: '#00bfa5',
position: 'bottom'
}
});
// 2. Tag at Recovery (or in middle if recovery): "X Days Recovery"
if (drawdownInfo.recoveryDate) {
// Middle point for the label? Or at the red line?
// Screenshot has "36 Days Recovery" in a Red Box pointing to the area/line.
// We put it at the end (Recovery point).
points.push({
xAxis: drawdownInfo.recoveryDate,
yAxis: drawdownInfo.recoveryValue,
itemStyle: { color: '#ff5252' }, // Red dot
if (drawdownInfo && drawdownInfo.peakDate) {
const endDate = drawdownInfo.recoveryDate || netWorth[netWorth.length - 1][0];
seriesData.markArea.data.push([
{ xAxis: drawdownInfo.peakDate },
{ xAxis: endDate }
]);
const points = [];
points.push({
coord: [drawdownInfo.peakDate, drawdownInfo.peakValue],
itemStyle: { color: '#ff9800' },
label: { show: false }
});
points.push({
coord: [drawdownInfo.valleyDate, drawdownInfo.valleyValue],
itemStyle: { color: '#00bfa5' },
label: {
offset: [0, -15],
formatter: `${drawdownInfo.days}天修复`,
backgroundColor: '#ff5252',
offset: [0, 15],
formatter: `最大回撤${drawdownInfo.val}%`,
backgroundColor: '#00bfa5',
position: 'top'
}
});
}
seriesData.markPoint.data = points;
});
if (drawdownInfo.recoveryDate) {
points.push({
coord: [drawdownInfo.recoveryDate, drawdownInfo.recoveryValue],
itemStyle: { color: '#ff5252' },
label: {
offset: [0, -15],
formatter: `${drawdownInfo.days}天修复`,
backgroundColor: '#ff5252',
position: 'bottom'
}
});
}
seriesData.markPoint.data = points;
}
option.series.push(seriesData)
}
option.series.push(seriesData);
}
chartInstance.setOption(option, true) // true = not merge, replace
chartInstance.setOption(option)
}
onMounted(() => {
@@ -327,10 +432,10 @@ export default {
window.removeEventListener('resize', () => chartInstance?.resize())
})
watch(() => props.netWorthTrend, () => {
updateChart()
watch([() => props.netWorthTrend, () => props.grandTotal], () => {
nextTick(() => updateChart())
}, { deep: true })
return {
chartEl,
timeRanges,
@@ -340,6 +445,7 @@ export default {
switchTab,
fundChange,
maxDrawdownInfo,
comparisonInfo,
getColor
}
}
@@ -350,10 +456,13 @@ export default {
.fund-chart-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow: hidden;
padding-bottom: 10px;
position: relative;
min-height: 400px;
margin-bottom: 24px;
}
.top-tabs {

View File

@@ -8,14 +8,10 @@
<!-- 中心区域图表展示 -->
<div class="charts-section">
<!-- 净值走势图 -->
<!-- 净值走势图 (含收益对比回撤修复) -->
<FundChart
:netWorthTrend="processedNetWorthTrend"
:acWorthTrend="processedAcWorthTrend"
/>
<!-- 累计收益率对比图 -->
<FundPerformanceComparison
:grandTotal="fundDetail.grand_total"
/>
@@ -65,7 +61,6 @@
import { ref, watch, computed } from 'vue'
import FundBasicInfo from './FundBasicInfo.vue'
import FundChart from './FundChart.vue'
import FundPerformanceComparison from './FundPerformanceComparison.vue'
import FundRankingTrend from './FundRankingTrend.vue'
import FundAssetAllocation from './FundAssetAllocation.vue'
import FundScaleChange from './FundScaleChange.vue'
@@ -76,7 +71,6 @@ export default {
components: {
FundBasicInfo,
FundChart,
FundPerformanceComparison,
FundRankingTrend,
FundAssetAllocation,
FundScaleChange

View File

@@ -1,167 +0,0 @@
<template>
<div class="fund-performance-card">
<div class="card-header">
<h3>📉 收益率对比分析</h3>
</div>
<div class="card-body">
<div v-if="hasGrandTotalData" class="performance-content">
<div ref="performanceChartEl" class="performance-chart"></div>
</div>
<div v-else class="no-data">
<p>暂无收益率对比数据</p>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
export default {
name: 'FundPerformanceComparison',
props: {
grandTotal: {
type: Array,
default: () => []
}
},
setup(props) {
const performanceChartEl = ref(null)
let performanceChartInstance = null
const hasGrandTotalData = computed(() =>
props.grandTotal && props.grandTotal.length > 0
)
const initPerformanceChart = () => {
if (!performanceChartEl.value || !hasGrandTotalData.value) return
if (performanceChartInstance) {
performanceChartInstance.dispose()
}
performanceChartInstance = echarts.init(performanceChartEl.value)
const series = props.grandTotal.map((item, index) => {
const colors = ['#667eea', '#91cc75', '#fac858']
return {
name: item.name,
type: 'line',
data: item.data,
smooth: true,
symbol: 'none',
lineStyle: {
width: 2,
color: colors[index % colors.length]
}
}
})
const option = {
tooltip: {
trigger: 'axis',
formatter: (params) => {
let result = `<div style="font-weight: bold; margin-bottom: 8px;">${new Date(params[0].axisValue).toLocaleDateString()}</div>`
params.forEach(param => {
result += `<div style="margin: 4px 0;">
<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${param.color};"></span>
${param.seriesName}: <strong>${param.value[1]}%</strong>
</div>`
})
return result
}
},
legend: {
data: props.grandTotal.map(item => item.name),
bottom: 10,
icon: 'circle'
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '5%',
containLabel: true
},
xAxis: {
type: 'time',
boundaryGap: false
},
yAxis: {
type: 'value',
name: '累计收益率(%)',
axisLabel: {
formatter: '{value}%'
}
},
series: series
}
performanceChartInstance.setOption(option)
}
onMounted(() => {
nextTick(() => {
initPerformanceChart()
})
})
watch(() => props.grandTotal, () => {
nextTick(() => {
initPerformanceChart()
})
}, { deep: true })
return {
performanceChartEl,
hasGrandTotalData
}
}
}
</script>
<style scoped>
.fund-performance-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
margin-bottom: 24px;
}
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 16px 20px;
}
.card-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.card-body {
padding: 24px;
}
.performance-content {
width: 100%;
}
.performance-chart {
width: 100%;
height: 400px;
}
.no-data {
text-align: center;
padding: 60px 20px;
color: #999;
}
.no-data p {
font-size: 16px;
}
</style>

View File

@@ -13,7 +13,7 @@
<th>日期</th>
<th>排名</th>
<th>同类基金总数</th>
<th>排名百分比</th>
<th>击败同类</th>
</tr>
</thead>
<tbody>
@@ -21,8 +21,8 @@
<td>{{ formatDate(item.x) }}</td>
<td class="rank-value">{{ item.y }}/{{ item.sc }}</td>
<td>{{ item.sc }}</td>
<td :class="getPercentClass(item.percent)">
{{ item.percent }}%
<td :class="getPercentClass((1 - item.y / item.sc) * 100)">
{{ ((1 - item.y / item.sc) * 100).toFixed(2) }}%
</td>
</tr>
</tbody>
@@ -84,9 +84,11 @@ export default {
return new Date(timestamp).toLocaleDateString('zh-CN')
}
const getPercentClass = (percent) => {
if (percent <= 20) return 'excellent'
if (percent <= 50) return 'good'
const getPercentClass = (defeatPercent) => {
// defeatPercent is 100 - rankPercent
// higher is better
if (defeatPercent >= 80) return 'excellent'
if (defeatPercent >= 50) return 'good'
return 'normal'
}
@@ -108,10 +110,11 @@ export default {
formatter: (params) => {
const dataIndex = params[0].dataIndex
const item = combinedData.value[dataIndex]
const defeated = ((1 - item.y / item.sc) * 100).toFixed(2);
return `
<div style="font-weight: bold; margin-bottom: 8px;">${formatDate(item.x)}</div>
<div>排名: <strong>${item.y}/${item.sc}</strong></div>
<div>百分比: <strong>${item.percent.toFixed(2)}%</strong></div>
<div>击败同类: <strong>${defeated}%</strong></div>
`
}
},

View File

@@ -218,11 +218,11 @@ export default {
}
.mom-value.positive {
color: #52c41a;
color: #ff4d4f;
}
.mom-value.negative {
color: #ff4d4f;
color: #52c41a;
}
.no-data {