美化了前端,修改了数据结构
This commit is contained in:
Binary file not shown.
BIN
Backend/__pycache__/stock_service.cpython-313.pyc
Normal file
BIN
Backend/__pycache__/stock_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
Backend/__pycache__/stock_service.cpython-314.pyc
Normal file
BIN
Backend/__pycache__/stock_service.cpython-314.pyc
Normal file
Binary file not shown.
@@ -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
131
Backend/stock_service.py
Normal 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': '--'}
|
||||
BIN
Data/funds.db
BIN
Data/funds.db
Binary file not shown.
3
Frontend/node_modules/.vite/deps_temp_41c04da4/package.json
generated
vendored
3
Frontend/node_modules/.vite/deps_temp_41c04da4/package.json
generated
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
12914
Frontend/node_modules/.vite/deps_temp_41c04da4/vue.js
generated
vendored
12914
Frontend/node_modules/.vite/deps_temp_41c04da4/vue.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
Frontend/node_modules/.vite/deps_temp_41c04da4/vue.js.map
generated
vendored
7
Frontend/node_modules/.vite/deps_temp_41c04da4/vue.js.map
generated
vendored
File diff suppressed because one or more lines are too long
3
Frontend/node_modules/.vite/deps_temp_e9bcd78c/package.json
generated
vendored
3
Frontend/node_modules/.vite/deps_temp_e9bcd78c/package.json
generated
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
291
Frontend/src/components/FundAbilityEval.vue
Normal file
291
Frontend/src/components/FundAbilityEval.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
528
Frontend/src/components/FundEvaluation.vue
Normal file
528
Frontend/src/components/FundEvaluation.vue
Normal 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>
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
278
Frontend/src/components/FundSubscription.vue
Normal file
278
Frontend/src/components/FundSubscription.vue
Normal 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
4666
fund_019127_full.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user