美化了可视化界面

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: if not fund_code:
return jsonify({"error": "Fund code is required"}), 400 return jsonify({"error": "Fund code is required"}), 400
# 首先检查数据库是否有缓存
db = next(get_db()) db = next(get_db())
cached_fund = db.query(FundDetail).filter(FundDetail.fund_code == fund_code).first()
if cached_fund: # 直接从API获取最新数据
# 返回缓存数据
try:
data = json.loads(cached_fund.data_json)
return jsonify(data)
except:
pass
# 从API获取数据
detail_data = fund_api.get_fund_detail(fund_code) detail_data = fund_api.get_fund_detail(fund_code)
basic_info = fund_api.get_fund_basic_info(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 detail_data['basic_info'] = basic_info
# 缓存到数据库或更新现有记录 # 检查数据库是否存在记录
cached_fund = db.query(FundDetail).filter(FundDetail.fund_code == fund_code).first()
# 更新或新增记录
if cached_fund: if cached_fund:
cached_fund.data_json = json.dumps(detail_data, ensure_ascii=False) 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) 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.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) 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']) @app.route('/api/fund/<fund_code>/basic', methods=['GET'])
def get_fund_basic(fund_code): def get_fund_basic(fund_code):

Binary file not shown.

View File

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

View File

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

View File

@@ -8,6 +8,13 @@
> >
业绩走势 业绩走势
</div> </div>
<div
class="tab-item"
:class="{ active: activeTab === 'comparison' }"
@click="switchTab('comparison')"
>
收益对比
</div>
<div <div
class="tab-item" class="tab-item"
:class="{ active: activeTab === 'drawdown' }" :class="{ active: activeTab === 'drawdown' }"
@@ -24,10 +31,9 @@
<br> <br>
<span class="value" :class="getColor(fundChange)">{{ fundChange > 0 ? '+' : ''}}{{ fundChange }}%</span> <span class="value" :class="getColor(fundChange)">{{ fundChange > 0 ? '+' : ''}}{{ fundChange }}%</span>
</div> </div>
<!-- Placeholder for standard/benchmark if data exists -->
</div> </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="info-group">
<div class="legend-dot-row"> <div class="legend-dot-row">
<span class="legend-line green"></span> <span class="legend-line green"></span>
@@ -40,7 +46,17 @@
<span class="legend-box pink"></span> <span class="legend-box pink"></span>
<span class="label">最大回撤修复天数</span> <span class="label">最大回撤修复天数</span>
</div> </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>
</div> </div>
@@ -63,7 +79,7 @@
</template> </template>
<script> <script>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
export default { export default {
@@ -76,6 +92,10 @@ export default {
acWorthTrend: { acWorthTrend: {
type: Array, type: Array,
default: () => [] default: () => []
},
grandTotal: {
type: Array,
default: () => []
} }
}, },
setup(props) { setup(props) {
@@ -99,7 +119,13 @@ export default {
const switchTab = (tab) => { const switchTab = (tab) => {
activeTab.value = tab activeTab.value = tab
updateChart() nextTick(() => {
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
initChart();
})
} }
const getColor = (val) => { const getColor = (val) => {
@@ -110,120 +136,145 @@ export default {
// Computed properties for summary // Computed properties for summary
const fundChange = ref('0.00') const fundChange = ref('0.00')
const maxDrawdownInfo = ref({ val: '0.00', days: 0 }) 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 = () => { 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() const startVal = filtered[0][1]
let startDate = new Date(0) const endVal = filtered[filtered.length - 1][1]
fundChange.value = startVal !== 0 ? ((endVal - startVal) / startVal * 100).toFixed(2) : '0.00'
if (selectedRange.value === '3m') { // Prepare Percentage Data
startDate = new Date(now.setMonth(now.getMonth() - 3)) const toPercent = (val) => startVal !== 0 ? parseFloat(((val - startVal) / startVal * 100).toFixed(2)) : 0
} else if (selectedRange.value === '6m') { const percentTrend = filtered.map(item => [item[0], toPercent(item[1])])
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))
}
// Filter Data // Calculate Max Drawdown & Recovery
const filtered = props.netWorthTrend.filter(item => item.x >= startDate.getTime()) let curMaxdd = 0;
let globalPeakIndex = 0;
if (filtered.length === 0) return { netWorth: [], drawdownInfo: null } 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 ddInfo = {
const startVal = filtered[0].y val: (curMaxdd * 100).toFixed(2),
const endVal = filtered[filtered.length - 1].y peakDate,
fundChange.value = ((endVal - startVal) / startVal * 100).toFixed(2) 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 maxDrawdownInfo.value = ddInfo
// Then find recovery from that specific Peak
// Also process Comparison Data just in case we need to filter for Comparison Tab?
let curMaxdd = 0; // Usually comparison tab shows "All" or follows the range selector if enabled.
let globalPeakIndex = 0; // User requirements usually imply comparison follows standard range or all.
let globalValleyIndex = 0; // But the range selector is hidden for comparison in template: v-if="activeTab !== 'comparison'"
let runningPeakValue = -Infinity; return {
let runningPeakIndex = 0; netWorth: percentTrend,
drawdownInfo: ddInfo
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
}
} }
const initChart = () => { const initChart = () => {
if (!chartEl.value) return if (!chartEl.value) return
if (!chartInstance) {
chartInstance = echarts.init(chartEl.value) chartInstance = echarts.init(chartEl.value)
}
updateChart() updateChart()
} }
const updateChart = () => { const updateChart = () => {
if (!chartInstance) return if (!chartInstance) return
const { netWorth, drawdownInfo } = processData() chartInstance.clear();
// Common Options
const option = { const option = {
grid: { left: '3%', right: '5%', bottom: '3%', top: '10%', containLabel: true }, grid: { left: '3%', right: '5%', bottom: '10%', top: '15%', containLabel: true },
tooltip: { trigger: 'axis' }, 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 } }, 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: [] series: []
} }
if (activeTab.value === 'performance') { if (activeTab.value === 'performance') {
const { netWorth } = processData()
option.series.push({ option.series.push({
name: '本基金', name: '本基金',
type: 'line', type: 'line',
@@ -238,81 +289,135 @@ export default {
]) ])
} }
}) })
} else {
// Drawdown View option.tooltip.formatter = function (params) {
const seriesData = { let res = '<div>' + echarts.format.formatTime('yyyy-MM-dd', params[0].value[0]) + '</div>'
name: '本基金', params.forEach(item => {
type: 'line', res += `<div>${item.marker} ${item.seriesName}: ${item.value[1]}%</div>`
data: netWorth, })
smooth: true, return res;
symbol: 'none', }
lineStyle: { width: 2, color: '#88aaff' }, // Lighter blue } else if (activeTab.value === 'comparison') {
markArea: { const comparisonData = props.grandTotal || []
itemStyle: { color: 'rgba(255, 230, 230, 0.6)' }, // Light Pink
data: [] if (comparisonData.length > 0) {
}, const colors = ['#007bff', '#91cc75', '#fac858', '#ee6666', '#5470c6'];
markPoint: {
symbol: 'circle', // Update Legend Info
symbolSize: 8, comparisonInfo.value = comparisonData.map((item, index) => ({
label: { name: item.name,
show: true, color: colors[index % colors.length]
position: 'top', }))
color: '#fff',
padding: [4, 8], const series = comparisonData.map((item, index) => {
borderRadius: 4 const rawData = item.data || [];
}, const filteredData = filterByDate(rawData, selectedRange.value);
data: []
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) { if (netWorth.length > 0) {
const endDate = drawdownInfo.recoveryDate || netWorth[netWorth.length - 1][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) if (drawdownInfo && drawdownInfo.peakDate) {
seriesData.markArea.data.push([ const endDate = drawdownInfo.recoveryDate || netWorth[netWorth.length - 1][0];
{ xAxis: drawdownInfo.peakDate },
{ xAxis: endDate } seriesData.markArea.data.push([
]); { xAxis: drawdownInfo.peakDate },
{ xAxis: endDate }
const points = []; ]);
// 1. Tag at Valley: "Max Drawdown X%" const points = [];
points.push({ points.push({
xAxis: drawdownInfo.valleyDate, coord: [drawdownInfo.peakDate, drawdownInfo.peakValue],
yAxis: drawdownInfo.valleyValue, itemStyle: { color: '#ff9800' },
itemStyle: { color: '#00bfa5' }, // Green dot label: { show: false }
label: { });
offset: [0, 15],
formatter: `最大回撤${drawdownInfo.val}%`, points.push({
backgroundColor: '#00bfa5', coord: [drawdownInfo.valleyDate, drawdownInfo.valleyValue],
position: 'bottom' itemStyle: { color: '#00bfa5' },
}
});
// 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
label: { label: {
offset: [0, -15], offset: [0, 15],
formatter: `${drawdownInfo.days}天修复`, formatter: `最大回撤${drawdownInfo.val}%`,
backgroundColor: '#ff5252', backgroundColor: '#00bfa5',
position: 'top'
} }
}); });
}
if (drawdownInfo.recoveryDate) {
seriesData.markPoint.data = points; 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(() => { onMounted(() => {
@@ -327,10 +432,10 @@ export default {
window.removeEventListener('resize', () => chartInstance?.resize()) window.removeEventListener('resize', () => chartInstance?.resize())
}) })
watch(() => props.netWorthTrend, () => { watch([() => props.netWorthTrend, () => props.grandTotal], () => {
updateChart() nextTick(() => updateChart())
}, { deep: true }) }, { deep: true })
return { return {
chartEl, chartEl,
timeRanges, timeRanges,
@@ -340,6 +445,7 @@ export default {
switchTab, switchTab,
fundChange, fundChange,
maxDrawdownInfo, maxDrawdownInfo,
comparisonInfo,
getColor getColor
} }
} }
@@ -350,10 +456,13 @@ export default {
.fund-chart-card { .fund-chart-card {
background: white; background: white;
border-radius: 12px; 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; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow: hidden; overflow: hidden;
padding-bottom: 10px; padding-bottom: 10px;
position: relative;
min-height: 400px;
margin-bottom: 24px;
} }
.top-tabs { .top-tabs {

View File

@@ -8,14 +8,10 @@
<!-- 中心区域图表展示 --> <!-- 中心区域图表展示 -->
<div class="charts-section"> <div class="charts-section">
<!-- 净值走势图 --> <!-- 净值走势图 (含收益对比回撤修复) -->
<FundChart <FundChart
:netWorthTrend="processedNetWorthTrend" :netWorthTrend="processedNetWorthTrend"
:acWorthTrend="processedAcWorthTrend" :acWorthTrend="processedAcWorthTrend"
/>
<!-- 累计收益率对比图 -->
<FundPerformanceComparison
:grandTotal="fundDetail.grand_total" :grandTotal="fundDetail.grand_total"
/> />
@@ -65,7 +61,6 @@
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'
import FundBasicInfo from './FundBasicInfo.vue' import FundBasicInfo from './FundBasicInfo.vue'
import FundChart from './FundChart.vue' import FundChart from './FundChart.vue'
import FundPerformanceComparison from './FundPerformanceComparison.vue'
import FundRankingTrend from './FundRankingTrend.vue' import FundRankingTrend from './FundRankingTrend.vue'
import FundAssetAllocation from './FundAssetAllocation.vue' import FundAssetAllocation from './FundAssetAllocation.vue'
import FundScaleChange from './FundScaleChange.vue' import FundScaleChange from './FundScaleChange.vue'
@@ -76,7 +71,6 @@ export default {
components: { components: {
FundBasicInfo, FundBasicInfo,
FundChart, FundChart,
FundPerformanceComparison,
FundRankingTrend, FundRankingTrend,
FundAssetAllocation, FundAssetAllocation,
FundScaleChange 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> <th>同类基金总数</th>
<th>排名百分比</th> <th>击败同类</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -21,8 +21,8 @@
<td>{{ formatDate(item.x) }}</td> <td>{{ formatDate(item.x) }}</td>
<td class="rank-value">{{ item.y }}/{{ item.sc }}</td> <td class="rank-value">{{ item.y }}/{{ item.sc }}</td>
<td>{{ item.sc }}</td> <td>{{ item.sc }}</td>
<td :class="getPercentClass(item.percent)"> <td :class="getPercentClass((1 - item.y / item.sc) * 100)">
{{ item.percent }}% {{ ((1 - item.y / item.sc) * 100).toFixed(2) }}%
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -84,9 +84,11 @@ export default {
return new Date(timestamp).toLocaleDateString('zh-CN') return new Date(timestamp).toLocaleDateString('zh-CN')
} }
const getPercentClass = (percent) => { const getPercentClass = (defeatPercent) => {
if (percent <= 20) return 'excellent' // defeatPercent is 100 - rankPercent
if (percent <= 50) return 'good' // higher is better
if (defeatPercent >= 80) return 'excellent'
if (defeatPercent >= 50) return 'good'
return 'normal' return 'normal'
} }
@@ -108,10 +110,11 @@ export default {
formatter: (params) => { formatter: (params) => {
const dataIndex = params[0].dataIndex const dataIndex = params[0].dataIndex
const item = combinedData.value[dataIndex] const item = combinedData.value[dataIndex]
const defeated = ((1 - item.y / item.sc) * 100).toFixed(2);
return ` return `
<div style="font-weight: bold; margin-bottom: 8px;">${formatDate(item.x)}</div> <div style="font-weight: bold; margin-bottom: 8px;">${formatDate(item.x)}</div>
<div>排名: <strong>${item.y}/${item.sc}</strong></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 { .mom-value.positive {
color: #52c41a; color: #ff4d4f;
} }
.mom-value.negative { .mom-value.negative {
color: #ff4d4f; color: #52c41a;
} }
.no-data { .no-data {