美化了前端,修改了数据结构

This commit is contained in:
Sebastian
2026-01-16 22:01:10 +08:00
parent 811e49e872
commit 7c8bde04f5
20 changed files with 6009 additions and 13084 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -3,12 +3,14 @@ import json
import re
from datetime import datetime
from typing import Dict, List, Any, Union
from stock_service import StockService
# --- 数据清洗器 (原 api_handler.py) ---
class FundDataCleaner:
def __init__(self):
self.cleaned_data = {}
self.stock_service = StockService()
def clean_js_variable(self, value: str) -> Any:
"""清洗JavaScript变量值"""
@@ -144,10 +146,35 @@ class FundDataCleaner:
def clean_portfolio_data(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
"""清洗投资组合数据"""
# 获取原始代码列表
stock_codes_raw = self.clean_array_data(raw_data.get('stockCodes'))
# 转换为包含名称的对象列表
enriched_stocks = []
if stock_codes_raw:
for code in stock_codes_raw:
try:
stock_info = self.stock_service.get_stock_info(code)
display_code = self.stock_service.normalize_code(code)
enriched_stocks.append({
'code': display_code,
'original_code': code,
'name': stock_info.get('name', 'Unknown'),
'market': stock_info.get('market', '--'),
'ratio': 0 # 数据源缺失占比设为0
})
except Exception as e:
print(f"Error processing stock code {code}: {e}")
enriched_stocks.append({'code': str(code), 'name': 'Unknown', 'market': '--', 'ratio': 0})
portfolio = {
'stock_codes': self.clean_array_data(raw_data.get('stockCodes')),
'stock_codes': enriched_stocks,
'bond_codes': self.clean_array_data(raw_data.get('zqCodes')),
'stock_codes_new': self.clean_array_data(raw_data.get('stockCodesNew')),
# 为了让前端统一使用 enriched_stocks我们将 stock_codes_new 也设为同样的数据
# 或者是 None让前端回退到 stock_codes。
# 鉴于 stock_codes_new 格式复杂 (116.xxxx),我们直接用处理好的数据覆盖它
'stock_codes_new': enriched_stocks,
'bond_codes_new': self.clean_array_data(raw_data.get('zqCodesNew'))
}
return portfolio

131
Backend/stock_service.py Normal file
View File

@@ -0,0 +1,131 @@
import requests
import json
import threading
import time
class StockService:
_instance = None
_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super(StockService, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self.stock_details = {} # Map code -> {name, market}
self.last_update = 0
self.cache_ttl = 24 * 3600 # 24 hours
self._load_data()
self._initialized = True
def _load_data(self):
"""Fetch data from APIs in a separate thread to avoid blocking startup"""
threading.Thread(target=self._fetch_all, daemon=True).start()
def _fetch_all(self):
self._fetch_hk_stocks()
self._fetch_ashare_stocks()
self.last_update = time.time()
print(f"Stock data loaded. Total: {len(self.stock_details)}")
def _fetch_hk_stocks(self):
url = "https://api.biyingapi.com/hk/list/all/biyinglicence"
try:
response = requests.get(url, timeout=30)
if response.status_code == 200:
data = response.json()
for item in data:
# dm format: "00001.HK"
full_code = str(item.get('dm', ''))
name = item.get('mc', '')
if full_code and name:
code = full_code.split('.')[0]
self.stock_details[code] = {
'name': name,
'market': '港交所'
}
except Exception as e:
print(f"Error fetching HK stocks: {e}")
def _fetch_ashare_stocks(self):
url = "https://api.mairuiapi.com/hslt/list/LICENCE-66D8-9F96-0C7F0FBCD073"
try:
response = requests.get(url, timeout=30)
if response.status_code == 200:
data = response.json()
for item in data:
# dm format: "000001.SZ"
full_code = str(item.get('dm', ''))
name = item.get('mc', '')
jys = item.get('jys', '')
market = 'A股'
if jys == 'SZ':
market = '深交所'
elif jys == 'SH' or full_code.endswith('.SH'):
market = '上交所'
# Fallback based on code prefix if JYS not clear
elif full_code.startswith('6') or full_code.startswith('9'):
market = '上交所'
elif full_code.startswith('0') or full_code.startswith('3'):
market = '深交所'
elif full_code.startswith('4') or full_code.startswith('8'):
market = '北交所'
if full_code and name:
code = full_code.split('.')[0]
self.stock_details[code] = {
'name': name,
'market': market
}
except Exception as e:
print(f"Error fetching A-Share stocks: {e}")
def normalize_code(self, internal_code):
"""
Normalize internal EastMoney code to standard stock code.
- HK: 6990116 -> 06990
- A: 0025580 -> 002558, 6034861 -> 603486
"""
if not internal_code:
return ""
str_code = str(internal_code)
# Check if HK stock
if str_code.endswith("116") and len(str_code) > 3:
raw_code = str_code[:-3]
return raw_code.zfill(5)
# Assume A-Share (remove last digit suffix)
if len(str_code) > 1:
raw_code = str_code[:-1]
else:
raw_code = str_code
return raw_code.zfill(6)
def get_stock_name(self, internal_code):
"""
Convert internal code to name. (Backward compatibility)
"""
info = self.get_stock_info(internal_code)
return info.get('name', str(internal_code)) if info else str(internal_code)
def get_stock_info(self, internal_code):
"""
Convert internal code to full info {name, market}.
"""
search_code = self.normalize_code(internal_code)
if search_code in self.stock_details:
return self.stock_details[search_code]
return {'name': search_code, 'market': '--'}

Binary file not shown.

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

View File

@@ -0,0 +1,291 @@
<template>
<div class="ability-card">
<div class="card-header">
<h3>📊 本基金历史表现</h3>
<div class="avg-score" v-if="avgScore">
<span class="score-label">综合</span>
<span class="score-value" :class="getScoreClass(avgScore)">{{ avgScore }}</span>
</div>
</div>
<div class="card-body">
<div v-if="hasEvalData" class="eval-content">
<div ref="radarChartEl" class="radar-chart"></div>
<div class="eval-details">
<div
v-for="(item, index) in evalItems"
:key="index"
class="eval-item"
>
<div class="eval-item-header">
<span class="eval-name">{{ item.name }}</span>
<span class="eval-score" :class="getScoreClass(item.score)">{{ item.score }}</span>
</div>
<div class="eval-bar">
<div class="eval-bar-fill" :style="{ width: item.score + '%', background: getBarColor(item.score) }"></div>
</div>
</div>
</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: 'FundAbilityEval',
props: {
performanceEvaluation: {
type: Object,
default: () => ({})
}
},
setup(props) {
const radarChartEl = ref(null)
let radarChart = null
const hasEvalData = computed(() => {
const data = props.performanceEvaluation?.data
return data && Array.isArray(data) && data.some(v => v !== null)
})
const avgScore = computed(() => props.performanceEvaluation?.avr || null)
const evalItems = computed(() => {
const categories = props.performanceEvaluation?.categories || []
const data = props.performanceEvaluation?.data || []
return categories.map((name, index) => ({
name,
score: data[index] ?? 0
}))
})
const getScoreClass = (score) => {
if (score >= 80) return 'excellent'
if (score >= 60) return 'good'
if (score >= 40) return 'normal'
return 'poor'
}
const getBarColor = (score) => {
if (score >= 80) return 'linear-gradient(90deg, #52c41a 0%, #73d13d 100%)'
if (score >= 60) return 'linear-gradient(90deg, #1890ff 0%, #69c0ff 100%)'
if (score >= 40) return 'linear-gradient(90deg, #faad14 0%, #ffc53d 100%)'
return 'linear-gradient(90deg, #ff4d4f 0%, #ff7875 100%)'
}
const initRadarChart = () => {
if (!radarChartEl.value || !hasEvalData.value) return
if (radarChart) radarChart.dispose()
radarChart = echarts.init(radarChartEl.value)
const categories = props.performanceEvaluation?.categories || []
const data = props.performanceEvaluation?.data || []
const option = {
tooltip: {
trigger: 'item'
},
radar: {
indicator: categories.map(name => ({
name,
max: 100
})),
radius: '70%',
axisName: {
color: '#666',
fontSize: 10
},
splitArea: {
areaStyle: {
color: ['rgba(102, 126, 234, 0.05)', 'rgba(102, 126, 234, 0.1)']
}
},
axisLine: {
lineStyle: {
color: 'rgba(102, 126, 234, 0.3)'
}
},
splitLine: {
lineStyle: {
color: 'rgba(102, 126, 234, 0.3)'
}
}
},
series: [{
type: 'radar',
data: [{
value: data,
name: '能力评分',
areaStyle: {
color: 'rgba(102, 126, 234, 0.3)'
},
lineStyle: {
color: '#667eea',
width: 2
},
itemStyle: {
color: '#667eea'
}
}]
}]
}
radarChart.setOption(option)
}
onMounted(() => {
nextTick(() => {
initRadarChart()
})
})
watch(() => props.performanceEvaluation, () => {
nextTick(() => {
initRadarChart()
})
}, { deep: true })
return {
radarChartEl,
hasEvalData,
avgScore,
evalItems,
getScoreClass,
getBarColor
}
}
}
</script>
<style scoped>
.ability-card {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 10px 14px;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
color: white;
font-size: 14px;
font-weight: 600;
}
.avg-score {
display: flex;
align-items: center;
gap: 6px;
}
.score-label {
font-size: 11px;
color: rgba(255,255,255,0.8);
}
.score-value {
font-size: 16px;
font-weight: bold;
padding: 2px 8px;
border-radius: 10px;
background: rgba(255,255,255,0.2);
}
.score-value.excellent { color: #52c41a; }
.score-value.good { color: #69c0ff; }
.score-value.normal { color: #faad14; }
.score-value.poor { color: #ff4d4f; }
.card-body {
padding: 10px;
flex: 1;
overflow-y: auto;
}
.eval-content {
display: flex;
flex-direction: column;
height: 100%;
}
.radar-chart {
width: 100%;
height: 180px;
flex-shrink: 0;
}
.eval-details {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.eval-item {
padding: 6px 8px;
background: #fafafa;
border-radius: 6px;
}
.eval-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.eval-name {
font-size: 11px;
font-weight: 500;
color: #333;
}
.eval-score {
font-size: 12px;
font-weight: bold;
}
.eval-score.excellent { color: #52c41a; }
.eval-score.good { color: #1890ff; }
.eval-score.normal { color: #faad14; }
.eval-score.poor { color: #ff4d4f; }
.eval-bar {
height: 4px;
background: #e8e8e8;
border-radius: 2px;
overflow: hidden;
}
.eval-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.no-data {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
font-size: 13px;
}
</style>

View File

@@ -45,6 +45,13 @@
/>
</div>
</div>
<!-- 申购赎回情况 - 全宽 -->
<div class="card card-full clickable" @click="openModal('subscription')">
<FundSubscription
:subscriptionRedemption="fundDetail.subscription_redemption"
/>
</div>
</div>
<!-- 右侧边栏 -->
@@ -59,6 +66,11 @@
:fundManagers="fundDetail.fund_managers"
/>
</div>
<div class="card card-sidebar clickable" @click="openModal('ability')">
<FundAbilityEval
:performanceEvaluation="fundDetail.performance_evaluation"
/>
</div>
</div>
</div>
@@ -71,6 +83,7 @@
v-if="modalType === 'ranking'"
:rateInSimilarType="fundDetail.ranking_trend"
:rateInSimilarPercent="fundDetail.ranking_percentage"
:isExpanded="true"
/>
<FundAssetAllocation
v-if="modalType === 'asset'"
@@ -92,6 +105,14 @@
v-if="modalType === 'manager'"
:fundManagers="fundDetail.fund_managers"
/>
<FundAbilityEval
v-if="modalType === 'ability'"
:performanceEvaluation="fundDetail.performance_evaluation"
/>
<FundSubscription
v-if="modalType === 'subscription'"
:subscriptionRedemption="fundDetail.subscription_redemption"
/>
</div>
</div>
</div>
@@ -127,6 +148,8 @@ import FundScaleChange from './FundScaleChange.vue'
import FundManagerInfo from './FundManagerInfo.vue'
import FundHolderStructure from './FundHolderStructure.vue'
import FundPortfolio from './FundPortfolio.vue'
import FundAbilityEval from './FundAbilityEval.vue'
import FundSubscription from './FundSubscription.vue'
import { fundAPI } from '../services/api'
export default {
@@ -139,7 +162,9 @@ export default {
FundScaleChange,
FundManagerInfo,
FundHolderStructure,
FundPortfolio
FundPortfolio,
FundAbilityEval,
FundSubscription
},
props: {
fundCode: {
@@ -354,6 +379,20 @@ export default {
flex-direction: column;
}
/* 大卡片 - 综合评价 */
.card-lg {
height: 550px;
display: flex;
flex-direction: column;
}
/* 全宽卡片 - 申购赎回 */
.card-full {
height: 400px;
display: flex;
flex-direction: column;
}
/* 侧边栏卡片 */
.card-sidebar {
flex: 1;

View File

@@ -0,0 +1,528 @@
<template>
<div class="evaluation-card">
<div class="card-header">
<h3>📊 基金综合评价</h3>
<div class="avg-score" v-if="avgScore">
<span class="score-label">综合评分</span>
<span class="score-value" :class="getScoreClass(avgScore)">{{ avgScore }}</span>
</div>
</div>
<div class="card-body">
<div v-if="hasEvalData || hasRedemptionData" class="evaluation-content">
<!-- 雷达图 + 指标详情 -->
<div v-if="hasEvalData" class="eval-section">
<div class="section-title">能力评估</div>
<div class="eval-grid">
<div ref="radarChartEl" class="radar-chart"></div>
<div class="eval-details">
<div
v-for="(item, index) in evalItems"
:key="index"
class="eval-item"
>
<div class="eval-item-header">
<span class="eval-name">{{ item.name }}</span>
<span class="eval-score" :class="getScoreClass(item.score)">{{ item.score }}</span>
</div>
<div class="eval-bar">
<div class="eval-bar-fill" :style="{ width: item.score + '%', background: getBarColor(item.score) }"></div>
</div>
<div class="eval-desc" v-html="item.desc"></div>
</div>
</div>
</div>
</div>
<!-- 申购赎回情况 -->
<div v-if="hasRedemptionData" class="redemption-section">
<div class="section-title">申购赎回情况</div>
<div ref="redemptionChartEl" class="redemption-chart"></div>
<div class="redemption-table">
<table>
<thead>
<tr>
<th>日期</th>
<th>期间申购(亿)</th>
<th>期间赎回(亿)</th>
<th>净申购(亿)</th>
<th>总份额(亿)</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in redemptionTableData" :key="index">
<td>{{ item.date }}</td>
<td class="buy">{{ item.buy }}</td>
<td class="sell">{{ item.sell }}</td>
<td :class="item.netBuy >= 0 ? 'positive' : 'negative'">{{ item.netBuy }}</td>
<td>{{ item.total }}</td>
</tr>
</tbody>
</table>
</div>
</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: 'FundEvaluation',
props: {
performanceEvaluation: {
type: Object,
default: () => ({})
},
subscriptionRedemption: {
type: Object,
default: () => ({})
}
},
setup(props) {
const radarChartEl = ref(null)
const redemptionChartEl = ref(null)
let radarChart = null
let redemptionChart = null
const hasEvalData = computed(() => {
const data = props.performanceEvaluation?.data
return data && Array.isArray(data) && data.some(v => v !== null)
})
const hasRedemptionData = computed(() => {
const series = props.subscriptionRedemption?.series
return series && Array.isArray(series) && series.length > 0
})
const avgScore = computed(() => props.performanceEvaluation?.avr || null)
const evalItems = computed(() => {
const categories = props.performanceEvaluation?.categories || []
const data = props.performanceEvaluation?.data || []
const dsc = props.performanceEvaluation?.dsc || []
return categories.map((name, index) => ({
name,
score: data[index] ?? 0,
desc: dsc[index] || ''
}))
})
const redemptionTableData = computed(() => {
const categories = props.subscriptionRedemption?.categories || []
const series = props.subscriptionRedemption?.series || []
const buyData = series.find(s => s.name === '期间申购')?.data || []
const sellData = series.find(s => s.name === '期间赎回')?.data || []
const totalData = series.find(s => s.name === '总份额')?.data || []
return categories.map((date, index) => {
const buy = buyData[index] ?? 0
const sell = sellData[index] ?? 0
return {
date,
buy: buy.toFixed(2),
sell: sell.toFixed(2),
netBuy: (buy - sell).toFixed(2),
total: (totalData[index] ?? 0).toFixed(2)
}
})
})
const getScoreClass = (score) => {
if (score >= 80) return 'excellent'
if (score >= 60) return 'good'
if (score >= 40) return 'normal'
return 'poor'
}
const getBarColor = (score) => {
if (score >= 80) return 'linear-gradient(90deg, #52c41a 0%, #73d13d 100%)'
if (score >= 60) return 'linear-gradient(90deg, #1890ff 0%, #69c0ff 100%)'
if (score >= 40) return 'linear-gradient(90deg, #faad14 0%, #ffc53d 100%)'
return 'linear-gradient(90deg, #ff4d4f 0%, #ff7875 100%)'
}
const initRadarChart = () => {
if (!radarChartEl.value || !hasEvalData.value) return
if (radarChart) radarChart.dispose()
radarChart = echarts.init(radarChartEl.value)
const categories = props.performanceEvaluation?.categories || []
const data = props.performanceEvaluation?.data || []
const option = {
tooltip: {
trigger: 'item'
},
radar: {
indicator: categories.map(name => ({
name,
max: 100
})),
radius: '65%',
axisName: {
color: '#666',
fontSize: 11
},
splitArea: {
areaStyle: {
color: ['rgba(102, 126, 234, 0.05)', 'rgba(102, 126, 234, 0.1)']
}
},
axisLine: {
lineStyle: {
color: 'rgba(102, 126, 234, 0.3)'
}
},
splitLine: {
lineStyle: {
color: 'rgba(102, 126, 234, 0.3)'
}
}
},
series: [{
type: 'radar',
data: [{
value: data,
name: '能力评分',
areaStyle: {
color: 'rgba(102, 126, 234, 0.3)'
},
lineStyle: {
color: '#667eea',
width: 2
},
itemStyle: {
color: '#667eea'
}
}]
}]
}
radarChart.setOption(option)
}
const initRedemptionChart = () => {
if (!redemptionChartEl.value || !hasRedemptionData.value) return
if (redemptionChart) redemptionChart.dispose()
redemptionChart = echarts.init(redemptionChartEl.value)
const categories = props.subscriptionRedemption?.categories || []
const series = props.subscriptionRedemption?.series || []
const buyData = series.find(s => s.name === '期间申购')?.data || []
const sellData = series.find(s => s.name === '期间赎回')?.data || []
const totalData = series.find(s => s.name === '总份额')?.data || []
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['期间申购', '期间赎回', '总份额'],
bottom: 0,
textStyle: {
fontSize: 11
}
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '10%',
containLabel: true
},
xAxis: {
type: 'category',
data: categories,
axisLabel: {
fontSize: 10,
rotate: 30
}
},
yAxis: [
{
type: 'value',
name: '申购/赎回(亿)',
position: 'left',
axisLabel: {
fontSize: 10
}
},
{
type: 'value',
name: '总份额(亿)',
position: 'right',
axisLabel: {
fontSize: 10
}
}
],
series: [
{
name: '期间申购',
type: 'bar',
data: buyData,
itemStyle: {
color: '#52c41a'
},
barWidth: '25%'
},
{
name: '期间赎回',
type: 'bar',
data: sellData,
itemStyle: {
color: '#ff4d4f'
},
barWidth: '25%'
},
{
name: '总份额',
type: 'line',
yAxisIndex: 1,
data: totalData,
itemStyle: {
color: '#1890ff'
},
lineStyle: {
width: 2
},
symbol: 'circle',
symbolSize: 6
}
]
}
redemptionChart.setOption(option)
}
onMounted(() => {
nextTick(() => {
initRadarChart()
initRedemptionChart()
})
})
watch([() => props.performanceEvaluation, () => props.subscriptionRedemption], () => {
nextTick(() => {
initRadarChart()
initRedemptionChart()
})
}, { deep: true })
return {
radarChartEl,
redemptionChartEl,
hasEvalData,
hasRedemptionData,
avgScore,
evalItems,
redemptionTableData,
getScoreClass,
getBarColor
}
}
}
</script>
<style scoped>
.evaluation-card {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 10px 16px;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
color: white;
font-size: 15px;
font-weight: 600;
}
.avg-score {
display: flex;
align-items: center;
gap: 8px;
}
.score-label {
font-size: 12px;
color: rgba(255,255,255,0.8);
}
.score-value {
font-size: 18px;
font-weight: bold;
padding: 2px 10px;
border-radius: 12px;
background: rgba(255,255,255,0.2);
}
.score-value.excellent { color: #52c41a; background: rgba(82, 196, 26, 0.2); }
.score-value.good { color: #69c0ff; background: rgba(24, 144, 255, 0.2); }
.score-value.normal { color: #faad14; background: rgba(250, 173, 20, 0.2); }
.score-value.poor { color: #ff4d4f; background: rgba(255, 77, 79, 0.2); }
.card-body {
padding: 12px;
flex: 1;
overflow-y: auto;
}
.evaluation-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
padding-left: 8px;
border-left: 3px solid #667eea;
}
.eval-section .eval-grid {
display: grid;
grid-template-columns: 200px 1fr;
gap: 16px;
}
.radar-chart {
width: 200px;
height: 180px;
}
.eval-details {
display: flex;
flex-direction: column;
gap: 12px;
}
.eval-item {
padding: 8px;
background: #fafafa;
border-radius: 8px;
}
.eval-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.eval-name {
font-size: 13px;
font-weight: 500;
color: #333;
}
.eval-score {
font-size: 14px;
font-weight: bold;
}
.eval-score.excellent { color: #52c41a; }
.eval-score.good { color: #1890ff; }
.eval-score.normal { color: #faad14; }
.eval-score.poor { color: #ff4d4f; }
.eval-bar {
height: 6px;
background: #e8e8e8;
border-radius: 3px;
overflow: hidden;
margin-bottom: 4px;
}
.eval-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.eval-desc {
font-size: 11px;
color: #999;
line-height: 1.4;
}
.redemption-section .redemption-chart {
width: 100%;
height: 200px;
}
.redemption-table {
margin-top: 12px;
overflow-x: auto;
}
.redemption-table table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.redemption-table th,
.redemption-table td {
padding: 8px 10px;
text-align: center;
border-bottom: 1px solid #e8e8e8;
}
.redemption-table th {
background: #f5f5f5;
font-weight: 600;
color: #333;
}
.redemption-table .buy { color: #52c41a; }
.redemption-table .sell { color: #ff4d4f; }
.redemption-table .positive { color: #52c41a; font-weight: 600; }
.redemption-table .negative { color: #ff4d4f; font-weight: 600; }
.no-data {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #999;
}
@media (max-width: 600px) {
.eval-section .eval-grid {
grid-template-columns: 1fr;
}
.radar-chart {
width: 100%;
height: 200px;
}
}
</style>

View File

@@ -58,9 +58,12 @@ export default {
const hasData = computed(() => categories.value.length > 0 && series.value.length > 0)
const colors = {
'机构持有': '#667eea',
'个人持有': '#91cc75',
'内部持有': '#fac858'
'机构持有比例': '#5470c6', // 蓝色
'个人持有比例': '#ee6666', // 红色
'内部持有比例': '#91cc75', // 绿色
'机构持有': '#5470c6',
'个人持有': '#ee6666',
'内部持有': '#91cc75'
}
const getColor = (name) => {

View File

@@ -1,7 +1,7 @@
<template>
<div class="fund-manager-card">
<div class="card-header">
<h3>👨💼 基金经理</h3>
<h3>👨💼 基金经理能力评估</h3>
</div>
<div class="card-body">
<div v-if="hasManagers" class="managers-container">

View File

@@ -2,30 +2,16 @@
<div class="portfolio-card">
<div class="card-header">
<h3>📈 持仓明细</h3>
<div class="tab-switch">
<button
:class="{ active: activeTab === 'stock' }"
@click="activeTab = 'stock'"
>
股票持仓
</button>
<button
:class="{ active: activeTab === 'bond' }"
@click="activeTab = 'bond'"
>
债券持仓
</button>
</div>
</div>
<div class="card-body">
<!-- 股票持仓 -->
<div v-if="activeTab === 'stock'" class="portfolio-content">
<div class="portfolio-content">
<div v-if="hasStockData" class="stock-list">
<div class="portfolio-header">
<span class="col-rank">排名</span>
<span class="col-code">代码</span>
<span class="col-name">名称</span>
<span class="col-ratio">占比</span>
<span class="col-market">交易所</span>
</div>
<div
v-for="(stock, index) in stockList"
@@ -35,48 +21,13 @@
<span class="col-rank">{{ index + 1 }}</span>
<span class="col-code">{{ stock.code }}</span>
<span class="col-name">{{ stock.name }}</span>
<span class="col-ratio">
<div class="ratio-bar">
<div class="ratio-fill" :style="{ width: getRatioWidth(stock.ratio) }"></div>
</div>
<span class="ratio-text">{{ formatRatio(stock.ratio) }}</span>
</span>
<span class="col-market">{{ stock.market || '--' }}</span>
</div>
</div>
<div v-else class="no-data">
<p>暂无股票持仓数据</p>
</div>
</div>
<!-- 债券持仓 -->
<div v-if="activeTab === 'bond'" class="portfolio-content">
<div v-if="hasBondData" class="bond-list">
<div class="portfolio-header">
<span class="col-rank">排名</span>
<span class="col-code">代码</span>
<span class="col-name">名称</span>
<span class="col-ratio">占比</span>
</div>
<div
v-for="(bond, index) in bondList"
:key="bond.code || index"
class="portfolio-item"
>
<span class="col-rank">{{ index + 1 }}</span>
<span class="col-code">{{ bond.code }}</span>
<span class="col-name">{{ bond.name }}</span>
<span class="col-ratio">
<div class="ratio-bar bond-bar">
<div class="ratio-fill" :style="{ width: getRatioWidth(bond.ratio) }"></div>
</div>
<span class="ratio-text">{{ formatRatio(bond.ratio) }}</span>
</span>
</div>
</div>
<div v-else class="no-data">
<p>暂无债券持仓数据</p>
</div>
</div>
</div>
</div>
</template>
@@ -93,24 +44,28 @@ export default {
}
},
setup(props) {
const activeTab = ref('stock')
// 解析持仓数据 - 格式: ["代码", "名称", "占比", ...]
// 解析持仓数据
const parseHoldings = (codes) => {
if (!codes || !Array.isArray(codes)) return []
// 如果数据已经是对象列表(新格式),直接返回
if (codes.length > 0 && typeof codes[0] === 'object' && codes[0] !== null) {
return codes
}
const holdings = []
// 每3个元素为一组: [代码, 名称, 占比]
// 旧格式: 每3个元素为一组: [代码, 名称, 占比]
for (let i = 0; i < codes.length; i += 3) {
if (i + 2 < codes.length) {
holdings.push({
code: codes[i],
name: codes[i + 1],
ratio: parseFloat(codes[i + 2]) || 0
ratio: parseFloat(codes[i + 2]) || 0,
market: '--'
})
}
}
return holdings.sort((a, b) => b.ratio - a.ratio)
return holdings
}
// 优先使用最新数据 (stock_codes_new),否则使用旧数据
@@ -120,36 +75,11 @@ export default {
return parseHoldings(newCodes?.length ? newCodes : oldCodes)
})
const bondList = computed(() => {
const newCodes = props.portfolio?.bond_codes_new
const oldCodes = props.portfolio?.bond_codes
return parseHoldings(newCodes?.length ? newCodes : oldCodes)
})
const hasStockData = computed(() => stockList.value.length > 0)
const hasBondData = computed(() => bondList.value.length > 0)
const formatRatio = (ratio) => {
if (ratio === null || ratio === undefined) return '--'
return ratio.toFixed(2) + '%'
}
const getRatioWidth = (ratio) => {
if (!ratio) return '0%'
// 最大占比假设为20%,计算相对宽度
const maxRatio = 20
const width = Math.min((ratio / maxRatio) * 100, 100)
return width + '%'
}
return {
activeTab,
stockList,
bondList,
hasStockData,
hasBondData,
formatRatio,
getRatioWidth
hasStockData
}
}
}
@@ -179,32 +109,6 @@ export default {
font-weight: 600;
}
.tab-switch {
display: flex;
gap: 6px;
}
.tab-switch button {
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: 4px 12px;
border-radius: 12px;
cursor: pointer;
font-size: 12px;
transition: all 0.3s;
}
.tab-switch button.active {
background: white;
color: #667eea;
font-weight: 600;
}
.tab-switch button:hover:not(.active) {
background: rgba(255,255,255,0.3);
}
.card-body {
padding: 10px;
flex: 1;
@@ -243,19 +147,23 @@ export default {
}
.col-rank {
width: 32px;
width: 40px;
text-align: center;
color: #999;
font-weight: 500;
margin-right: 12px;
}
.portfolio-item .col-rank {
width: 32px;
height: 20px;
line-height: 20px;
width: 24px;
height: 24px;
line-height: 24px;
background: #f0f0f0;
border-radius: 10px;
border-radius: 50%;
font-size: 11px;
margin: 0 8px;
display: inline-block;
text-align: center;
}
.portfolio-item:nth-child(2) .col-rank { background: #ffd700; color: #fff; }
@@ -263,7 +171,7 @@ export default {
.portfolio-item:nth-child(4) .col-rank { background: #cd7f32; color: #fff; }
.col-code {
width: 65px;
width: 60px;
color: #667eea;
font-family: monospace;
font-size: 11px;
@@ -279,36 +187,10 @@ export default {
white-space: nowrap;
}
.col-ratio {
width: 90px;
display: flex;
align-items: center;
gap: 6px;
}
.ratio-bar {
flex: 1;
height: 6px;
background: #f0f0f0;
border-radius: 3px;
overflow: hidden;
}
.ratio-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
border-radius: 3px;
}
.bond-bar .ratio-fill {
background: linear-gradient(90deg, #52c41a 0%, #73d13d 100%);
}
.ratio-text {
width: 40px;
.col-market {
width: 60px;
text-align: right;
font-weight: 600;
color: #333;
color: #888;
font-size: 11px;
}
@@ -317,7 +199,6 @@ export default {
align-items: center;
justify-content: center;
height: 100%;
color: #999;
font-size: 13px;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="fund-ranking-card">
<div class="card-header">
<div class="card-header" :class="{ 'header-expanded': isExpanded }">
<h3>🏆 同类排名走势</h3>
<div class="time-ranges">
<span
@@ -59,6 +59,10 @@ export default {
rateInSimilarPercent: {
type: Array,
default: () => []
},
isExpanded: {
type: Boolean,
default: false
}
},
setup(props) {
@@ -183,7 +187,7 @@ export default {
},
grid: {
left: '11%',
right: '12%',
right: '13%',
bottom: '12%',
top: '4%',
containLabel: false
@@ -290,6 +294,10 @@ export default {
font-weight: 600;
}
.header-expanded {
padding-right: 50px; /* Space for close button */
}
.time-ranges {
display: flex;
gap: 4px;

View File

@@ -0,0 +1,278 @@
<template>
<div class="subscription-card">
<div class="card-header">
<h3>💰 申购赎回情况</h3>
</div>
<div class="card-body">
<div v-if="hasRedemptionData" class="subscription-content">
<div ref="chartEl" class="subscription-chart"></div>
<div class="subscription-table">
<table>
<thead>
<tr>
<th>日期</th>
<th>期间申购(亿)</th>
<th>期间赎回(亿)</th>
<th>净申购(亿)</th>
<th>总份额(亿)</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in tableData" :key="index">
<td>{{ item.date }}</td>
<td class="buy">{{ item.buy }}</td>
<td class="sell">{{ item.sell }}</td>
<td :class="parseFloat(item.netBuy) >= 0 ? 'positive' : 'negative'">{{ item.netBuy }}</td>
<td>{{ item.total }}</td>
</tr>
</tbody>
</table>
</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: 'FundSubscription',
props: {
subscriptionRedemption: {
type: Object,
default: () => ({})
}
},
setup(props) {
const chartEl = ref(null)
let chartInstance = null
const hasRedemptionData = computed(() => {
const series = props.subscriptionRedemption?.series
return series && Array.isArray(series) && series.length > 0
})
const tableData = computed(() => {
const categories = props.subscriptionRedemption?.categories || []
const series = props.subscriptionRedemption?.series || []
const buyData = series.find(s => s.name === '期间申购')?.data || []
const sellData = series.find(s => s.name === '期间赎回')?.data || []
const totalData = series.find(s => s.name === '总份额')?.data || []
return categories.map((date, index) => {
const buy = buyData[index] ?? 0
const sell = sellData[index] ?? 0
return {
date,
buy: buy.toFixed(2),
sell: sell.toFixed(2),
netBuy: (buy - sell).toFixed(2),
total: (totalData[index] ?? 0).toFixed(2)
}
})
})
const initChart = () => {
if (!chartEl.value || !hasRedemptionData.value) return
if (chartInstance) chartInstance.dispose()
chartInstance = echarts.init(chartEl.value)
const categories = props.subscriptionRedemption?.categories || []
const series = props.subscriptionRedemption?.series || []
const buyData = series.find(s => s.name === '期间申购')?.data || []
const sellData = series.find(s => s.name === '期间赎回')?.data || []
const totalData = series.find(s => s.name === '总份额')?.data || []
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['期间申购', '期间赎回', '总份额'],
bottom: 0,
textStyle: {
fontSize: 11
}
},
grid: {
left: '3%',
right: '4%',
bottom: '12%',
top: '13%',
containLabel: true
},
xAxis: {
type: 'category',
data: categories,
axisLabel: {
fontSize: 11
}
},
yAxis: [
{
type: 'value',
name: '申购/赎回(亿)',
position: 'left',
axisLabel: {
fontSize: 10
}
},
{
type: 'value',
name: '总份额(亿)',
position: 'right',
axisLabel: {
fontSize: 10
}
}
],
series: [
{
name: '期间申购',
type: 'bar',
data: buyData,
itemStyle: {
color: '#52c41a'
},
barWidth: '20%'
},
{
name: '期间赎回',
type: 'bar',
data: sellData,
itemStyle: {
color: '#ff4d4f'
},
barWidth: '20%'
},
{
name: '总份额',
type: 'line',
yAxisIndex: 1,
data: totalData,
itemStyle: {
color: '#1890ff'
},
lineStyle: {
width: 2
},
symbol: 'circle',
symbolSize: 6
}
]
}
chartInstance.setOption(option)
}
onMounted(() => {
nextTick(() => {
initChart()
})
})
watch(() => props.subscriptionRedemption, () => {
nextTick(() => {
initChart()
})
}, { deep: true })
return {
chartEl,
hasRedemptionData,
tableData
}
}
}
</script>
<style scoped>
.subscription-card {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 10px 16px;
flex-shrink: 0;
}
.card-header h3 {
margin: 0;
color: white;
font-size: 15px;
font-weight: 600;
}
.card-body {
padding: 12px;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.subscription-content {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
}
.subscription-chart {
width: 100%;
height: 220px;
flex-shrink: 0;
}
.subscription-table {
flex: 1;
overflow: auto;
}
.subscription-table table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.subscription-table th,
.subscription-table td {
padding: 8px 12px;
text-align: center;
border-bottom: 1px solid #e8e8e8;
}
.subscription-table th {
background: #f5f5f5;
font-weight: 600;
color: #333;
}
.subscription-table .buy { color: #52c41a; }
.subscription-table .sell { color: #ff4d4f; }
.subscription-table .positive { color: #52c41a; font-weight: 600; }
.subscription-table .negative { color: #ff4d4f; font-weight: 600; }
.no-data {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #999;
}
</style>

4666
fund_019127_full.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long