添加了自选功能
This commit is contained in:
BIN
Backend/__pycache__/database.cpython-313.pyc
Normal file
BIN
Backend/__pycache__/database.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/__pycache__/models.cpython-313.pyc
Normal file
BIN
Backend/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
324
Backend/app.py
324
Backend/app.py
@@ -1,7 +1,7 @@
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
from database import init_db, get_db
|
||||
from models import FundBasicInfo, FundTrend, FundEstimate, FundPortfolio, FundExtraData
|
||||
from models import FundBasicInfo, FundTrend, FundEstimate, FundPortfolio, FundExtraData, FundWatchlist, FundWatchlistGroup
|
||||
from fund_api import FundAPI
|
||||
from fund_list_cache import get_fund_list_cache
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -306,5 +306,327 @@ def get_fund_trend(fund_code):
|
||||
|
||||
return jsonify({"error": "Fund trend data not found"}), 404
|
||||
|
||||
|
||||
# ==================== 自选基金 API ====================
|
||||
|
||||
@app.route('/api/watchlist', methods=['GET'])
|
||||
def get_watchlist():
|
||||
"""获取自选基金列表(按分组和排序顺序)"""
|
||||
db = next(get_db())
|
||||
|
||||
# 获取所有分组
|
||||
groups = db.query(FundWatchlistGroup).order_by(FundWatchlistGroup.sort_order).all()
|
||||
|
||||
# 获取所有基金
|
||||
watchlist = db.query(FundWatchlist).order_by(FundWatchlist.sort_order).all()
|
||||
|
||||
# 构建分组数据
|
||||
groups_data = []
|
||||
for group in groups:
|
||||
groups_data.append({
|
||||
'id': group.id,
|
||||
'name': group.name,
|
||||
'sort_order': group.sort_order
|
||||
})
|
||||
|
||||
# 构建基金数据
|
||||
funds_data = []
|
||||
for item in watchlist:
|
||||
estimate = db.query(FundEstimate).filter(FundEstimate.fund_code == item.fund_code).first()
|
||||
|
||||
fund_data = {
|
||||
'fund_code': item.fund_code,
|
||||
'fund_name': item.fund_name,
|
||||
'fund_type': item.fund_type,
|
||||
'group_id': item.group_id,
|
||||
'sort_order': item.sort_order,
|
||||
'created_time': item.created_time.isoformat() if item.created_time else None,
|
||||
'net_worth': estimate.net_worth if estimate else None,
|
||||
'net_worth_date': estimate.net_worth_date if estimate else None,
|
||||
'estimate_value': estimate.estimate_value if estimate else None,
|
||||
'estimate_change': estimate.estimate_change if estimate else None,
|
||||
'estimate_time': estimate.estimate_time if estimate else None
|
||||
}
|
||||
funds_data.append(fund_data)
|
||||
|
||||
return jsonify({
|
||||
'groups': groups_data,
|
||||
'data': funds_data
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/watchlist/<fund_code>', methods=['GET'])
|
||||
def check_watchlist(fund_code):
|
||||
"""检查基金是否在自选列表中"""
|
||||
db = next(get_db())
|
||||
exists = db.query(FundWatchlist).filter(FundWatchlist.fund_code == fund_code).first() is not None
|
||||
return jsonify({'in_watchlist': exists})
|
||||
|
||||
|
||||
@app.route('/api/watchlist', methods=['POST'])
|
||||
def add_to_watchlist():
|
||||
"""添加基金到自选列表"""
|
||||
data = request.get_json()
|
||||
fund_code = data.get('fund_code')
|
||||
fund_name = data.get('fund_name', '')
|
||||
fund_type = data.get('fund_type', '')
|
||||
group_id = data.get('group_id') # 可选的分组ID
|
||||
|
||||
if not fund_code:
|
||||
return jsonify({'error': 'Fund code is required'}), 400
|
||||
|
||||
db = next(get_db())
|
||||
|
||||
# 检查是否已存在
|
||||
existing = db.query(FundWatchlist).filter(FundWatchlist.fund_code == fund_code).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Fund already in watchlist', 'fund_code': fund_code}), 409
|
||||
|
||||
# 获取当前最大排序值(在同一分组内)
|
||||
query = db.query(FundWatchlist)
|
||||
if group_id:
|
||||
query = query.filter(FundWatchlist.group_id == group_id)
|
||||
max_order = query.order_by(FundWatchlist.sort_order.desc()).first()
|
||||
new_order = (max_order.sort_order + 1) if max_order else 0
|
||||
|
||||
# 创建新记录
|
||||
new_item = FundWatchlist(
|
||||
fund_code=fund_code,
|
||||
fund_name=fund_name,
|
||||
fund_type=fund_type,
|
||||
group_id=group_id,
|
||||
sort_order=new_order
|
||||
)
|
||||
|
||||
try:
|
||||
db.add(new_item)
|
||||
db.commit()
|
||||
return jsonify({
|
||||
'message': 'Fund added to watchlist',
|
||||
'fund_code': fund_code,
|
||||
'sort_order': new_order
|
||||
}), 201
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/watchlist/<fund_code>', methods=['DELETE'])
|
||||
def remove_from_watchlist(fund_code):
|
||||
"""从自选列表移除基金"""
|
||||
db = next(get_db())
|
||||
|
||||
item = db.query(FundWatchlist).filter(FundWatchlist.fund_code == fund_code).first()
|
||||
if not item:
|
||||
return jsonify({'error': 'Fund not in watchlist'}), 404
|
||||
|
||||
try:
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return jsonify({'message': 'Fund removed from watchlist', 'fund_code': fund_code})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/watchlist/batch-delete', methods=['POST'])
|
||||
def batch_delete_from_watchlist():
|
||||
"""批量删除自选基金"""
|
||||
data = request.get_json()
|
||||
fund_codes = data.get('fund_codes', [])
|
||||
|
||||
if not fund_codes:
|
||||
return jsonify({'error': 'Fund codes are required'}), 400
|
||||
|
||||
db = next(get_db())
|
||||
|
||||
try:
|
||||
deleted_count = db.query(FundWatchlist).filter(
|
||||
FundWatchlist.fund_code.in_(fund_codes)
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
return jsonify({
|
||||
'message': f'Deleted {deleted_count} funds from watchlist',
|
||||
'deleted_count': deleted_count
|
||||
})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/watchlist/reorder', methods=['PUT'])
|
||||
def reorder_watchlist():
|
||||
"""
|
||||
更新自选基金排序
|
||||
请求体格式: { "order": ["000001", "000002", "000003"], "group_id": 1 }
|
||||
数组顺序即为排序顺序,索引值作为 sort_order
|
||||
group_id 可选,用于同时更新基金的分组
|
||||
"""
|
||||
data = request.get_json()
|
||||
order = data.get('order', [])
|
||||
group_id = data.get('group_id') # 可选,移动到某个分组
|
||||
|
||||
if not order:
|
||||
return jsonify({'error': 'Order array is required'}), 400
|
||||
|
||||
db = next(get_db())
|
||||
|
||||
try:
|
||||
for index, fund_code in enumerate(order):
|
||||
update_data = {'sort_order': index}
|
||||
if group_id is not None:
|
||||
update_data['group_id'] = group_id if group_id > 0 else None
|
||||
db.query(FundWatchlist).filter(
|
||||
FundWatchlist.fund_code == fund_code
|
||||
).update(update_data)
|
||||
db.commit()
|
||||
return jsonify({'message': 'Watchlist reordered successfully'})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 分组管理 API ====================
|
||||
|
||||
@app.route('/api/watchlist/groups', methods=['GET'])
|
||||
def get_groups():
|
||||
"""获取所有分组"""
|
||||
db = next(get_db())
|
||||
groups = db.query(FundWatchlistGroup).order_by(FundWatchlistGroup.sort_order).all()
|
||||
|
||||
result = [{
|
||||
'id': g.id,
|
||||
'name': g.name,
|
||||
'sort_order': g.sort_order
|
||||
} for g in groups]
|
||||
|
||||
return jsonify({'data': result})
|
||||
|
||||
|
||||
@app.route('/api/watchlist/groups', methods=['POST'])
|
||||
def create_group():
|
||||
"""创建新分组"""
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'Group name is required'}), 400
|
||||
|
||||
db = next(get_db())
|
||||
|
||||
# 获取最大排序值
|
||||
max_order = db.query(FundWatchlistGroup).order_by(FundWatchlistGroup.sort_order.desc()).first()
|
||||
new_order = (max_order.sort_order + 1) if max_order else 0
|
||||
|
||||
new_group = FundWatchlistGroup(name=name, sort_order=new_order)
|
||||
|
||||
try:
|
||||
db.add(new_group)
|
||||
db.commit()
|
||||
return jsonify({
|
||||
'message': 'Group created',
|
||||
'group': {
|
||||
'id': new_group.id,
|
||||
'name': new_group.name,
|
||||
'sort_order': new_group.sort_order
|
||||
}
|
||||
}), 201
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/watchlist/groups/<int:group_id>', methods=['PUT'])
|
||||
def update_group(group_id):
|
||||
"""更新分组(重命名)"""
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'Group name is required'}), 400
|
||||
|
||||
db = next(get_db())
|
||||
group = db.query(FundWatchlistGroup).filter(FundWatchlistGroup.id == group_id).first()
|
||||
|
||||
if not group:
|
||||
return jsonify({'error': 'Group not found'}), 404
|
||||
|
||||
try:
|
||||
group.name = name
|
||||
db.commit()
|
||||
return jsonify({'message': 'Group updated', 'group': {'id': group.id, 'name': group.name}})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/watchlist/groups/<int:group_id>', methods=['DELETE'])
|
||||
def delete_group(group_id):
|
||||
"""删除分组(分组内的基金会变为未分组)"""
|
||||
db = next(get_db())
|
||||
group = db.query(FundWatchlistGroup).filter(FundWatchlistGroup.id == group_id).first()
|
||||
|
||||
if not group:
|
||||
return jsonify({'error': 'Group not found'}), 404
|
||||
|
||||
try:
|
||||
# 将该分组的基金设为未分组
|
||||
db.query(FundWatchlist).filter(FundWatchlist.group_id == group_id).update({'group_id': None})
|
||||
db.delete(group)
|
||||
db.commit()
|
||||
return jsonify({'message': 'Group deleted'})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/watchlist/groups/reorder', methods=['PUT'])
|
||||
def reorder_groups():
|
||||
"""更新分组排序"""
|
||||
data = request.get_json()
|
||||
order = data.get('order', []) # [group_id1, group_id2, ...]
|
||||
|
||||
if not order:
|
||||
return jsonify({'error': 'Order array is required'}), 400
|
||||
|
||||
db = next(get_db())
|
||||
|
||||
try:
|
||||
for index, group_id in enumerate(order):
|
||||
db.query(FundWatchlistGroup).filter(
|
||||
FundWatchlistGroup.id == group_id
|
||||
).update({'sort_order': index})
|
||||
db.commit()
|
||||
return jsonify({'message': 'Groups reordered successfully'})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/watchlist/move', methods=['PUT'])
|
||||
def move_fund_to_group():
|
||||
"""移动基金到指定分组"""
|
||||
data = request.get_json()
|
||||
fund_code = data.get('fund_code')
|
||||
group_id = data.get('group_id') # None 或 0 表示移到未分组
|
||||
|
||||
if not fund_code:
|
||||
return jsonify({'error': 'Fund code is required'}), 400
|
||||
|
||||
db = next(get_db())
|
||||
fund = db.query(FundWatchlist).filter(FundWatchlist.fund_code == fund_code).first()
|
||||
|
||||
if not fund:
|
||||
return jsonify({'error': 'Fund not in watchlist'}), 404
|
||||
|
||||
try:
|
||||
fund.group_id = group_id if group_id and group_id > 0 else None
|
||||
db.commit()
|
||||
return jsonify({'message': 'Fund moved successfully'})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from models import Base
|
||||
from pathlib import Path
|
||||
@@ -16,10 +16,27 @@ DATABASE_URL = f"sqlite:///{DATABASE_PATH.as_posix()}"
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
def migrate_db():
|
||||
"""数据库迁移:为现有表添加缺失的列"""
|
||||
with engine.connect() as conn:
|
||||
# 检查并添加 fund_watchlist.group_id 列
|
||||
try:
|
||||
result = conn.execute(text("PRAGMA table_info(fund_watchlist)"))
|
||||
columns = [row[1] for row in result.fetchall()]
|
||||
if 'group_id' not in columns:
|
||||
conn.execute(text("ALTER TABLE fund_watchlist ADD COLUMN group_id INTEGER DEFAULT NULL"))
|
||||
conn.commit()
|
||||
print("Migration: Added group_id column to fund_watchlist table")
|
||||
except Exception as e:
|
||||
print(f"Migration check for fund_watchlist: {e}")
|
||||
|
||||
def init_db():
|
||||
# 确保 Data 目录存在
|
||||
(PROJECT_ROOT / "Data").mkdir(exist_ok=True)
|
||||
# 创建所有表(新表会被创建,已有表不会被覆盖)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
# 执行数据库迁移
|
||||
migrate_db()
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
|
||||
@@ -73,4 +73,29 @@ class FundExtraData(Base):
|
||||
fund_managers_json = Column(Text)
|
||||
subscription_redemption_json = Column(Text)
|
||||
same_type_funds_json = Column(Text)
|
||||
updated_time = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
class FundWatchlistGroup(Base):
|
||||
"""自选分组表"""
|
||||
__tablename__ = 'fund_watchlist_group'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(50), nullable=False)
|
||||
sort_order = Column(Integer, default=0) # 分组排序
|
||||
created_time = Column(DateTime, default=datetime.now)
|
||||
updated_time = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
class FundWatchlist(Base):
|
||||
"""基金自选表 - 存储用户自选的基金列表"""
|
||||
__tablename__ = 'fund_watchlist'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
fund_code = Column(String(6), unique=True, nullable=False)
|
||||
fund_name = Column(String(100), nullable=False)
|
||||
fund_type = Column(String(50))
|
||||
group_id = Column(Integer, default=None) # 所属分组ID,None表示未分组
|
||||
sort_order = Column(Integer, default=0) # 排序顺序,数字越小越靠前
|
||||
created_time = Column(DateTime, default=datetime.now)
|
||||
updated_time = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
BIN
Data/funds.db
BIN
Data/funds.db
Binary file not shown.
@@ -2,15 +2,31 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<header class="app-header">
|
||||
<h1>GoFundBot</h1>
|
||||
<p>一个有趣的基金分析机器人</p>
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<h1>GoFundBot</h1>
|
||||
<p>一个有趣的基金分析机器人</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<FundSearch @fund-selected="handleFundSelected" />
|
||||
<FundDetail v-if="selectedFundCode" :fundCode="selectedFundCode" />
|
||||
<div v-else class="welcome">
|
||||
<p>请在搜索框中输入基金代码或名称开始分析</p>
|
||||
<div class="main-layout">
|
||||
<!-- 左侧:自选列表 -->
|
||||
<aside class="sidebar-left">
|
||||
<FundWatchlist @view-fund="handleFundSelected" />
|
||||
</aside>
|
||||
|
||||
<!-- 右侧:搜索和详情 -->
|
||||
<div class="content-area">
|
||||
<FundSearch @fund-selected="handleFundSelected" />
|
||||
<FundDetail v-if="selectedFundCode" :fundCode="selectedFundCode" />
|
||||
<div v-else class="welcome">
|
||||
<div class="welcome-icon">📊</div>
|
||||
<p>请在搜索框中输入基金代码或名称</p>
|
||||
<p class="welcome-hint">或从左侧自选列表中选择基金开始分析</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -21,15 +37,17 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import FundSearch from './components/FundSearch.vue'
|
||||
import FundDetail from './components/FundDetail.vue'
|
||||
import FundWatchlist from './components/FundWatchlist.vue'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
FundSearch,
|
||||
FundDetail
|
||||
FundDetail,
|
||||
FundWatchlist
|
||||
},
|
||||
setup() {
|
||||
const selectedFundCode = ref('')
|
||||
@@ -75,55 +93,100 @@ export default {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
padding: 15px 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.2rem;
|
||||
margin-bottom: 8px;
|
||||
.header-content {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
.header-left h1 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.header-left p {
|
||||
opacity: 0.9;
|
||||
font-size: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
max-width: 1600px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 主布局:左侧自选 + 右侧内容 */
|
||||
.main-layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-height: calc(100vh - 160px);
|
||||
}
|
||||
|
||||
/* 左侧边栏 */
|
||||
.sidebar-left {
|
||||
width: 360px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 右侧内容区 */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
padding: 80px 20px;
|
||||
color: #7f8c8d;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
margin-top: 20px;
|
||||
border: 2px dashed #dee2e6;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.welcome-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.welcome p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.welcome-hint {
|
||||
font-size: 0.9rem !important;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
background: #f8f9fa;
|
||||
background: white;
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* 响应式:小屏幕时自选列表折叠或在上方 */
|
||||
@media (max-width: 1024px) {
|
||||
.main-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-left {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,17 @@
|
||||
<div class="header-left">
|
||||
<h2>{{ fundInfo.name || '未知基金' }}</h2>
|
||||
<span class="fund-code">{{ fundCode }}</span>
|
||||
<!-- 自选按钮 -->
|
||||
<button
|
||||
class="watchlist-btn"
|
||||
:class="{ 'in-watchlist': isInWatchlist }"
|
||||
@click="toggleWatchlist"
|
||||
:disabled="watchlistLoading"
|
||||
:title="isInWatchlist ? '移除自选' : '添加自选'"
|
||||
>
|
||||
<span class="star-icon">{{ isInWatchlist ? '★' : '☆' }}</span>
|
||||
<span class="btn-text">{{ isInWatchlist ? '已自选' : '自选' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="net-worth-box">
|
||||
@@ -64,7 +75,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { fundAPI } from '../services/api'
|
||||
import { fundAPI, watchlistAPI } from '../services/api'
|
||||
|
||||
export default {
|
||||
name: 'FundBasicInfo',
|
||||
@@ -82,7 +93,9 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
fundInfo: null,
|
||||
loading: false
|
||||
loading: false,
|
||||
isInWatchlist: false,
|
||||
watchlistLoading: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -102,10 +115,53 @@ export default {
|
||||
if (newCode && !this.fundData) {
|
||||
this.fetchFundInfo()
|
||||
}
|
||||
// 检查自选状态
|
||||
if (newCode) {
|
||||
this.checkWatchlistStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 检查是否在自选列表中
|
||||
async checkWatchlistStatus() {
|
||||
try {
|
||||
const response = await watchlistAPI.checkInWatchlist(this.fundCode)
|
||||
this.isInWatchlist = response.data.in_watchlist
|
||||
} catch (error) {
|
||||
console.error('检查自选状态失败:', error)
|
||||
this.isInWatchlist = false
|
||||
}
|
||||
},
|
||||
|
||||
// 切换自选状态
|
||||
async toggleWatchlist() {
|
||||
if (this.watchlistLoading || !this.fundCode) return
|
||||
|
||||
this.watchlistLoading = true
|
||||
try {
|
||||
if (this.isInWatchlist) {
|
||||
// 移除自选
|
||||
await watchlistAPI.removeFromWatchlist(this.fundCode)
|
||||
this.isInWatchlist = false
|
||||
} else {
|
||||
// 添加自选
|
||||
const fundName = this.fundInfo?.name || this.fundInfo?.fund_name || this.fundCode
|
||||
const fundType = this.fundInfo?.fund_type || ''
|
||||
await watchlistAPI.addToWatchlist(this.fundCode, fundName, fundType)
|
||||
this.isInWatchlist = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('操作自选失败:', error)
|
||||
// 如果是已存在的错误,说明实际上已经在自选中了
|
||||
if (error.response?.status === 409) {
|
||||
this.isInWatchlist = true
|
||||
}
|
||||
} finally {
|
||||
this.watchlistLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 处理基金数据(可来自父组件传递或自己请求)
|
||||
processFundData(data) {
|
||||
const realtime = data.realtime_estimate || {}
|
||||
@@ -202,6 +258,7 @@ export default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
@@ -218,6 +275,50 @@ export default {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 自选按钮样式 */
|
||||
.watchlist-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.watchlist-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.watchlist-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.watchlist-btn.in-watchlist {
|
||||
background: rgba(255, 215, 0, 0.3);
|
||||
border-color: #ffd700;
|
||||
}
|
||||
|
||||
.watchlist-btn.in-watchlist:hover:not(:disabled) {
|
||||
background: rgba(255, 215, 0, 0.4);
|
||||
}
|
||||
|
||||
.star-icon {
|
||||
font-size: 16px;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
|
||||
158
Frontend/src/components/FundListItems.vue
Normal file
158
Frontend/src/components/FundListItems.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="fund-list-items" @dragover.prevent @drop="$emit('drop', $event, groupId)">
|
||||
<div
|
||||
v-for="(fund, index) in funds"
|
||||
:key="fund.fund_code"
|
||||
class="list-item"
|
||||
:class="{
|
||||
'selected': selectedFunds.includes(fund.fund_code),
|
||||
'dragging': isDragging(index)
|
||||
}"
|
||||
:draggable="editMode"
|
||||
@dragstart="$emit('drag-start', $event, index, groupId)"
|
||||
@dragend="$emit('drag-end')"
|
||||
@dragover="$emit('drag-over', $event, index, groupId)"
|
||||
>
|
||||
<!-- 编辑模式:选择框 -->
|
||||
<div class="col-checkbox" v-if="editMode">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedFunds.includes(fund.fund_code)"
|
||||
@change="$emit('toggle-select', fund.fund_code)"
|
||||
class="checkbox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 编辑模式:拖拽手柄 -->
|
||||
<div class="col-drag" v-if="editMode">
|
||||
<span class="drag-handle">⋮⋮</span>
|
||||
</div>
|
||||
|
||||
<!-- 基金名称/代码 -->
|
||||
<div class="col-name" @click="!editMode && $emit('view-fund', fund.fund_code)">
|
||||
<div class="fund-name">{{ fund.fund_name }}</div>
|
||||
<div class="fund-code">{{ fund.fund_code }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 最新净值 -->
|
||||
<div class="col-nav">
|
||||
<div class="nav-value">{{ fund.net_worth || fund.estimate_value || '--' }}</div>
|
||||
<div class="nav-date">{{ fund.net_worth_date || fund.estimate_time || '' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 日涨跌幅 -->
|
||||
<div class="col-change" :class="getChangeClass(fund.estimate_change)">
|
||||
{{ formatChange(fund.estimate_change) }}
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="col-action" v-if="!editMode">
|
||||
<button class="btn-icon btn-remove" @click.stop="$emit('remove-fund', fund.fund_code)" title="移除">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FundListItems',
|
||||
props: {
|
||||
funds: { type: Array, default: () => [] },
|
||||
editMode: { type: Boolean, default: false },
|
||||
selectedFunds: { type: Array, default: () => [] },
|
||||
draggingIndex: { type: Object, default: null },
|
||||
groupId: { type: [Number, null], default: null }
|
||||
},
|
||||
emits: ['toggle-select', 'view-fund', 'remove-fund', 'drag-start', 'drag-end', 'drag-over', 'drop'],
|
||||
methods: {
|
||||
isDragging(index) {
|
||||
return this.draggingIndex &&
|
||||
this.draggingIndex.index === index &&
|
||||
this.draggingIndex.groupId === this.groupId
|
||||
},
|
||||
formatChange(change) {
|
||||
if (!change && change !== 0) return '--'
|
||||
const num = parseFloat(change)
|
||||
if (isNaN(num)) return change
|
||||
const sign = num > 0 ? '+' : ''
|
||||
return `${sign}${num.toFixed(2)}%`
|
||||
},
|
||||
getChangeClass(change) {
|
||||
if (!change && change !== 0) return ''
|
||||
const num = parseFloat(change)
|
||||
if (isNaN(num)) return ''
|
||||
if (num > 0) return 'change-up'
|
||||
if (num < 0) return 'change-down'
|
||||
return 'change-flat'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fund-list-items {
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
transition: all 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-item:last-child { border-bottom: none; }
|
||||
.list-item:hover { background: #f9fafb; }
|
||||
.list-item.selected { background: #eef2ff; }
|
||||
.list-item.dragging { opacity: 0.5; background: #eef2ff; }
|
||||
|
||||
.col-checkbox { width: 24px; flex-shrink: 0; }
|
||||
.col-drag { width: 20px; flex-shrink: 0; }
|
||||
.col-name { flex: 1; min-width: 0; overflow: hidden; }
|
||||
.col-nav { width: 65px; flex-shrink: 0; text-align: right; }
|
||||
.col-change { width: 60px; flex-shrink: 0; text-align: right; font-weight: 600; font-size: 12px; }
|
||||
.col-action { width: 30px; flex-shrink: 0; display: flex; justify-content: flex-end; }
|
||||
|
||||
.fund-name {
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fund-code { color: #9ca3af; font-size: 11px; margin-top: 1px; }
|
||||
.nav-value { color: #1f2937; font-size: 12px; font-weight: 500; }
|
||||
.nav-date { color: #9ca3af; font-size: 10px; margin-top: 1px; }
|
||||
|
||||
.change-up { color: #ef4444; }
|
||||
.change-down { color: #10b981; }
|
||||
.change-flat { color: #9ca3af; }
|
||||
|
||||
.checkbox { width: 16px; height: 16px; cursor: pointer; accent-color: #667eea; }
|
||||
|
||||
.drag-handle { cursor: grab; color: #9ca3af; font-size: 14px; user-select: none; }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.btn-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-icon:hover { background: #fee2e2; color: #ef4444; }
|
||||
</style>
|
||||
@@ -146,13 +146,16 @@ export default {
|
||||
<style scoped>
|
||||
.fund-search {
|
||||
margin-bottom: 20px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.search-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -165,10 +168,17 @@ export default {
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
@@ -176,15 +186,16 @@ export default {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.search-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.db-status {
|
||||
@@ -192,10 +203,11 @@ export default {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background: #f5f7fa;
|
||||
color: #6b7280;
|
||||
background: #f9fafb;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
@@ -203,7 +215,7 @@ export default {
|
||||
}
|
||||
|
||||
.status-empty {
|
||||
color: #e74c3c;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.update-btn {
|
||||
@@ -235,7 +247,7 @@ export default {
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #ddd;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
@@ -246,21 +258,23 @@ export default {
|
||||
}
|
||||
|
||||
.search-results {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
max-height: 200px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.fund-item {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.fund-item:last-child {
|
||||
@@ -268,32 +282,35 @@ export default {
|
||||
}
|
||||
|
||||
.fund-item:hover {
|
||||
background: #f5f7fa;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.fund-code {
|
||||
font-weight: bold;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
font-family: monospace;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 13px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.fund-name {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fund-type {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
background: #f0f0f0;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
background: #f3f4f6;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
color: #666;
|
||||
padding: 12px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
660
Frontend/src/components/FundWatchlist.vue
Normal file
660
Frontend/src/components/FundWatchlist.vue
Normal file
@@ -0,0 +1,660 @@
|
||||
<template>
|
||||
<div class="watchlist-container">
|
||||
<!-- 头部操作栏 -->
|
||||
<div class="watchlist-header">
|
||||
<h2>
|
||||
<span class="header-icon">⭐</span>
|
||||
我的自选
|
||||
<span class="count-badge" v-if="totalCount">{{ totalCount }}</span>
|
||||
</h2>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-add-group" @click="openAddGroupModal" title="新建分组">
|
||||
+📁
|
||||
</button>
|
||||
<button
|
||||
v-if="!editMode && totalCount > 0"
|
||||
class="btn btn-edit"
|
||||
@click="enterEditMode"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<template v-if="editMode">
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
:disabled="selectedFunds.length === 0"
|
||||
@click="batchDelete"
|
||||
>
|
||||
删除{{ selectedFunds.length > 0 ? `(${selectedFunds.length})` : '' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="exitEditMode">
|
||||
完成
|
||||
</button>
|
||||
</template>
|
||||
<button class="btn btn-refresh" @click="refreshWatchlist" :disabled="loading" title="刷新">
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading && totalCount === 0" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="totalCount === 0" class="empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<p>暂无自选基金</p>
|
||||
<p class="empty-hint">在基金详情页点击 ⭐ 添加自选</p>
|
||||
</div>
|
||||
|
||||
<!-- 分组列表 -->
|
||||
<div v-else class="watchlist-content">
|
||||
<!-- 未分组的基金 -->
|
||||
<div class="fund-group" v-if="ungroupedFunds.length > 0 || groups.length === 0">
|
||||
<div class="group-header" @click="toggleGroup(null)">
|
||||
<span class="group-toggle">{{ isGroupExpanded(null) ? '▼' : '▶' }}</span>
|
||||
<span class="group-name">{{ groups.length > 0 ? '未分组' : '全部基金' }}</span>
|
||||
<span class="group-count">{{ ungroupedFunds.length }}</span>
|
||||
</div>
|
||||
<div class="group-content" v-show="isGroupExpanded(null)">
|
||||
<FundListItems
|
||||
:funds="ungroupedFunds"
|
||||
:editMode="editMode"
|
||||
:selectedFunds="selectedFunds"
|
||||
:draggingIndex="draggingIndex"
|
||||
:groupId="null"
|
||||
@toggle-select="toggleSelect"
|
||||
@view-fund="viewFundDetail"
|
||||
@remove-fund="removeFund"
|
||||
@drag-start="onDragStart"
|
||||
@drag-end="onDragEnd"
|
||||
@drag-over="onDragOver"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 各分组 -->
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="fund-group"
|
||||
@dragover.prevent="onGroupDragOver($event, group.id)"
|
||||
@drop="onGroupDrop($event, group.id)"
|
||||
>
|
||||
<div class="group-header" @click="toggleGroup(group.id)">
|
||||
<span class="group-toggle">{{ isGroupExpanded(group.id) ? '▼' : '▶' }}</span>
|
||||
<span class="group-name">📁 {{ group.name }}</span>
|
||||
<span class="group-count">{{ getGroupFunds(group.id).length }}</span>
|
||||
<div class="group-actions" v-if="editMode" @click.stop>
|
||||
<button class="btn-icon-sm" @click="openEditGroupModal(group)" title="重命名">✏️</button>
|
||||
<button class="btn-icon-sm btn-del" @click="deleteGroup(group)" title="删除分组">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-content" v-show="isGroupExpanded(group.id)">
|
||||
<FundListItems
|
||||
:funds="getGroupFunds(group.id)"
|
||||
:editMode="editMode"
|
||||
:selectedFunds="selectedFunds"
|
||||
:draggingIndex="draggingIndex"
|
||||
:groupId="group.id"
|
||||
@toggle-select="toggleSelect"
|
||||
@view-fund="viewFundDetail"
|
||||
@remove-fund="removeFund"
|
||||
@drag-start="onDragStart"
|
||||
@drag-end="onDragEnd"
|
||||
@drag-over="onDragOver"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
<div v-if="getGroupFunds(group.id).length === 0" class="group-empty">
|
||||
暂无基金,拖拽基金到此分组
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑分组弹窗 -->
|
||||
<div v-if="showGroupModal" class="modal-overlay" @click.self="closeGroupModal">
|
||||
<div class="modal-box">
|
||||
<h3>{{ editingGroup ? '重命名分组' : '新建分组' }}</h3>
|
||||
<input
|
||||
v-model="groupName"
|
||||
type="text"
|
||||
placeholder="请输入分组名称"
|
||||
class="modal-input"
|
||||
@keyup.enter="saveGroup"
|
||||
ref="groupNameInput"
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="closeGroupModal">取消</button>
|
||||
<button class="btn btn-primary" @click="saveGroup" :disabled="!groupName.trim()">
|
||||
{{ editingGroup ? '保存' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { watchlistAPI } from '../services/api'
|
||||
import FundListItems from './FundListItems.vue'
|
||||
|
||||
export default {
|
||||
name: 'FundWatchlist',
|
||||
components: { FundListItems },
|
||||
emits: ['view-fund'],
|
||||
setup(props, { emit }) {
|
||||
const watchlist = ref([])
|
||||
const groups = ref([])
|
||||
const loading = ref(false)
|
||||
const editMode = ref(false)
|
||||
const selectedFunds = ref([])
|
||||
const draggingIndex = ref(null)
|
||||
const dragOverIndex = ref(null)
|
||||
const expandedGroups = ref([null])
|
||||
|
||||
// 分组弹窗
|
||||
const showGroupModal = ref(false)
|
||||
const editingGroup = ref(null)
|
||||
const groupName = ref('')
|
||||
const groupNameInput = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const totalCount = computed(() => watchlist.value.length)
|
||||
|
||||
const ungroupedFunds = computed(() =>
|
||||
watchlist.value.filter(f => !f.group_id)
|
||||
)
|
||||
|
||||
const getGroupFunds = (groupId) => {
|
||||
return watchlist.value.filter(f => f.group_id === groupId)
|
||||
}
|
||||
|
||||
const isGroupExpanded = (groupId) => {
|
||||
return expandedGroups.value.includes(groupId)
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
const loadWatchlist = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await watchlistAPI.getWatchlist()
|
||||
watchlist.value = response.data.data || []
|
||||
groups.value = response.data.groups || []
|
||||
// 默认展开所有分组
|
||||
expandedGroups.value = [null, ...groups.value.map(g => g.id)]
|
||||
} catch (error) {
|
||||
console.error('加载自选列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshWatchlist = () => loadWatchlist()
|
||||
|
||||
// 分组展开/折叠
|
||||
const toggleGroup = (groupId) => {
|
||||
const index = expandedGroups.value.indexOf(groupId)
|
||||
if (index > -1) {
|
||||
expandedGroups.value.splice(index, 1)
|
||||
} else {
|
||||
expandedGroups.value.push(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑模式
|
||||
const enterEditMode = () => {
|
||||
editMode.value = true
|
||||
selectedFunds.value = []
|
||||
}
|
||||
|
||||
const exitEditMode = () => {
|
||||
editMode.value = false
|
||||
selectedFunds.value = []
|
||||
}
|
||||
|
||||
// 选择基金
|
||||
const toggleSelect = (fundCode) => {
|
||||
const index = selectedFunds.value.indexOf(fundCode)
|
||||
if (index > -1) {
|
||||
selectedFunds.value.splice(index, 1)
|
||||
} else {
|
||||
selectedFunds.value.push(fundCode)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const batchDelete = async () => {
|
||||
if (selectedFunds.value.length === 0) return
|
||||
if (!confirm(`确定删除选中的 ${selectedFunds.value.length} 只基金吗?`)) return
|
||||
|
||||
try {
|
||||
await watchlistAPI.batchDelete(selectedFunds.value)
|
||||
watchlist.value = watchlist.value.filter(
|
||||
f => !selectedFunds.value.includes(f.fund_code)
|
||||
)
|
||||
selectedFunds.value = []
|
||||
if (watchlist.value.length === 0) exitEditMode()
|
||||
} catch (error) {
|
||||
console.error('批量删除失败:', error)
|
||||
alert('删除失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 移除单个
|
||||
const removeFund = async (fundCode) => {
|
||||
if (!confirm('确定移除该基金吗?')) return
|
||||
try {
|
||||
await watchlistAPI.removeFromWatchlist(fundCode)
|
||||
watchlist.value = watchlist.value.filter(f => f.fund_code !== fundCode)
|
||||
} catch (error) {
|
||||
console.error('移除失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const viewFundDetail = (fundCode) => {
|
||||
emit('view-fund', fundCode)
|
||||
}
|
||||
|
||||
// 拖拽排序
|
||||
const onDragStart = (event, index, groupId) => {
|
||||
draggingIndex.value = { index, groupId }
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
const onDragEnd = async () => {
|
||||
if (draggingIndex.value !== null && dragOverIndex.value !== null) {
|
||||
const fromGroupId = draggingIndex.value.groupId
|
||||
const toGroupId = dragOverIndex.value.groupId
|
||||
const fromFunds = fromGroupId === null ? ungroupedFunds.value : getGroupFunds(fromGroupId)
|
||||
|
||||
if (fromGroupId === toGroupId) {
|
||||
// 同分组内排序
|
||||
const funds = [...fromFunds]
|
||||
const [moved] = funds.splice(draggingIndex.value.index, 1)
|
||||
funds.splice(dragOverIndex.value.index, 0, moved)
|
||||
|
||||
try {
|
||||
await watchlistAPI.reorder(funds.map(f => f.fund_code), fromGroupId)
|
||||
loadWatchlist()
|
||||
} catch (error) {
|
||||
console.error('排序失败:', error)
|
||||
}
|
||||
} else {
|
||||
// 跨分组移动
|
||||
const fund = fromFunds[draggingIndex.value.index]
|
||||
try {
|
||||
await watchlistAPI.moveFundToGroup(fund.fund_code, toGroupId)
|
||||
loadWatchlist()
|
||||
} catch (error) {
|
||||
console.error('移动失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draggingIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
const onDragOver = (event, index, groupId) => {
|
||||
event.preventDefault()
|
||||
dragOverIndex.value = { index, groupId }
|
||||
}
|
||||
|
||||
const onDrop = (event, groupId) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// 拖拽到分组区域
|
||||
const onGroupDragOver = (event, groupId) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onGroupDrop = async (event, groupId) => {
|
||||
event.preventDefault()
|
||||
if (draggingIndex.value && draggingIndex.value.groupId !== groupId) {
|
||||
const fromFunds = draggingIndex.value.groupId === null
|
||||
? ungroupedFunds.value
|
||||
: getGroupFunds(draggingIndex.value.groupId)
|
||||
const fund = fromFunds[draggingIndex.value.index]
|
||||
|
||||
try {
|
||||
await watchlistAPI.moveFundToGroup(fund.fund_code, groupId)
|
||||
loadWatchlist()
|
||||
} catch (error) {
|
||||
console.error('移动失败:', error)
|
||||
}
|
||||
}
|
||||
draggingIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
// 分组管理
|
||||
const openAddGroupModal = () => {
|
||||
editingGroup.value = null
|
||||
groupName.value = ''
|
||||
showGroupModal.value = true
|
||||
nextTick(() => groupNameInput.value?.focus())
|
||||
}
|
||||
|
||||
const openEditGroupModal = (group) => {
|
||||
editingGroup.value = group
|
||||
groupName.value = group.name
|
||||
showGroupModal.value = true
|
||||
nextTick(() => groupNameInput.value?.focus())
|
||||
}
|
||||
|
||||
const closeGroupModal = () => {
|
||||
showGroupModal.value = false
|
||||
editingGroup.value = null
|
||||
groupName.value = ''
|
||||
}
|
||||
|
||||
const saveGroup = async () => {
|
||||
const name = groupName.value.trim()
|
||||
if (!name) return
|
||||
|
||||
try {
|
||||
if (editingGroup.value) {
|
||||
await watchlistAPI.renameGroup(editingGroup.value.id, name)
|
||||
} else {
|
||||
await watchlistAPI.createGroup(name)
|
||||
}
|
||||
closeGroupModal()
|
||||
loadWatchlist()
|
||||
} catch (error) {
|
||||
console.error('保存分组失败:', error)
|
||||
alert('操作失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteGroup = async (group) => {
|
||||
if (!confirm(`确定删除分组"${group.name}"吗?\n分组内的基金将移到未分组。`)) return
|
||||
try {
|
||||
await watchlistAPI.deleteGroup(group.id)
|
||||
loadWatchlist()
|
||||
} catch (error) {
|
||||
console.error('删除分组失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadWatchlist()
|
||||
})
|
||||
|
||||
return {
|
||||
watchlist,
|
||||
groups,
|
||||
loading,
|
||||
editMode,
|
||||
selectedFunds,
|
||||
draggingIndex,
|
||||
expandedGroups,
|
||||
totalCount,
|
||||
ungroupedFunds,
|
||||
getGroupFunds,
|
||||
isGroupExpanded,
|
||||
showGroupModal,
|
||||
editingGroup,
|
||||
groupName,
|
||||
groupNameInput,
|
||||
loadWatchlist,
|
||||
refreshWatchlist,
|
||||
toggleGroup,
|
||||
enterEditMode,
|
||||
exitEditMode,
|
||||
toggleSelect,
|
||||
batchDelete,
|
||||
removeFund,
|
||||
viewFundDetail,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onGroupDragOver,
|
||||
onGroupDrop,
|
||||
openAddGroupModal,
|
||||
openEditGroupModal,
|
||||
closeGroupModal,
|
||||
saveGroup,
|
||||
deleteGroup
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.watchlist-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.watchlist-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.watchlist-header h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-icon { font-size: 18px; }
|
||||
|
||||
.count-badge {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-add-group { background: #f0fdf4; color: #16a34a; }
|
||||
.btn-add-group:hover { background: #dcfce7; }
|
||||
.btn-edit { background: #f3f4f6; color: #667eea; }
|
||||
.btn-edit:hover { background: #e5e7eb; }
|
||||
.btn-danger { background: #fef2f2; color: #ef4444; }
|
||||
.btn-danger:hover:not(:disabled) { background: #fee2e2; }
|
||||
.btn-secondary { background: #f3f4f6; color: #374151; }
|
||||
.btn-secondary:hover { background: #e5e7eb; }
|
||||
.btn-refresh { background: #ecfdf5; color: #10b981; padding: 5px 8px; }
|
||||
.btn-refresh:hover:not(:disabled) { background: #d1fae5; }
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
|
||||
.btn-primary:hover:not(:disabled) { opacity: 0.9; }
|
||||
|
||||
/* 状态 */
|
||||
.loading-state, .empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 15px;
|
||||
color: #9ca3af;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.empty-icon { font-size: 36px; margin-bottom: 10px; }
|
||||
.empty-state p { margin: 0; font-size: 14px; color: #6b7280; }
|
||||
.empty-hint { font-size: 12px !important; color: #9ca3af !important; margin-top: 6px !important; }
|
||||
|
||||
/* 列表内容 */
|
||||
.watchlist-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 分组 */
|
||||
.fund-group {
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #f9fafb;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.group-header:hover { background: #f3f4f6; }
|
||||
|
||||
.group-toggle {
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
background: #e5e7eb;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.group-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-icon-sm {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-icon-sm:hover { background: #e5e7eb; }
|
||||
.btn-icon-sm.btn-del:hover { background: #fee2e2; }
|
||||
|
||||
.group-content {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.group-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 弹窗 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
width: 300px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-box h3 {
|
||||
margin: 0 0 15px;
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 15px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 滚动条 */
|
||||
.watchlist-content::-webkit-scrollbar { width: 6px; }
|
||||
.watchlist-content::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 3px; }
|
||||
.watchlist-content::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 3px; }
|
||||
.watchlist-content::-webkit-scrollbar-thumb:hover { background: #a1a1a1; }
|
||||
</style>
|
||||
@@ -41,4 +41,84 @@ export const fundAPI = {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 自选基金 API ====================
|
||||
export const watchlistAPI = {
|
||||
// 获取自选列表(包含分组信息)
|
||||
getWatchlist() {
|
||||
return api.get('/watchlist')
|
||||
},
|
||||
|
||||
// 检查基金是否在自选列表中
|
||||
checkInWatchlist(fundCode) {
|
||||
return api.get(`/watchlist/${fundCode}`)
|
||||
},
|
||||
|
||||
// 添加基金到自选
|
||||
addToWatchlist(fundCode, fundName, fundType = '', groupId = null) {
|
||||
return api.post('/watchlist', {
|
||||
fund_code: fundCode,
|
||||
fund_name: fundName,
|
||||
fund_type: fundType,
|
||||
group_id: groupId
|
||||
})
|
||||
},
|
||||
|
||||
// 从自选中移除
|
||||
removeFromWatchlist(fundCode) {
|
||||
return api.delete(`/watchlist/${fundCode}`)
|
||||
},
|
||||
|
||||
// 批量删除
|
||||
batchDelete(fundCodes) {
|
||||
return api.post('/watchlist/batch-delete', {
|
||||
fund_codes: fundCodes
|
||||
})
|
||||
},
|
||||
|
||||
// 更新排序 - 传入基金代码数组,顺序即为排序
|
||||
reorder(fundCodeOrder, groupId = null) {
|
||||
return api.put('/watchlist/reorder', {
|
||||
order: fundCodeOrder,
|
||||
group_id: groupId
|
||||
})
|
||||
},
|
||||
|
||||
// 移动基金到分组
|
||||
moveFundToGroup(fundCode, groupId) {
|
||||
return api.put('/watchlist/move', {
|
||||
fund_code: fundCode,
|
||||
group_id: groupId
|
||||
})
|
||||
},
|
||||
|
||||
// ==================== 分组 API ====================
|
||||
|
||||
// 获取所有分组
|
||||
getGroups() {
|
||||
return api.get('/watchlist/groups')
|
||||
},
|
||||
|
||||
// 创建分组
|
||||
createGroup(name) {
|
||||
return api.post('/watchlist/groups', { name })
|
||||
},
|
||||
|
||||
// 重命名分组
|
||||
renameGroup(groupId, name) {
|
||||
return api.put(`/watchlist/groups/${groupId}`, { name })
|
||||
},
|
||||
|
||||
// 删除分组
|
||||
deleteGroup(groupId) {
|
||||
return api.delete(`/watchlist/groups/${groupId}`)
|
||||
},
|
||||
|
||||
// 分组排序
|
||||
reorderGroups(groupIdOrder) {
|
||||
return api.put('/watchlist/groups/reorder', {
|
||||
order: groupIdOrder
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default api
|
||||
Reference in New Issue
Block a user