添加了自选功能

This commit is contained in:
Sebastian
2026-01-17 16:38:04 +08:00
parent 0abc79d164
commit 3f0919f1bc
14 changed files with 1499 additions and 56 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -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)

View File

@@ -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()

View File

@@ -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) # 所属分组IDNone表示未分组
sort_order = Column(Integer, default=0) # 排序顺序,数字越小越靠前
created_time = Column(DateTime, default=datetime.now)
updated_time = Column(DateTime, default=datetime.now, onupdate=datetime.now)

Binary file not shown.

View File

@@ -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>

View File

@@ -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;

View 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>

View File

@@ -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>

View 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>

View File

@@ -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