diff --git a/CLAUDE.md b/CLAUDE.md index dbc264ce..0d161d2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,10 @@ **前端** - **核心框架**: React 18.3.1 - **类型系统**: TypeScript 5.9.3(渐进式接入中,支持 JS/TS 混合开发) -- **UI 组件库**: Chakra UI 2.10.9(主要) + Ant Design 5.27.4(表格/表单) +- **UI 组件库**: + - Chakra UI 2.10.9(主要,全局使用) + - Ant Design 5.27.4(表格/表单) + - **HeroUI 3.0.0-beta**(AgentChat 专用,2025-11-22 升级) - **状态管理**: Redux Toolkit 2.9.2 - **路由**: React Router v6.30.1 配合 React.lazy() 实现代码分割 - **构建系统**: CRACO 7.1.0 + 激进的 webpack 5 优化 @@ -59,6 +62,8 @@ - **虚拟化**: @tanstack/react-virtual 3.13.12(性能优化) - **其他**: Draft.js(富文本编辑)、React Markdown、React Quill +**注意**: HeroUI v3 文档参考 https://v3.heroui.com/llms.txt,详细升级说明见 [HEROUI_V3_UPGRADE_GUIDE.md](./HEROUI_V3_UPGRADE_GUIDE.md) + **后端** - Flask + SQLAlchemy ORM - ClickHouse(分析型数据库)+ MySQL/PostgreSQL(事务型数据库) diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md deleted file mode 100644 index c599b9fb..00000000 --- a/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/README.md b/README.md deleted file mode 100644 index 7ff19cea..00000000 --- a/README.md +++ /dev/null @@ -1,198 +0,0 @@ -# vf_react - -前端 - ---- - -## 📚 重构记录 - -### 2025-10-30: EventList.js 组件化重构 - -#### 🎯 重构目标 -将 Community 社区页面的 `EventList.js` 组件(1095行)拆分为多个可复用的子组件,提高代码可维护性和复用性。 - -#### 📊 重构成果 -- **重构前**: 1095 行 -- **重构后**: 497 行 -- **减少**: 598 行 (-54.6%) - ---- - -### 📁 新增目录结构 - -``` -src/views/Community/components/EventCard/ -├── index.js (60行) - EventCard 统一入口,智能路由紧凑/详细模式 -│ -├── ────────────────────────────────────────────────────────── -│ 原子组件 (Atoms) - 7个基础UI组件 -├── ────────────────────────────────────────────────────────── -│ -├── EventTimeline.js (60行) - 时间轴显示组件 -│ └── Props: createdAt, timelineStyle, borderColor, minHeight -│ -├── EventImportanceBadge.js (100行) - 重要性等级标签 (S/A/B/C/D) -│ └── Props: importance, showTooltip, showIcon, size -│ -├── EventStats.js (60行) - 统计信息 (浏览/帖子/关注) -│ └── Props: viewCount, postCount, followerCount, size, spacing -│ -├── EventFollowButton.js (40行) - 关注按钮 -│ └── Props: isFollowing, followerCount, onToggle, size, showCount -│ -├── EventPriceDisplay.js (130行) - 价格变动显示 (平均/最大/周) -│ └── Props: avgChange, maxChange, weekChange, compact, inline -│ -├── EventDescription.js (60行) - 描述文本 (支持展开/收起) -│ └── Props: description, textColor, minLength, noOfLines -│ -├── EventHeader.js (100行) - 事件标题头部 -│ └── Props: title, importance, onTitleClick, linkColor, compact -│ -├── ────────────────────────────────────────────────────────── -│ 组合组件 (Molecules) - 2个卡片组件 -├── ────────────────────────────────────────────────────────── -│ -├── CompactEventCard.js (160行) - 紧凑模式事件卡片 -│ ├── 使用: EventTimeline, EventHeader, EventStats, EventFollowButton -│ └── Props: event, index, isFollowing, followerCount, callbacks... -│ -└── DetailedEventCard.js (170行) - 详细模式事件卡片 - ├── 使用: EventTimeline, EventHeader, EventStats, EventFollowButton, - │ EventPriceDisplay, EventDescription - └── Props: event, isFollowing, followerCount, callbacks... -``` - -**总计**: 10个文件,940行代码 - ---- - -### 🔧 重构的文件 - -#### `src/views/Community/components/EventList.js` - -**移除的内容**: -- ❌ `renderPriceChange` 函数 (~60行) -- ❌ `renderCompactEvent` 函数 (~200行) -- ❌ `renderDetailedEvent` 函数 (~300行) -- ❌ `expandedDescriptions` state(展开状态管理移至子组件) -- ❌ 冗余的 Chakra UI 导入 - -**保留的功能**: -- ✅ WebSocket 实时推送 -- ✅ 浏览器原生通知 -- ✅ 关注状态管理 (followingMap, followCountMap) -- ✅ 分页控制 -- ✅ 视图模式切换(紧凑/详细) -- ✅ 推送权限管理 - -**新增引入**: -```javascript -import EventCard from './EventCard'; -``` - ---- - -### 🏗️ 架构改进 - -#### 重构前(单体架构) -``` -EventList.js (1095行) -├── 业务逻辑 (WebSocket, 关注, 通知) -├── renderCompactEvent (200行) -│ └── 所有UI代码内联 -├── renderDetailedEvent (300行) -│ └── 所有UI代码内联 -└── renderPriceChange (60行) -``` - -#### 重构后(组件化架构) -``` -EventList.js (497行) - 容器组件 -├── 业务逻辑 (WebSocket, 关注, 通知) -└── 渲染逻辑 - └── EventCard (智能路由) - ├── CompactEventCard (紧凑模式) - │ ├── EventTimeline - │ ├── EventHeader (compact) - │ ├── EventStats - │ └── EventFollowButton - └── DetailedEventCard (详细模式) - ├── EventTimeline - ├── EventHeader (detailed) - ├── EventStats - ├── EventFollowButton - ├── EventPriceDisplay - └── EventDescription -``` - ---- - -### ✨ 优势 - -1. **可维护性** ⬆️ - - 每个组件职责单一(单一职责原则) - - 代码行数减少 54.6% - - 组件边界清晰,易于理解 - -2. **可复用性** ⬆️ - - 原子组件可在其他页面复用 - - 例如:EventImportanceBadge 可用于任何需要显示事件等级的地方 - -3. **可测试性** ⬆️ - - 小组件更容易编写单元测试 - - 可独立测试每个组件的渲染和交互 - -4. **性能优化** ⬆️ - - React 可以更精确地追踪变化 - - 减少不必要的重渲染 - - 每个子组件可独立优化(useMemo, React.memo) - -5. **开发效率** ⬆️ - - 新增功能时只需修改对应的子组件 - - 代码审查更高效 - - 降低了代码冲突的概率 - ---- - -### 📦 依赖工具函数 - -本次重构使用了之前提取的工具函数: - -``` -src/utils/priceFormatters.js (105行) -├── getPriceChangeColor(value) - 获取价格变化文字颜色 -├── getPriceChangeBg(value) - 获取价格变化背景颜色 -├── getPriceChangeBorderColor(value) - 获取价格变化边框颜色 -├── formatPriceChange(value) - 格式化价格为字符串 -└── PriceArrow({ value }) - 价格涨跌箭头组件 - -src/constants/animations.js (72行) -├── pulseAnimation - 脉冲动画(S/A级标签) -├── fadeIn - 渐入动画 -├── slideInUp - 从下往上滑入 -├── scaleIn - 缩放进入 -└── spin - 旋转动画(Loading) -``` - ---- - -### 🚀 下一步优化计划 - -Phase 1 已完成,后续可继续优化: - -- **Phase 2**: 拆分 StockDetailPanel.js (1067行 → ~250行) -- **Phase 3**: 拆分 InvestmentCalendar.js (827行 → ~200行) -- **Phase 4**: 拆分 MidjourneyHeroSection.js (813行 → ~200行) -- **Phase 5**: 拆分 UnifiedSearchBox.js (679行 → ~180行) - ---- - -### 🔗 相关提交 - -- `feat: 拆分 EventList.js/提取价格相关工具函数到 utils/priceFormatters.js` -- `feat(EventList): 创建事件卡片原子组件` -- `feat(EventList): 创建事件卡片组合组件` -- `refactor(EventList): 使用组件化架构替换内联渲染函数` - ---- \ No newline at end of file diff --git a/__pycache__/app.cpython-310.pyc b/__pycache__/app.cpython-310.pyc new file mode 100644 index 00000000..88dd3f9a Binary files /dev/null and b/__pycache__/app.cpython-310.pyc differ diff --git a/app.py b/app.py index 32bd7c22..5599f784 100755 --- a/app.py +++ b/app.py @@ -2832,6 +2832,16 @@ def register(): user.email_confirmed = True # 暂时默认已确认 db.session.add(user) + db.session.flush() # 获取 user.id + + # 自动创建积分账户,初始10000积分 + credit_account = UserCreditAccount( + user_id=user.id, + balance=10000, + frozen=0 + ) + db.session.add(credit_account) + db.session.commit() # 自动登录 @@ -2995,6 +3005,16 @@ def register_with_phone(): user.phone_confirmed = True db.session.add(user) + db.session.flush() # 获取 user.id + + # 自动创建积分账户,初始10000积分 + credit_account = UserCreditAccount( + user_id=user.id, + balance=10000, + frozen=0 + ) + db.session.add(credit_account) + db.session.commit() # 清除验证码 @@ -3244,6 +3264,16 @@ def register_with_email(): user.email_confirmed = True db.session.add(user) + db.session.flush() # 获取 user.id + + # 自动创建积分账户,初始10000积分 + credit_account = UserCreditAccount( + user_id=user.id, + balance=10000, + frozen=0 + ) + db.session.add(credit_account) + db.session.commit() # 清除验证码 @@ -4485,6 +4515,374 @@ class PostLike(db.Model): __table_args__ = (db.UniqueConstraint('user_id', 'post_id'),) +# =========================== +# 预测市场系统模型 +# =========================== + +class UserCreditAccount(db.Model): + """用户积分账户""" + __tablename__ = 'user_credit_account' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, unique=True) + + # 积分余额 + balance = db.Column(db.Float, default=10000.0, nullable=False) # 初始10000积分 + frozen_balance = db.Column(db.Float, default=0.0, nullable=False) # 冻结积分 + total_earned = db.Column(db.Float, default=0.0, nullable=False) # 累计获得 + total_spent = db.Column(db.Float, default=0.0, nullable=False) # 累计消费 + + # 时间 + created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + last_daily_bonus_at = db.Column(db.DateTime) # 最后一次领取每日奖励时间 + + # 关系 + user = db.relationship('User', backref=db.backref('credit_account', uselist=False)) + + def __repr__(self): + return f'' + + +class PredictionTopic(db.Model): + """预测话题""" + __tablename__ = 'prediction_topic' + + id = db.Column(db.Integer, primary_key=True) + creator_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + + # 基本信息 + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text) + category = db.Column(db.String(50), default='stock') # stock/event/market + + # 市场数据 + yes_total_shares = db.Column(db.Integer, default=0, nullable=False) # YES方总份额 + no_total_shares = db.Column(db.Integer, default=0, nullable=False) # NO方总份额 + yes_price = db.Column(db.Float, default=500.0, nullable=False) # YES方价格(0-1000) + no_price = db.Column(db.Float, default=500.0, nullable=False) # NO方价格(0-1000) + + # 奖池 + total_pool = db.Column(db.Float, default=0.0, nullable=False) # 总奖池(2%交易税累积) + + # 领主信息 + yes_lord_id = db.Column(db.Integer, db.ForeignKey('user.id')) # YES方领主 + no_lord_id = db.Column(db.Integer, db.ForeignKey('user.id')) # NO方领主 + + # 状态 + status = db.Column(db.String(20), default='active', nullable=False) # active/settled/cancelled + result = db.Column(db.String(10)) # yes/no/draw(结算结果) + + # 时间 + deadline = db.Column(db.DateTime, nullable=False) # 截止时间 + settled_at = db.Column(db.DateTime) # 结算时间 + created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + # 统计 + views_count = db.Column(db.Integer, default=0) + comments_count = db.Column(db.Integer, default=0) + participants_count = db.Column(db.Integer, default=0) + + # 关系 + creator = db.relationship('User', foreign_keys=[creator_id], backref='created_topics') + yes_lord = db.relationship('User', foreign_keys=[yes_lord_id], backref='yes_lord_topics') + no_lord = db.relationship('User', foreign_keys=[no_lord_id], backref='no_lord_topics') + positions = db.relationship('PredictionPosition', backref='topic', lazy='dynamic') + transactions = db.relationship('PredictionTransaction', backref='topic', lazy='dynamic') + comments = db.relationship('TopicComment', backref='topic', lazy='dynamic') + + def __repr__(self): + return f'' + + +class PredictionPosition(db.Model): + """用户持仓""" + __tablename__ = 'prediction_position' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + topic_id = db.Column(db.Integer, db.ForeignKey('prediction_topic.id'), nullable=False) + + # 持仓信息 + direction = db.Column(db.String(3), nullable=False) # yes/no + shares = db.Column(db.Integer, default=0, nullable=False) # 持有份额 + avg_cost = db.Column(db.Float, default=0.0, nullable=False) # 平均成本 + total_invested = db.Column(db.Float, default=0.0, nullable=False) # 总投入 + + # 时间 + created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + # 关系 + user = db.relationship('User', backref='prediction_positions') + + # 唯一约束:每个用户在每个话题的每个方向只能有一个持仓 + __table_args__ = (db.UniqueConstraint('user_id', 'topic_id', 'direction'),) + + def __repr__(self): + return f'' + + +class PredictionTransaction(db.Model): + """预测交易记录""" + __tablename__ = 'prediction_transaction' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + topic_id = db.Column(db.Integer, db.ForeignKey('prediction_topic.id'), nullable=False) + + # 交易信息 + trade_type = db.Column(db.String(10), nullable=False) # buy/sell + direction = db.Column(db.String(3), nullable=False) # yes/no + shares = db.Column(db.Integer, nullable=False) # 份额数量 + price = db.Column(db.Float, nullable=False) # 成交价格 + + # 费用 + amount = db.Column(db.Float, nullable=False) # 交易金额 + tax = db.Column(db.Float, default=0.0, nullable=False) # 手续费(2%) + total_cost = db.Column(db.Float, nullable=False) # 总成本(amount + tax) + + # 时间 + created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) + + # 关系 + user = db.relationship('User', backref='prediction_transactions') + + def __repr__(self): + return f'' + + +class CreditTransaction(db.Model): + """积分交易记录""" + __tablename__ = 'credit_transaction' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + + # 交易信息 + transaction_type = db.Column(db.String(30), nullable=False) # prediction_buy/prediction_sell/daily_bonus/create_topic/settle_win + amount = db.Column(db.Float, nullable=False) # 金额(正数=增加,负数=减少) + balance_after = db.Column(db.Float, nullable=False) # 交易后余额 + + # 关联 + related_topic_id = db.Column(db.Integer, db.ForeignKey('prediction_topic.id')) # 相关话题 + related_transaction_id = db.Column(db.Integer, db.ForeignKey('prediction_transaction.id')) # 相关预测交易 + + # 描述 + description = db.Column(db.String(200)) # 交易描述 + + # 时间 + created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) + + # 关系 + user = db.relationship('User', backref='credit_transactions') + related_topic = db.relationship('PredictionTopic', backref='credit_transactions') + + def __repr__(self): + return f'' + + +class TopicComment(db.Model): + """话题评论""" + __tablename__ = 'topic_comment' + + id = db.Column(db.Integer, primary_key=True) + topic_id = db.Column(db.Integer, db.ForeignKey('prediction_topic.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + + # 内容 + content = db.Column(db.Text, nullable=False) + parent_id = db.Column(db.Integer, db.ForeignKey('topic_comment.id')) # 父评论ID(回复功能) + + # 状态 + is_pinned = db.Column(db.Boolean, default=False, nullable=False) # 是否置顶(领主特权) + status = db.Column(db.String(20), default='active') # active/hidden/deleted + + # 统计 + likes_count = db.Column(db.Integer, default=0, nullable=False) + + # 观点IPO 相关 + total_investment = db.Column(db.Integer, default=0, nullable=False) # 总投资额 + investor_count = db.Column(db.Integer, default=0, nullable=False) # 投资人数 + is_verified = db.Column(db.Boolean, default=False, nullable=False) # 是否已验证 + verification_result = db.Column(db.String(20)) # 验证结果:correct/incorrect/null + position_rank = db.Column(db.Integer) # 评论位置排名(用于首发权拍卖) + + # 时间 + created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + # 关系 + user = db.relationship('User', backref='topic_comments') + replies = db.relationship('TopicComment', backref=db.backref('parent', remote_side=[id]), lazy='dynamic') + likes = db.relationship('TopicCommentLike', backref='comment', lazy='dynamic') + + def __repr__(self): + return f'' + + +class TopicCommentLike(db.Model): + """话题评论点赞""" + __tablename__ = 'topic_comment_like' + + id = db.Column(db.Integer, primary_key=True) + comment_id = db.Column(db.Integer, db.ForeignKey('topic_comment.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) + + # 关系 + user = db.relationship('User', backref='topic_comment_likes') + + # 唯一约束 + __table_args__ = (db.UniqueConstraint('comment_id', 'user_id'),) + + def __repr__(self): + return f'' + + +class CommentInvestment(db.Model): + """评论投资记录(观点IPO)""" + __tablename__ = 'comment_investment' + + id = db.Column(db.Integer, primary_key=True) + comment_id = db.Column(db.Integer, db.ForeignKey('topic_comment.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + + # 投资数据 + shares = db.Column(db.Integer, nullable=False) # 投资份额 + amount = db.Column(db.Integer, nullable=False) # 投资金额 + avg_price = db.Column(db.Float, nullable=False) # 平均价格 + + # 状态 + status = db.Column(db.String(20), default='active', nullable=False) # active/settled + + # 时间 + created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) + + # 关系 + user = db.relationship('User', backref='comment_investments') + comment = db.relationship('TopicComment', backref='investments') + + def __repr__(self): + return f'' + + +class CommentPositionBid(db.Model): + """评论位置竞拍记录(首发权拍卖)""" + __tablename__ = 'comment_position_bid' + + id = db.Column(db.Integer, primary_key=True) + topic_id = db.Column(db.Integer, db.ForeignKey('prediction_topic.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + + # 竞拍数据 + position = db.Column(db.Integer, nullable=False) # 位置:1/2/3 + bid_amount = db.Column(db.Integer, nullable=False) # 出价金额 + status = db.Column(db.String(20), default='pending', nullable=False) # pending/won/lost + + # 时间 + created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) + expires_at = db.Column(db.DateTime, nullable=False) # 竞拍截止时间 + + # 关系 + user = db.relationship('User', backref='comment_position_bids') + topic = db.relationship('PredictionTopic', backref='position_bids') + + def __repr__(self): + return f'' + + +class TimeCapsuleTopic(db.Model): + """时间胶囊话题(长期预测)""" + __tablename__ = 'time_capsule_topic' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + + # 话题内容 + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text) + encrypted_content = db.Column(db.Text) # 加密的预测内容 + encryption_key = db.Column(db.String(500)) # 加密密钥(后端存储) + + # 时间范围 + start_year = db.Column(db.Integer, nullable=False) # 起始年份 + end_year = db.Column(db.Integer, nullable=False) # 结束年份 + + # 状态 + status = db.Column(db.String(20), default='active', nullable=False) # active/settled + is_decrypted = db.Column(db.Boolean, default=False, nullable=False) # 是否已解密 + actual_happened_year = db.Column(db.Integer) # 实际发生年份 + + # 统计 + total_pool = db.Column(db.Integer, default=0, nullable=False) # 总奖池 + + # 时间 + created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + # 关系 + user = db.relationship('User', backref='time_capsule_topics') + time_slots = db.relationship('TimeCapsuleTimeSlot', backref='topic', lazy='dynamic') + + def __repr__(self): + return f'' + + +class TimeCapsuleTimeSlot(db.Model): + """时间胶囊时间段""" + __tablename__ = 'time_capsule_time_slot' + + id = db.Column(db.Integer, primary_key=True) + topic_id = db.Column(db.Integer, db.ForeignKey('time_capsule_topic.id'), nullable=False) + + # 时间段 + year_start = db.Column(db.Integer, nullable=False) + year_end = db.Column(db.Integer, nullable=False) + + # 竞拍数据 + current_holder_id = db.Column(db.Integer, db.ForeignKey('user.id')) # 当前持有者 + current_price = db.Column(db.Integer, default=100, nullable=False) # 当前价格 + total_bids = db.Column(db.Integer, default=0, nullable=False) # 总竞拍次数 + + # 状态 + status = db.Column(db.String(20), default='active', nullable=False) # active/won/expired + + # 时间 + created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + # 关系 + current_holder = db.relationship('User', foreign_keys=[current_holder_id]) + bids = db.relationship('TimeSlotBid', backref='time_slot', lazy='dynamic') + + def __repr__(self): + return f'' + + +class TimeSlotBid(db.Model): + """时间段竞拍记录""" + __tablename__ = 'time_slot_bid' + + id = db.Column(db.Integer, primary_key=True) + slot_id = db.Column(db.Integer, db.ForeignKey('time_capsule_time_slot.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + + # 竞拍数据 + bid_amount = db.Column(db.Integer, nullable=False) + status = db.Column(db.String(20), default='outbid', nullable=False) # outbid/holding/won + + # 时间 + created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) + + # 关系 + user = db.relationship('User', backref='time_slot_bids') + + def __repr__(self): + return f'' + + class Event(db.Model): """事件模型""" id = db.Column(db.Integer, primary_key=True) @@ -11679,6 +12077,119 @@ def get_value_chain_analysis(company_code): }), 500 +@app.route('/api/company/value-chain/related-companies', methods=['GET']) +def get_related_companies_by_node(): + """ + 根据产业链节点名称查询相关公司(结合nodes和flows表) + 参数: node_name - 节点名称(如 "中芯国际"、"EDA/IP"等) + 返回: 包含该节点的所有公司列表,附带节点层级、类型、关系描述等信息 + """ + try: + node_name = request.args.get('node_name') + + if not node_name: + return jsonify({ + 'success': False, + 'error': '缺少必需参数 node_name' + }), 400 + + # 查询包含该节点的所有公司及其节点信息 + query = text(""" + SELECT DISTINCT + n.company_code as stock_code, + s.SECNAME as stock_name, + s.ORGNAME as company_name, + n.node_level, + n.node_type, + n.node_description, + n.importance_score, + n.market_share, + n.dependency_degree + FROM company_value_chain_nodes n + LEFT JOIN ea_stocklist s ON n.company_code = s.SECCODE + WHERE n.node_name = :node_name + ORDER BY n.importance_score DESC, n.company_code + """) + + with engine.connect() as conn: + nodes_result = conn.execute(query, {'node_name': node_name}).fetchall() + + # 构建返回数据 + companies = [] + for row in nodes_result: + company_data = { + 'stock_code': row.stock_code, + 'stock_name': row.stock_name or row.stock_code, + 'company_name': row.company_name, + 'node_info': { + 'node_level': row.node_level, + 'node_type': row.node_type, + 'node_description': row.node_description, + 'importance_score': row.importance_score, + 'market_share': format_decimal(row.market_share), + 'dependency_degree': format_decimal(row.dependency_degree) + }, + 'relationships': [] + } + + # 查询该节点在该公司产业链中的流向关系 + flows_query = text(""" + SELECT + source_node, + source_type, + source_level, + target_node, + target_type, + target_level, + flow_type, + relationship_desc, + flow_value, + flow_ratio + FROM company_value_chain_flows + WHERE company_code = :company_code + AND (source_node = :node_name OR target_node = :node_name) + ORDER BY flow_ratio DESC + LIMIT 5 + """) + + with engine.connect() as conn: + flows_result = conn.execute(flows_query, { + 'company_code': row.stock_code, + 'node_name': node_name + }).fetchall() + + # 添加流向关系信息 + for flow in flows_result: + # 判断节点在流向中的角色 + is_source = (flow.source_node == node_name) + + relationship = { + 'role': 'source' if is_source else 'target', + 'connected_node': flow.target_node if is_source else flow.source_node, + 'connected_type': flow.target_type if is_source else flow.source_type, + 'connected_level': flow.target_level if is_source else flow.source_level, + 'flow_type': flow.flow_type, + 'relationship_desc': flow.relationship_desc, + 'flow_ratio': format_decimal(flow.flow_ratio) + } + company_data['relationships'].append(relationship) + + companies.append(company_data) + + return jsonify({ + 'success': True, + 'data': companies, + 'total': len(companies), + 'node_name': node_name + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + @app.route('/api/company/key-factors-timeline/', methods=['GET']) def get_key_factors_timeline(company_code): """获取公司关键因素和时间线数据""" @@ -12886,6 +13397,1592 @@ def reset_simulation_account(): return jsonify({'success': False, 'error': str(e)}), 500 +# =========================== +# 预测市场 API 路由 +# 请将此文件内容插入到 app.py 的 `if __name__ == '__main__':` 之前 +# =========================== + +# --- 积分系统 API --- + +@app.route('/api/prediction/credit/account', methods=['GET']) +@login_required +def get_credit_account(): + """获取用户积分账户""" + try: + account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() + + # 如果账户不存在,自动创建 + if not account: + account = UserCreditAccount(user_id=current_user.id) + db.session.add(account) + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'balance': float(account.balance), + 'frozen_balance': float(account.frozen_balance), + 'available_balance': float(account.balance - account.frozen_balance), + 'total_earned': float(account.total_earned), + 'total_spent': float(account.total_spent), + 'last_daily_bonus_at': account.last_daily_bonus_at.isoformat() if account.last_daily_bonus_at else None + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/prediction/credit/daily-bonus', methods=['POST']) +@login_required +def claim_daily_bonus(): + """领取每日奖励(100积分)""" + try: + account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() + + if not account: + account = UserCreditAccount(user_id=current_user.id) + db.session.add(account) + + # 检查是否已领取今日奖励 + today = beijing_now().date() + if account.last_daily_bonus_at and account.last_daily_bonus_at.date() == today: + return jsonify({ + 'success': False, + 'error': '今日奖励已领取' + }), 400 + + # 发放奖励 + bonus_amount = 100.0 + account.balance += bonus_amount + account.total_earned += bonus_amount + account.last_daily_bonus_at = beijing_now() + + # 记录交易 + transaction = CreditTransaction( + user_id=current_user.id, + transaction_type='daily_bonus', + amount=bonus_amount, + balance_after=account.balance, + description='每日登录奖励' + ) + db.session.add(transaction) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': f'领取成功,获得 {bonus_amount} 积分', + 'data': { + 'bonus_amount': bonus_amount, + 'new_balance': float(account.balance) + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +# --- 预测话题 API --- + +@app.route('/api/prediction/topics', methods=['POST']) +@login_required +def create_prediction_topic(): + """创建预测话题(消耗100积分)""" + try: + data = request.get_json() + title = data.get('title', '').strip() + description = data.get('description', '').strip() + category = data.get('category', 'stock') + deadline_str = data.get('deadline') + + # 验证参数 + if not title or len(title) < 5: + return jsonify({'success': False, 'error': '标题至少5个字符'}), 400 + + if not deadline_str: + return jsonify({'success': False, 'error': '请设置截止时间'}), 400 + + # 解析截止时间(移除时区信息以匹配数据库格式) + deadline = datetime.fromisoformat(deadline_str.replace('Z', '+00:00')) + # 移除时区信息,转换为naive datetime + if deadline.tzinfo is not None: + deadline = deadline.replace(tzinfo=None) + + if deadline <= beijing_now(): + return jsonify({'success': False, 'error': '截止时间必须在未来'}), 400 + + # 检查积分账户 + account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() + if not account or account.balance < 100: + return jsonify({'success': False, 'error': '积分不足(需要100积分)'}), 400 + + # 扣除创建费用 + create_cost = 100.0 + account.balance -= create_cost + account.total_spent += create_cost + + # 创建话题 + topic = PredictionTopic( + creator_id=current_user.id, + title=title, + description=description, + category=category, + deadline=deadline + ) + db.session.add(topic) + + # 记录积分交易 + transaction = CreditTransaction( + user_id=current_user.id, + transaction_type='create_topic', + amount=-create_cost, + balance_after=account.balance, + description=f'创建预测话题:{title}' + ) + db.session.add(transaction) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '话题创建成功', + 'data': { + 'topic_id': topic.id, + 'title': topic.title, + 'new_balance': float(account.balance) + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/prediction/topics', methods=['GET']) +def get_prediction_topics(): + """获取预测话题列表""" + try: + status = request.args.get('status', 'active') + category = request.args.get('category') + sort_by = request.args.get('sort_by', 'created_at') + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + + # 构建查询 + query = PredictionTopic.query + + if status: + query = query.filter_by(status=status) + if category: + query = query.filter_by(category=category) + + # 排序 + if sort_by == 'hot': + query = query.order_by(desc(PredictionTopic.views_count)) + elif sort_by == 'participants': + query = query.order_by(desc(PredictionTopic.participants_count)) + else: + query = query.order_by(desc(PredictionTopic.created_at)) + + # 分页 + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + topics = pagination.items + + # 格式化返回数据 + topics_data = [] + for topic in topics: + # 计算市场倾向 + total_shares = topic.yes_total_shares + topic.no_total_shares + yes_prob = (topic.yes_total_shares / total_shares * 100) if total_shares > 0 else 50.0 + + # 处理datetime,确保移除时区信息 + deadline = topic.deadline + if hasattr(deadline, 'replace') and deadline.tzinfo is not None: + deadline = deadline.replace(tzinfo=None) + + created_at = topic.created_at + if hasattr(created_at, 'replace') and created_at.tzinfo is not None: + created_at = created_at.replace(tzinfo=None) + + topics_data.append({ + 'id': topic.id, + 'title': topic.title, + 'description': topic.description, + 'category': topic.category, + 'status': topic.status, + 'yes_price': float(topic.yes_price), + 'no_price': float(topic.no_price), + 'yes_probability': round(yes_prob, 1), + 'total_pool': float(topic.total_pool), + 'yes_lord': { + 'id': topic.yes_lord.id, + 'username': topic.yes_lord.username, + 'nickname': topic.yes_lord.nickname or topic.yes_lord.username, + 'avatar_url': topic.yes_lord.avatar_url + } if topic.yes_lord else None, + 'no_lord': { + 'id': topic.no_lord.id, + 'username': topic.no_lord.username, + 'nickname': topic.no_lord.nickname or topic.no_lord.username, + 'avatar_url': topic.no_lord.avatar_url + } if topic.no_lord else None, + 'deadline': deadline.isoformat() if deadline else None, + 'created_at': created_at.isoformat() if created_at else None, + 'views_count': topic.views_count, + 'comments_count': topic.comments_count, + 'participants_count': topic.participants_count, + 'creator': { + 'id': topic.creator.id, + 'username': topic.creator.username, + 'nickname': topic.creator.nickname or topic.creator.username + } + }) + + return jsonify({ + 'success': True, + 'data': topics_data, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': pagination.total, + 'pages': pagination.pages, + 'has_next': pagination.has_next, + 'has_prev': pagination.has_prev + } + }) + + except Exception as e: + import traceback + print(f"[ERROR] 获取话题列表失败: {str(e)}") + print(traceback.format_exc()) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/prediction/topics/', methods=['GET']) +def get_prediction_topic_detail(topic_id): + """获取预测话题详情""" + try: + # 刷新会话,确保获取最新数据 + db.session.expire_all() + + topic = PredictionTopic.query.get_or_404(topic_id) + + # 增加浏览量 + topic.views_count += 1 + db.session.commit() + + # 计算市场倾向 + total_shares = topic.yes_total_shares + topic.no_total_shares + yes_prob = (topic.yes_total_shares / total_shares * 100) if total_shares > 0 else 50.0 + + # 获取 TOP 5 持仓(YES 和 NO 各5个) + yes_top_positions = PredictionPosition.query.filter_by( + topic_id=topic_id, + direction='yes' + ).order_by(desc(PredictionPosition.shares)).limit(5).all() + + no_top_positions = PredictionPosition.query.filter_by( + topic_id=topic_id, + direction='no' + ).order_by(desc(PredictionPosition.shares)).limit(5).all() + + def format_position(position): + return { + 'user': { + 'id': position.user.id, + 'username': position.user.username, + 'nickname': position.user.nickname or position.user.username, + 'avatar_url': position.user.avatar_url + }, + 'shares': position.shares, + 'avg_cost': float(position.avg_cost), + 'total_invested': float(position.total_invested), + 'is_lord': (topic.yes_lord_id == position.user_id and position.direction == 'yes') or + (topic.no_lord_id == position.user_id and position.direction == 'no') + } + + return jsonify({ + 'success': True, + 'data': { + 'id': topic.id, + 'title': topic.title, + 'description': topic.description, + 'category': topic.category, + 'status': topic.status, + 'result': topic.result, + 'yes_price': float(topic.yes_price), + 'no_price': float(topic.no_price), + 'yes_total_shares': topic.yes_total_shares, + 'no_total_shares': topic.no_total_shares, + 'yes_probability': round(yes_prob, 1), + 'no_probability': round(100 - yes_prob, 1), + 'total_pool': float(topic.total_pool), + 'yes_lord': { + 'id': topic.yes_lord.id, + 'username': topic.yes_lord.username, + 'nickname': topic.yes_lord.nickname or topic.yes_lord.username, + 'avatar_url': topic.yes_lord.avatar_url + } if topic.yes_lord else None, + 'no_lord': { + 'id': topic.no_lord.id, + 'username': topic.no_lord.username, + 'nickname': topic.no_lord.nickname or topic.no_lord.username, + 'avatar_url': topic.no_lord.avatar_url + } if topic.no_lord else None, + 'yes_top_positions': [format_position(p) for p in yes_top_positions], + 'no_top_positions': [format_position(p) for p in no_top_positions], + 'deadline': topic.deadline.isoformat(), + 'settled_at': topic.settled_at.isoformat() if topic.settled_at else None, + 'created_at': topic.created_at.isoformat(), + 'views_count': topic.views_count, + 'comments_count': topic.comments_count, + 'participants_count': topic.participants_count, + 'creator': { + 'id': topic.creator.id, + 'username': topic.creator.username, + 'nickname': topic.creator.nickname or topic.creator.username, + 'avatar_url': topic.creator.avatar_url + } + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/prediction/topics//settle', methods=['POST']) +@login_required +def settle_prediction_topic(topic_id): + """结算预测话题(仅创建者可操作)""" + try: + topic = PredictionTopic.query.get_or_404(topic_id) + + # 验证权限 + if topic.creator_id != current_user.id: + return jsonify({'success': False, 'error': '只有创建者可以结算'}), 403 + + # 验证状态 + if topic.status != 'active': + return jsonify({'success': False, 'error': '话题已结算或已取消'}), 400 + + # 验证截止时间 + if beijing_now() < topic.deadline: + return jsonify({'success': False, 'error': '未到截止时间'}), 400 + + # 获取结算结果 + data = request.get_json() + result = data.get('result') # 'yes', 'no', 'draw' + + if result not in ['yes', 'no', 'draw']: + return jsonify({'success': False, 'error': '无效的结算结果'}), 400 + + # 更新话题状态 + topic.status = 'settled' + topic.result = result + topic.settled_at = beijing_now() + + # 获取获胜方的所有持仓 + if result == 'draw': + # 平局:所有人按投入比例分配奖池 + all_positions = PredictionPosition.query.filter_by(topic_id=topic_id).all() + total_invested = sum(p.total_invested for p in all_positions) + + for position in all_positions: + if total_invested > 0: + share_ratio = position.total_invested / total_invested + prize = topic.total_pool * share_ratio + + # 发放奖金 + account = UserCreditAccount.query.filter_by(user_id=position.user_id).first() + if account: + account.balance += prize + account.total_earned += prize + + # 记录交易 + transaction = CreditTransaction( + user_id=position.user_id, + transaction_type='settle_win', + amount=prize, + balance_after=account.balance, + related_topic_id=topic_id, + description=f'预测平局,获得奖池分红:{topic.title}' + ) + db.session.add(transaction) + + else: + # YES 或 NO 获胜 + winning_direction = result + winning_positions = PredictionPosition.query.filter_by( + topic_id=topic_id, + direction=winning_direction + ).all() + + if winning_positions: + total_winning_shares = sum(p.shares for p in winning_positions) + + for position in winning_positions: + # 按份额比例分配奖池 + share_ratio = position.shares / total_winning_shares + prize = topic.total_pool * share_ratio + + # 发放奖金 + account = UserCreditAccount.query.filter_by(user_id=position.user_id).first() + if account: + account.balance += prize + account.total_earned += prize + + # 记录交易 + transaction = CreditTransaction( + user_id=position.user_id, + transaction_type='settle_win', + amount=prize, + balance_after=account.balance, + related_topic_id=topic_id, + description=f'预测正确,获得奖金:{topic.title}' + ) + db.session.add(transaction) + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': f'话题已结算,结果为:{result}', + 'data': { + 'topic_id': topic.id, + 'result': result, + 'total_pool': float(topic.total_pool), + 'settled_at': topic.settled_at.isoformat() + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +# --- 交易 API --- + +@app.route('/api/prediction/trade/buy', methods=['POST']) +@login_required +def buy_prediction_shares(): + """买入预测份额""" + try: + data = request.get_json() + topic_id = data.get('topic_id') + direction = data.get('direction') # 'yes' or 'no' + shares = data.get('shares', 0) + + # 验证参数 + if not topic_id or direction not in ['yes', 'no'] or shares <= 0: + return jsonify({'success': False, 'error': '参数错误'}), 400 + + if shares > 1000: + return jsonify({'success': False, 'error': '单次最多买入1000份额'}), 400 + + # 获取话题 + topic = PredictionTopic.query.get_or_404(topic_id) + + if topic.status != 'active': + return jsonify({'success': False, 'error': '话题已结算或已取消'}), 400 + + if beijing_now() >= topic.deadline: + return jsonify({'success': False, 'error': '话题已截止'}), 400 + + # 获取积分账户 + account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() + if not account: + account = UserCreditAccount(user_id=current_user.id) + db.session.add(account) + db.session.flush() + + # 计算价格 + current_price = topic.yes_price if direction == 'yes' else topic.no_price + + # 简化的AMM定价:price = (对应方份额 / 总份额) * 1000 + total_shares = topic.yes_total_shares + topic.no_total_shares + if total_shares > 0: + if direction == 'yes': + current_price = (topic.yes_total_shares / total_shares) * 1000 + else: + current_price = (topic.no_total_shares / total_shares) * 1000 + else: + current_price = 500.0 # 初始价格 + + # 买入后价格会上涨,使用平均价格 + after_total = total_shares + shares + if direction == 'yes': + after_yes_shares = topic.yes_total_shares + shares + after_price = (after_yes_shares / after_total) * 1000 + else: + after_no_shares = topic.no_total_shares + shares + after_price = (after_no_shares / after_total) * 1000 + + avg_price = (current_price + after_price) / 2 + + # 计算费用 + amount = avg_price * shares + tax = amount * 0.02 # 2% 手续费 + total_cost = amount + tax + + # 检查余额 + if account.balance < total_cost: + return jsonify({'success': False, 'error': '积分不足'}), 400 + + # 扣除费用 + account.balance -= total_cost + account.total_spent += total_cost + + # 更新话题数据 + if direction == 'yes': + topic.yes_total_shares += shares + topic.yes_price = after_price + else: + topic.no_total_shares += shares + topic.no_price = after_price + + topic.total_pool += tax # 手续费进入奖池 + + # 更新或创建持仓 + position = PredictionPosition.query.filter_by( + user_id=current_user.id, + topic_id=topic_id, + direction=direction + ).first() + + if position: + # 更新平均成本 + old_cost = position.avg_cost * position.shares + new_cost = avg_price * shares + position.shares += shares + position.avg_cost = (old_cost + new_cost) / position.shares + position.total_invested += total_cost + else: + position = PredictionPosition( + user_id=current_user.id, + topic_id=topic_id, + direction=direction, + shares=shares, + avg_cost=avg_price, + total_invested=total_cost + ) + db.session.add(position) + topic.participants_count += 1 + + # 更新领主 + if direction == 'yes': + # 找到YES方持仓最多的用户 + top_yes = db.session.query(PredictionPosition).filter_by( + topic_id=topic_id, + direction='yes' + ).order_by(desc(PredictionPosition.shares)).first() + if top_yes: + topic.yes_lord_id = top_yes.user_id + else: + # 找到NO方持仓最多的用户 + top_no = db.session.query(PredictionPosition).filter_by( + topic_id=topic_id, + direction='no' + ).order_by(desc(PredictionPosition.shares)).first() + if top_no: + topic.no_lord_id = top_no.user_id + + # 记录交易 + transaction = PredictionTransaction( + user_id=current_user.id, + topic_id=topic_id, + trade_type='buy', + direction=direction, + shares=shares, + price=avg_price, + amount=amount, + tax=tax, + total_cost=total_cost + ) + db.session.add(transaction) + + # 记录积分交易 + credit_transaction = CreditTransaction( + user_id=current_user.id, + transaction_type='prediction_buy', + amount=-total_cost, + balance_after=account.balance, + related_topic_id=topic_id, + related_transaction_id=transaction.id, + description=f'买入 {direction.upper()} 份额:{topic.title}' + ) + db.session.add(credit_transaction) + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '买入成功', + 'data': { + 'transaction_id': transaction.id, + 'shares': shares, + 'price': round(avg_price, 2), + 'total_cost': round(total_cost, 2), + 'tax': round(tax, 2), + 'new_balance': float(account.balance), + 'new_position': { + 'shares': position.shares, + 'avg_cost': float(position.avg_cost) + } + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/prediction/positions', methods=['GET']) +@login_required +def get_user_positions(): + """获取用户的所有持仓""" + try: + positions = PredictionPosition.query.filter_by(user_id=current_user.id).all() + + positions_data = [] + for position in positions: + topic = position.topic + + # 计算当前市值(如果话题还在进行中) + current_value = 0 + profit = 0 + profit_rate = 0 + + if topic.status == 'active': + current_price = topic.yes_price if position.direction == 'yes' else topic.no_price + current_value = current_price * position.shares + profit = current_value - position.total_invested + profit_rate = (profit / position.total_invested * 100) if position.total_invested > 0 else 0 + + positions_data.append({ + 'id': position.id, + 'topic': { + 'id': topic.id, + 'title': topic.title, + 'status': topic.status, + 'result': topic.result, + 'deadline': topic.deadline.isoformat() + }, + 'direction': position.direction, + 'shares': position.shares, + 'avg_cost': float(position.avg_cost), + 'total_invested': float(position.total_invested), + 'current_value': round(current_value, 2), + 'profit': round(profit, 2), + 'profit_rate': round(profit_rate, 2), + 'created_at': position.created_at.isoformat(), + 'is_lord': (topic.yes_lord_id == current_user.id and position.direction == 'yes') or + (topic.no_lord_id == current_user.id and position.direction == 'no') + }) + + return jsonify({ + 'success': True, + 'data': positions_data, + 'count': len(positions_data) + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +# --- 评论 API --- + +@app.route('/api/prediction/topics//comments', methods=['POST']) +@login_required +def create_topic_comment(topic_id): + """发表话题评论""" + try: + topic = PredictionTopic.query.get_or_404(topic_id) + + data = request.get_json() + content = data.get('content', '').strip() + parent_id = data.get('parent_id') + + if not content or len(content) < 2: + return jsonify({'success': False, 'error': '评论内容至少2个字符'}), 400 + + # 创建评论 + comment = TopicComment( + topic_id=topic_id, + user_id=current_user.id, + content=content, + parent_id=parent_id + ) + + # 如果是领主评论,自动置顶 + is_lord = (topic.yes_lord_id == current_user.id) or (topic.no_lord_id == current_user.id) + if is_lord: + comment.is_pinned = True + + db.session.add(comment) + + # 更新话题评论数 + topic.comments_count += 1 + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '评论成功', + 'data': { + 'comment_id': comment.id, + 'content': comment.content, + 'is_pinned': comment.is_pinned, + 'created_at': comment.created_at.isoformat() + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/prediction/topics//comments', methods=['GET']) +def get_topic_comments(topic_id): + """获取话题评论列表""" + try: + topic = PredictionTopic.query.get_or_404(topic_id) + + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + + # 置顶评论在前,然后按时间倒序 + query = TopicComment.query.filter_by( + topic_id=topic_id, + status='active', + parent_id=None # 只获取顶级评论 + ).order_by( + desc(TopicComment.is_pinned), + desc(TopicComment.created_at) + ) + + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + comments = pagination.items + + def format_comment(comment): + # 获取回复 + replies = TopicComment.query.filter_by( + parent_id=comment.id, + status='active' + ).order_by(TopicComment.created_at).limit(5).all() + + return { + 'id': comment.id, + 'content': comment.content, + 'is_pinned': comment.is_pinned, + 'likes_count': comment.likes_count, + 'created_at': comment.created_at.isoformat(), + 'user': { + 'id': comment.user.id, + 'username': comment.user.username, + 'nickname': comment.user.nickname or comment.user.username, + 'avatar_url': comment.user.avatar_url + }, + 'is_lord': (topic.yes_lord_id == comment.user_id) or (topic.no_lord_id == comment.user_id), + 'replies': [{ + 'id': reply.id, + 'content': reply.content, + 'created_at': reply.created_at.isoformat(), + 'user': { + 'id': reply.user.id, + 'username': reply.user.username, + 'nickname': reply.user.nickname or reply.user.username, + 'avatar_url': reply.user.avatar_url + } + } for reply in replies] + } + + comments_data = [format_comment(comment) for comment in comments] + + return jsonify({ + 'success': True, + 'data': comments_data, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': pagination.total, + 'pages': pagination.pages + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/prediction/comments//like', methods=['POST']) +@login_required +def like_topic_comment(comment_id): + """点赞/取消点赞评论""" + try: + comment = TopicComment.query.get_or_404(comment_id) + + # 检查是否已点赞 + existing_like = TopicCommentLike.query.filter_by( + comment_id=comment_id, + user_id=current_user.id + ).first() + + if existing_like: + # 取消点赞 + db.session.delete(existing_like) + comment.likes_count = max(0, comment.likes_count - 1) + action = 'unliked' + else: + # 点赞 + like = TopicCommentLike( + comment_id=comment_id, + user_id=current_user.id + ) + db.session.add(like) + comment.likes_count += 1 + action = 'liked' + + db.session.commit() + + return jsonify({ + 'success': True, + 'action': action, + 'likes_count': comment.likes_count + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +# ==================== 观点IPO API ==================== + +@app.route('/api/prediction/comments//invest', methods=['POST']) +@login_required +def invest_comment(comment_id): + """投资评论(观点IPO)""" + try: + data = request.json + shares = data.get('shares', 1) + + # 获取评论 + comment = TopicComment.query.get_or_404(comment_id) + + # 检查评论是否已结算 + if comment.is_verified: + return jsonify({'success': False, 'error': '该评论已结算,无法继续投资'}), 400 + + # 检查是否是自己的评论 + if comment.user_id == current_user.id: + return jsonify({'success': False, 'error': '不能投资自己的评论'}), 400 + + # 计算投资金额(简化:每份100积分基础价格 + 已有投资额/10) + base_price = 100 + price_increase = comment.total_investment / 10 if comment.total_investment > 0 else 0 + price_per_share = base_price + price_increase + amount = int(price_per_share * shares) + + # 获取用户积分账户 + account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() + if not account: + return jsonify({'success': False, 'error': '账户不存在'}), 404 + + # 检查余额 + if account.balance < amount: + return jsonify({'success': False, 'error': '积分不足'}), 400 + + # 扣减积分 + account.balance -= amount + + # 检查是否已有投资记录 + existing_investment = CommentInvestment.query.filter_by( + comment_id=comment_id, + user_id=current_user.id, + status='active' + ).first() + + if existing_investment: + # 更新投资记录 + total_shares = existing_investment.shares + shares + total_amount = existing_investment.amount + amount + existing_investment.shares = total_shares + existing_investment.amount = total_amount + existing_investment.avg_price = total_amount / total_shares + else: + # 创建新投资记录 + investment = CommentInvestment( + comment_id=comment_id, + user_id=current_user.id, + shares=shares, + amount=amount, + avg_price=price_per_share + ) + db.session.add(investment) + comment.investor_count += 1 + + # 更新评论统计 + comment.total_investment += amount + + # 记录积分交易 + transaction = CreditTransaction( + user_id=current_user.id, + type='comment_investment', + amount=-amount, + balance_after=account.balance, + description=f'投资评论 #{comment_id}' + ) + db.session.add(transaction) + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'shares': shares, + 'amount': amount, + 'price_per_share': price_per_share, + 'total_investment': comment.total_investment, + 'investor_count': comment.investor_count, + 'new_balance': account.balance + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/prediction/comments//investments', methods=['GET']) +def get_comment_investments(comment_id): + """获取评论的投资列表""" + try: + investments = CommentInvestment.query.filter_by( + comment_id=comment_id, + status='active' + ).all() + + result = [] + for inv in investments: + user = User.query.get(inv.user_id) + result.append({ + 'id': inv.id, + 'user_id': inv.user_id, + 'user_name': user.username if user else '未知用户', + 'user_avatar': user.avatar if user else None, + 'shares': inv.shares, + 'amount': inv.amount, + 'avg_price': inv.avg_price, + 'created_at': inv.created_at.strftime('%Y-%m-%d %H:%M:%S') + }) + + return jsonify({ + 'success': True, + 'data': result + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/prediction/comments//verify', methods=['POST']) +@login_required +def verify_comment(comment_id): + """管理员验证评论预测结果""" + try: + # 检查管理员权限(简化版:假设 user_id=1 是管理员) + if current_user.id != 1: + return jsonify({'success': False, 'error': '无权限操作'}), 403 + + data = request.json + result = data.get('result') # 'correct' or 'incorrect' + + if result not in ['correct', 'incorrect']: + return jsonify({'success': False, 'error': '无效的验证结果'}), 400 + + comment = TopicComment.query.get_or_404(comment_id) + + # 检查是否已验证 + if comment.is_verified: + return jsonify({'success': False, 'error': '该评论已验证'}), 400 + + # 更新验证状态 + comment.is_verified = True + comment.verification_result = result + + # 如果预测正确,进行收益分配 + if result == 'correct' and comment.total_investment > 0: + # 获取所有投资记录 + investments = CommentInvestment.query.filter_by( + comment_id=comment_id, + status='active' + ).all() + + # 计算总收益(总投资额的1.5倍) + total_reward = int(comment.total_investment * 1.5) + + # 按份额比例分配收益 + total_shares = sum([inv.shares for inv in investments]) + + for inv in investments: + # 计算该投资者的收益 + investor_reward = int((inv.shares / total_shares) * total_reward) + + # 获取投资者账户 + account = UserCreditAccount.query.filter_by(user_id=inv.user_id).first() + if account: + account.balance += investor_reward + + # 记录积分交易 + transaction = CreditTransaction( + user_id=inv.user_id, + type='comment_investment_profit', + amount=investor_reward, + balance_after=account.balance, + description=f'评论投资收益 #{comment_id}' + ) + db.session.add(transaction) + + # 更新投资状态 + inv.status = 'settled' + + # 评论作者也获得奖励(总投资额的20%) + author_reward = int(comment.total_investment * 0.2) + author_account = UserCreditAccount.query.filter_by(user_id=comment.user_id).first() + if author_account: + author_account.balance += author_reward + + transaction = CreditTransaction( + user_id=comment.user_id, + type='comment_author_bonus', + amount=author_reward, + balance_after=author_account.balance, + description=f'评论作者奖励 #{comment_id}' + ) + db.session.add(transaction) + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'comment_id': comment_id, + 'verification_result': result, + 'total_investment': comment.total_investment + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/prediction/topics//bid-position', methods=['POST']) +@login_required +def bid_comment_position(topic_id): + """竞拍评论位置(首发权拍卖)""" + try: + data = request.json + position = data.get('position') # 1/2/3 + bid_amount = data.get('bid_amount') + + if position not in [1, 2, 3]: + return jsonify({'success': False, 'error': '无效的位置'}), 400 + + if bid_amount < 500: + return jsonify({'success': False, 'error': '最低出价500积分'}), 400 + + # 获取用户积分账户 + account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() + if not account or account.balance < bid_amount: + return jsonify({'success': False, 'error': '积分不足'}), 400 + + # 检查该位置的当前最高出价 + current_highest = CommentPositionBid.query.filter_by( + topic_id=topic_id, + position=position, + status='pending' + ).order_by(CommentPositionBid.bid_amount.desc()).first() + + if current_highest and bid_amount <= current_highest.bid_amount: + return jsonify({ + 'success': False, + 'error': f'出价必须高于当前最高价 {current_highest.bid_amount}' + }), 400 + + # 扣减积分(冻结) + account.balance -= bid_amount + account.frozen += bid_amount + + # 如果有之前的出价,退还积分 + user_previous_bid = CommentPositionBid.query.filter_by( + topic_id=topic_id, + position=position, + user_id=current_user.id, + status='pending' + ).first() + + if user_previous_bid: + account.frozen -= user_previous_bid.bid_amount + account.balance += user_previous_bid.bid_amount + user_previous_bid.status = 'lost' + + # 创建竞拍记录 + topic = PredictionTopic.query.get_or_404(topic_id) + bid = CommentPositionBid( + topic_id=topic_id, + user_id=current_user.id, + position=position, + bid_amount=bid_amount, + expires_at=topic.deadline # 竞拍截止时间与话题截止时间相同 + ) + db.session.add(bid) + + # 记录积分交易 + transaction = CreditTransaction( + user_id=current_user.id, + type='position_bid', + amount=-bid_amount, + balance_after=account.balance, + description=f'竞拍评论位置 #{position} (话题#{topic_id})' + ) + db.session.add(transaction) + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'bid_id': bid.id, + 'position': position, + 'bid_amount': bid_amount, + 'new_balance': account.balance, + 'frozen': account.frozen + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/prediction/topics//position-bids', methods=['GET']) +def get_position_bids(topic_id): + """获取话题的位置竞拍列表""" + try: + result = {} + + for position in [1, 2, 3]: + bids = CommentPositionBid.query.filter_by( + topic_id=topic_id, + position=position, + status='pending' + ).order_by(CommentPositionBid.bid_amount.desc()).limit(5).all() + + position_bids = [] + for bid in bids: + user = User.query.get(bid.user_id) + position_bids.append({ + 'id': bid.id, + 'user_id': bid.user_id, + 'user_name': user.username if user else '未知用户', + 'user_avatar': user.avatar if user else None, + 'bid_amount': bid.bid_amount, + 'created_at': bid.created_at.strftime('%Y-%m-%d %H:%M:%S') + }) + + result[f'position_{position}'] = position_bids + + return jsonify({ + 'success': True, + 'data': result + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +# ==================== 时间胶囊 API ==================== + +@app.route('/api/time-capsule/topics', methods=['POST']) +@login_required +def create_time_capsule_topic(): + """创建时间胶囊话题""" + try: + data = request.json + title = data.get('title') + description = data.get('description', '') + encrypted_content = data.get('encrypted_content') + encryption_key = data.get('encryption_key') + start_year = data.get('start_year') + end_year = data.get('end_year') + + # 验证 + if not title or not encrypted_content or not encryption_key: + return jsonify({'success': False, 'error': '缺少必要参数'}), 400 + + if not start_year or not end_year or end_year <= start_year: + return jsonify({'success': False, 'error': '无效的时间范围'}), 400 + + # 获取用户积分账户 + account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() + if not account or account.balance < 100: + return jsonify({'success': False, 'error': '积分不足,需要100积分'}), 400 + + # 扣减积分 + account.balance -= 100 + + # 创建话题 + topic = TimeCapsuleTopic( + user_id=current_user.id, + title=title, + description=description, + encrypted_content=encrypted_content, + encryption_key=encryption_key, + start_year=start_year, + end_year=end_year, + total_pool=100 # 创建费用进入奖池 + ) + db.session.add(topic) + db.session.flush() # 获取 topic.id + + # 自动创建时间段(每年一个时间段) + for year in range(start_year, end_year + 1): + slot = TimeCapsuleTimeSlot( + topic_id=topic.id, + year_start=year, + year_end=year + ) + db.session.add(slot) + + # 记录积分交易 + transaction = CreditTransaction( + user_id=current_user.id, + type='time_capsule_create', + amount=-100, + balance_after=account.balance, + description=f'创建时间胶囊话题 #{topic.id}' + ) + db.session.add(transaction) + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'topic_id': topic.id, + 'title': topic.title, + 'new_balance': account.balance + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/time-capsule/topics', methods=['GET']) +def get_time_capsule_topics(): + """获取时间胶囊话题列表""" + try: + status = request.args.get('status', 'active') + + query = TimeCapsuleTopic.query.filter_by(status=status) + topics = query.order_by(TimeCapsuleTopic.created_at.desc()).all() + + result = [] + for topic in topics: + # 获取用户信息 + user = User.query.get(topic.user_id) + + # 获取时间段统计 + slots = TimeCapsuleTimeSlot.query.filter_by(topic_id=topic.id).all() + total_slots = len(slots) + active_slots = len([s for s in slots if s.status == 'active']) + + result.append({ + 'id': topic.id, + 'title': topic.title, + 'description': topic.description, + 'start_year': topic.start_year, + 'end_year': topic.end_year, + 'total_pool': topic.total_pool, + 'total_slots': total_slots, + 'active_slots': active_slots, + 'is_decrypted': topic.is_decrypted, + 'status': topic.status, + 'author_id': topic.user_id, + 'author_name': user.username if user else '未知用户', + 'author_avatar': user.avatar if user else None, + 'created_at': topic.created_at.strftime('%Y-%m-%d %H:%M:%S') + }) + + return jsonify({ + 'success': True, + 'data': result + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/time-capsule/topics/', methods=['GET']) +def get_time_capsule_topic(topic_id): + """获取时间胶囊话题详情""" + try: + topic = TimeCapsuleTopic.query.get_or_404(topic_id) + user = User.query.get(topic.user_id) + + # 获取所有时间段 + slots = TimeCapsuleTimeSlot.query.filter_by(topic_id=topic_id).order_by(TimeCapsuleTimeSlot.year_start).all() + + slots_data = [] + for slot in slots: + holder = User.query.get(slot.current_holder_id) if slot.current_holder_id else None + + slots_data.append({ + 'id': slot.id, + 'year_start': slot.year_start, + 'year_end': slot.year_end, + 'current_price': slot.current_price, + 'total_bids': slot.total_bids, + 'status': slot.status, + 'current_holder_id': slot.current_holder_id, + 'current_holder_name': holder.username if holder else None, + 'current_holder_avatar': holder.avatar if holder else None + }) + + result = { + 'id': topic.id, + 'title': topic.title, + 'description': topic.description, + 'start_year': topic.start_year, + 'end_year': topic.end_year, + 'total_pool': topic.total_pool, + 'is_decrypted': topic.is_decrypted, + 'decrypted_content': topic.encrypted_content if topic.is_decrypted else None, + 'actual_happened_year': topic.actual_happened_year, + 'status': topic.status, + 'author_id': topic.user_id, + 'author_name': user.username if user else '未知用户', + 'author_avatar': user.avatar if user else None, + 'time_slots': slots_data, + 'created_at': topic.created_at.strftime('%Y-%m-%d %H:%M:%S') + } + + return jsonify({ + 'success': True, + 'data': result + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/time-capsule/slots//bid', methods=['POST']) +@login_required +def bid_time_slot(slot_id): + """竞拍时间段""" + try: + data = request.json + bid_amount = data.get('bid_amount') + + slot = TimeCapsuleTimeSlot.query.get_or_404(slot_id) + + # 检查时间段是否还在竞拍 + if slot.status != 'active': + return jsonify({'success': False, 'error': '该时间段已结束竞拍'}), 400 + + # 检查出价是否高于当前价格 + min_bid = slot.current_price + 50 # 至少比当前价格高50积分 + if bid_amount < min_bid: + return jsonify({ + 'success': False, + 'error': f'出价必须至少为 {min_bid} 积分' + }), 400 + + # 获取用户积分账户 + account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() + if not account or account.balance < bid_amount: + return jsonify({'success': False, 'error': '积分不足'}), 400 + + # 扣减积分 + account.balance -= bid_amount + + # 如果有前任持有者,退还积分 + if slot.current_holder_id: + prev_holder_account = UserCreditAccount.query.filter_by(user_id=slot.current_holder_id).first() + if prev_holder_account: + prev_holder_account.balance += slot.current_price + + # 更新前任的竞拍记录状态 + prev_bid = TimeSlotBid.query.filter_by( + slot_id=slot_id, + user_id=slot.current_holder_id, + status='holding' + ).first() + if prev_bid: + prev_bid.status = 'outbid' + + # 创建竞拍记录 + bid = TimeSlotBid( + slot_id=slot_id, + user_id=current_user.id, + bid_amount=bid_amount, + status='holding' + ) + db.session.add(bid) + + # 更新时间段 + slot.current_holder_id = current_user.id + slot.current_price = bid_amount + slot.total_bids += 1 + + # 更新话题奖池 + topic = TimeCapsuleTopic.query.get(slot.topic_id) + price_increase = bid_amount - (slot.current_price if slot.current_holder_id else 100) + topic.total_pool += price_increase + + # 记录积分交易 + transaction = CreditTransaction( + user_id=current_user.id, + type='time_slot_bid', + amount=-bid_amount, + balance_after=account.balance, + description=f'竞拍时间段 {slot.year_start}-{slot.year_end}' + ) + db.session.add(transaction) + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'slot_id': slot_id, + 'bid_amount': bid_amount, + 'new_balance': account.balance, + 'total_pool': topic.total_pool + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/time-capsule/topics//decrypt', methods=['POST']) +@login_required +def decrypt_time_capsule(topic_id): + """解密时间胶囊(管理员或作者)""" + try: + topic = TimeCapsuleTopic.query.get_or_404(topic_id) + + # 检查权限(管理员或作者) + if current_user.id != 1 and current_user.id != topic.user_id: + return jsonify({'success': False, 'error': '无权限操作'}), 403 + + # 检查是否已解密 + if topic.is_decrypted: + return jsonify({'success': False, 'error': '该话题已解密'}), 400 + + # 解密(前端会用密钥解密内容) + topic.is_decrypted = True + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'encrypted_content': topic.encrypted_content, + 'encryption_key': topic.encryption_key + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/time-capsule/topics//settle', methods=['POST']) +@login_required +def settle_time_capsule(topic_id): + """结算时间胶囊话题""" + try: + # 检查管理员权限 + if current_user.id != 1: + return jsonify({'success': False, 'error': '无权限操作'}), 403 + + data = request.json + happened_year = data.get('happened_year') + + topic = TimeCapsuleTopic.query.get_or_404(topic_id) + + # 检查是否已结算 + if topic.status == 'settled': + return jsonify({'success': False, 'error': '该话题已结算'}), 400 + + # 更新话题状态 + topic.status = 'settled' + topic.actual_happened_year = happened_year + + # 找到中奖的时间段 + winning_slot = TimeCapsuleTimeSlot.query.filter_by( + topic_id=topic_id, + year_start=happened_year + ).first() + + if winning_slot and winning_slot.current_holder_id: + # 中奖者获得全部奖池 + winner_account = UserCreditAccount.query.filter_by(user_id=winning_slot.current_holder_id).first() + if winner_account: + winner_account.balance += topic.total_pool + + # 记录积分交易 + transaction = CreditTransaction( + user_id=winning_slot.current_holder_id, + type='time_capsule_win', + amount=topic.total_pool, + balance_after=winner_account.balance, + description=f'时间胶囊中奖 #{topic_id}' + ) + db.session.add(transaction) + + # 更新竞拍记录 + winning_bid = TimeSlotBid.query.filter_by( + slot_id=winning_slot.id, + user_id=winning_slot.current_holder_id, + status='holding' + ).first() + if winning_bid: + winning_bid.status = 'won' + + # 更新时间段状态 + winning_slot.status = 'won' + + # 其他时间段设为过期 + other_slots = TimeCapsuleTimeSlot.query.filter( + TimeCapsuleTimeSlot.topic_id == topic_id, + TimeCapsuleTimeSlot.id != (winning_slot.id if winning_slot else None) + ).all() + + for slot in other_slots: + slot.status = 'expired' + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'topic_id': topic_id, + 'happened_year': happened_year, + 'winner_id': winning_slot.current_holder_id if winning_slot else None, + 'prize': topic.total_pool + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + if __name__ == '__main__': # 创建数据库表 with app.app_context(): diff --git a/concept_api.py b/concept_api.py index 5ea690e0..1370346d 100644 --- a/concept_api.py +++ b/concept_api.py @@ -110,7 +110,7 @@ class SearchRequest(BaseModel): semantic_weight: Optional[float] = Field(None, ge=0.0, le=1.0, description="语义搜索权重(0-1),None表示自动计算") filter_stocks: Optional[List[str]] = Field(None, description="过滤特定股票代码或名称") trade_date: Optional[date] = Field(None, description="交易日期,格式:YYYY-MM-DD,默认返回最新日期数据") - sort_by: str = Field("change_pct", description="排序方式: change_pct, _score, stock_count, concept_name") + sort_by: str = Field("change_pct", description="排序方式: change_pct, _score, stock_count, concept_name, added_date") use_knn: bool = Field(True, description="是否使用KNN搜索优化语义搜索") @@ -548,12 +548,12 @@ async def search_concepts(request: SearchRequest): # 已经在generate_embedding中记录了详细日志,这里只调整语义权重 semantic_weight = 0 - # 【关键修改】:如果按涨跌幅排序,需要获取更多结果 + # 【关键修改】:如果按涨跌幅或添加日期排序,需要获取更多结果 effective_search_size = request.search_size - if request.sort_by == "change_pct": - # 按涨跌幅排序时,获取更多结果以确保排序准确性 + if request.sort_by in ["change_pct", "added_date"]: + # 按涨跌幅或添加日期排序时,获取更多结果以确保排序准确性 effective_search_size = min(1000, request.search_size * 10) # 最多获取1000个 - logger.info(f"Using expanded search size {effective_search_size} for change_pct sorting") + logger.info(f"Using expanded search size {effective_search_size} for {request.sort_by} sorting") # 构建查询体 search_body = {} @@ -721,6 +721,14 @@ async def search_concepts(request: SearchRequest): all_results.sort(key=lambda x: x.stock_count, reverse=True) elif request.sort_by == "concept_name": all_results.sort(key=lambda x: x.concept) + elif request.sort_by == "added_date": + # 按添加日期排序(降序 - 最新的在前) + all_results.sort( + key=lambda x: ( + x.happened_times[0] if x.happened_times and len(x.happened_times) > 0 else '1900-01-01' + ), + reverse=True + ) # _score排序已经由ES处理 # 计算分页 diff --git a/craco.config.js b/craco.config.js index 6db9ef20..e5edcef6 100644 --- a/craco.config.js +++ b/craco.config.js @@ -76,7 +76,7 @@ module.exports = { }, // 日期/日历库 calendar: { - test: /[\\/]node_modules[\\/](dayjs|date-fns|@fullcalendar|react-big-calendar)[\\/]/, + test: /[\\/]node_modules[\\/](dayjs|date-fns|@fullcalendar)[\\/]/, name: 'calendar-lib', priority: 18, reuseExistingChunk: true, diff --git a/package.json b/package.json index f6d45165..0477a556 100755 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "homepage": "/", "dependencies": { "@ant-design/icons": "^6.0.0", - "@asseinfo/react-kanban": "^2.2.0", "@chakra-ui/icons": "^2.2.6", "@chakra-ui/react": "^2.10.9", "@chakra-ui/theme-tools": "^2.2.6", @@ -15,9 +14,10 @@ "@fontsource/open-sans": "^4.5.0", "@fontsource/raleway": "^4.5.0", "@fontsource/roboto": "^4.5.0", - "@fullcalendar/daygrid": "^5.9.0", - "@fullcalendar/interaction": "^5.9.0", - "@fullcalendar/react": "^5.9.0", + "@fullcalendar/core": "^6.1.19", + "@fullcalendar/daygrid": "^6.1.19", + "@fullcalendar/interaction": "^6.1.19", + "@fullcalendar/react": "^6.1.19", "@reduxjs/toolkit": "^2.9.2", "@splidejs/react-splide": "^0.7.12", "@tanstack/react-virtual": "^3.13.12", @@ -25,6 +25,8 @@ "@visx/responsive": "^3.12.0", "@visx/scale": "^3.12.0", "@visx/text": "^3.12.0", + "@tsparticles/react": "^3.0.0", + "@tsparticles/slim": "^3.0.0", "@visx/visx": "^3.12.0", "@visx/wordcloud": "^3.12.0", "antd": "^5.27.4", @@ -38,59 +40,55 @@ "echarts": "^5.6.0", "echarts-for-react": "^3.0.2", "echarts-wordcloud": "^2.1.0", - "framer-motion": "^4.1.17", + "framer-motion": "^12.23.24", "fullcalendar": "^5.9.0", "globalize": "^1.7.0", "history": "^5.3.0", + "klinecharts": "^10.0.0-beta1", "lucide-react": "^0.540.0", "match-sorter": "6.3.0", "nouislider": "15.0.0", "posthog-js": "^1.295.0", - "react": "18.3.1", + "react": "^19.0.0", "react-apexcharts": "^1.3.9", - "react-big-calendar": "^0.33.2", - "react-bootstrap-sweetalert": "5.2.0", "react-circular-slider-svg": "^0.1.5", "react-custom-scrollbars-2": "^4.4.0", - "react-datetime": "^3.0.4", - "react-dom": "^18.3.1", - "react-dropzone": "^11.4.2", + "react-dom": "^19.0.0", "react-github-btn": "^1.2.1", "react-icons": "^4.12.0", "react-input-pin-code": "^1.1.5", "react-just-parallax": "^3.1.16", - "react-jvectormap": "0.0.16", "react-markdown": "^10.1.0", - "react-quill": "^2.0.0-beta.4", "react-redux": "^9.2.0", "react-responsive": "^10.0.1", "react-responsive-masonry": "^2.7.1", "react-router-dom": "^6.30.1", "react-scripts": "^5.0.1", + "react-is": "^19.0.0", "react-scroll": "^1.8.4", "react-scroll-into-view": "^2.1.3", - "react-swipeable-views": "0.13.9", "react-table": "^7.7.0", "react-tagsinput": "3.19.0", "react-to-print": "^2.13.0", "react-tsparticles": "^2.12.2", + "react-to-print": "^3.0.3", "recharts": "^3.1.2", "sass": "^1.49.9", - "scroll-lock": "^2.1.5", "socket.io-client": "^4.7.4", "styled-components": "^5.3.11", "stylis": "^4.0.10", "stylis-plugin-rtl": "^2.1.1", - "tsparticles-slim": "^2.12.0", "typescript": "^5.9.3" }, "resolutions": { "react-error-overlay": "6.0.9", - "@types/react": "18.2.0", - "@types/react-dom": "18.2.0" + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0" }, "overrides": { - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "scripts": { "prestart": "kill-port 3000", @@ -103,7 +101,7 @@ "frontend:test": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.test craco start", "dev": "npm start", "backend": "python app.py", - "build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.production craco build && gulp licenses", + "build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' TSC_COMPILE_ON_ERROR=true DISABLE_ESLINT_PLUGIN=true env-cmd -f .env.production craco build && gulp licenses", "build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build", "test": "craco test --env=jsdom", "eject": "react-scripts eject", @@ -120,12 +118,11 @@ "devDependencies": { "@craco/craco": "^7.1.0", "@types/node": "^20.19.25", - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@typescript-eslint/eslint-plugin": "^8.46.4", "@typescript-eslint/parser": "^8.46.4", "ajv": "^8.17.1", - "autoprefixer": "^10.4.21", "concurrently": "^8.2.2", "env-cmd": "^11.0.0", "eslint-config-prettier": "8.3.0", @@ -137,7 +134,6 @@ "imagemin-pngquant": "^10.0.0", "kill-port": "^2.0.1", "msw": "^2.11.5", - "postcss": "^8.5.6", "prettier": "2.2.1", "react-error-overlay": "6.0.9", "sharp": "^0.34.4", diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index 07d5d790..00000000 --- a/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: [ - require('tailwindcss'), - require('autoprefixer'), - ], -} diff --git a/privacy_policy.docx b/privacy_policy.docx index a2b6d604..c9bb70f5 100644 Binary files a/privacy_policy.docx and b/privacy_policy.docx differ diff --git a/public/LOGO_badge.png b/public/LOGO_badge.png new file mode 100644 index 00000000..07b02708 Binary files /dev/null and b/public/LOGO_badge.png differ diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js index dba1e93b..f5cddde0 100644 --- a/public/mockServiceWorker.js +++ b/public/mockServiceWorker.js @@ -7,7 +7,7 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.12.0' +const PACKAGE_VERSION = '2.12.2' const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/serve.log b/serve.log deleted file mode 100644 index 012db7e5..00000000 --- a/serve.log +++ /dev/null @@ -1,3 +0,0 @@ - INFO Accepting connections at http://localhost:58321 - - INFO Gracefully shutting down. Please wait... diff --git a/src/components/ImageLightbox/index.js b/src/components/ImageLightbox/index.js new file mode 100644 index 00000000..13811a8e --- /dev/null +++ b/src/components/ImageLightbox/index.js @@ -0,0 +1,309 @@ +/** + * 图片灯箱组件 + * 点击图片放大查看 + */ + +import React, { useState } from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalBody, + ModalCloseButton, + Image, + Box, + IconButton, + HStack, + useDisclosure, +} from '@chakra-ui/react'; +import { ChevronLeft, ChevronRight, X, ZoomIn } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; + +const MotionBox = motion(Box); + +/** + * 单图片灯箱 + */ +export const ImageLightbox = ({ src, alt, ...props }) => { + const { isOpen, onOpen, onClose } = useDisclosure(); + + return ( + <> + {/* 缩略图 */} + + {alt} + + {/* 放大图标 */} + + + + + + + + {/* 灯箱模态框 */} + + + + + + + {alt} + + + + + + ); +}; + +/** + * 多图片轮播灯箱 + */ +export const ImageGalleryLightbox = ({ images, initialIndex = 0, ...props }) => { + const { isOpen, onOpen, onClose } = useDisclosure(); + const [currentIndex, setCurrentIndex] = useState(initialIndex); + + const handleOpen = (index) => { + setCurrentIndex(index); + onOpen(); + }; + + const handlePrev = () => { + setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1)); + }; + + const handleNext = () => { + setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1)); + }; + + const handleKeyDown = (e) => { + if (e.key === 'ArrowLeft') handlePrev(); + if (e.key === 'ArrowRight') handleNext(); + if (e.key === 'Escape') onClose(); + }; + + return ( + <> + {/* 缩略图网格 */} + + {images.map((image, index) => ( + handleOpen(index)} + _hover={{ + '& .zoom-icon': { + opacity: 1, + }, + }} + > + {image.alt + + {/* 放大图标 */} + + + + + + + ))} + + + {/* 灯箱模态框(带轮播) */} + + + + {/* 关闭按钮 */} + } + position="fixed" + top="4" + right="4" + size="lg" + color="white" + bg="blackAlpha.600" + _hover={{ bg: 'blackAlpha.800' }} + borderRadius="full" + zIndex={2} + onClick={onClose} + /> + + + {/* 左箭头 */} + {images.length > 1 && ( + } + position="absolute" + left="4" + top="50%" + transform="translateY(-50%)" + size="lg" + color="white" + bg="blackAlpha.600" + _hover={{ bg: 'blackAlpha.800' }} + borderRadius="full" + zIndex={2} + onClick={handlePrev} + /> + )} + + {/* 图片 */} + + + {images[currentIndex].alt + + + + {/* 右箭头 */} + {images.length > 1 && ( + } + position="absolute" + right="4" + top="50%" + transform="translateY(-50%)" + size="lg" + color="white" + bg="blackAlpha.600" + _hover={{ bg: 'blackAlpha.800' }} + borderRadius="full" + zIndex={2} + onClick={handleNext} + /> + )} + + {/* 图片计数 */} + {images.length > 1 && ( + + {currentIndex + 1} / {images.length} + + )} + + + + + ); +}; + +export default ImageLightbox; diff --git a/src/components/ImagePreviewModal/index.js b/src/components/ImagePreviewModal/index.js new file mode 100644 index 00000000..04b6608b --- /dev/null +++ b/src/components/ImagePreviewModal/index.js @@ -0,0 +1,270 @@ +/** + * 图片预览弹窗组件 + * 支持多张图片左右切换、缩放、下载 + */ + +import React, { useState } from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalBody, + ModalCloseButton, + Image, + IconButton, + HStack, + Text, + Box, +} from '@chakra-ui/react'; +import { ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; + +const MotionBox = motion(Box); + +const ImagePreviewModal = ({ isOpen, onClose, images = [], initialIndex = 0 }) => { + const [currentIndex, setCurrentIndex] = useState(initialIndex); + const [scale, setScale] = useState(1); + + // 切换到上一张 + const handlePrevious = () => { + setCurrentIndex((prev) => (prev - 1 + images.length) % images.length); + setScale(1); // 重置缩放 + }; + + // 切换到下一张 + const handleNext = () => { + setCurrentIndex((prev) => (prev + 1) % images.length); + setScale(1); // 重置缩放 + }; + + // 放大 + const handleZoomIn = () => { + setScale((prev) => Math.min(prev + 0.25, 3)); + }; + + // 缩小 + const handleZoomOut = () => { + setScale((prev) => Math.max(prev - 0.25, 0.5)); + }; + + // 下载图片 + const handleDownload = () => { + const link = document.createElement('a'); + link.href = images[currentIndex]; + link.download = `image-${currentIndex + 1}.jpg`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + // 键盘快捷键 + React.useEffect(() => { + const handleKeyDown = (e) => { + if (!isOpen) return; + + switch (e.key) { + case 'ArrowLeft': + handlePrevious(); + break; + case 'ArrowRight': + handleNext(); + break; + case 'Escape': + onClose(); + break; + case '+': + case '=': + handleZoomIn(); + break; + case '-': + handleZoomOut(); + break; + default: + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, currentIndex]); + + // 关闭时重置状态 + const handleClose = () => { + setScale(1); + setCurrentIndex(initialIndex); + onClose(); + }; + + if (!images || images.length === 0) return null; + + return ( + + + + + + + {/* 图片显示区域 */} + + + {`图片 1 ? 'grab' : 'default'} + userSelect="none" + /> + + + + {/* 左右切换按钮(仅多张图片时显示) */} + {images.length > 1 && ( + <> + } + position="absolute" + left="20px" + top="50%" + transform="translateY(-50%)" + onClick={handlePrevious} + size="lg" + borderRadius="full" + bg="blackAlpha.600" + color="white" + _hover={{ bg: 'blackAlpha.800', transform: 'translateY(-50%) scale(1.1)' }} + _active={{ transform: 'translateY(-50%) scale(0.95)' }} + aria-label="上一张" + zIndex="2" + /> + + } + position="absolute" + right="20px" + top="50%" + transform="translateY(-50%)" + onClick={handleNext} + size="lg" + borderRadius="full" + bg="blackAlpha.600" + color="white" + _hover={{ bg: 'blackAlpha.800', transform: 'translateY(-50%) scale(1.1)' }} + _active={{ transform: 'translateY(-50%) scale(0.95)' }} + aria-label="下一张" + zIndex="2" + /> + + )} + + {/* 底部工具栏 */} + + + {/* 缩放控制 */} + + } + size="sm" + variant="ghost" + color="white" + onClick={handleZoomOut} + isDisabled={scale <= 0.5} + _hover={{ bg: 'whiteAlpha.200' }} + aria-label="缩小" + /> + + {Math.round(scale * 100)}% + + } + size="sm" + variant="ghost" + color="white" + onClick={handleZoomIn} + isDisabled={scale >= 3} + _hover={{ bg: 'whiteAlpha.200' }} + aria-label="放大" + /> + + + {/* 下载按钮 */} + } + size="sm" + variant="ghost" + color="white" + onClick={handleDownload} + _hover={{ bg: 'whiteAlpha.200' }} + aria-label="下载图片" + /> + + {/* 图片计数(仅多张图片时显示) */} + {images.length > 1 && ( + + {currentIndex + 1} / {images.length} + + )} + + + + {/* 快捷键提示 */} + + + 快捷键: ← → 切换 | + - 缩放 | ESC 关闭 + + + + + + ); +}; + +export default ImagePreviewModal; diff --git a/src/components/Navbars/HomeNavbar.js b/src/components/Navbars/HomeNavbar.js index 5fda71d9..288b0ca0 100644 --- a/src/components/Navbars/HomeNavbar.js +++ b/src/components/Navbars/HomeNavbar.js @@ -54,13 +54,11 @@ import { WatchlistMenu, FollowingEventsMenu } from './components/FeatureMenus'; import { useWatchlist } from '../../hooks/useWatchlist'; import { useFollowingEvents } from '../../hooks/useFollowingEvents'; -// Phase 7 优化: 提取的二级导航、资料完整性、右侧功能区组件 -import SecondaryNav from './components/SecondaryNav'; +// Phase 7 优化: 提取的资料完整性、右侧功能区组件 import ProfileCompletenessAlert from './components/ProfileCompletenessAlert'; import { useProfileCompleteness } from '../../hooks/useProfileCompleteness'; import NavbarActions from './components/NavbarActions'; -// Phase 7: SecondaryNav 组件已提取到 ./components/SecondaryNav/index.js // Phase 4: MoreNavMenu 和 NavItems 组件已提取到 Navigation 目录 export default function HomeNavbar() { @@ -152,8 +150,10 @@ export default function HomeNavbar() { )} - {/* 二级导航栏 - 显示当前页面所属的二级菜单 */} - {!isMobile && } - {/* 投资日历 Modal - 已移至 CalendarButton 组件内部 */} ); diff --git a/src/components/Navbars/components/NavbarActions/index.js b/src/components/Navbars/components/NavbarActions/index.js index 1680ea64..25a72120 100644 --- a/src/components/Navbars/components/NavbarActions/index.js +++ b/src/components/Navbars/components/NavbarActions/index.js @@ -41,9 +41,6 @@ const NavbarActions = memo(({ }) => { return ( - {/* 主题切换按钮 */} - - {/* 显示加载状态 */} {isLoading ? ( diff --git a/src/components/Navbars/components/SecondaryNav/config.js b/src/components/Navbars/components/SecondaryNav/config.js deleted file mode 100644 index e597649c..00000000 --- a/src/components/Navbars/components/SecondaryNav/config.js +++ /dev/null @@ -1,144 +0,0 @@ -// src/components/Navbars/components/SecondaryNav/config.js -// 二级导航配置数据 - -/** - * 二级导航配置结构 - * - key: 匹配的路径前缀 - * - title: 导航组标题 - * - items: 导航项列表 - * - path: 路径 - * - label: 显示文本 - * - badges: 徽章列表 (可选) - * - external: 是否外部链接 (可选) - */ -export const secondaryNavConfig = { - '/community': { - title: '高频跟踪', - items: [ - { - path: '/community', - label: '事件中心', - badges: [ - { text: 'HOT', colorScheme: 'green' }, - { text: 'NEW', colorScheme: 'red' } - ] - }, - { - path: '/concepts', - label: '概念中心', - badges: [{ text: 'NEW', colorScheme: 'red' }] - }, - { - path: '/data-browser', - label: '数据浏览器', - badges: [{ text: 'NEW', colorScheme: 'red' }] - } - ] - }, - '/concepts': { - title: '高频跟踪', - items: [ - { - path: '/community', - label: '事件中心', - badges: [ - { text: 'HOT', colorScheme: 'green' }, - { text: 'NEW', colorScheme: 'red' } - ] - }, - { - path: '/concepts', - label: '概念中心', - badges: [{ text: 'NEW', colorScheme: 'red' }] - }, - { - path: '/data-browser', - label: '数据浏览器', - badges: [{ text: 'NEW', colorScheme: 'red' }] - } - ] - }, - '/data-browser': { - title: '高频跟踪', - items: [ - { - path: '/community', - label: '事件中心', - badges: [ - { text: 'HOT', colorScheme: 'green' }, - { text: 'NEW', colorScheme: 'red' } - ] - }, - { - path: '/concepts', - label: '概念中心', - badges: [{ text: 'NEW', colorScheme: 'red' }] - }, - { - path: '/data-browser', - label: '数据浏览器', - badges: [{ text: 'NEW', colorScheme: 'red' }] - } - ] - }, - '/limit-analyse': { - title: '行情复盘', - items: [ - { - path: '/limit-analyse', - label: '涨停分析', - badges: [{ text: 'FREE', colorScheme: 'blue' }] - }, - { - path: '/stocks', - label: '个股中心', - badges: [{ text: 'HOT', colorScheme: 'green' }] - }, - { - path: '/trading-simulation', - label: '模拟盘', - badges: [{ text: 'NEW', colorScheme: 'red' }] - } - ] - }, - '/stocks': { - title: '行情复盘', - items: [ - { - path: '/limit-analyse', - label: '涨停分析', - badges: [{ text: 'FREE', colorScheme: 'blue' }] - }, - { - path: '/stocks', - label: '个股中心', - badges: [{ text: 'HOT', colorScheme: 'green' }] - }, - { - path: '/trading-simulation', - label: '模拟盘', - badges: [{ text: 'NEW', colorScheme: 'red' }] - } - ] - }, - '/trading-simulation': { - title: '行情复盘', - items: [ - { - path: '/limit-analyse', - label: '涨停分析', - badges: [{ text: 'FREE', colorScheme: 'blue' }] - }, - { - path: '/stocks', - label: '个股中心', - badges: [{ text: 'HOT', colorScheme: 'green' }] - }, - { - path: '/trading-simulation', - label: '模拟盘', - badges: [{ text: 'NEW', colorScheme: 'red' }] - } - ] - } -}; diff --git a/src/components/Navbars/components/SecondaryNav/index.js b/src/components/Navbars/components/SecondaryNav/index.js deleted file mode 100644 index e297a7fd..00000000 --- a/src/components/Navbars/components/SecondaryNav/index.js +++ /dev/null @@ -1,138 +0,0 @@ -// src/components/Navbars/components/SecondaryNav/index.js -// 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 - -import React, { memo } from 'react'; -import { - Box, - Container, - HStack, - Text, - Button, - Flex, - Badge, - useColorModeValue -} from '@chakra-ui/react'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { useNavigationEvents } from '../../../../hooks/useNavigationEvents'; -import { secondaryNavConfig } from './config'; - -/** - * 二级导航栏组件 - * 根据当前路径显示对应的二级菜单项 - * - * @param {Object} props - * @param {boolean} props.showCompletenessAlert - 是否显示完整性提醒(影响 sticky top 位置) - */ -const SecondaryNav = memo(({ showCompletenessAlert }) => { - const navigate = useNavigate(); - const location = useLocation(); - - // 颜色模式 - const navbarBg = useColorModeValue('gray.50', 'gray.700'); - const itemHoverBg = useColorModeValue('white', 'gray.600'); - const borderColorValue = useColorModeValue('gray.200', 'gray.600'); - - // 导航埋点 - const navEvents = useNavigationEvents({ component: 'secondary_nav' }); - - // 找到当前路径对应的二级导航配置 - const currentConfig = Object.keys(secondaryNavConfig).find(key => - location.pathname.includes(key) - ); - - // 如果没有匹配的二级导航,不显示 - if (!currentConfig) return null; - - const config = secondaryNavConfig[currentConfig]; - - return ( - - - - {/* 显示一级菜单标题 */} - - {config.title}: - - - {/* 二级菜单项 */} - {config.items.map((item, index) => { - const isActive = location.pathname.includes(item.path); - - return item.external ? ( - - ) : ( - - ); - })} - - - - ); -}); - -SecondaryNav.displayName = 'SecondaryNav'; - -export default SecondaryNav; diff --git a/src/components/Navbars/components/ThemeToggleButton.js b/src/components/Navbars/components/ThemeToggleButton.js deleted file mode 100644 index 16e61580..00000000 --- a/src/components/Navbars/components/ThemeToggleButton.js +++ /dev/null @@ -1,51 +0,0 @@ -// src/components/Navbars/components/ThemeToggleButton.js -// 主题切换按钮组件 - Phase 7 优化:添加导航埋点支持 - -import React, { memo } from 'react'; -import { IconButton, useColorMode } from '@chakra-ui/react'; -import { SunIcon, MoonIcon } from '@chakra-ui/icons'; -import { useNavigationEvents } from '../../../hooks/useNavigationEvents'; - -/** - * 主题切换按钮组件 - * 支持在亮色和暗色主题之间切换,包含导航埋点 - * - * 性能优化: - * - 使用 memo 避免父组件重新渲染时的不必要更新 - * - 只依赖 colorMode,当主题切换时才重新渲染 - * - * @param {Object} props - * @param {string} props.size - 按钮大小,默认 'sm' - * @param {string} props.variant - 按钮样式,默认 'ghost' - * @returns {JSX.Element} - */ -const ThemeToggleButton = memo(({ size = 'sm', variant = 'ghost' }) => { - const { colorMode, toggleColorMode } = useColorMode(); - const navEvents = useNavigationEvents({ component: 'theme_toggle' }); - - const handleToggle = () => { - // 追踪主题切换 - const fromTheme = colorMode; - const toTheme = colorMode === 'light' ? 'dark' : 'light'; - navEvents.trackThemeChanged(fromTheme, toTheme); - - // 切换主题 - toggleColorMode(); - }; - - return ( - : } - onClick={handleToggle} - variant={variant} - size={size} - minW={{ base: '36px', md: '40px' }} - minH={{ base: '36px', md: '40px' }} - /> - ); -}); - -ThemeToggleButton.displayName = 'ThemeToggleButton'; - -export default ThemeToggleButton; diff --git a/src/components/PricingList/index.js b/src/components/PricingList/index.js deleted file mode 100644 index ecdcdfea..00000000 --- a/src/components/PricingList/index.js +++ /dev/null @@ -1,138 +0,0 @@ -import { useRef, useState } from "react"; -import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide"; -import Button from "@/components/Button"; -import Image from "@/components/Image"; - -import { pricing } from "@/mocks/pricing"; - -type PricingListProps = { - monthly?: boolean; -}; - -const PricingList = ({ monthly = true }: PricingListProps) => { - const [activeIndex, setActiveIndex] = useState(0); - - const ref = useRef(null); - - const handleClick = (index: number) => { - setActiveIndex(index); - ref.current?.go(index); - }; - - return ( - setActiveIndex(newIndex)} - hasTrack={false} - ref={ref} - > - - {pricing.map((item, index) => ( - -
-

- {item.title} -

-

- {item.description} -

-
- {item.price && ( - <> -
$
-
- {monthly - ? item.price - : item.price !== "0" - ? ( - +item.price * - 12 * - 0.9 - ).toFixed(1) - : item.price} -
- - )} -
- -
    - {item.features.map((feature, index) => ( -
  • - Check -

    {feature}

    -
  • - ))} -
-
-
- ))} -
-
- {pricing.map((item, index) => ( - - ))} -
-
- ); -}; - -export default PricingList; diff --git a/src/components/PricingList/index.tsx b/src/components/PricingList/index.tsx deleted file mode 100644 index ecdcdfea..00000000 --- a/src/components/PricingList/index.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useRef, useState } from "react"; -import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide"; -import Button from "@/components/Button"; -import Image from "@/components/Image"; - -import { pricing } from "@/mocks/pricing"; - -type PricingListProps = { - monthly?: boolean; -}; - -const PricingList = ({ monthly = true }: PricingListProps) => { - const [activeIndex, setActiveIndex] = useState(0); - - const ref = useRef(null); - - const handleClick = (index: number) => { - setActiveIndex(index); - ref.current?.go(index); - }; - - return ( - setActiveIndex(newIndex)} - hasTrack={false} - ref={ref} - > - - {pricing.map((item, index) => ( - -
-

- {item.title} -

-

- {item.description} -

-
- {item.price && ( - <> -
$
-
- {monthly - ? item.price - : item.price !== "0" - ? ( - +item.price * - 12 * - 0.9 - ).toFixed(1) - : item.price} -
- - )} -
- -
    - {item.features.map((feature, index) => ( -
  • - Check -

    {feature}

    -
  • - ))} -
-
-
- ))} -
-
- {pricing.map((item, index) => ( - - ))} -
-
- ); -}; - -export default PricingList; diff --git a/src/components/StockChart/StockChartKLineModal.tsx b/src/components/StockChart/StockChartKLineModal.tsx new file mode 100644 index 00000000..1db9a9bb --- /dev/null +++ b/src/components/StockChart/StockChartKLineModal.tsx @@ -0,0 +1,287 @@ +/** + * StockChartKLineModal - K 线图表模态框组件 + * + * 使用 KLineChart 库实现的专业金融图表组件 + * 替换原有的 ECharts 实现(StockChartAntdModal.js) + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { Modal, Button, Radio, Select, Space, Spin, Alert } from 'antd'; +import type { RadioChangeEvent } from 'antd'; +import { + LineChartOutlined, + BarChartOutlined, + SettingOutlined, + ReloadOutlined, +} from '@ant-design/icons'; +import { Box } from '@chakra-ui/react'; + +// 自定义 Hooks +import { useKLineChart, useKLineData, useEventMarker } from './hooks'; + +// 类型定义 +import type { ChartType, StockInfo } from './types'; + +// 配置常量 +import { + CHART_TYPE_CONFIG, + CHART_HEIGHTS, + INDICATORS, + DEFAULT_SUB_INDICATORS, +} from './config'; + +// 工具函数 +import { createSubIndicators } from './utils'; + +// 日志 +import { logger } from '@utils/logger'; + +// ==================== 组件 Props ==================== + +export interface StockChartKLineModalProps { + /** 是否显示模态框 */ + visible: boolean; + /** 关闭模态框回调 */ + onClose: () => void; + /** 股票信息 */ + stock: StockInfo; + /** 事件时间(ISO 字符串,可选) */ + eventTime?: string; + /** 事件标题(用于标记标签,可选) */ + eventTitle?: string; +} + +// ==================== 主组件 ==================== + +const StockChartKLineModal: React.FC = ({ + visible, + onClose, + stock, + eventTime, + eventTitle, +}) => { + // ==================== 状态管理 ==================== + + /** 图表类型(分时图/日K线) */ + const [chartType, setChartType] = useState('daily'); + + /** 选中的副图指标 */ + const [selectedIndicators, setSelectedIndicators] = useState( + DEFAULT_SUB_INDICATORS + ); + + // ==================== 自定义 Hooks ==================== + + /** 图表实例管理 */ + const { chart, chartRef, isInitialized, error: chartError } = useKLineChart({ + containerId: `kline-chart-${stock.stock_code}`, + height: CHART_HEIGHTS.main, + autoResize: true, + }); + + /** 数据加载管理 */ + const { + data, + loading: dataLoading, + error: dataError, + loadData, + } = useKLineData({ + chart, + stockCode: stock.stock_code, + chartType, + eventTime, + autoLoad: visible, // 模态框打开时自动加载 + }); + + /** 事件标记管理 */ + const { marker } = useEventMarker({ + chart, + data, + eventTime, + eventTitle, + autoCreate: true, + }); + + // ==================== 事件处理 ==================== + + /** + * 切换图表类型(分时图 ↔ 日K线) + */ + const handleChartTypeChange = useCallback((e: RadioChangeEvent) => { + const newType = e.target.value as ChartType; + setChartType(newType); + + logger.debug('StockChartKLineModal', 'handleChartTypeChange', '切换图表类型', { + newType, + }); + }, []); + + /** + * 切换副图指标 + */ + const handleIndicatorChange = useCallback( + (values: string[]) => { + setSelectedIndicators(values); + + if (!chart) { + return; + } + + // 先移除所有副图指标(KLineChart 会自动移除) + // 然后创建新的指标 + createSubIndicators(chart, values); + + logger.debug('StockChartKLineModal', 'handleIndicatorChange', '切换副图指标', { + indicators: values, + }); + }, + [chart] + ); + + /** + * 刷新数据 + */ + const handleRefresh = useCallback(() => { + loadData(); + logger.debug('StockChartKLineModal', 'handleRefresh', '刷新数据'); + }, [loadData]); + + // ==================== 计算属性 ==================== + + /** 是否有错误 */ + const hasError = useMemo(() => { + return !!chartError || !!dataError; + }, [chartError, dataError]); + + /** 错误消息 */ + const errorMessage = useMemo(() => { + if (chartError) { + return `图表初始化失败: ${chartError.message}`; + } + if (dataError) { + return `数据加载失败: ${dataError.message}`; + } + return null; + }, [chartError, dataError]); + + /** 模态框标题 */ + const modalTitle = useMemo(() => { + return `${stock.stock_name}(${stock.stock_code}) - ${CHART_TYPE_CONFIG[chartType].label}`; + }, [stock, chartType]); + + /** 是否显示加载状态 */ + const showLoading = useMemo(() => { + return dataLoading || !isInitialized; + }, [dataLoading, isInitialized]); + + // ==================== 副作用 ==================== + + // 无副作用,都在 Hooks 中管理 + + // ==================== 渲染 ==================== + + return ( + + {/* 工具栏 */} + + + {/* 图表类型切换 */} + + + 分时图 + + + 日K线 + + + + {/* 副图指标选择 */} + + + {/* 刷新按钮 */} + + + + + {/* 错误提示 */} + {hasError && ( + + )} + + {/* 图表容器 */} + + {/* 加载遮罩 */} + {showLoading && ( + + + + )} + + {/* KLineChart 容器 */} +
+ + + {/* 数据信息(调试用,生产环境可移除) */} + {process.env.NODE_ENV === 'development' && ( + + + 数据点数: {data.length} + 事件标记: {marker ? marker.label : '无'} + 图表ID: {chart?.id || '未初始化'} + + + )} + + ); +}; + +export default StockChartKLineModal; diff --git a/src/components/StockChart/StockChartModal.js b/src/components/StockChart/StockChartModal.js.backup similarity index 92% rename from src/components/StockChart/StockChartModal.js rename to src/components/StockChart/StockChartModal.js.backup index e637edef..f4b0ca80 100644 --- a/src/components/StockChart/StockChartModal.js +++ b/src/components/StockChart/StockChartModal.js.backup @@ -1,5 +1,5 @@ // src/components/StockChart/StockChartModal.js - 统一的股票图表组件 -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, Button, ButtonGroup, VStack, HStack, Text, Badge, Box, Flex, CircularProgress } from '@chakra-ui/react'; import ReactECharts from 'echarts-for-react'; import * as echarts from 'echarts'; @@ -7,6 +7,7 @@ import dayjs from 'dayjs'; import { stockService } from '../../services/eventService'; import { logger } from '../../utils/logger'; import RiskDisclaimer from '../RiskDisclaimer'; +import { RelationDescription } from '../StockRelation'; const StockChartModal = ({ isOpen, @@ -14,34 +15,16 @@ const StockChartModal = ({ stock, eventTime, isChakraUI = true, // 是否使用Chakra UI,默认true;如果false则使用Antd - size = "6xl" + size = "6xl", + initialChartType = 'timeline' // 初始图表类型(timeline/daily) }) => { const chartRef = useRef(null); const chartInstanceRef = useRef(null); - const [chartType, setChartType] = useState('timeline'); + const [chartType, setChartType] = useState(initialChartType); const [loading, setLoading] = useState(false); const [chartData, setChartData] = useState(null); const [preloadedData, setPreloadedData] = useState({}); - // 处理关联描述(兼容对象和字符串格式) - const getRelationDesc = () => { - const relationDesc = stock?.relation_desc; - - if (!relationDesc) return null; - - if (typeof relationDesc === 'string') { - return relationDesc; - } else if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) { - // 新格式:{data: [{query_part: "...", sentences: "..."}]} - return relationDesc.data - .map(item => item.query_part || item.sentences || '') - .filter(s => s) - .join(';') || null; - } - - return null; - }; - // 预加载数据 const preloadData = async (type) => { if (!stock || preloadedData[type]) return; @@ -539,7 +522,8 @@ const StockChartModal = ({ - + {/* 图表区域 */} + {loading && ( - {getRelationDesc() && ( - - 关联描述: - {getRelationDesc()} - - )} + {/* 关联描述 */} + {/* 风险提示 */} - - {process.env.NODE_ENV === 'development' && chartData && ( - - 调试信息: - 数据条数: {chartData.data ? chartData.data.length : 0} - 交易日期: {chartData.trade_date} - 图表类型: {chartType} - 原始事件时间: {eventTime} - - )} diff --git a/src/components/StockChart/StockChartModal.tsx b/src/components/StockChart/StockChartModal.tsx new file mode 100644 index 00000000..5bb10da5 --- /dev/null +++ b/src/components/StockChart/StockChartModal.tsx @@ -0,0 +1,213 @@ +// src/components/StockChart/StockChartModal.tsx - 统一的股票图表组件(KLineChart 实现) +import React, { useState } from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalCloseButton, + ModalBody, + Button, + ButtonGroup, + VStack, + HStack, + Text, + Badge, + Box, + Flex, + CircularProgress, +} from '@chakra-ui/react'; +import RiskDisclaimer from '../RiskDisclaimer'; +import { RelationDescription } from '../StockRelation'; +import type { RelationDescType } from '../StockRelation'; +import { useKLineChart, useKLineData, useEventMarker } from './hooks'; +import { Alert, AlertIcon } from '@chakra-ui/react'; + +/** + * 图表类型 + */ +type ChartType = 'timeline' | 'daily'; + +/** + * 股票信息 + */ +interface StockInfo { + stock_code: string; + stock_name?: string; + relation_desc?: RelationDescType; +} + +/** + * StockChartModal 组件 Props + */ +export interface StockChartModalProps { + /** 模态框是否打开 */ + isOpen: boolean; + + /** 关闭回调 */ + onClose: () => void; + + /** 股票信息 */ + stock: StockInfo | null; + + /** 事件时间 */ + eventTime?: string | null; + + /** 是否使用 Chakra UI(保留字段,当前未使用) */ + isChakraUI?: boolean; + + /** 模态框大小 */ + size?: string; + + /** 初始图表类型 */ + initialChartType?: ChartType; +} + + +const StockChartModal: React.FC = ({ + isOpen, + onClose, + stock, + eventTime, + isChakraUI = true, + size = '6xl', + initialChartType = 'timeline', +}) => { + // 状态管理 + const [chartType, setChartType] = useState(initialChartType); + + // KLineChart Hooks + const { chart, chartRef, isInitialized, error: chartError } = useKLineChart({ + containerId: `kline-chart-${stock?.stock_code || 'default'}`, + height: 500, + autoResize: true, + chartType, // ✅ 传递 chartType,让 Hook 根据类型应用不同样式 + }); + + const { data, loading, error: dataError } = useKLineData({ + chart, + stockCode: stock?.stock_code || '', + chartType, + eventTime: eventTime || undefined, + autoLoad: true, // 改为 true,让 Hook 内部根据 stockCode 和 chart 判断是否加载 + }); + + const { marker } = useEventMarker({ + chart, + data, + eventTime: eventTime || undefined, + eventTitle: '事件发生', + autoCreate: true, + }); + + // 守卫子句 + if (!stock) return null; + + return ( + + + + + + + + {stock.stock_name || stock.stock_code} ({stock.stock_code}) - 股票详情 + + {data.length > 0 && 数据点: {data.length}} + + + + + + + + {/* 重件发生标签 - 仅在有 eventTime 时显示 */} + {eventTime && ( + + 重件发生(影响日) + + )} + + + + {/* 错误提示 */} + {(chartError || dataError) && ( + + + 图表加载失败:{chartError?.message || dataError?.message} + + )} + + {/* 图表区域 - 响应式高度 */} + + {loading && ( + + + + 加载图表数据... + + + )} +
+ + + {/* 关联描述 */} + + + {/* 风险提示 */} + + + + + + + ); +}; + +export default StockChartModal; diff --git a/src/components/StockChart/config/chartConfig.ts b/src/components/StockChart/config/chartConfig.ts new file mode 100644 index 00000000..7cd91fe3 --- /dev/null +++ b/src/components/StockChart/config/chartConfig.ts @@ -0,0 +1,205 @@ +/** + * KLineChart 图表常量配置 + * + * 包含图表默认配置、技术指标列表、事件标记配置等 + */ + +import type { ChartConfig, ChartType } from '../types'; + +/** + * 图表默认高度(px) + */ +export const CHART_HEIGHTS = { + /** 主图高度 */ + main: 400, + /** 副图高度(技术指标) */ + sub: 150, + /** 移动端主图高度 */ + mainMobile: 300, + /** 移动端副图高度 */ + subMobile: 100, +} as const; + +/** + * 技术指标配置 + */ +export const INDICATORS = { + /** 主图指标(叠加在 K 线图上) */ + main: [ + { + name: 'MA', + label: '均线', + params: [5, 10, 20, 30], + colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A'], + }, + { + name: 'EMA', + label: '指数移动平均', + params: [5, 10, 20, 30], + colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A'], + }, + { + name: 'BOLL', + label: '布林带', + params: [20, 2], + colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'], + }, + ], + + /** 副图指标(单独窗口显示) */ + sub: [ + { + name: 'VOL', + label: '成交量', + params: [5, 10, 20], + colors: ['#ef5350', '#26a69a'], + }, + { + name: 'MACD', + label: 'MACD', + params: [12, 26, 9], + colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'], + }, + { + name: 'KDJ', + label: 'KDJ', + params: [9, 3, 3], + colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'], + }, + { + name: 'RSI', + label: 'RSI', + params: [6, 12, 24], + colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'], + }, + ], +} as const; + +/** + * 默认主图指标(初始显示) + */ +export const DEFAULT_MAIN_INDICATOR = 'MA'; + +/** + * 默认副图指标(初始显示) + */ +export const DEFAULT_SUB_INDICATORS = ['VOL', 'MACD']; + +/** + * 图表类型配置 + */ +export const CHART_TYPE_CONFIG: Record = { + timeline: { + label: '分时图', + dateFormat: 'HH:mm', // 时间格式:09:30 + }, + daily: { + label: '日K线', + dateFormat: 'YYYY-MM-DD', // 日期格式:2024-01-01 + }, +} as const; + +/** + * 事件标记配置 + */ +export const EVENT_MARKER_CONFIG = { + /** 默认颜色 */ + defaultColor: '#ff9800', + /** 默认位置 */ + defaultPosition: 'top' as const, + /** 默认图标 */ + defaultIcon: '📌', + /** 标记大小 */ + size: { + point: 8, // 标记点半径 + icon: 20, // 图标大小 + }, + /** 文本配置 */ + text: { + fontSize: 12, + fontFamily: 'Helvetica, Arial, sans-serif', + color: '#ffffff', + padding: 4, + borderRadius: 4, + }, +} as const; + +/** + * 数据加载配置 + */ +export const DATA_LOADER_CONFIG = { + /** 最大数据点数(避免性能问题) */ + maxDataPoints: 1000, + /** 初始加载数据点数 */ + initialLoadCount: 100, + /** 加载更多时的数据点数 */ + loadMoreCount: 50, +} as const; + +/** + * 缩放配置 + */ +export const ZOOM_CONFIG = { + /** 最小缩放比例(显示更多 K 线) */ + minZoom: 0.5, + /** 最大缩放比例(显示更少 K 线) */ + maxZoom: 2.0, + /** 默认缩放比例 */ + defaultZoom: 1.0, + /** 缩放步长 */ + zoomStep: 0.1, +} as const; + +/** + * 默认图表配置 + */ +export const DEFAULT_CHART_CONFIG: ChartConfig = { + type: 'daily', + showIndicators: true, + defaultIndicators: DEFAULT_SUB_INDICATORS, + height: CHART_HEIGHTS.main, + showGrid: true, + showCrosshair: true, +} as const; + +/** + * 图表初始化选项(传递给 KLineChart.init) + */ +export const CHART_INIT_OPTIONS = { + /** 时区(中国标准时间) */ + timezone: 'Asia/Shanghai', + /** 语言 */ + locale: 'zh-CN', + /** 自定义配置 */ + customApi: { + formatDate: (timestamp: number, format: string) => { + // 可在此处自定义日期格式化逻辑 + return new Date(timestamp).toLocaleString('zh-CN'); + }, + }, +} as const; + +/** + * 分时图特殊配置 + */ +export const TIMELINE_CONFIG = { + /** 交易时段(A 股) */ + tradingSessions: [ + { start: '09:30', end: '11:30' }, // 上午 + { start: '13:00', end: '15:00' }, // 下午 + ], + /** 是否显示均价线 */ + showAverageLine: true, + /** 均价线颜色 */ + averageLineColor: '#FFB74D', +} as const; + +/** + * 日K线特殊配置 + */ +export const DAILY_KLINE_CONFIG = { + /** 最大显示天数 */ + maxDays: 250, // 约一年交易日 + /** 默认显示天数 */ + defaultDays: 60, +} as const; diff --git a/src/components/StockChart/config/index.ts b/src/components/StockChart/config/index.ts new file mode 100644 index 00000000..09d12dc3 --- /dev/null +++ b/src/components/StockChart/config/index.ts @@ -0,0 +1,32 @@ +/** + * StockChart 配置统一导出 + * + * 使用方式: + * import { lightTheme, DEFAULT_CHART_CONFIG } from '@components/StockChart/config'; + */ + +// 主题配置(仅浅色主题) +export { + CHART_COLORS, + lightTheme, + // darkTheme, // ❌ 已删除深色主题 + timelineTheme, + getTheme, + getTimelineTheme, +} from './klineTheme'; + +// 图表配置 +export { + CHART_HEIGHTS, + INDICATORS, + DEFAULT_MAIN_INDICATOR, + DEFAULT_SUB_INDICATORS, + CHART_TYPE_CONFIG, + EVENT_MARKER_CONFIG, + DATA_LOADER_CONFIG, + ZOOM_CONFIG, + DEFAULT_CHART_CONFIG, + CHART_INIT_OPTIONS, + TIMELINE_CONFIG, + DAILY_KLINE_CONFIG, +} from './chartConfig'; diff --git a/src/components/StockChart/config/klineTheme.ts b/src/components/StockChart/config/klineTheme.ts new file mode 100644 index 00000000..814f6c68 --- /dev/null +++ b/src/components/StockChart/config/klineTheme.ts @@ -0,0 +1,370 @@ +/** + * KLineChart 主题配置(仅浅色主题) + * + * 适配 klinecharts@10.0.0-beta1 + * 参考: https://github.com/klinecharts/KLineChart/blob/main/docs/en-US/guide/styles.md + * + * ⚠️ 重要说明: + * - 本项目已移除深色模式支持(2025-01) + * - 应用通过 colorModeManager 强制使用浅色主题 + * - 已删除 darkTheme 和 timelineThemeDark 配置 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +// ⚠️ 使用 any 类型绕过 KLineChart 类型定义的限制(beta 版本类型不完整) +// import type { DeepPartial, Styles } from 'klinecharts'; // ⚠️ 未使用(保留以便将来扩展) + +/** + * 图表主题颜色配置(浅色主题) + * ⚠️ 已移除深色模式相关颜色常量 + */ +export const CHART_COLORS = { + // 涨跌颜色(中国市场习惯:红涨绿跌) + up: '#ef5350', // 上涨红色 + down: '#26a69a', // 下跌绿色 + neutral: '#888888', // 平盘灰色 + + // 主题色(继承自 Argon Dashboard) + primary: '#1b3bbb', // Navy 500 + secondary: '#728fea', // Navy 300 + background: '#ffffff', + + // 文本颜色 + text: '#333333', + textSecondary: '#888888', + + // 网格颜色 + grid: '#e0e0e0', + + // 边框颜色 + border: '#e0e0e0', + + // 事件标记颜色 + eventMarker: '#ff9800', + eventMarkerText: '#ffffff', +}; + +/** + * 浅色主题配置(默认) + */ +export const lightTheme: any = { + candle: { + type: 'candle_solid', // 实心蜡烛图 + bar: { + upColor: CHART_COLORS.up, + downColor: CHART_COLORS.down, + noChangeColor: CHART_COLORS.neutral, + }, + priceMark: { + show: true, + high: { + color: CHART_COLORS.up, + }, + low: { + color: CHART_COLORS.down, + }, + }, + tooltip: { + showRule: 'always', + showType: 'standard', + // labels: ['时间: ', '开: ', '收: ', '高: ', '低: ', '成交量: '], // ❌ KLineChart 类型不支持自定义 labels + text: { + size: 12, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + color: CHART_COLORS.text, + }, + }, + }, + indicator: { + tooltip: { + showRule: 'always', + showType: 'standard', + text: { + size: 12, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + color: CHART_COLORS.text, + }, + }, + }, + xAxis: { + axisLine: { + show: true, + color: CHART_COLORS.border, + }, + tickLine: { + show: true, + length: 3, + color: CHART_COLORS.border, + }, + tickText: { + show: true, + color: CHART_COLORS.textSecondary, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + size: 12, + }, + }, + yAxis: { + axisLine: { + show: true, + color: CHART_COLORS.border, + }, + tickLine: { + show: true, + length: 3, + color: CHART_COLORS.border, + }, + tickText: { + show: true, + color: CHART_COLORS.textSecondary, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + size: 12, + }, + type: 'normal', // 'normal' | 'percentage' | 'log' + }, + grid: { + show: true, + horizontal: { + show: true, + size: 1, + color: CHART_COLORS.grid, + style: 'dashed', + }, + vertical: { + show: false, // 垂直网格线通常关闭,避免过于密集 + }, + }, + separator: { + size: 1, + color: CHART_COLORS.border, + }, + crosshair: { + show: true, + horizontal: { + show: true, + line: { + show: true, + style: 'dashed', + dashedValue: [4, 2], // ✅ 修复: 使用 dashedValue 而非 dashValue + size: 1, + color: CHART_COLORS.primary, + }, + text: { + show: true, + color: '#ffffff', // 白色文字(十字线标签背景是深蓝色) + size: 12, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + backgroundColor: CHART_COLORS.primary, + }, + }, + vertical: { + show: true, + line: { + show: true, + style: 'dashed', + dashedValue: [4, 2], // ✅ 修复: 使用 dashedValue 而非 dashValue + size: 1, + color: CHART_COLORS.primary, + }, + text: { + show: true, + color: '#ffffff', // 白色文字(十字线标签背景是深蓝色) + size: 12, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + backgroundColor: CHART_COLORS.primary, + }, + }, + }, + overlay: { + // 事件标记覆盖层样式 + point: { + color: CHART_COLORS.eventMarker, + borderColor: CHART_COLORS.eventMarker, + borderSize: 1, + radius: 5, + activeColor: CHART_COLORS.eventMarker, + activeBorderColor: CHART_COLORS.eventMarker, + activeBorderSize: 2, + activeRadius: 6, + }, + line: { + style: 'solid', + smooth: false, + color: CHART_COLORS.eventMarker, + size: 1, + dashedValue: [2, 2], + }, + text: { + style: 'fill', + color: CHART_COLORS.eventMarkerText, + size: 12, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + offset: [0, 0], + }, + rect: { + style: 'fill', + color: CHART_COLORS.eventMarker, + borderColor: CHART_COLORS.eventMarker, + borderSize: 1, + borderRadius: 4, + borderStyle: 'solid', + borderDashedValue: [2, 2], + }, + }, +}; + +// ❌ 已删除 darkTheme 配置(不再支持深色模式) + +/** + * 分时图专用主题配置 + * 特点:面积图样式、均价线、百分比Y轴 + */ +export const timelineTheme: any = { + ...lightTheme, + candle: { + type: 'area', // ✅ 面积图模式(分时线) + area: { + lineSize: 2, + lineColor: CHART_COLORS.up, // 默认红色,实际会根据涨跌动态调整 + value: 'close', + backgroundColor: [ + { + offset: 0, + color: 'rgba(239, 83, 80, 0.2)', // 红色半透明渐变(顶部) + }, + { + offset: 1, + color: 'rgba(239, 83, 80, 0.01)', // 红色几乎透明(底部) + }, + ], + }, + priceMark: { + show: true, + high: { + show: false, // 分时图不显示最高最低价标记 + }, + low: { + show: false, + }, + last: { + show: true, + upColor: CHART_COLORS.up, + downColor: CHART_COLORS.down, + noChangeColor: CHART_COLORS.neutral, + line: { + show: true, + style: 'dashed', + dashedValue: [4, 2], // ✅ 修复: 使用 dashedValue 而非 dashValue + size: 1, + }, + text: { + show: true, + size: 12, + paddingLeft: 4, + paddingTop: 2, + paddingRight: 4, + paddingBottom: 2, + borderRadius: 2, + }, + }, + }, + tooltip: { + showRule: 'always', + showType: 'standard', + // ❌ KLineChart 类型不支持自定义 labels 和 formatter(需要在运行时通过 API 设置) + // labels: ['时间: ', '现价: ', '涨跌: ', '均价: ', '昨收: ', '成交量: '], + // formatter: (data: any, indicator: any) => { ... }, + text: { + size: 12, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + color: CHART_COLORS.text, + }, + }, + }, + yAxis: { + ...lightTheme.yAxis, + type: 'percentage', // ✅ 百分比模式 + position: 'left', // Y轴在左侧 + inside: false, + reverse: false, + tickText: { + ...lightTheme.yAxis?.tickText, + // ❌ KLineChart 类型不支持自定义 formatter(需要在运行时通过 API 设置) + // formatter: (value: any) => { + // const percent = (value * 100).toFixed(2); + // if (Math.abs(value) < 0.0001) return '0.00%'; + // return value > 0 ? `+${percent}%` : `${percent}%`; + // }, + }, + }, + grid: { + show: true, + horizontal: { + show: true, + size: 1, + color: CHART_COLORS.grid, + style: 'solid', // 分时图使用实线网格 + }, + vertical: { + show: false, + }, + }, +}; + +// ❌ 已删除 timelineThemeDark 配置(不再支持深色模式) + +/** + * 获取主题配置(固定返回浅色主题) + * ⚠️ 已移除深色模式支持 + * @deprecated colorMode 参数已废弃,始终返回浅色主题 + */ +export const getTheme = (_colorMode?: 'light' | 'dark'): any => { + // ✅ 始终返回浅色主题 + return lightTheme; +}; + +/** + * 获取分时图主题配置(固定返回浅色主题) + * ⚠️ 已移除深色模式支持 + * @deprecated colorMode 参数已废弃,始终返回浅色主题 + */ +export const getTimelineTheme = (_colorMode?: 'light' | 'dark'): any => { + // ✅ 始终使用浅色主题 + const baseTheme = timelineTheme; + + // ✅ 添加成交量指标样式(蓝色渐变柱状图)+ 成交量单位格式化 + return { + ...baseTheme, + indicator: { + ...baseTheme.indicator, + bars: [ + { + upColor: 'rgba(59, 130, 246, 0.6)', // 蓝色(涨) + downColor: 'rgba(59, 130, 246, 0.6)', // 蓝色(跌)- 分时图成交量统一蓝色 + noChangeColor: 'rgba(59, 130, 246, 0.6)', + } + ], + // ❌ KLineChart 类型不支持自定义 formatter(需要在运行时通过 API 设置) + tooltip: { + ...baseTheme.indicator?.tooltip, + // formatter: (params: any) => { + // if (params.name === 'VOL' && params.calcParamsText) { + // const volume = params.calcParamsText.match(/\d+/)?.[0]; + // if (volume) { + // const hands = Math.floor(Number(volume) / 100); + // return `成交量: ${hands.toLocaleString()}手`; + // } + // } + // return params.calcParamsText || ''; + // }, + }, + }, + }; +}; diff --git a/src/components/StockChart/hooks/index.ts b/src/components/StockChart/hooks/index.ts new file mode 100644 index 00000000..8dbc9dc7 --- /dev/null +++ b/src/components/StockChart/hooks/index.ts @@ -0,0 +1,15 @@ +/** + * StockChart 自定义 Hooks 统一导出 + * + * 使用方式: + * import { useKLineChart, useKLineData, useEventMarker } from '@components/StockChart/hooks'; + */ + +export { useKLineChart } from './useKLineChart'; +export type { UseKLineChartOptions, UseKLineChartReturn } from './useKLineChart'; + +export { useKLineData } from './useKLineData'; +export type { UseKLineDataOptions, UseKLineDataReturn } from './useKLineData'; + +export { useEventMarker } from './useEventMarker'; +export type { UseEventMarkerOptions, UseEventMarkerReturn } from './useEventMarker'; diff --git a/src/components/StockChart/hooks/useEventMarker.ts b/src/components/StockChart/hooks/useEventMarker.ts new file mode 100644 index 00000000..c0913432 --- /dev/null +++ b/src/components/StockChart/hooks/useEventMarker.ts @@ -0,0 +1,238 @@ +/** + * useEventMarker Hook + * + * 管理事件标记的创建、更新和删除 + */ + +import { useEffect, useState, useCallback } from 'react'; +import type { Chart } from 'klinecharts'; +import type { EventMarker, KLineDataPoint } from '../types'; +import { + createEventMarkerFromTime, + createEventMarkerOverlay, + createEventHighlightOverlay, + removeAllEventMarkers, +} from '../utils/eventMarkerUtils'; +import { logger } from '@utils/logger'; + +export interface UseEventMarkerOptions { + /** KLineChart 实例 */ + chart: Chart | null; + /** K 线数据(用于定位标记) */ + data: KLineDataPoint[]; + /** 事件时间(ISO 字符串) */ + eventTime?: string; + /** 事件标题(用于标记标签) */ + eventTitle?: string; + /** 是否自动创建标记 */ + autoCreate?: boolean; +} + +export interface UseEventMarkerReturn { + /** 当前标记 */ + marker: EventMarker | null; + /** 标记 ID(已添加到图表) */ + markerId: string | null; + /** 创建标记 */ + createMarker: (time: string, label: string, color?: string) => void; + /** 移除标记 */ + removeMarker: () => void; + /** 移除所有标记 */ + removeAllMarkers: () => void; +} + +/** + * 事件标记管理 Hook + * + * @param options 配置选项 + * @returns UseEventMarkerReturn + * + * @example + * const { marker, createMarker, removeMarker } = useEventMarker({ + * chart, + * data, + * eventTime: '2024-01-01 10:00:00', + * eventTitle: '重大公告', + * autoCreate: true, + * }); + */ +export const useEventMarker = ( + options: UseEventMarkerOptions +): UseEventMarkerReturn => { + const { + chart, + data, + eventTime, + eventTitle = '事件发生', + autoCreate = true, + } = options; + + const [marker, setMarker] = useState(null); + const [markerId, setMarkerId] = useState(null); + const [highlightId, setHighlightId] = useState(null); + + /** + * 创建事件标记 + */ + const createMarker = useCallback( + (time: string, label: string, color?: string) => { + if (!chart || !data || data.length === 0) { + logger.warn('useEventMarker', 'createMarker', '图表或数据未准备好', { + hasChart: !!chart, + dataLength: data?.length || 0, + }); + return; + } + + try { + // 1. 创建事件标记配置 + const eventMarker = createEventMarkerFromTime(time, label, color); + setMarker(eventMarker); + + // 2. 创建 Overlay + const overlay = createEventMarkerOverlay(eventMarker, data); + + if (!overlay) { + logger.warn('useEventMarker', 'createMarker', 'Overlay 创建失败', { + eventMarker, + }); + return; + } + + // 3. 添加到图表 + const id = chart.createOverlay(overlay); + + if (!id || (Array.isArray(id) && id.length === 0)) { + logger.warn('useEventMarker', 'createMarker', '标记添加失败', { + overlay, + }); + return; + } + + const actualId = Array.isArray(id) ? id[0] : id; + setMarkerId(actualId as string); + + // 4. 创建黄色高亮背景(事件影响日) + const highlightOverlay = createEventHighlightOverlay(time, data); + if (highlightOverlay) { + const highlightResult = chart.createOverlay(highlightOverlay); + const actualHighlightId = Array.isArray(highlightResult) ? highlightResult[0] : highlightResult; + setHighlightId(actualHighlightId as string); + + logger.info('useEventMarker', 'createMarker', '事件高亮背景创建成功', { + highlightId: actualHighlightId, + }); + } + + logger.info('useEventMarker', 'createMarker', '事件标记创建成功', { + markerId: actualId, + label, + time, + chartId: chart.id, + }); + } catch (err) { + logger.error('useEventMarker', 'createMarker', err as Error, { + time, + label, + }); + } + }, + [chart, data] + ); + + /** + * 移除事件标记 + */ + const removeMarker = useCallback(() => { + if (!chart) { + return; + } + + try { + if (markerId) { + chart.removeOverlay(markerId); + } + if (highlightId) { + chart.removeOverlay(highlightId); + } + + setMarker(null); + setMarkerId(null); + setHighlightId(null); + + logger.debug('useEventMarker', 'removeMarker', '移除事件标记和高亮', { + markerId, + highlightId, + chartId: chart.id, + }); + } catch (err) { + logger.error('useEventMarker', 'removeMarker', err as Error, { + markerId, + highlightId, + }); + } + }, [chart, markerId, highlightId]); + + /** + * 移除所有标记 + */ + const removeAllMarkers = useCallback(() => { + if (!chart) { + return; + } + + try { + removeAllEventMarkers(chart); + setMarker(null); + setMarkerId(null); + setHighlightId(null); + + logger.debug('useEventMarker', 'removeAllMarkers', '移除所有事件标记和高亮', { + chartId: chart.id, + }); + } catch (err) { + logger.error('useEventMarker', 'removeAllMarkers', err as Error); + } + }, [chart]); + + // 自动创建标记(当 eventTime 和数据都准备好时) + useEffect(() => { + if ( + autoCreate && + eventTime && + chart && + data && + data.length > 0 && + !markerId // 避免重复创建 + ) { + createMarker(eventTime, eventTitle); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventTime, chart, data, autoCreate]); + + // 清理:组件卸载时移除所有标记 + useEffect(() => { + return () => { + if (chart) { + try { + if (markerId) { + chart.removeOverlay(markerId); + } + if (highlightId) { + chart.removeOverlay(highlightId); + } + } catch (err) { + // 忽略清理时的错误 + } + } + }; + }, [chart, markerId, highlightId]); + + return { + marker, + markerId, + createMarker, + removeMarker, + removeAllMarkers, + }; +}; diff --git a/src/components/StockChart/hooks/useKLineChart.ts b/src/components/StockChart/hooks/useKLineChart.ts new file mode 100644 index 00000000..ffa82e0b --- /dev/null +++ b/src/components/StockChart/hooks/useKLineChart.ts @@ -0,0 +1,247 @@ +/** + * useKLineChart Hook + * + * 管理 KLineChart 实例的初始化、配置和销毁 + */ + +import { useEffect, useRef, useState } from 'react'; +import { init, dispose, registerIndicator } from 'klinecharts'; +import type { Chart } from 'klinecharts'; +// import { useColorMode } from '@chakra-ui/react'; // ❌ 已移除深色模式支持 +import { getTheme, getTimelineTheme } from '../config/klineTheme'; +import { CHART_INIT_OPTIONS } from '../config'; +import { logger } from '@utils/logger'; +import { avgPriceIndicator } from '../indicators/avgPriceIndicator'; + +export interface UseKLineChartOptions { + /** 图表容器 ID */ + containerId: string; + /** 图表高度(px) */ + height?: number; + /** 是否自动调整大小 */ + autoResize?: boolean; + /** 图表类型(timeline/daily) */ + chartType?: 'timeline' | 'daily'; +} + +export interface UseKLineChartReturn { + /** KLineChart 实例 */ + chart: Chart | null; + /** 容器 Ref */ + chartRef: React.RefObject; + /** 是否已初始化 */ + isInitialized: boolean; + /** 初始化错误 */ + error: Error | null; +} + +/** + * KLineChart 初始化和生命周期管理 Hook + * + * @param options 配置选项 + * @returns UseKLineChartReturn + * + * @example + * const { chart, chartRef, isInitialized } = useKLineChart({ + * containerId: 'kline-chart', + * height: 400, + * autoResize: true, + * }); + */ +export const useKLineChart = ( + options: UseKLineChartOptions +): UseKLineChartReturn => { + const { containerId, height = 400, autoResize = true, chartType = 'daily' } = options; + + const chartRef = useRef(null); + const chartInstanceRef = useRef(null); + const [chartInstance, setChartInstance] = useState(null); // ✅ 新增:chart state(触发重渲染) + const [isInitialized, setIsInitialized] = useState(false); + const [error, setError] = useState(null); + + // ✅ 固定使用浅色主题(已移除 useColorMode) + const colorMode = 'light'; + + // 全局注册自定义均价线指标(只执行一次) + useEffect(() => { + try { + registerIndicator(avgPriceIndicator); + logger.debug('useKLineChart', '✅ 自定义均价线指标(AVG)注册成功'); + } catch (err) { + // 如果已注册会报错,忽略即可 + logger.debug('useKLineChart', 'AVG指标已注册或注册失败', err); + } + }, []); + + // 图表初始化(添加延迟重试机制,处理 Modal 动画延迟) + useEffect(() => { + // 图表初始化函数 + const initChart = (): boolean => { + if (!chartRef.current) { + logger.warn('useKLineChart', 'init', '图表容器未挂载,将在 50ms 后重试', { containerId }); + return false; + } + + try { + logger.debug('useKLineChart', 'init', '开始初始化图表', { + containerId, + height, + colorMode, + }); + + // 初始化图表实例(KLineChart 10.0 API) + // ✅ 根据 chartType 选择主题 + const themeStyles = chartType === 'timeline' + ? getTimelineTheme(colorMode) + : getTheme(colorMode); + + const chartInstance = init(chartRef.current, { + ...CHART_INIT_OPTIONS, + // 设置初始样式(根据主题和图表类型) + styles: themeStyles, + }); + + if (!chartInstance) { + throw new Error('图表初始化失败:返回 null'); + } + + chartInstanceRef.current = chartInstance; + setChartInstance(chartInstance); // ✅ 新增:更新 state,触发重渲染 + setIsInitialized(true); + setError(null); + + // ✅ 新增:创建成交量指标窗格 + try { + const volumePaneId = chartInstance.createIndicator('VOL', false, { + height: 100, // 固定高度 100px(约占整体的 20-25%) + }); + + logger.debug('useKLineChart', 'init', '成交量窗格创建成功', { + volumePaneId, + }); + } catch (err) { + logger.warn('useKLineChart', 'init', '成交量窗格创建失败', { + error: err, + }); + // 不阻塞主流程,继续执行 + } + + logger.info('useKLineChart', 'init', '✅ 图表初始化成功', { + containerId, + chartId: chartInstance.id, + }); + + return true; + } catch (err) { + const error = err as Error; + logger.error('useKLineChart', 'init', error, { containerId }); + setError(error); + setIsInitialized(false); + return false; + } + }; + + // 立即尝试初始化 + if (initChart()) { + // 成功,直接返回清理函数 + return () => { + if (chartInstanceRef.current) { + logger.debug('useKLineChart', 'dispose', '销毁图表实例', { + containerId, + chartId: chartInstanceRef.current.id, + }); + + dispose(chartInstanceRef.current); + chartInstanceRef.current = null; + setChartInstance(null); // ✅ 新增:清空 state + setIsInitialized(false); + } + }; + } + + // 失败则延迟重试(处理 Modal 动画延迟导致的 DOM 未挂载) + const timer = setTimeout(() => { + logger.debug('useKLineChart', 'init', '执行延迟重试', { containerId }); + initChart(); + }, 50); + + // 清理函数:清除定时器和销毁图表实例 + return () => { + clearTimeout(timer); + if (chartInstanceRef.current) { + logger.debug('useKLineChart', 'dispose', '销毁图表实例', { + containerId, + chartId: chartInstanceRef.current.id, + }); + + dispose(chartInstanceRef.current); + chartInstanceRef.current = null; + setChartInstance(null); // ✅ 新增:清空 state + setIsInitialized(false); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [containerId, chartType]); // containerId 或 chartType 变化时重新初始化 + + // 主题切换:更新图表样式 + useEffect(() => { + if (!chartInstanceRef.current || !isInitialized) { + return; + } + + try { + // ✅ 根据 chartType 选择主题 + const newTheme = chartType === 'timeline' + ? getTimelineTheme(colorMode) + : getTheme(colorMode); + chartInstanceRef.current.setStyles(newTheme); + + logger.debug('useKLineChart', 'updateTheme', '更新图表主题', { + colorMode, + chartType, + chartId: chartInstanceRef.current.id, + }); + } catch (err) { + logger.error('useKLineChart', 'updateTheme', err as Error, { colorMode, chartType }); + } + }, [colorMode, chartType, isInitialized]); + + // 容器尺寸变化:调整图表大小 + useEffect(() => { + if (!chartInstanceRef.current || !isInitialized || !autoResize) { + return; + } + + const handleResize = () => { + if (chartInstanceRef.current) { + chartInstanceRef.current.resize(); + logger.debug('useKLineChart', 'resize', '调整图表大小'); + } + }; + + // 监听窗口大小变化 + window.addEventListener('resize', handleResize); + + // 使用 ResizeObserver 监听容器大小变化(更精确) + let resizeObserver: ResizeObserver | null = null; + if (chartRef.current && typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(chartRef.current); + } + + return () => { + window.removeEventListener('resize', handleResize); + if (resizeObserver && chartRef.current) { + resizeObserver.unobserve(chartRef.current); + resizeObserver.disconnect(); + } + }; + }, [isInitialized, autoResize]); + + return { + chart: chartInstance, // ✅ 返回 state 而非 ref,确保变化触发重渲染 + chartRef, + isInitialized, + error, + }; +}; diff --git a/src/components/StockChart/hooks/useKLineData.ts b/src/components/StockChart/hooks/useKLineData.ts new file mode 100644 index 00000000..693a6cf6 --- /dev/null +++ b/src/components/StockChart/hooks/useKLineData.ts @@ -0,0 +1,329 @@ +/** + * useKLineData Hook + * + * 管理 K 线数据的加载、转换和更新 + */ + +import { useEffect, useState, useCallback } from 'react'; +import type { Chart } from 'klinecharts'; +import type { ChartType, KLineDataPoint, RawDataPoint } from '../types'; +import { processChartData } from '../utils/dataAdapter'; +import { logger } from '@utils/logger'; +import { stockService } from '@services/eventService'; +import { klineDataCache, getCacheKey } from '@views/Community/components/StockDetailPanel/utils/klineDataCache'; + +export interface UseKLineDataOptions { + /** KLineChart 实例 */ + chart: Chart | null; + /** 股票代码 */ + stockCode: string; + /** 图表类型 */ + chartType: ChartType; + /** 事件时间(用于调整数据加载范围) */ + eventTime?: string; + /** 是否自动加载数据 */ + autoLoad?: boolean; +} + +export interface UseKLineDataReturn { + /** 处理后的 K 线数据 */ + data: KLineDataPoint[]; + /** 原始数据 */ + rawData: RawDataPoint[]; + /** 是否加载中 */ + loading: boolean; + /** 加载错误 */ + error: Error | null; + /** 手动加载数据 */ + loadData: () => Promise; + /** 更新数据 */ + updateData: (newData: KLineDataPoint[]) => void; + /** 清空数据 */ + clearData: () => void; +} + +/** + * K 线数据加载和管理 Hook + * + * @param options 配置选项 + * @returns UseKLineDataReturn + * + * @example + * const { data, loading, error, loadData } = useKLineData({ + * chart, + * stockCode: '600000.SH', + * chartType: 'daily', + * eventTime: '2024-01-01 10:00:00', + * autoLoad: true, + * }); + */ +export const useKLineData = ( + options: UseKLineDataOptions +): UseKLineDataReturn => { + const { + chart, + stockCode, + chartType, + eventTime, + autoLoad = true, + } = options; + + const [data, setData] = useState([]); + const [rawData, setRawData] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + /** + * 加载数据(从后端 API) + */ + const loadData = useCallback(async () => { + if (!stockCode) { + logger.warn('useKLineData', 'loadData', '股票代码为空', { chartType }); + return; + } + + setLoading(true); + setError(null); + + try { + logger.debug('useKLineData', 'loadData', '开始加载数据', { + stockCode, + chartType, + eventTime, + }); + + // 1. 先检查缓存 + const cacheKey = getCacheKey(stockCode, eventTime, chartType); + const cachedData = klineDataCache.get(cacheKey); + + let rawDataList; + + if (cachedData && cachedData.length > 0) { + // 使用缓存数据 + rawDataList = cachedData; + } else { + // 2. 缓存没有数据,调用 API 请求 + const response = await stockService.getKlineData( + stockCode, + chartType, + eventTime + ); + + if (!response || !response.data) { + throw new Error('后端返回数据为空'); + } + + rawDataList = response.data; + + // 3. 将数据写入缓存(避免下次重复请求) + klineDataCache.set(cacheKey, rawDataList); + } + + setRawData(rawDataList); + + // 数据转换和处理 + const processedData = processChartData(rawDataList, chartType, eventTime); + + setData(processedData); + + logger.info('useKLineData', 'loadData', '数据加载成功', { + stockCode, + chartType, + rawCount: rawDataList.length, + processedCount: processedData.length, + }); + } catch (err) { + const error = err as Error; + logger.error('useKLineData', 'loadData', error, { + stockCode, + chartType, + }); + setError(error); + setData([]); + setRawData([]); + } finally { + setLoading(false); + } + }, [stockCode, chartType, eventTime]); + + /** + * 更新图表数据(使用 setDataLoader 方法) + */ + const updateChartData = useCallback( + (klineData: KLineDataPoint[]) => { + if (!chart || klineData.length === 0) { + return; + } + + try { + // 步骤 1: 设置 symbol(必需!getBars 调用的前置条件) + (chart as any).setSymbol({ + ticker: stockCode || 'UNKNOWN', // 股票代码 + pricePrecision: 2, // 价格精度(2位小数) + volumePrecision: 0 // 成交量精度(整数) + }); + + // 步骤 2: 设置 period(必需!getBars 调用的前置条件) + const periodType = chartType === 'timeline' ? 'minute' : 'day'; + (chart as any).setPeriod({ + type: periodType, // 分时图=minute,日K=day + span: 1 // 周期跨度(1分钟/1天) + }); + + // 步骤 3: 设置 DataLoader(同步数据加载器) + (chart as any).setDataLoader({ + getBars: (params: any) => { + if (params.type === 'init') { + // 初始化加载:返回完整数据 + params.callback(klineData, false); // false = 无更多数据可加载 + } else if (params.type === 'forward' || params.type === 'backward') { + // 向前/向后加载:我们没有更多数据,返回空数组 + params.callback([], false); + } + } + }); + + // 步骤 4: 触发初始化加载(这会调用 getBars with type="init") + (chart as any).resetData(); + + // 步骤 5: 根据数据量调整可见范围和柱子间距(让 K 线柱子填满图表区域) + setTimeout(() => { + try { + const dataLength = klineData.length; + + if (dataLength > 0) { + // 获取图表容器宽度 + const chartDom = (chart as any).getDom(); + const chartWidth = chartDom?.clientWidth || 1200; + + // 计算最优柱子间距 + // 公式:barSpace = (图表宽度 / 数据数量) * 0.7 + // 0.7 是为了留出一些间距,让图表不会太拥挤 + const optimalBarSpace = Math.max(8, Math.min(50, (chartWidth / dataLength) * 0.7)); + + (chart as any).setBarSpace(optimalBarSpace); + + // 减少右侧空白(默认值可能是 100-200,调小会减少右侧空白) + (chart as any).setOffsetRightDistance(50); + } + } catch (err) { + logger.error('useKLineData', 'updateChartData', err as Error, { + step: '调整可见范围失败', + }); + } + }, 100); // 延迟 100ms 确保数据已加载和渲染 + + // ✅ 步骤 4: 分时图添加均价线(使用自定义 AVG 指标) + if (chartType === 'timeline' && klineData.length > 0) { + setTimeout(() => { + try { + // 在主图窗格创建 AVG 均价线指标 + (chart as any).createIndicator('AVG', true, { + id: 'candle_pane', // 主图窗格 + }); + + console.log('[DEBUG] ✅ 均价线(AVG指标)添加成功'); + } catch (err) { + console.error('[DEBUG] ❌ 均价线添加失败:', err); + } + }, 150); // 延迟 150ms,确保数据加载完成后再创建指标 + + // ✅ 步骤 5: 添加昨收价基准线(灰色虚线) + setTimeout(() => { + try { + const prevClose = klineData[0]?.prev_close; + if (prevClose && prevClose > 0) { + // 创建水平线覆盖层 + (chart as any).createOverlay({ + name: 'horizontalStraightLine', + id: 'prev_close_line', + points: [{ value: prevClose }], + styles: { + line: { + style: 'dashed', + dashValue: [4, 2], + size: 1, + color: '#888888', // 灰色虚线 + }, + }, + extendData: { + label: `昨收: ${prevClose.toFixed(2)}`, + }, + }); + + console.log('[DEBUG] ✅ 昨收价基准线添加成功:', prevClose); + } + } catch (err) { + console.error('[DEBUG] ❌ 昨收价基准线添加失败:', err); + } + }, 200); // 延迟 200ms,确保均价线创建完成后再添加 + } + + logger.debug( + 'useKLineData', + `updateChartData - ${stockCode} (${chartType}) - ${klineData.length}条数据加载成功` + ); + } catch (err) { + logger.error('useKLineData', 'updateChartData', err as Error, { + dataCount: klineData.length, + }); + } + }, + [chart, stockCode, chartType] + ); + + /** + * 手动更新数据(外部调用) + */ + const updateData = useCallback( + (newData: KLineDataPoint[]) => { + setData(newData); + updateChartData(newData); + + logger.debug( + 'useKLineData', + `updateData - ${stockCode} (${chartType}) - ${newData.length}条数据手动更新` + ); + }, + [updateChartData] + ); + + /** + * 清空数据 + */ + const clearData = useCallback(() => { + setData([]); + setRawData([]); + setError(null); + + if (chart) { + chart.resetData(); + logger.debug('useKLineData', `clearData - chartId: ${(chart as any).id}`); + } + }, [chart]); + + // 自动加载数据(当 stockCode/chartType/eventTime 变化时) + useEffect(() => { + if (autoLoad && stockCode && chart) { + loadData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stockCode, chartType, eventTime, autoLoad, chart]); + + // 数据变化时更新图表 + useEffect(() => { + if (data.length > 0 && chart) { + updateChartData(data); + } + }, [data, chart, updateChartData]); + + return { + data, + rawData, + loading, + error, + loadData, + updateData, + clearData, + }; +}; diff --git a/src/components/StockChart/indicators/avgPriceIndicator.ts b/src/components/StockChart/indicators/avgPriceIndicator.ts new file mode 100644 index 00000000..3c0e44b8 --- /dev/null +++ b/src/components/StockChart/indicators/avgPriceIndicator.ts @@ -0,0 +1,93 @@ +/** + * 自定义均价线指标 + * + * 用于分时图显示橙黄色均价线 + * 计算公式:累计成交额 / 累计成交量 + */ + +import type { Indicator, KLineData } from 'klinecharts'; + +export const avgPriceIndicator: Indicator = { + name: 'AVG', + shortName: 'AVG', + calcParams: [], + shouldOhlc: false, // 不显示 OHLC 信息 + shouldFormatBigNumber: false, + precision: 2, + minValue: null, + maxValue: null, + + figures: [ + { + key: 'avg', + title: '均价: ', + type: 'line', + }, + ], + + /** + * 计算均价 + * @param dataList K线数据列表 + * @returns 均价数据 + */ + calc: (dataList: KLineData[]) => { + let totalAmount = 0; // 累计成交额 + let totalVolume = 0; // 累计成交量 + + return dataList.map((kLineData) => { + const { close = 0, volume = 0 } = kLineData; + + totalAmount += close * volume; + totalVolume += volume; + + const avgPrice = totalVolume > 0 ? totalAmount / totalVolume : close; + + return { avg: avgPrice }; + }); + }, + + /** + * 绘制样式配置 + */ + styles: { + lines: [ + { + color: '#FF9800', // 橙黄色 + size: 2, + style: 'solid', + smooth: true, + }, + ], + }, + + /** + * Tooltip 格式化(显示均价 + 涨跌幅) + */ + createTooltipDataSource: ({ kLineData, indicator, defaultStyles }: any) => { + if (!indicator?.avg) { + return { + title: { text: '均价', color: defaultStyles.tooltip.text.color }, + value: { text: '--', color: '#FF9800' }, + }; + } + + const avgPrice = indicator.avg; + const prevClose = kLineData?.prev_close; + + // 计算均价涨跌幅 + let changeText = `¥${avgPrice.toFixed(2)}`; + if (prevClose && prevClose > 0) { + const changePercent = ((avgPrice - prevClose) / prevClose * 100).toFixed(2); + const changeValue = (avgPrice - prevClose).toFixed(2); + changeText = `¥${avgPrice.toFixed(2)} (${changeValue}, ${changePercent}%)`; + } + + return { + title: { text: '均价', color: defaultStyles.tooltip.text.color }, + value: { + text: changeText, + color: '#FF9800', + }, + }; + }, +}; diff --git a/src/components/StockChart/types/chart.types.ts b/src/components/StockChart/types/chart.types.ts new file mode 100644 index 00000000..da2f9164 --- /dev/null +++ b/src/components/StockChart/types/chart.types.ts @@ -0,0 +1,126 @@ +/** + * KLineChart 图表类型定义 + * + * 适配 klinecharts@10.0.0-beta1 + * 文档: https://github.com/klinecharts/KLineChart + */ + +/** + * K 线数据点(符合 KLineChart 10.0 规范) + * + * 注意: 10.0 版本要求 timestamp 为数字类型(毫秒时间戳) + */ +export interface KLineDataPoint { + /** 时间戳(毫秒) */ + timestamp: number; + /** 开盘价 */ + open: number; + /** 最高价 */ + high: number; + /** 最低价 */ + low: number; + /** 收盘价 */ + close: number; + /** 成交量 */ + volume: number; + /** 成交额(可选) */ + turnover?: number; + /** 昨收价(用于百分比计算和基准线)- 分时图专用 */ + prev_close?: number; +} + +/** + * 后端原始数据格式 + * + * 支持多种时间字段格式(time/date/timestamp) + */ +export interface RawDataPoint { + /** 时间字符串(分时图格式:HH:mm) */ + time?: string; + /** 日期字符串(日线格式:YYYY-MM-DD) */ + date?: string; + /** 时间戳字符串或数字 */ + timestamp?: string | number; + /** 开盘价 */ + open: number; + /** 最高价 */ + high: number; + /** 最低价 */ + low: number; + /** 收盘价 */ + close: number; + /** 成交量 */ + volume: number; + /** 均价(分时图专用) */ + avg_price?: number; + /** 昨收价(用于百分比计算和基准线)- 分时图专用 */ + prev_close?: number; +} + +/** + * 图表类型枚举 + */ +export type ChartType = 'timeline' | 'daily'; + +/** + * 图表配置接口 + */ +export interface ChartConfig { + /** 图表类型 */ + type: ChartType; + /** 显示技术指标 */ + showIndicators: boolean; + /** 默认技术指标列表 */ + defaultIndicators?: string[]; + /** 图表高度(px) */ + height?: number; + /** 是否显示网格 */ + showGrid?: boolean; + /** 是否显示十字光标 */ + showCrosshair?: boolean; +} + +/** + * 事件标记接口 + * + * 用于在 K 线图上标记重要事件发生时间点 + */ +export interface EventMarker { + /** 唯一标识 */ + id: string; + /** 时间戳(毫秒) */ + timestamp: number; + /** 标签文本 */ + label: string; + /** 标记位置 */ + position: 'top' | 'middle' | 'bottom'; + /** 标记颜色 */ + color: string; + /** 图标(可选) */ + icon?: string; + /** 是否可拖动(默认 false) */ + draggable?: boolean; +} + +/** + * DataLoader 回调参数(KLineChart 10.0 新增) + */ +export interface DataLoaderCallbackParams { + /** K 线数据 */ + data: KLineDataPoint[]; + /** 是否还有更多数据 */ + more: boolean; +} + +/** + * DataLoader getBars 参数(KLineChart 10.0 新增) + */ +export interface DataLoaderGetBarsParams { + /** 回调函数 */ + callback: (data: KLineDataPoint[], options?: { more: boolean }) => void; + /** 范围参数(可选) */ + range?: { + from: number; + to: number; + }; +} diff --git a/src/components/StockChart/types/index.ts b/src/components/StockChart/types/index.ts new file mode 100644 index 00000000..204a38d6 --- /dev/null +++ b/src/components/StockChart/types/index.ts @@ -0,0 +1,25 @@ +/** + * StockChart 类型定义统一导出 + * + * 使用方式: + * import type { KLineDataPoint, StockInfo } from '@components/StockChart/types'; + */ + +// 图表相关类型 +export type { + KLineDataPoint, + RawDataPoint, + ChartType, + ChartConfig, + EventMarker, + DataLoaderCallbackParams, + DataLoaderGetBarsParams, +} from './chart.types'; + +// 股票相关类型 +export type { + StockInfo, + ChartDataResponse, + StockQuote, + EventInfo, +} from './stock.types'; diff --git a/src/components/StockChart/types/stock.types.ts b/src/components/StockChart/types/stock.types.ts new file mode 100644 index 00000000..f972f963 --- /dev/null +++ b/src/components/StockChart/types/stock.types.ts @@ -0,0 +1,80 @@ +/** + * 股票相关类型定义 + * + * 用于股票信息和图表数据的类型声明 + */ + +import type { RawDataPoint } from './chart.types'; + +/** + * 股票基础信息 + */ +export interface StockInfo { + /** 股票代码(如:600000.SH) */ + stock_code: string; + /** 股票名称(如:浦发银行) */ + stock_name: string; + /** 关联描述(可能是字符串或对象) */ + relation_desc?: + | string + | { + /** 数据字段 */ + data?: string; + /** 内容字段 */ + content?: string; + }; +} + +/** + * 图表数据 API 响应格式 + */ +export interface ChartDataResponse { + /** K 线数据数组 */ + data: RawDataPoint[]; + /** 交易日期(YYYY-MM-DD) */ + trade_date?: string; + /** 昨收价 */ + prev_close?: number; + /** 状态码(可选) */ + code?: number; + /** 消息(可选) */ + message?: string; +} + +/** + * 股票实时行情 + */ +export interface StockQuote { + /** 股票代码 */ + stock_code: string; + /** 当前价 */ + price: number; + /** 涨跌幅(%) */ + change_percent: number; + /** 涨跌额 */ + change_amount: number; + /** 成交量 */ + volume: number; + /** 成交额 */ + turnover: number; + /** 更新时间 */ + update_time: string; +} + +/** + * 事件信息(用于事件中心) + */ +export interface EventInfo { + /** 事件 ID */ + id: number | string; + /** 事件标题 */ + title: string; + /** 事件内容 */ + content: string; + /** 事件发生时间(ISO 字符串) */ + event_time: string; + /** 重要性等级(1-5) */ + importance?: number; + /** 关联股票列表 */ + related_stocks?: StockInfo[]; +} diff --git a/src/components/StockChart/utils/chartUtils.ts b/src/components/StockChart/utils/chartUtils.ts new file mode 100644 index 00000000..de2de104 --- /dev/null +++ b/src/components/StockChart/utils/chartUtils.ts @@ -0,0 +1,295 @@ +/** + * 图表通用工具函数 + * + * 包含图表初始化、技术指标管理等通用逻辑 + */ + +import type { Chart } from 'klinecharts'; +import { logger } from '@utils/logger'; + +/** + * 安全地执行图表操作(捕获异常) + * + * @param operation 操作名称 + * @param fn 执行函数 + * @returns T | null 执行结果或 null + */ +export const safeChartOperation = ( + operation: string, + fn: () => T +): T | null => { + try { + return fn(); + } catch (error) { + logger.error('chartUtils', operation, error as Error); + return null; + } +}; + +/** + * 创建技术指标 + * + * @param chart KLineChart 实例 + * @param indicatorName 指标名称(如 'MA', 'MACD', 'VOL') + * @param params 指标参数(可选) + * @param isStack 是否叠加(主图指标为 true,副图为 false) + * @returns string | null 指标 ID + */ +export const createIndicator = ( + chart: Chart, + indicatorName: string, + params?: number[], + isStack: boolean = false +): string | null => { + return safeChartOperation(`createIndicator:${indicatorName}`, () => { + const indicatorId = chart.createIndicator( + { + name: indicatorName, + ...(params && { calcParams: params }), + }, + isStack + ); + + logger.debug('chartUtils', 'createIndicator', '创建技术指标', { + indicatorName, + params, + isStack, + indicatorId, + }); + + return indicatorId; + }); +}; + +/** + * 移除技术指标 + * + * @param chart KLineChart 实例 + * @param indicatorId 指标 ID(不传则移除所有指标) + */ +export const removeIndicator = (chart: Chart, indicatorId?: string): void => { + safeChartOperation('removeIndicator', () => { + chart.removeIndicator(indicatorId); + logger.debug('chartUtils', 'removeIndicator', '移除技术指标', { indicatorId }); + }); +}; + +/** + * 批量创建副图指标 + * + * @param chart KLineChart 实例 + * @param indicators 指标名称数组 + * @returns string[] 指标 ID 数组 + */ +export const createSubIndicators = ( + chart: Chart, + indicators: string[] +): string[] => { + const ids: string[] = []; + + indicators.forEach((name) => { + const id = createIndicator(chart, name, undefined, false); + if (id) { + ids.push(id); + } + }); + + logger.debug('chartUtils', 'createSubIndicators', '批量创建副图指标', { + indicators, + createdIds: ids, + }); + + return ids; +}; + +/** + * 设置图表缩放级别 + * + * @param chart KLineChart 实例 + * @param zoom 缩放级别(0.5 - 2.0) + */ +export const setChartZoom = (chart: Chart, zoom: number): void => { + safeChartOperation('setChartZoom', () => { + // KLineChart 10.0: 使用 setBarSpace 方法调整 K 线宽度(实现缩放效果) + const baseBarSpace = 8; // 默认 K 线宽度(px) + const newBarSpace = Math.max(4, Math.min(16, baseBarSpace * zoom)); + + // 注意:KLineChart 10.0 可能没有直接的 zoom API,需要通过调整样式实现 + chart.setStyles({ + candle: { + bar: { + upBorderColor: undefined, // 保持默认 + upColor: undefined, + downBorderColor: undefined, + downColor: undefined, + }, + // 通过调整蜡烛图宽度实现缩放效果 + tooltip: { + showRule: 'always', + }, + }, + }); + + logger.debug('chartUtils', 'setChartZoom', '设置图表缩放', { + zoom, + newBarSpace, + }); + }); +}; + +/** + * 滚动到指定时间 + * + * @param chart KLineChart 实例 + * @param timestamp 目标时间戳 + */ +export const scrollToTimestamp = (chart: Chart, timestamp: number): void => { + safeChartOperation('scrollToTimestamp', () => { + // KLineChart 10.0: 使用 scrollToTimestamp 方法 + chart.scrollToTimestamp(timestamp); + + logger.debug('chartUtils', 'scrollToTimestamp', '滚动到指定时间', { timestamp }); + }); +}; + +/** + * 调整图表大小(响应式) + * + * @param chart KLineChart 实例 + */ +export const resizeChart = (chart: Chart): void => { + safeChartOperation('resizeChart', () => { + chart.resize(); + logger.debug('chartUtils', 'resizeChart', '调整图表大小'); + }); +}; + +/** + * 获取图表可见数据范围 + * + * @param chart KLineChart 实例 + * @returns { from: number, to: number } | null 可见范围 + */ +export const getVisibleRange = (chart: Chart): { from: number; to: number } | null => { + return safeChartOperation('getVisibleRange', () => { + const data = chart.getDataList(); + if (!data || data.length === 0) { + return null; + } + + // 简化实现:返回所有数据范围 + // 实际项目中可通过 chart 的内部状态获取可见范围 + return { + from: 0, + to: data.length - 1, + }; + }); +}; + +/** + * 清空图表数据 + * + * @param chart KLineChart 实例 + */ +export const clearChartData = (chart: Chart): void => { + safeChartOperation('clearChartData', () => { + chart.resetData(); + logger.debug('chartUtils', 'clearChartData', '清空图表数据'); + }); +}; + +/** + * 截图(导出图表为图片) + * + * @param chart KLineChart 实例 + * @param includeOverlay 是否包含 overlay + * @returns string | null Base64 图片数据 + */ +export const exportChartImage = ( + chart: Chart, + includeOverlay: boolean = true +): string | null => { + return safeChartOperation('exportChartImage', () => { + // KLineChart 10.0: 使用 getConvertPictureUrl 方法 + const imageData = chart.getConvertPictureUrl(includeOverlay, 'png', '#ffffff'); + + logger.debug('chartUtils', 'exportChartImage', '导出图表图片', { + includeOverlay, + hasData: !!imageData, + }); + + return imageData; + }); +}; + +/** + * 切换十字光标显示 + * + * @param chart KLineChart 实例 + * @param show 是否显示 + */ +export const toggleCrosshair = (chart: Chart, show: boolean): void => { + safeChartOperation('toggleCrosshair', () => { + chart.setStyles({ + crosshair: { + show, + }, + }); + + logger.debug('chartUtils', 'toggleCrosshair', '切换十字光标', { show }); + }); +}; + +/** + * 切换网格显示 + * + * @param chart KLineChart 实例 + * @param show 是否显示 + */ +export const toggleGrid = (chart: Chart, show: boolean): void => { + safeChartOperation('toggleGrid', () => { + chart.setStyles({ + grid: { + show, + }, + }); + + logger.debug('chartUtils', 'toggleGrid', '切换网格', { show }); + }); +}; + +/** + * 订阅图表事件 + * + * @param chart KLineChart 实例 + * @param eventName 事件名称 + * @param handler 事件处理函数 + */ +export const subscribeChartEvent = ( + chart: Chart, + eventName: string, + handler: (...args: any[]) => void +): void => { + safeChartOperation(`subscribeChartEvent:${eventName}`, () => { + chart.subscribeAction(eventName, handler); + logger.debug('chartUtils', 'subscribeChartEvent', '订阅图表事件', { eventName }); + }); +}; + +/** + * 取消订阅图表事件 + * + * @param chart KLineChart 实例 + * @param eventName 事件名称 + * @param handler 事件处理函数 + */ +export const unsubscribeChartEvent = ( + chart: Chart, + eventName: string, + handler: (...args: any[]) => void +): void => { + safeChartOperation(`unsubscribeChartEvent:${eventName}`, () => { + chart.unsubscribeAction(eventName, handler); + logger.debug('chartUtils', 'unsubscribeChartEvent', '取消订阅图表事件', { eventName }); + }); +}; diff --git a/src/components/StockChart/utils/dataAdapter.ts b/src/components/StockChart/utils/dataAdapter.ts new file mode 100644 index 00000000..671b7c54 --- /dev/null +++ b/src/components/StockChart/utils/dataAdapter.ts @@ -0,0 +1,320 @@ +/** + * 数据转换适配器 + * + * 将后端返回的各种格式数据转换为 KLineChart 10.0 所需的标准格式 + */ + +import dayjs from 'dayjs'; +import type { KLineDataPoint, RawDataPoint, ChartType } from '../types'; +import { logger } from '@utils/logger'; + +/** + * 将后端原始数据转换为 KLineChart 标准格式 + * + * @param rawData 后端原始数据数组 + * @param chartType 图表类型(timeline/daily) + * @param eventTime 事件时间(用于日期基准) + * @returns KLineDataPoint[] 标准K线数据 + */ +export const convertToKLineData = ( + rawData: RawDataPoint[], + chartType: ChartType, + eventTime?: string +): KLineDataPoint[] => { + if (!rawData || !Array.isArray(rawData) || rawData.length === 0) { + logger.warn('dataAdapter', 'convertToKLineData', '原始数据为空', { chartType }); + return []; + } + + try { + return rawData.map((item, index) => { + const timestamp = parseTimestamp(item, chartType, eventTime, index); + + return { + timestamp, + open: Number(item.open) || 0, + high: Number(item.high) || 0, + low: Number(item.low) || 0, + close: Number(item.close) || 0, + volume: Number(item.volume) || 0, + turnover: item.turnover ? Number(item.turnover) : undefined, + prev_close: item.prev_close ? Number(item.prev_close) : undefined, // ✅ 新增:昨收价(用于百分比计算和基准线) + }; + }); + } catch (error) { + logger.error('dataAdapter', 'convertToKLineData', error as Error, { + chartType, + dataLength: rawData.length, + }); + return []; + } +}; + +/** + * 解析时间戳(兼容多种时间格式) + * + * @param item 原始数据项 + * @param chartType 图表类型 + * @param eventTime 事件时间 + * @param index 数据索引(用于分时图时间推算) + * @returns number 毫秒时间戳 + */ +const parseTimestamp = ( + item: RawDataPoint, + chartType: ChartType, + eventTime?: string, + index?: number +): number => { + // 优先级1: 使用 timestamp 字段 + if (item.timestamp) { + const ts = typeof item.timestamp === 'number' ? item.timestamp : Number(item.timestamp); + // 判断是秒级还是毫秒级时间戳 + return ts > 10000000000 ? ts : ts * 1000; + } + + // 优先级2: 使用 date 字段(日K线) + if (item.date) { + return dayjs(item.date).valueOf(); + } + + // 优先级3: 使用 time 字段(分时图) + if (item.time && eventTime) { + return parseTimelineTimestamp(item.time, eventTime); + } + + // 优先级4: 根据 chartType 和 index 推算(兜底逻辑) + if (chartType === 'timeline' && eventTime && typeof index === 'number') { + // 分时图:从事件时间推算(假设 09:30 开盘) + const baseTime = dayjs(eventTime).startOf('day').add(9, 'hour').add(30, 'minute'); + return baseTime.add(index, 'minute').valueOf(); + } + + // 默认返回当前时间(避免图表崩溃) + logger.warn('dataAdapter', 'parseTimestamp', '无法解析时间戳,使用当前时间', { item }); + return Date.now(); +}; + +/** + * 解析分时图时间戳 + * + * 将 "HH:mm" 格式转换为完整时间戳 + * + * @param time 时间字符串(如 "09:30") + * @param eventTime 事件时间(YYYY-MM-DD HH:mm:ss) + * @returns number 毫秒时间戳 + */ +const parseTimelineTimestamp = (time: string, eventTime: string): number => { + try { + const [hours, minutes] = time.split(':').map(Number); + const eventDate = dayjs(eventTime).startOf('day'); + return eventDate.hour(hours).minute(minutes).second(0).valueOf(); + } catch (error) { + logger.error('dataAdapter', 'parseTimelineTimestamp', error as Error, { time, eventTime }); + return dayjs(eventTime).valueOf(); + } +}; + +/** + * 数据验证和清洗 + * + * 移除无效数据(价格/成交量异常) + * + * @param data K线数据 + * @returns KLineDataPoint[] 清洗后的数据 + */ +export const validateAndCleanData = (data: KLineDataPoint[]): KLineDataPoint[] => { + return data.filter((item) => { + // 移除价格为 0 或负数的数据 + if (item.open <= 0 || item.high <= 0 || item.low <= 0 || item.close <= 0) { + logger.warn('dataAdapter', 'validateAndCleanData', '价格异常,已移除', { item }); + return false; + } + + // 移除 high < low 的数据(数据错误) + if (item.high < item.low) { + logger.warn('dataAdapter', 'validateAndCleanData', '最高价 < 最低价,已移除', { item }); + return false; + } + + // 移除成交量为负数的数据 + if (item.volume < 0) { + logger.warn('dataAdapter', 'validateAndCleanData', '成交量异常,已移除', { item }); + return false; + } + + return true; + }); +}; + +/** + * 数据排序(按时间升序) + * + * @param data K线数据 + * @returns KLineDataPoint[] 排序后的数据 + */ +export const sortDataByTime = (data: KLineDataPoint[]): KLineDataPoint[] => { + return [...data].sort((a, b) => a.timestamp - b.timestamp); +}; + +/** + * 数据去重(移除时间戳重复的数据,保留最后一条) + * + * @param data K线数据 + * @returns KLineDataPoint[] 去重后的数据 + */ +export const deduplicateData = (data: KLineDataPoint[]): KLineDataPoint[] => { + const map = new Map(); + + data.forEach((item) => { + map.set(item.timestamp, item); // 相同时间戳会覆盖 + }); + + return Array.from(map.values()); +}; + +/** + * 根据事件时间裁剪数据范围(前后2周) + * + * @param data K线数据 + * @param eventTime 事件时间(ISO字符串) + * @param chartType 图表类型 + * @returns KLineDataPoint[] 裁剪后的数据 + */ +export const trimDataByEventTime = ( + data: KLineDataPoint[], + eventTime: string, + chartType: ChartType +): KLineDataPoint[] => { + if (!eventTime || !data || data.length === 0) { + return data; + } + + try { + const eventTimestamp = dayjs(eventTime).valueOf(); + + // 根据图表类型设置不同的时间范围 + let beforeDays: number; + let afterDays: number; + + if (chartType === 'timeline') { + // 分时图:只显示事件当天(前后0天) + beforeDays = 0; + afterDays = 0; + } else { + // 日K线:显示前后14天(2周) + beforeDays = 14; + afterDays = 14; + } + + const startTime = dayjs(eventTime).subtract(beforeDays, 'day').startOf('day').valueOf(); + const endTime = dayjs(eventTime).add(afterDays, 'day').endOf('day').valueOf(); + + const trimmedData = data.filter((item) => { + return item.timestamp >= startTime && item.timestamp <= endTime; + }); + + logger.debug('dataAdapter', 'trimDataByEventTime', '数据时间范围裁剪完成', { + originalLength: data.length, + trimmedLength: trimmedData.length, + eventTime, + chartType, + dateRange: `${dayjs(startTime).format('YYYY-MM-DD')} ~ ${dayjs(endTime).format('YYYY-MM-DD')}`, + }); + + return trimmedData; + } catch (error) { + logger.error('dataAdapter', 'trimDataByEventTime', error as Error, { eventTime }); + return data; // 出错时返回原始数据 + } +}; + +/** + * 完整的数据处理流程 + * + * 转换 → 验证 → 去重 → 排序 → 时间裁剪(如果有 eventTime) + * + * @param rawData 后端原始数据 + * @param chartType 图表类型 + * @param eventTime 事件时间 + * @returns KLineDataPoint[] 处理后的数据 + */ +export const processChartData = ( + rawData: RawDataPoint[], + chartType: ChartType, + eventTime?: string +): KLineDataPoint[] => { + // 1. 转换数据格式 + let data = convertToKLineData(rawData, chartType, eventTime); + + // 2. 验证和清洗 + data = validateAndCleanData(data); + + // 3. 去重 + data = deduplicateData(data); + + // 4. 排序 + data = sortDataByTime(data); + + // 5. 根据事件时间裁剪范围(如果提供了 eventTime) + if (eventTime) { + data = trimDataByEventTime(data, eventTime, chartType); + } + + logger.debug('dataAdapter', 'processChartData', '数据处理完成', { + rawLength: rawData.length, + processedLength: data.length, + chartType, + hasEventTime: !!eventTime, + }); + + return data; +}; + +/** + * 获取数据时间范围 + * + * @param data K线数据 + * @returns { start: number, end: number } 时间范围(毫秒时间戳) + */ +export const getDataTimeRange = ( + data: KLineDataPoint[] +): { start: number; end: number } | null => { + if (!data || data.length === 0) { + return null; + } + + const timestamps = data.map((item) => item.timestamp); + return { + start: Math.min(...timestamps), + end: Math.max(...timestamps), + }; +}; + +/** + * 查找最接近指定时间的数据点 + * + * @param data K线数据 + * @param targetTime 目标时间戳 + * @returns KLineDataPoint | null 最接近的数据点 + */ +export const findClosestDataPoint = ( + data: KLineDataPoint[], + targetTime: number +): KLineDataPoint | null => { + if (!data || data.length === 0) { + return null; + } + + let closest = data[0]; + let minDiff = Math.abs(data[0].timestamp - targetTime); + + data.forEach((item) => { + const diff = Math.abs(item.timestamp - targetTime); + if (diff < minDiff) { + minDiff = diff; + closest = item; + } + }); + + return closest; +}; diff --git a/src/components/StockChart/utils/eventMarkerUtils.ts b/src/components/StockChart/utils/eventMarkerUtils.ts new file mode 100644 index 00000000..b6b6df63 --- /dev/null +++ b/src/components/StockChart/utils/eventMarkerUtils.ts @@ -0,0 +1,360 @@ +/** + * 事件标记工具函数 + * + * 用于在 K 线图上创建、管理事件标记(Overlay) + */ + +import dayjs from 'dayjs'; +import type { OverlayCreate } from 'klinecharts'; +import type { EventMarker, KLineDataPoint } from '../types'; +import { EVENT_MARKER_CONFIG } from '../config'; +import { findClosestDataPoint } from './dataAdapter'; +import { logger } from '@utils/logger'; + +/** + * 创建事件标记 Overlay(KLineChart 10.0 格式) + * + * @param marker 事件标记配置 + * @param data K线数据(用于定位标记位置) + * @returns OverlayCreate | null Overlay 配置对象 + */ +export const createEventMarkerOverlay = ( + marker: EventMarker, + data: KLineDataPoint[] +): OverlayCreate | null => { + try { + // 查找最接近事件时间的数据点 + const closestPoint = findClosestDataPoint(data, marker.timestamp); + + if (!closestPoint) { + logger.warn('eventMarkerUtils', 'createEventMarkerOverlay', '未找到匹配的数据点', { + markerId: marker.id, + timestamp: marker.timestamp, + }); + return null; + } + + // 根据位置计算 Y 坐标 + const yValue = calculateMarkerYPosition(closestPoint, marker.position); + + // 创建 Overlay 配置(KLineChart 10.0 规范) + const overlay: OverlayCreate = { + name: 'simpleAnnotation', // 使用内置的简单标注类型 + id: marker.id, + points: [ + { + timestamp: closestPoint.timestamp, + value: yValue, + }, + ], + styles: { + point: { + color: marker.color, + borderColor: marker.color, + borderSize: 2, + radius: EVENT_MARKER_CONFIG.size.point, + }, + text: { + color: EVENT_MARKER_CONFIG.text.color, + size: EVENT_MARKER_CONFIG.text.fontSize, + family: EVENT_MARKER_CONFIG.text.fontFamily, + weight: 'bold', + }, + rect: { + style: 'fill', + color: marker.color, + borderRadius: EVENT_MARKER_CONFIG.text.borderRadius, + paddingLeft: EVENT_MARKER_CONFIG.text.padding, + paddingRight: EVENT_MARKER_CONFIG.text.padding, + paddingTop: EVENT_MARKER_CONFIG.text.padding, + paddingBottom: EVENT_MARKER_CONFIG.text.padding, + }, + }, + // 标记文本内容 + extendData: { + label: marker.label, + icon: marker.icon, + }, + }; + + logger.debug('eventMarkerUtils', 'createEventMarkerOverlay', '创建事件标记', { + markerId: marker.id, + timestamp: closestPoint.timestamp, + label: marker.label, + }); + + return overlay; + } catch (error) { + logger.error('eventMarkerUtils', 'createEventMarkerOverlay', error as Error, { + markerId: marker.id, + }); + return null; + } +}; + +/** + * 创建事件日K线黄色高亮覆盖层(垂直矩形背景) + * + * @param eventTime 事件时间(ISO字符串) + * @param data K线数据 + * @returns OverlayCreate | null 高亮覆盖层配置 + */ +export const createEventHighlightOverlay = ( + eventTime: string, + data: KLineDataPoint[] +): OverlayCreate | null => { + try { + const eventTimestamp = dayjs(eventTime).valueOf(); + const closestPoint = findClosestDataPoint(data, eventTimestamp); + + if (!closestPoint) { + logger.warn('eventMarkerUtils', 'createEventHighlightOverlay', '未找到匹配的数据点'); + return null; + } + + // 创建垂直矩形覆盖层(从图表顶部到底部的黄色半透明背景) + const overlay: OverlayCreate = { + name: 'rect', // 矩形覆盖层 + id: `event-highlight-${eventTimestamp}`, + points: [ + { + timestamp: closestPoint.timestamp, + value: closestPoint.high * 1.05, // 顶部位置(高于最高价5%) + }, + { + timestamp: closestPoint.timestamp, + value: closestPoint.low * 0.95, // 底部位置(低于最低价5%) + }, + ], + styles: { + style: 'fill', + color: 'rgba(255, 193, 7, 0.15)', // 黄色半透明背景(15%透明度) + borderColor: '#FFD54F', // 黄色边框 + borderSize: 2, + borderStyle: 'solid', + }, + }; + + logger.debug('eventMarkerUtils', 'createEventHighlightOverlay', '创建事件高亮覆盖层', { + timestamp: closestPoint.timestamp, + eventTime, + }); + + return overlay; + } catch (error) { + logger.error('eventMarkerUtils', 'createEventHighlightOverlay', error as Error); + return null; + } +}; + +/** + * 计算标记的 Y 轴位置 + * + * @param dataPoint K线数据点 + * @param position 标记位置(top/middle/bottom) + * @returns number Y轴数值 + */ +const calculateMarkerYPosition = ( + dataPoint: KLineDataPoint, + position: 'top' | 'middle' | 'bottom' +): number => { + switch (position) { + case 'top': + return dataPoint.high * 1.02; // 在最高价上方 2% + case 'bottom': + return dataPoint.low * 0.98; // 在最低价下方 2% + case 'middle': + default: + return (dataPoint.high + dataPoint.low) / 2; // 中间位置 + } +}; + +/** + * 从事件时间创建标记配置 + * + * @param eventTime 事件时间字符串(ISO 格式) + * @param label 标记标签(可选,默认为"事件发生") + * @param color 标记颜色(可选,使用默认颜色) + * @returns EventMarker 事件标记配置 + */ +export const createEventMarkerFromTime = ( + eventTime: string, + label: string = '事件发生', + color: string = EVENT_MARKER_CONFIG.defaultColor +): EventMarker => { + const timestamp = dayjs(eventTime).valueOf(); + + return { + id: `event-${timestamp}`, + timestamp, + label, + position: EVENT_MARKER_CONFIG.defaultPosition, + color, + icon: EVENT_MARKER_CONFIG.defaultIcon, + draggable: false, + }; +}; + +/** + * 批量创建事件标记 Overlays + * + * @param markers 事件标记配置数组 + * @param data K线数据 + * @returns OverlayCreate[] Overlay 配置数组 + */ +export const createEventMarkerOverlays = ( + markers: EventMarker[], + data: KLineDataPoint[] +): OverlayCreate[] => { + if (!markers || markers.length === 0) { + return []; + } + + const overlays: OverlayCreate[] = []; + + markers.forEach((marker) => { + const overlay = createEventMarkerOverlay(marker, data); + if (overlay) { + overlays.push(overlay); + } + }); + + logger.debug('eventMarkerUtils', 'createEventMarkerOverlays', '批量创建事件标记', { + totalMarkers: markers.length, + createdOverlays: overlays.length, + }); + + return overlays; +}; + +/** + * 移除事件标记 + * + * @param chart KLineChart 实例 + * @param markerId 标记 ID + */ +export const removeEventMarker = (chart: any, markerId: string): void => { + try { + chart.removeOverlay(markerId); + logger.debug('eventMarkerUtils', 'removeEventMarker', '移除事件标记', { markerId }); + } catch (error) { + logger.error('eventMarkerUtils', 'removeEventMarker', error as Error, { markerId }); + } +}; + +/** + * 移除所有事件标记 + * + * @param chart KLineChart 实例 + */ +export const removeAllEventMarkers = (chart: any): void => { + try { + // KLineChart 10.0 API: removeOverlay() 不传参数时移除所有 overlays + chart.removeOverlay(); + logger.debug('eventMarkerUtils', 'removeAllEventMarkers', '移除所有事件标记'); + } catch (error) { + logger.error('eventMarkerUtils', 'removeAllEventMarkers', error as Error); + } +}; + +/** + * 更新事件标记 + * + * @param chart KLineChart 实例 + * @param markerId 标记 ID + * @param updates 更新内容(部分字段) + */ +export const updateEventMarker = ( + chart: any, + markerId: string, + updates: Partial +): void => { + try { + // 先移除旧标记 + removeEventMarker(chart, markerId); + + // 重新创建标记(KLineChart 10.0 不支持直接更新 overlay) + // 注意:需要在调用方重新创建并添加 overlay + + logger.debug('eventMarkerUtils', 'updateEventMarker', '更新事件标记', { + markerId, + updates, + }); + } catch (error) { + logger.error('eventMarkerUtils', 'updateEventMarker', error as Error, { markerId }); + } +}; + +/** + * 高亮事件标记(改变样式) + * + * @param chart KLineChart 实例 + * @param markerId 标记 ID + * @param highlight 是否高亮 + */ +export const highlightEventMarker = ( + chart: any, + markerId: string, + highlight: boolean +): void => { + try { + // KLineChart 10.0: 通过 overrideOverlay 修改样式 + chart.overrideOverlay({ + id: markerId, + styles: { + point: { + activeRadius: highlight ? 10 : EVENT_MARKER_CONFIG.size.point, + activeBorderSize: highlight ? 3 : 2, + }, + }, + }); + + logger.debug('eventMarkerUtils', 'highlightEventMarker', '高亮事件标记', { + markerId, + highlight, + }); + } catch (error) { + logger.error('eventMarkerUtils', 'highlightEventMarker', error as Error, { markerId }); + } +}; + +/** + * 格式化事件标记标签 + * + * @param eventTitle 事件标题 + * @param maxLength 最大长度(默认 10) + * @returns string 格式化后的标签 + */ +export const formatEventMarkerLabel = (eventTitle: string, maxLength: number = 10): string => { + if (!eventTitle) { + return '事件'; + } + + if (eventTitle.length <= maxLength) { + return eventTitle; + } + + return `${eventTitle.substring(0, maxLength)}...`; +}; + +/** + * 判断事件时间是否在数据范围内 + * + * @param eventTime 事件时间戳 + * @param data K线数据 + * @returns boolean 是否在范围内 + */ +export const isEventTimeInDataRange = ( + eventTime: number, + data: KLineDataPoint[] +): boolean => { + if (!data || data.length === 0) { + return false; + } + + const timestamps = data.map((item) => item.timestamp); + const minTime = Math.min(...timestamps); + const maxTime = Math.max(...timestamps); + + return eventTime >= minTime && eventTime <= maxTime; +}; diff --git a/src/components/StockChart/utils/index.ts b/src/components/StockChart/utils/index.ts new file mode 100644 index 00000000..f0ba7b5f --- /dev/null +++ b/src/components/StockChart/utils/index.ts @@ -0,0 +1,48 @@ +/** + * StockChart 工具函数统一导出 + * + * 使用方式: + * import { processChartData, createEventMarkerOverlay } from '@components/StockChart/utils'; + */ + +// 数据转换适配器 +export { + convertToKLineData, + validateAndCleanData, + sortDataByTime, + deduplicateData, + processChartData, + getDataTimeRange, + findClosestDataPoint, +} from './dataAdapter'; + +// 事件标记工具 +export { + createEventMarkerOverlay, + createEventMarkerFromTime, + createEventMarkerOverlays, + removeEventMarker, + removeAllEventMarkers, + updateEventMarker, + highlightEventMarker, + formatEventMarkerLabel, + isEventTimeInDataRange, +} from './eventMarkerUtils'; + +// 图表通用工具 +export { + safeChartOperation, + createIndicator, + removeIndicator, + createSubIndicators, + setChartZoom, + scrollToTimestamp, + resizeChart, + getVisibleRange, + clearChartData, + exportChartImage, + toggleCrosshair, + toggleGrid, + subscribeChartEvent, + unsubscribeChartEvent, +} from './chartUtils'; diff --git a/src/components/StockRelation/RelationDescription.tsx b/src/components/StockRelation/RelationDescription.tsx new file mode 100644 index 00000000..10f0f35e --- /dev/null +++ b/src/components/StockRelation/RelationDescription.tsx @@ -0,0 +1,121 @@ +/** + * 关联描述组件 + * + * 用于显示股票与事件的关联描述信息 + * 固定标题为"关联描述:" + * 自动处理多种数据格式(字符串、对象数组) + * + * @example + * ```tsx + * // 基础使用 - 传入原始 relation_desc 数据 + * + * + * // 自定义样式 + * + * ``` + */ + +import React, { useMemo } from 'react'; +import { Box, Text, BoxProps } from '@chakra-ui/react'; + +/** + * 关联描述数据类型 + * - 字符串格式:直接的描述文本 + * - 对象格式:包含多个句子的数组 + */ +export type RelationDescType = + | string + | { + data: Array<{ + query_part?: string; + sentences?: string; + }>; + } + | null + | undefined; + +export interface RelationDescriptionProps { + /** 原始关联描述数据(支持字符串或对象格式) */ + relationDesc: RelationDescType; + + /** 字体大小,默认 'sm' */ + fontSize?: string; + + /** 标题颜色,默认 'gray.700' */ + titleColor?: string; + + /** 文本颜色,默认 'gray.600' */ + textColor?: string; + + /** 行高,默认 '1.7' */ + lineHeight?: string; + + /** 容器额外属性 */ + containerProps?: BoxProps; +} + +export const RelationDescription: React.FC = ({ + relationDesc, + fontSize = 'sm', + titleColor = 'gray.700', + textColor = 'gray.600', + lineHeight = '1.7', + containerProps = {} +}) => { + // 处理关联描述(兼容对象和字符串格式) + const processedDesc = useMemo(() => { + if (!relationDesc) return null; + + // 字符串格式:直接返回 + if (typeof relationDesc === 'string') { + return relationDesc; + } + + // 对象格式:提取并拼接文本 + if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) { + return ( + relationDesc.data + .map((item) => item.query_part || item.sentences || '') + .filter((s) => s) + .join(';') || null + ); + } + + return null; + }, [relationDesc]); + + // 如果没有有效的描述内容,不渲染组件 + if (!processedDesc) { + return null; + } + + return ( + + + 关联描述: + + + {processedDesc} + + + ); +}; diff --git a/src/components/StockRelation/index.ts b/src/components/StockRelation/index.ts new file mode 100644 index 00000000..890b0970 --- /dev/null +++ b/src/components/StockRelation/index.ts @@ -0,0 +1,6 @@ +/** + * StockRelation 组件导出入口 + */ + +export { RelationDescription } from './RelationDescription'; +export type { RelationDescriptionProps, RelationDescType } from './RelationDescription'; diff --git a/src/index.js b/src/index.js index 1964cc9b..9c520b75 100755 --- a/src/index.js +++ b/src/index.js @@ -2,9 +2,12 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter as Router } from 'react-router-dom'; -// 导入 Tailwind CSS 和 Brainwave 样式 + +// 导入 Brainwave 样式(空文件,保留以避免错误) import './styles/brainwave.css'; -import './styles/brainwave-colors.css'; + +// 导入 Select 下拉框颜色修复样式 +import './styles/select-fix.css'; // 导入 Bytedesk 客服系统 z-index 覆盖样式(必须在所有样式之后导入) import './styles/bytedesk-override.css'; diff --git a/src/layouts/MainLayout.js b/src/layouts/MainLayout.js index 61ffd9eb..5c298c5b 100644 --- a/src/layouts/MainLayout.js +++ b/src/layouts/MainLayout.js @@ -30,12 +30,12 @@ const MemoizedAppFooter = memo(AppFooter); */ export default function MainLayout() { return ( - + {/* 导航栏 - 在所有页面间共享,memo 后不会在路由切换时重新渲染 */} {/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */} - + }> @@ -47,11 +47,11 @@ export default function MainLayout() { {/* 返回顶部按钮 - 滚动超过阈值时显示 */} - + /> */} ); } diff --git a/src/mocks/data/kline.js b/src/mocks/data/kline.js index ced4aab1..f614143b 100644 --- a/src/mocks/data/kline.js +++ b/src/mocks/data/kline.js @@ -78,10 +78,16 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) { const volume = Math.floor(Math.random() * 500000000 + 100000000); + // ✅ 修复:为分时图添加完整的 OHLC 字段 + const closePrice = parseFloat(price.toFixed(2)); data.push({ time: formatTime(current), - price: parseFloat(price.toFixed(2)), - close: parseFloat(price.toFixed(2)), + timestamp: current.getTime(), // ✅ 新增:毫秒时间戳 + open: parseFloat((price * 0.9999).toFixed(2)), // ✅ 新增:开盘价(略低于收盘) + high: parseFloat((price * 1.0002).toFixed(2)), // ✅ 新增:最高价(略高于收盘) + low: parseFloat((price * 0.9997).toFixed(2)), // ✅ 新增:最低价(略低于收盘) + close: closePrice, // ✅ 保留:收盘价 + price: closePrice, // ✅ 保留:兼容字段(供 MiniTimelineChart 使用) volume: volume, prev_close: basePrice }); diff --git a/src/providers/AppProviders.js b/src/providers/AppProviders.js index c5050843..e1962243 100644 --- a/src/providers/AppProviders.js +++ b/src/providers/AppProviders.js @@ -21,11 +21,12 @@ import { NotificationProvider } from '../contexts/NotificationContext'; * * Provider 层级顺序 (从外到内): * 1. ReduxProvider - 状态管理层 - * 2. ChakraProvider - UI 框架层 + * 2. ChakraProvider - UI 框架层(主要) * 3. NotificationProvider - 通知系统 * 4. AuthProvider - 认证系统 * * 注意: + * - HeroUI v3 不再需要 HeroUIProvider,样式通过 CSS 导入加载 (src/styles/heroui.css) * - AuthModal 已迁移到 Redux (authModalSlice + useAuthModal Hook) * - ErrorBoundary 在各 Layout 层实现,不在全局层,以实现精细化错误隔离 * - MainLayout: PageTransitionWrapper 包含 ErrorBoundary (页面错误不影响导航栏) @@ -39,6 +40,13 @@ export function AppProviders({ children }) { 'light', // 始终返回 'light' + set: () => {}, // 禁止设置(忽略切换操作) + }} toastOptions={{ defaultOptions: { position: 'top', diff --git a/src/routes/lazy-components.js b/src/routes/lazy-components.js index 48353be6..b9c31e9e 100644 --- a/src/routes/lazy-components.js +++ b/src/routes/lazy-components.js @@ -11,7 +11,7 @@ export const lazyComponents = { // Home 模块 HomePage: React.lazy(() => import('../views/Home/HomePage')), CenterDashboard: React.lazy(() => import('../views/Dashboard/Center')), - ProfilePage: React.lazy(() => import('../views/Profile/ProfilePage')), + ProfilePage: React.lazy(() => import('../views/Profile')), SettingsPage: React.lazy(() => import('../views/Settings/SettingsPage')), Subscription: React.lazy(() => import('../views/Pages/Account/Subscription')), PrivacyPolicy: React.lazy(() => import('../views/Pages/PrivacyPolicy')), @@ -42,6 +42,7 @@ export const lazyComponents = { // 价值论坛模块 ValueForum: React.lazy(() => import('../views/ValueForum')), ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')), + PredictionTopicDetail: React.lazy(() => import('../views/ValueForum/PredictionTopicDetail')), // 数据浏览器模块 DataBrowser: React.lazy(() => import('../views/DataBrowser')), diff --git a/src/routes/routeConfig.js b/src/routes/routeConfig.js index 289c8726..3c0fe14a 100644 --- a/src/routes/routeConfig.js +++ b/src/routes/routeConfig.js @@ -181,16 +181,26 @@ export const routeConfig = [ description: '论坛帖子详细内容' } }, + { + path: 'value-forum/prediction/:topicId', + component: lazyComponents.PredictionTopicDetail, + protection: PROTECTION_MODES.MODAL, + layout: 'main', + meta: { + title: '预测话题详情', + description: '预测市场话题详细信息' + } + }, // ==================== Agent模块 ==================== { path: 'agent-chat', component: lazyComponents.AgentChat, protection: PROTECTION_MODES.MODAL, - layout: 'main', + layout: 'main', // 使用主布局(带导航栏) meta: { - title: '价小前投研', - description: '北京价值前沿科技公司的AI投研聊天助手' + title: '价小前投研 AI', + description: '超炫酷的 AI 投研聊天助手 - 基于 Hero UI' } }, ]; diff --git a/src/services/creditSystemService.js b/src/services/creditSystemService.js new file mode 100644 index 00000000..e97fb6a2 --- /dev/null +++ b/src/services/creditSystemService.js @@ -0,0 +1,492 @@ +/** + * 积分系统服务 + * 管理用户积分账户、交易、奖励等 + */ + +// ==================== 常量配置 ==================== + +export const CREDIT_CONFIG = { + INITIAL_BALANCE: 10000, // 初始积分 + MIN_BALANCE: 100, // 最低保留余额(破产保护) + MAX_SINGLE_BET: 1000, // 单次下注上限 + DAILY_BONUS: 100, // 每日签到奖励 + CREATE_TOPIC_COST: 100, // 创建话题费用 +}; + +// 积分账户存储(生产环境应使用数据库) +const userAccounts = new Map(); + +// 交易记录存储 +const transactions = []; + +// ==================== 账户管理 ==================== + +/** + * 获取用户账户 + * @param {string} userId - 用户ID + * @returns {Object} 用户账户信息 + */ +export const getUserAccount = (userId) => { + if (!userAccounts.has(userId)) { + // 首次访问,创建新账户 + const newAccount = { + user_id: userId, + balance: CREDIT_CONFIG.INITIAL_BALANCE, + frozen: 0, + total: CREDIT_CONFIG.INITIAL_BALANCE, + total_earned: CREDIT_CONFIG.INITIAL_BALANCE, + total_spent: 0, + total_profit: 0, + active_positions: [], + stats: { + total_topics: 0, + win_count: 0, + loss_count: 0, + win_rate: 0, + best_profit: 0, + }, + last_daily_bonus: null, + }; + userAccounts.set(userId, newAccount); + } + + return userAccounts.get(userId); +}; + +/** + * 更新用户账户 + * @param {string} userId - 用户ID + * @param {Object} updates - 更新内容 + */ +export const updateUserAccount = (userId, updates) => { + const account = getUserAccount(userId); + const updated = { ...account, ...updates }; + userAccounts.set(userId, updated); + return updated; +}; + +/** + * 获取用户积分余额 + * @param {string} userId - 用户ID + * @returns {number} 可用余额 + */ +export const getBalance = (userId) => { + const account = getUserAccount(userId); + return account.balance; +}; + +/** + * 检查用户是否能支付 + * @param {string} userId - 用户ID + * @param {number} amount - 金额 + * @returns {boolean} 是否能支付 + */ +export const canAfford = (userId, amount) => { + const account = getUserAccount(userId); + const afterBalance = account.balance - amount; + + // 必须保留最低余额 + return afterBalance >= CREDIT_CONFIG.MIN_BALANCE; +}; + +// ==================== 积分操作 ==================== + +/** + * 增加积分 + * @param {string} userId - 用户ID + * @param {number} amount - 金额 + * @param {string} reason - 原因 + */ +export const addCredits = (userId, amount, reason = '系统增加') => { + const account = getUserAccount(userId); + + const updated = { + balance: account.balance + amount, + total: account.total + amount, + total_earned: account.total_earned + amount, + }; + + updateUserAccount(userId, updated); + + // 记录交易 + logTransaction({ + user_id: userId, + type: 'earn', + amount, + reason, + balance_after: updated.balance, + }); + + return updated; +}; + +/** + * 扣除积分 + * @param {string} userId - 用户ID + * @param {number} amount - 金额 + * @param {string} reason - 原因 + * @throws {Error} 如果余额不足 + */ +export const deductCredits = (userId, amount, reason = '系统扣除') => { + if (!canAfford(userId, amount)) { + throw new Error(`积分不足,需要${amount}积分,但只有${getBalance(userId)}积分`); + } + + const account = getUserAccount(userId); + + const updated = { + balance: account.balance - amount, + total_spent: account.total_spent + amount, + }; + + updateUserAccount(userId, updated); + + // 记录交易 + logTransaction({ + user_id: userId, + type: 'spend', + amount: -amount, + reason, + balance_after: updated.balance, + }); + + return updated; +}; + +/** + * 冻结积分(席位占用) + * @param {string} userId - 用户ID + * @param {number} amount - 金额 + */ +export const freezeCredits = (userId, amount) => { + const account = getUserAccount(userId); + + if (account.balance < amount) { + throw new Error('可用余额不足'); + } + + const updated = { + balance: account.balance - amount, + frozen: account.frozen + amount, + }; + + updateUserAccount(userId, updated); + return updated; +}; + +/** + * 解冻积分 + * @param {string} userId - 用户ID + * @param {number} amount - 金额 + */ +export const unfreezeCredits = (userId, amount) => { + const account = getUserAccount(userId); + + const updated = { + balance: account.balance + amount, + frozen: account.frozen - amount, + }; + + updateUserAccount(userId, updated); + return updated; +}; + +// ==================== 每日奖励 ==================== + +/** + * 领取每日签到奖励 + * @param {string} userId - 用户ID + * @returns {Object} 奖励信息 + */ +export const claimDailyBonus = (userId) => { + const account = getUserAccount(userId); + const today = new Date().toDateString(); + + // 检查是否已领取 + if (account.last_daily_bonus === today) { + return { + success: false, + message: '今日已领取', + }; + } + + // 发放奖励 + addCredits(userId, CREDIT_CONFIG.DAILY_BONUS, '每日签到'); + + // 更新领取时间 + updateUserAccount(userId, { last_daily_bonus: today }); + + return { + success: true, + amount: CREDIT_CONFIG.DAILY_BONUS, + message: `获得${CREDIT_CONFIG.DAILY_BONUS}积分`, + }; +}; + +/** + * 检查今天是否已签到 + * @param {string} userId - 用户ID + * @returns {boolean} + */ +export const hasClaimedToday = (userId) => { + const account = getUserAccount(userId); + const today = new Date().toDateString(); + return account.last_daily_bonus === today; +}; + +// ==================== 持仓管理 ==================== + +/** + * 添加持仓 + * @param {string} userId - 用户ID + * @param {Object} position - 持仓信息 + */ +export const addPosition = (userId, position) => { + const account = getUserAccount(userId); + + const updated = { + active_positions: [...account.active_positions, position], + stats: { + ...account.stats, + total_topics: account.stats.total_topics + 1, + }, + }; + + updateUserAccount(userId, updated); + return updated; +}; + +/** + * 移除持仓 + * @param {string} userId - 用户ID + * @param {string} positionId - 持仓ID + */ +export const removePosition = (userId, positionId) => { + const account = getUserAccount(userId); + + const updated = { + active_positions: account.active_positions.filter((p) => p.id !== positionId), + }; + + updateUserAccount(userId, updated); + return updated; +}; + +/** + * 更新持仓 + * @param {string} userId - 用户ID + * @param {string} positionId - 持仓ID + * @param {Object} updates - 更新内容 + */ +export const updatePosition = (userId, positionId, updates) => { + const account = getUserAccount(userId); + + const updated = { + active_positions: account.active_positions.map((p) => + p.id === positionId ? { ...p, ...updates } : p + ), + }; + + updateUserAccount(userId, updated); + return updated; +}; + +/** + * 获取用户持仓 + * @param {string} userId - 用户ID + * @param {string} topicId - 话题ID(可选) + * @returns {Array} 持仓列表 + */ +export const getUserPositions = (userId, topicId = null) => { + const account = getUserAccount(userId); + + if (topicId) { + return account.active_positions.filter((p) => p.topic_id === topicId); + } + + return account.active_positions; +}; + +// ==================== 统计更新 ==================== + +/** + * 记录胜利 + * @param {string} userId - 用户ID + * @param {number} profit - 盈利金额 + */ +export const recordWin = (userId, profit) => { + const account = getUserAccount(userId); + + const newWinCount = account.stats.win_count + 1; + const totalGames = newWinCount + account.stats.loss_count; + const winRate = (newWinCount / totalGames) * 100; + + const updated = { + total_profit: account.total_profit + profit, + stats: { + ...account.stats, + win_count: newWinCount, + win_rate: winRate, + best_profit: Math.max(account.stats.best_profit, profit), + }, + }; + + updateUserAccount(userId, updated); + return updated; +}; + +/** + * 记录失败 + * @param {string} userId - 用户ID + * @param {number} loss - 损失金额 + */ +export const recordLoss = (userId, loss) => { + const account = getUserAccount(userId); + + const newLossCount = account.stats.loss_count + 1; + const totalGames = account.stats.win_count + newLossCount; + const winRate = (account.stats.win_count / totalGames) * 100; + + const updated = { + total_profit: account.total_profit - loss, + stats: { + ...account.stats, + loss_count: newLossCount, + win_rate: winRate, + }, + }; + + updateUserAccount(userId, updated); + return updated; +}; + +// ==================== 排行榜 ==================== + +/** + * 获取积分排行榜 + * @param {number} limit - 返回数量 + * @returns {Array} 排行榜数据 + */ +export const getLeaderboard = (limit = 100) => { + const accounts = Array.from(userAccounts.values()); + + return accounts + .sort((a, b) => b.total - a.total) + .slice(0, limit) + .map((account, index) => ({ + rank: index + 1, + user_id: account.user_id, + total: account.total, + total_profit: account.total_profit, + win_rate: account.stats.win_rate, + })); +}; + +/** + * 获取用户排名 + * @param {string} userId - 用户ID + * @returns {number} 排名 + */ +export const getUserRank = (userId) => { + const leaderboard = getLeaderboard(1000); + const index = leaderboard.findIndex((item) => item.user_id === userId); + return index >= 0 ? index + 1 : -1; +}; + +// ==================== 交易记录 ==================== + +/** + * 记录交易 + * @param {Object} transaction - 交易信息 + */ +const logTransaction = (transaction) => { + const record = { + id: `tx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + timestamp: new Date().toISOString(), + ...transaction, + }; + + transactions.push(record); + return record; +}; + +/** + * 获取用户交易记录 + * @param {string} userId - 用户ID + * @param {number} limit - 返回数量 + * @returns {Array} 交易记录 + */ +export const getUserTransactions = (userId, limit = 50) => { + return transactions + .filter((tx) => tx.user_id === userId) + .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) + .slice(0, limit); +}; + +// ==================== 批量操作 ==================== + +/** + * 批量发放积分(如活动奖励) + * @param {Array} recipients - [{user_id, amount, reason}] + */ +export const batchAddCredits = (recipients) => { + const results = recipients.map(({ user_id, amount, reason }) => { + try { + return { + user_id, + success: true, + account: addCredits(user_id, amount, reason), + }; + } catch (error) { + return { + user_id, + success: false, + error: error.message, + }; + } + }); + + return results; +}; + +// ==================== 导出所有功能 ==================== + +export default { + CREDIT_CONFIG, + + // 账户管理 + getUserAccount, + updateUserAccount, + getBalance, + canAfford, + + // 积分操作 + addCredits, + deductCredits, + freezeCredits, + unfreezeCredits, + + // 每日奖励 + claimDailyBonus, + hasClaimedToday, + + // 持仓管理 + addPosition, + removePosition, + updatePosition, + getUserPositions, + + // 统计更新 + recordWin, + recordLoss, + + // 排行榜 + getLeaderboard, + getUserRank, + + // 交易记录 + getUserTransactions, + + // 批量操作 + batchAddCredits, +}; diff --git a/src/services/predictionMarketService.api.js b/src/services/predictionMarketService.api.js new file mode 100644 index 00000000..30a42e92 --- /dev/null +++ b/src/services/predictionMarketService.api.js @@ -0,0 +1,325 @@ +/** + * 预测市场服务 - API 版本 + * 调用真实的后端 API,数据存储到 MySQL 数据库 + */ + +import axios from 'axios'; +import { getApiBase } from '@utils/apiConfig'; + +const api = axios.create({ + baseURL: getApiBase(), + timeout: 10000, + withCredentials: true, // 携带 Cookie(session) +}); + +// ==================== 积分系统 API ==================== + +/** + * 获取用户积分账户 + */ +export const getUserAccount = async () => { + try { + const response = await api.get('/api/prediction/credit/account'); + return response.data; + } catch (error) { + console.error('获取积分账户失败:', error); + throw error; + } +}; + +/** + * 领取每日奖励(100积分) + */ +export const claimDailyBonus = async () => { + try { + const response = await api.post('/api/prediction/credit/daily-bonus'); + return response.data; + } catch (error) { + console.error('领取每日奖励失败:', error); + throw error; + } +}; + +// ==================== 预测话题 API ==================== + +/** + * 创建预测话题 + * @param {Object} topicData - { title, description, category, deadline } + */ +export const createTopic = async (topicData) => { + try { + const response = await api.post('/api/prediction/topics', topicData); + return response.data; + } catch (error) { + console.error('创建预测话题失败:', error); + throw error; + } +}; + +/** + * 获取预测话题列表 + * @param {Object} params - { status, category, sort_by, page, per_page } + */ +export const getTopics = async (params = {}) => { + try { + const response = await api.get('/api/prediction/topics', { params }); + return response.data; + } catch (error) { + console.error('获取话题列表失败:', error); + throw error; + } +}; + +/** + * 获取预测话题详情 + * @param {number} topicId + */ +export const getTopicDetail = async (topicId) => { + try { + const response = await api.get(`/api/prediction/topics/${topicId}`); + return response.data; + } catch (error) { + console.error('获取话题详情失败:', error); + throw error; + } +}; + +/** + * 结算预测话题(仅创建者可操作) + * @param {number} topicId + * @param {string} result - 'yes' | 'no' | 'draw' + */ +export const settleTopic = async (topicId, result) => { + try { + const response = await api.post(`/api/prediction/topics/${topicId}/settle`, { result }); + return response.data; + } catch (error) { + console.error('结算话题失败:', error); + throw error; + } +}; + +// ==================== 交易 API ==================== + +/** + * 买入预测份额 + * @param {Object} tradeData - { topic_id, direction, shares } + */ +export const buyShares = async (tradeData) => { + try { + const response = await api.post('/api/prediction/trade/buy', tradeData); + return response.data; + } catch (error) { + console.error('买入份额失败:', error); + throw error; + } +}; + +/** + * 获取用户持仓列表 + */ +export const getUserPositions = async () => { + try { + const response = await api.get('/api/prediction/positions'); + return response.data; + } catch (error) { + console.error('获取持仓列表失败:', error); + throw error; + } +}; + +// ==================== 评论 API ==================== + +/** + * 发表话题评论 + * @param {number} topicId + * @param {Object} commentData - { content, parent_id } + */ +export const createComment = async (topicId, commentData) => { + try { + const response = await api.post(`/api/prediction/topics/${topicId}/comments`, commentData); + return response.data; + } catch (error) { + console.error('发表评论失败:', error); + throw error; + } +}; + +/** + * 获取话题评论列表 + * @param {number} topicId + * @param {Object} params - { page, per_page } + */ +export const getComments = async (topicId, params = {}) => { + try { + const response = await api.get(`/api/prediction/topics/${topicId}/comments`, { params }); + return response.data; + } catch (error) { + console.error('获取评论列表失败:', error); + throw error; + } +}; + +/** + * 点赞/取消点赞评论 + * @param {number} commentId + */ +export const likeComment = async (commentId) => { + try { + const response = await api.post(`/api/prediction/comments/${commentId}/like`); + return response.data; + } catch (error) { + console.error('点赞评论失败:', error); + throw error; + } +}; + +// ==================== 观点IPO API ==================== + +/** + * 投资评论(观点IPO) + * @param {number} commentId - 评论ID + * @param {number} shares - 投资份额 + */ +export const investComment = async (commentId, shares) => { + try { + const response = await api.post(`/api/prediction/comments/${commentId}/invest`, { shares }); + return response.data; + } catch (error) { + console.error('投资评论失败:', error); + throw error; + } +}; + +/** + * 获取评论的投资列表 + * @param {number} commentId - 评论ID + */ +export const getCommentInvestments = async (commentId) => { + try { + const response = await api.get(`/api/prediction/comments/${commentId}/investments`); + return response.data; + } catch (error) { + console.error('获取投资列表失败:', error); + throw error; + } +}; + +/** + * 验证评论结果(仅创建者可操作) + * @param {number} commentId - 评论ID + * @param {string} result - 'correct' | 'incorrect' + */ +export const verifyComment = async (commentId, result) => { + try { + const response = await api.post(`/api/prediction/comments/${commentId}/verify`, { result }); + return response.data; + } catch (error) { + console.error('验证评论失败:', error); + throw error; + } +}; + +// ==================== 工具函数(价格计算保留在前端,用于实时预览)==================== + +export const MARKET_CONFIG = { + MAX_SEATS_PER_SIDE: 5, + TAX_RATE: 0.02, + MIN_PRICE: 50, + MAX_PRICE: 950, + BASE_PRICE: 500, +}; + +/** + * 计算当前价格(简化版AMM) + * @param {number} yesShares - Yes方总份额 + * @param {number} noShares - No方总份额 + * @returns {Object} {yes: price, no: price} + */ +export const calculatePrice = (yesShares, noShares) => { + const totalShares = yesShares + noShares; + + if (totalShares === 0) { + return { + yes: MARKET_CONFIG.BASE_PRICE, + no: MARKET_CONFIG.BASE_PRICE, + }; + } + + const yesProb = yesShares / totalShares; + const noProb = noShares / totalShares; + + let yesPrice = yesProb * 1000; + let noPrice = noProb * 1000; + + yesPrice = Math.max(MARKET_CONFIG.MIN_PRICE, Math.min(MARKET_CONFIG.MAX_PRICE, yesPrice)); + noPrice = Math.max(MARKET_CONFIG.MIN_PRICE, Math.min(MARKET_CONFIG.MAX_PRICE, noPrice)); + + return { yes: Math.round(yesPrice), no: Math.round(noPrice) }; +}; + +/** + * 计算交易税 + * @param {number} amount - 交易金额 + * @returns {number} 税费 + */ +export const calculateTax = (amount) => { + return Math.floor(amount * MARKET_CONFIG.TAX_RATE); +}; + +/** + * 计算买入成本(用于前端预览) + * @param {number} currentShares - 当前方总份额 + * @param {number} otherShares - 对手方总份额 + * @param {number} buyAmount - 买入数量 + * @returns {Object} { amount, tax, total } + */ +export const calculateBuyCost = (currentShares, otherShares, buyAmount) => { + const currentPrice = calculatePrice(currentShares, otherShares); + const afterShares = currentShares + buyAmount; + const afterPrice = calculatePrice(afterShares, otherShares); + + const avgPrice = (currentPrice.yes + afterPrice.yes) / 2; + const amount = avgPrice * buyAmount; + const tax = calculateTax(amount); + const total = amount + tax; + + return { + amount: Math.round(amount), + tax: Math.round(tax), + total: Math.round(total), + avgPrice: Math.round(avgPrice), + }; +}; + +export default { + // 积分系统 + getUserAccount, + claimDailyBonus, + + // 话题管理 + createTopic, + getTopics, + getTopicDetail, + settleTopic, + + // 交易 + buyShares, + getUserPositions, + + // 评论 + createComment, + getComments, + likeComment, + + // 观点IPO + investComment, + getCommentInvestments, + verifyComment, + + // 工具函数 + calculatePrice, + calculateTax, + calculateBuyCost, + MARKET_CONFIG, +}; diff --git a/src/services/predictionMarketService.js b/src/services/predictionMarketService.js new file mode 100644 index 00000000..25e943cd --- /dev/null +++ b/src/services/predictionMarketService.js @@ -0,0 +1,738 @@ +/** + * 预测市场服务 + * 核心功能:话题管理、席位交易、动态定价、领主系统、奖池分配 + */ + +import { + addCredits, + deductCredits, + canAfford, + addPosition, + removePosition, + updatePosition, + getUserPositions, + recordWin, + recordLoss, + CREDIT_CONFIG, +} from './creditSystemService'; + +// ==================== 常量配置 ==================== + +export const MARKET_CONFIG = { + MAX_SEATS_PER_SIDE: 5, // 每个方向最多5个席位 + TAX_RATE: 0.02, // 交易税率 2% + MIN_PRICE: 50, // 最低价格 + MAX_PRICE: 950, // 最高价格 + BASE_PRICE: 500, // 基础价格 +}; + +// 话题存储(生产环境应使用Elasticsearch) +const topics = new Map(); + +// 席位存储 +const positions = new Map(); + +// 交易记录 +const trades = []; + +// ==================== 动态定价算法 ==================== + +/** + * 计算当前价格(简化版AMM) + * @param {number} yesShares - Yes方总份额 + * @param {number} noShares - No方总份额 + * @returns {Object} {yes: price, no: price} + */ +export const calculatePrice = (yesShares, noShares) => { + const totalShares = yesShares + noShares; + + if (totalShares === 0) { + // 初始状态,双方价格相同 + return { + yes: MARKET_CONFIG.BASE_PRICE, + no: MARKET_CONFIG.BASE_PRICE, + }; + } + + // 概率加权定价 + const yesProb = yesShares / totalShares; + const noProb = noShares / totalShares; + + // 价格 = 概率 * 1000,限制在 [MIN_PRICE, MAX_PRICE] + const yesPrice = Math.max( + MARKET_CONFIG.MIN_PRICE, + Math.min(MARKET_CONFIG.MAX_PRICE, yesProb * 1000) + ); + + const noPrice = Math.max( + MARKET_CONFIG.MIN_PRICE, + Math.min(MARKET_CONFIG.MAX_PRICE, noProb * 1000) + ); + + return { yes: yesPrice, no: noPrice }; +}; + +/** + * 计算购买成本(含滑点) + * @param {number} currentShares - 当前份额 + * @param {number} otherShares - 对手方份额 + * @param {number} buyAmount - 购买数量 + * @returns {number} 总成本 + */ +export const calculateBuyCost = (currentShares, otherShares, buyAmount) => { + let totalCost = 0; + let tempShares = currentShares; + + // 模拟逐步购买,累计成本 + for (let i = 0; i < buyAmount; i++) { + tempShares += 1; + const prices = calculatePrice(tempShares, otherShares); + // 假设购买的是yes方 + totalCost += prices.yes; + } + + return totalCost; +}; + +/** + * 计算卖出收益(含滑点) + * @param {number} currentShares - 当前份额 + * @param {number} otherShares - 对手方份额 + * @param {number} sellAmount - 卖出数量 + * @returns {number} 总收益 + */ +export const calculateSellRevenue = (currentShares, otherShares, sellAmount) => { + let totalRevenue = 0; + let tempShares = currentShares; + + // 模拟逐步卖出,累计收益 + for (let i = 0; i < sellAmount; i++) { + const prices = calculatePrice(tempShares, otherShares); + totalRevenue += prices.yes; + tempShares -= 1; + } + + return totalRevenue; +}; + +/** + * 计算交易税 + * @param {number} amount - 交易金额 + * @returns {number} 税费 + */ +export const calculateTax = (amount) => { + return Math.floor(amount * MARKET_CONFIG.TAX_RATE); +}; + +// ==================== 话题管理 ==================== + +/** + * 创建预测话题 + * @param {Object} topicData - 话题数据 + * @returns {Object} 创建的话题 + */ +export const createTopic = (topicData) => { + const { author_id, title, description, category, tags, deadline, settlement_date } = topicData; + + // 扣除创建费用 + deductCredits(author_id, CREDIT_CONFIG.CREATE_TOPIC_COST, '创建预测话题'); + + const topic = { + id: `topic_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + type: 'prediction', + + // 基础信息 + title, + description, + category, + tags: tags || [], + + // 作者信息 + author_id, + author_name: topicData.author_name, + author_avatar: topicData.author_avatar, + + // 时间管理 + created_at: new Date().toISOString(), + deadline: deadline || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 默认7天 + settlement_date: settlement_date || new Date(Date.now() + 8 * 24 * 60 * 60 * 1000).toISOString(), + status: 'active', + + // 预测选项 + options: [ + { id: 'yes', label: '看涨 / Yes', color: '#48BB78' }, + { id: 'no', label: '看跌 / No', color: '#F56565' }, + ], + + // 市场数据 + total_pool: CREDIT_CONFIG.CREATE_TOPIC_COST, // 创建费用进入奖池 + tax_rate: MARKET_CONFIG.TAX_RATE, + + // 席位数据 + positions: { + yes: { + seats: [], + total_shares: 0, + current_price: MARKET_CONFIG.BASE_PRICE, + lord_id: null, + }, + no: { + seats: [], + total_shares: 0, + current_price: MARKET_CONFIG.BASE_PRICE, + lord_id: null, + }, + }, + + // 交易统计 + stats: { + total_volume: 0, + total_transactions: 0, + unique_traders: new Set(), + }, + + // 结果 + settlement: { + result: null, + evidence: null, + settled_by: null, + settled_at: null, + }, + }; + + topics.set(topic.id, topic); + return topic; +}; + +/** + * 获取话题详情 + * @param {string} topicId - 话题ID + * @returns {Object} 话题详情 + */ +export const getTopic = (topicId) => { + return topics.get(topicId); +}; + +/** + * 更新话题 + * @param {string} topicId - 话题ID + * @param {Object} updates - 更新内容 + */ +export const updateTopic = (topicId, updates) => { + const topic = getTopic(topicId); + const updated = { ...topic, ...updates }; + topics.set(topicId, updated); + return updated; +}; + +/** + * 获取所有话题列表 + * @param {Object} filters - 筛选条件 + * @returns {Array} 话题列表 + */ +export const getTopics = (filters = {}) => { + let topicList = Array.from(topics.values()); + + // 按状态筛选 + if (filters.status) { + topicList = topicList.filter((t) => t.status === filters.status); + } + + // 按分类筛选 + if (filters.category) { + topicList = topicList.filter((t) => t.category === filters.category); + } + + // 排序 + const sortBy = filters.sortBy || 'created_at'; + topicList.sort((a, b) => { + if (sortBy === 'created_at') { + return new Date(b.created_at) - new Date(a.created_at); + } + if (sortBy === 'total_pool') { + return b.total_pool - a.total_pool; + } + if (sortBy === 'total_volume') { + return b.stats.total_volume - a.stats.total_volume; + } + return 0; + }); + + return topicList; +}; + +// ==================== 席位管理 ==================== + +/** + * 创建席位 + * @param {Object} positionData - 席位数据 + * @returns {Object} 创建的席位 + */ +const createPosition = (positionData) => { + const position = { + id: `pos_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + ...positionData, + acquired_at: new Date().toISOString(), + last_traded_at: new Date().toISOString(), + is_lord: false, + }; + + positions.set(position.id, position); + return position; +}; + +/** + * 获取席位 + * @param {string} positionId - 席位ID + * @returns {Object} 席位信息 + */ +export const getPosition = (positionId) => { + return positions.get(positionId); +}; + +/** + * 分配席位(取份额最高的前5名) + * @param {Array} allPositions - 所有持仓 + * @returns {Array} 席位列表 + */ +const allocateSeats = (allPositions) => { + // 按份额排序 + const sorted = [...allPositions].sort((a, b) => b.shares - a.shares); + + // 取前5名 + return sorted.slice(0, MARKET_CONFIG.MAX_SEATS_PER_SIDE); +}; + +/** + * 确定领主(份额最多的人) + * @param {Array} seats - 席位列表 + * @returns {string|null} 领主用户ID + */ +const determineLord = (seats) => { + if (seats.length === 0) return null; + + const lord = seats.reduce((max, seat) => (seat.shares > max.shares ? seat : max)); + + return lord.holder_id; +}; + +/** + * 更新领主标识 + * @param {string} topicId - 话题ID + * @param {string} optionId - 选项ID + */ +const updateLordStatus = (topicId, optionId) => { + const topic = getTopic(topicId); + const sideData = topic.positions[optionId]; + + // 重新分配席位 + const allPositions = Array.from(positions.values()).filter( + (p) => p.topic_id === topicId && p.option_id === optionId + ); + + const seats = allocateSeats(allPositions); + const lordId = determineLord(seats); + + // 更新所有席位的领主标识 + allPositions.forEach((position) => { + const isLord = position.holder_id === lordId; + positions.set(position.id, { ...position, is_lord: isLord }); + }); + + // 更新话题数据 + updateTopic(topicId, { + positions: { + ...topic.positions, + [optionId]: { + ...sideData, + seats, + lord_id: lordId, + }, + }, + }); + + return lordId; +}; + +// ==================== 交易执行 ==================== + +/** + * 购买席位 + * @param {Object} tradeData - 交易数据 + * @returns {Object} 交易结果 + */ +export const buyPosition = (tradeData) => { + const { user_id, user_name, user_avatar, topic_id, option_id, shares } = tradeData; + + // 验证 + const topic = getTopic(topic_id); + if (!topic) throw new Error('话题不存在'); + if (topic.status !== 'active') throw new Error('话题已关闭交易'); + if (topic.author_id === user_id) throw new Error('不能参与自己发起的话题'); + + // 检查购买上限 + if (shares * MARKET_CONFIG.BASE_PRICE > CREDIT_CONFIG.MAX_SINGLE_BET) { + throw new Error(`单次购买上限为${CREDIT_CONFIG.MAX_SINGLE_BET}积分`); + } + + // 获取当前市场数据 + const sideData = topic.positions[option_id]; + const otherOptionId = option_id === 'yes' ? 'no' : 'yes'; + const otherSideData = topic.positions[otherOptionId]; + + // 计算成本 + const cost = calculateBuyCost(sideData.total_shares, otherSideData.total_shares, shares); + const tax = calculateTax(cost); + const totalCost = cost + tax; + + // 检查余额 + if (!canAfford(user_id, totalCost)) { + throw new Error(`积分不足,需要${totalCost}积分`); + } + + // 扣除积分 + deductCredits(user_id, totalCost, `购买预测席位 - ${topic.title}`); + + // 税费进入奖池 + updateTopic(topic_id, { + total_pool: topic.total_pool + tax, + stats: { + ...topic.stats, + total_volume: topic.stats.total_volume + totalCost, + total_transactions: topic.stats.total_transactions + 1, + unique_traders: topic.stats.unique_traders.add(user_id), + }, + }); + + // 查找用户是否已有该选项的席位 + let userPosition = Array.from(positions.values()).find( + (p) => p.topic_id === topic_id && p.option_id === option_id && p.holder_id === user_id + ); + + if (userPosition) { + // 更新现有席位 + const newShares = userPosition.shares + shares; + const newAvgCost = (userPosition.avg_cost * userPosition.shares + cost) / newShares; + + positions.set(userPosition.id, { + ...userPosition, + shares: newShares, + avg_cost: newAvgCost, + last_traded_at: new Date().toISOString(), + }); + + // 更新用户账户持仓 + updatePosition(user_id, userPosition.id, { + shares: newShares, + avg_cost: newAvgCost, + }); + } else { + // 创建新席位 + const newPosition = createPosition({ + topic_id, + option_id, + holder_id: user_id, + holder_name: user_name, + holder_avatar: user_avatar, + shares, + avg_cost: cost / shares, + current_value: cost, + unrealized_pnl: 0, + }); + + // 添加到用户账户 + addPosition(user_id, { + id: newPosition.id, + topic_id, + option_id, + shares, + avg_cost: cost / shares, + }); + + userPosition = newPosition; + } + + // 更新话题席位数据 + updateTopic(topic_id, { + positions: { + ...topic.positions, + [option_id]: { + ...sideData, + total_shares: sideData.total_shares + shares, + }, + }, + }); + + // 更新价格 + const newPrices = calculatePrice( + topic.positions[option_id].total_shares + shares, + topic.positions[otherOptionId].total_shares + ); + + updateTopic(topic_id, { + positions: { + ...topic.positions, + yes: { ...topic.positions.yes, current_price: newPrices.yes }, + no: { ...topic.positions.no, current_price: newPrices.no }, + }, + }); + + // 更新领主状态 + const newLordId = updateLordStatus(topic_id, option_id); + + // 记录交易 + const trade = { + id: `trade_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + topic_id, + option_id, + buyer_id: user_id, + seller_id: null, + type: 'buy', + shares, + price: cost / shares, + total_cost: totalCost, + tax, + created_at: new Date().toISOString(), + }; + trades.push(trade); + + return { + success: true, + position: userPosition, + trade, + new_lord_id: newLordId, + current_price: newPrices[option_id], + }; +}; + +/** + * 卖出席位 + * @param {Object} tradeData - 交易数据 + * @returns {Object} 交易结果 + */ +export const sellPosition = (tradeData) => { + const { user_id, topic_id, option_id, shares } = tradeData; + + // 验证 + const topic = getTopic(topic_id); + if (!topic) throw new Error('话题不存在'); + if (topic.status !== 'active') throw new Error('话题已关闭交易'); + + // 查找用户席位 + const userPosition = Array.from(positions.values()).find( + (p) => p.topic_id === topic_id && p.option_id === option_id && p.holder_id === user_id + ); + + if (!userPosition) throw new Error('未持有该席位'); + if (userPosition.shares < shares) throw new Error('持有份额不足'); + + // 获取当前市场数据 + const sideData = topic.positions[option_id]; + const otherOptionId = option_id === 'yes' ? 'no' : 'yes'; + const otherSideData = topic.positions[otherOptionId]; + + // 计算收益 + const revenue = calculateSellRevenue(sideData.total_shares, otherSideData.total_shares, shares); + const tax = calculateTax(revenue); + const netRevenue = revenue - tax; + + // 返还积分 + addCredits(user_id, netRevenue, `卖出预测席位 - ${topic.title}`); + + // 税费进入奖池 + updateTopic(topic_id, { + total_pool: topic.total_pool + tax, + stats: { + ...topic.stats, + total_volume: topic.stats.total_volume + revenue, + total_transactions: topic.stats.total_transactions + 1, + }, + }); + + // 更新席位 + const newShares = userPosition.shares - shares; + + if (newShares === 0) { + // 完全卖出,删除席位 + positions.delete(userPosition.id); + removePosition(user_id, userPosition.id); + } else { + // 部分卖出,更新份额 + positions.set(userPosition.id, { + ...userPosition, + shares: newShares, + last_traded_at: new Date().toISOString(), + }); + + updatePosition(user_id, userPosition.id, { shares: newShares }); + } + + // 更新话题席位数据 + updateTopic(topic_id, { + positions: { + ...topic.positions, + [option_id]: { + ...sideData, + total_shares: sideData.total_shares - shares, + }, + }, + }); + + // 更新价格 + const newPrices = calculatePrice( + topic.positions[option_id].total_shares - shares, + topic.positions[otherOptionId].total_shares + ); + + updateTopic(topic_id, { + positions: { + ...topic.positions, + yes: { ...topic.positions.yes, current_price: newPrices.yes }, + no: { ...topic.positions.no, current_price: newPrices.no }, + }, + }); + + // 更新领主状态 + const newLordId = updateLordStatus(topic_id, option_id); + + // 记录交易 + const trade = { + id: `trade_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + topic_id, + option_id, + buyer_id: null, + seller_id: user_id, + type: 'sell', + shares, + price: revenue / shares, + total_cost: netRevenue, + tax, + created_at: new Date().toISOString(), + }; + trades.push(trade); + + return { + success: true, + trade, + new_lord_id: newLordId, + current_price: newPrices[option_id], + }; +}; + +// ==================== 结算 ==================== + +/** + * 结算话题 + * @param {string} topicId - 话题ID + * @param {string} result - 结果 'yes' | 'no' + * @param {string} evidence - 证据说明 + * @param {string} settledBy - 裁决者ID + * @returns {Object} 结算结果 + */ +export const settleTopic = (topicId, result, evidence, settledBy) => { + const topic = getTopic(topicId); + + if (!topic) throw new Error('话题不存在'); + if (topic.status === 'settled') throw new Error('话题已结算'); + + // 只有作者可以结算 + if (topic.author_id !== settledBy) throw new Error('无权结算'); + + // 获取获胜方和失败方 + const winningOption = result; + const losingOption = result === 'yes' ? 'no' : 'yes'; + + const winners = Array.from(positions.values()).filter( + (p) => p.topic_id === topicId && p.option_id === winningOption + ); + + const losers = Array.from(positions.values()).filter( + (p) => p.topic_id === topicId && p.option_id === losingOption + ); + + // 分配奖池 + if (winners.length === 0) { + // 无人获胜,奖池返还给作者 + addCredits(topic.author_id, topic.total_pool, '话题奖池返还'); + } else { + // 计算获胜方总份额 + const totalWinningShares = winners.reduce((sum, p) => sum + p.shares, 0); + + // 按份额分配 + winners.forEach((position) => { + const share = position.shares / totalWinningShares; + const reward = Math.floor(topic.total_pool * share); + + // 返还本金 + 奖池分成 + const refund = Math.floor(position.avg_cost * position.shares); + const total = refund + reward; + + addCredits(position.holder_id, total, `预测获胜 - ${topic.title}`); + + // 记录胜利 + recordWin(position.holder_id, reward); + + // 删除席位 + positions.delete(position.id); + removePosition(position.holder_id, position.id); + }); + } + + // 失败方损失本金 + losers.forEach((position) => { + const loss = Math.floor(position.avg_cost * position.shares); + + // 记录失败 + recordLoss(position.holder_id, loss); + + // 删除席位 + positions.delete(position.id); + removePosition(position.holder_id, position.id); + }); + + // 更新话题状态 + updateTopic(topicId, { + status: 'settled', + settlement: { + result, + evidence, + settled_by: settledBy, + settled_at: new Date().toISOString(), + }, + }); + + return { + success: true, + winners_count: winners.length, + losers_count: losers.length, + total_distributed: topic.total_pool, + }; +}; + +// ==================== 数据导出 ==================== + +export default { + MARKET_CONFIG, + + // 定价算法 + calculatePrice, + calculateBuyCost, + calculateSellRevenue, + calculateTax, + + // 话题管理 + createTopic, + getTopic, + updateTopic, + getTopics, + + // 席位管理 + getPosition, + + // 交易 + buyPosition, + sellPosition, + + // 结算 + settleTopic, +}; diff --git a/src/styles/brainwave-colors.css b/src/styles/brainwave-colors.css deleted file mode 100644 index 04ea14da..00000000 --- a/src/styles/brainwave-colors.css +++ /dev/null @@ -1,49 +0,0 @@ -/* Brainwave 色彩变量定义 */ -:root { - /* Brainwave 中性色系 */ - --color-n-1: #FFFFFF; - --color-n-2: #CAC6DD; - --color-n-3: #ADA8C3; - --color-n-4: #757185; - --color-n-5: #3F3A52; - --color-n-6: #252134; - --color-n-7: #15131D; - --color-n-8: #0E0C15; - - /* Brainwave 主题色 */ - --color-1: #AC6AFF; - --color-2: #FFC876; - --color-3: #FF776F; - --color-4: #7ADB78; - --color-5: #858DFF; - --color-6: #FF98E2; - - /* 描边色 */ - --stroke-1: #26242C; -} - -/* CSS类名映射到变量 */ -.bg-n-8 { background-color: var(--color-n-8) !important; } -.bg-n-7 { background-color: var(--color-n-7) !important; } -.bg-n-6 { background-color: var(--color-n-6) !important; } - -.text-n-1 { color: var(--color-n-1) !important; } -.text-n-2 { color: var(--color-n-2) !important; } -.text-n-3 { color: var(--color-n-3) !important; } -.text-n-4 { color: var(--color-n-4) !important; } - -.border-n-6 { border-color: var(--color-n-6) !important; } -.border-n-1\/10 { border-color: rgba(255, 255, 255, 0.1) !important; } -.border-n-2\/5 { border-color: rgba(202, 198, 221, 0.05) !important; } -.border-n-2\/10 { border-color: rgba(202, 198, 221, 0.1) !important; } - -.bg-stroke-1 { background-color: var(--stroke-1) !important; } - -/* 渐变背景 */ -.bg-conic-gradient { - background: conic-gradient(from 225deg, #FFC876, #79FFF7, #9F53FF, #FF98E2, #FFC876) !important; -} - -.bg-gradient-to-br { - background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)) !important; -} diff --git a/src/styles/brainwave.css b/src/styles/brainwave.css index 227c948a..4821c5d2 100644 --- a/src/styles/brainwave.css +++ b/src/styles/brainwave.css @@ -1,35 +1,12 @@ +/* Tailwind CSS 入口文件 */ @tailwind base; @tailwind components; @tailwind utilities; -body { -} - -/* styles for splide carousel */ -@layer components { - .splide-custom .splide__arrow { - @apply relative top-0 left-0 right-0 flex items-center justify-center w-12 h-12 bg-transparent border border-solid border-n-4/50 rounded-full transform-none transition-colors hover:border-n-3; - } - - .splide-custom .splide__arrow:hover svg { - @apply fill-n-1; - } - - .splide-custom .splide__arrow svg { - @apply w-4 h-4 fill-n-4 transform-none transition-colors; - } - - .splide-visible .splide__track { - @apply overflow-visible; - } - - .splide-pricing .splide__list { - @apply lg:grid !important; - @apply lg:grid-cols-3 lg:gap-4; - } - - .splide-benefits .splide__list { - @apply md:grid !important; - @apply md:grid-cols-3 md:gap-x-10 md:gap-y-[4.5rem] xl:gap-y-[6rem]; - } +/* 自定义工具类 */ +@layer utilities { + /* 毛玻璃效果 */ + .backdrop-blur-xl { + backdrop-filter: blur(24px); + } } diff --git a/src/styles/bytedesk-override.css b/src/styles/bytedesk-override.css index b479b0b1..8cd81c2c 100644 --- a/src/styles/bytedesk-override.css +++ b/src/styles/bytedesk-override.css @@ -12,13 +12,16 @@ [class*="bytedesk"], [id*="bytedesk"], [class*="BytedeskWeb"] { + position: fixed !important; z-index: 999999 !important; + pointer-events: auto !important; } /* Bytedesk iframe - 聊天窗口 */ iframe[src*="bytedesk"], iframe[src*="/chat/"], iframe[src*="/visitor/"] { + position: fixed !important; z-index: 999999 !important; } diff --git a/src/styles/select-fix.css b/src/styles/select-fix.css new file mode 100644 index 00000000..be912986 --- /dev/null +++ b/src/styles/select-fix.css @@ -0,0 +1,89 @@ +/** + * 修复 Chakra UI Select 组件的下拉选项颜色问题 + * 黑金主题下,下拉选项需要深色背景和白色文字 + */ + +/* 所有 select 元素的 option 样式 */ +select option { + background-color: #1A1A1A !important; /* 深色背景 */ + color: #FFFFFF !important; /* 白色文字 */ + padding: 8px !important; +} + +/* 选中的 option */ +select option:checked { + background-color: #2A2A2A !important; + color: #FFC107 !important; /* 金色高亮 */ +} + +/* hover 状态的 option (某些浏览器支持) */ +select option:hover { + background-color: #222222 !important; + color: #FFD700 !important; +} + +/* 禁用的 option */ +select option:disabled { + color: #808080 !important; + background-color: #151515 !important; +} + +/* Firefox 特殊处理 */ +@-moz-document url-prefix() { + select option { + background-color: #1A1A1A !important; + color: #FFFFFF !important; + } +} + +/* Webkit/Chrome 特殊处理 */ +select { + /* 自定义下拉箭头颜色 */ + color-scheme: dark; +} + +/* 修复 Chakra UI Select 组件的特定样式 */ +.chakra-select { + background-color: #1A1A1A !important; + color: #FFFFFF !important; + border-color: #333333 !important; +} + +.chakra-select:hover { + border-color: #404040 !important; +} + +.chakra-select:focus { + border-color: #FFC107 !important; + box-shadow: 0 0 0 1px rgba(255, 193, 7, 0.3) !important; +} + +/* 下拉箭头图标 */ +.chakra-select__icon-wrapper { + color: #FFFFFF !important; +} + +/* 修复所有表单 select 元素 */ +select[class*="chakra-select"], +select[class*="select"] { + background-color: #1A1A1A !important; + color: #FFFFFF !important; +} + +/* 自定义滚动条 (适用于下拉列表) */ +select::-webkit-scrollbar { + width: 8px; +} + +select::-webkit-scrollbar-track { + background: #0A0A0A; +} + +select::-webkit-scrollbar-thumb { + background: #333333; + border-radius: 4px; +} + +select::-webkit-scrollbar-thumb:hover { + background: #FFC107; +} diff --git a/src/templates/FeaturesPage/Benefits/index.js b/src/templates/FeaturesPage/Benefits/index.js deleted file mode 100644 index b47b4cd3..00000000 --- a/src/templates/FeaturesPage/Benefits/index.js +++ /dev/null @@ -1,83 +0,0 @@ -import { useRef, useState } from "react"; -import Link from "next/link"; -import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide"; -import Section from "@/components/Section"; -import Image from "@/components/Image"; - -import { benefits } from "@/mocks/benefits"; - -type BenefitsProps = {}; - -const Benefits = ({}: BenefitsProps) => { - const [activeIndex, setActiveIndex] = useState(0); - - const ref = useRef(null); - - const handleClick = (index: number) => { - setActiveIndex(index); - ref.current?.go(index); - }; - - return ( -
-
- setActiveIndex(newIndex)} - hasTrack={false} - ref={ref} - > - - {benefits.map((item) => ( - -
- {item.title} -
-
{item.title}
-

{item.text}

-
- ))} -
-
-
- {benefits.map((item, index) => ( - - ))} -
-
-
- ); -}; - -export default Benefits; diff --git a/src/templates/FeaturesPage/Benefits/index.tsx b/src/templates/FeaturesPage/Benefits/index.tsx deleted file mode 100644 index b47b4cd3..00000000 --- a/src/templates/FeaturesPage/Benefits/index.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useRef, useState } from "react"; -import Link from "next/link"; -import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide"; -import Section from "@/components/Section"; -import Image from "@/components/Image"; - -import { benefits } from "@/mocks/benefits"; - -type BenefitsProps = {}; - -const Benefits = ({}: BenefitsProps) => { - const [activeIndex, setActiveIndex] = useState(0); - - const ref = useRef(null); - - const handleClick = (index: number) => { - setActiveIndex(index); - ref.current?.go(index); - }; - - return ( -
-
- setActiveIndex(newIndex)} - hasTrack={false} - ref={ref} - > - - {benefits.map((item) => ( - -
- {item.title} -
-
{item.title}
-

{item.text}

-
- ))} -
-
-
- {benefits.map((item, index) => ( - - ))} -
-
-
- ); -}; - -export default Benefits; diff --git a/src/templates/FeaturesPage/Community/index.js b/src/templates/FeaturesPage/Community/index.js deleted file mode 100644 index 52df9a02..00000000 --- a/src/templates/FeaturesPage/Community/index.js +++ /dev/null @@ -1,107 +0,0 @@ -import { useRef, useState } from "react"; -import { Splide, SplideSlide } from "@splidejs/react-splide"; -import Section from "@/components/Section"; -import Image from "@/components/Image"; - -import { community } from "@/mocks/community"; - -type CommunityProps = {}; - -const Community = ({}: CommunityProps) => { - const [activeIndex, setActiveIndex] = useState(0); - - const ref = useRef(null); - - const handleClick = (index: number) => { - setActiveIndex(index); - ref.current?.go(index); - }; - - return ( -
-
-
-
- setActiveIndex(newIndex)} - ref={ref} - > - {community.map((comment) => ( - -
-
- {comment.text} -
-
-
- {comment.name} -
-
-
- {comment.name} -
-
- {comment.role} -
-
-
-
-
- ))} -
-
- {community.map((item: any, index: number) => ( - - ))} -
-
-
- “ -
-
- - - -
-
-
-
- ); -}; - -export default Community; diff --git a/src/templates/FeaturesPage/Community/index.tsx b/src/templates/FeaturesPage/Community/index.tsx deleted file mode 100644 index 52df9a02..00000000 --- a/src/templates/FeaturesPage/Community/index.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useRef, useState } from "react"; -import { Splide, SplideSlide } from "@splidejs/react-splide"; -import Section from "@/components/Section"; -import Image from "@/components/Image"; - -import { community } from "@/mocks/community"; - -type CommunityProps = {}; - -const Community = ({}: CommunityProps) => { - const [activeIndex, setActiveIndex] = useState(0); - - const ref = useRef(null); - - const handleClick = (index: number) => { - setActiveIndex(index); - ref.current?.go(index); - }; - - return ( -
-
-
-
- setActiveIndex(newIndex)} - ref={ref} - > - {community.map((comment) => ( - -
-
- {comment.text} -
-
-
- {comment.name} -
-
-
- {comment.name} -
-
- {comment.role} -
-
-
-
-
- ))} -
-
- {community.map((item: any, index: number) => ( - - ))} -
-
-
- “ -
-
- - - -
-
-
-
- ); -}; - -export default Community; diff --git a/src/templates/FeaturesPage/Features/index.js b/src/templates/FeaturesPage/Features/index.js deleted file mode 100644 index 4c415983..00000000 --- a/src/templates/FeaturesPage/Features/index.js +++ /dev/null @@ -1,101 +0,0 @@ -import Section from "@/components/Section"; -import Image from "@/components/Image"; - -type FeaturesProps = {}; - -const Features = ({}: FeaturesProps) => { - const content = [ - { - id: "0", - title: "Seamless Integration", - text: "With smart automation and top-notch security, it's the perfect solution for teams looking to work smarter.", - }, - { - id: "1", - title: "Smart Automation", - }, - { - id: "2", - title: "Top-notch Security", - }, - ]; - - return ( -
-
-
- {[ - { id: "0", imageUrl: "/images/features/image-1.jpg" }, - { id: "1", imageUrl: "/images/features/image-1.jpg" }, - { id: "2", imageUrl: "/images/features/image-1.jpg" }, - ].map((item, index) => ( -
-
- Image -
-
-
-
-
-
-

- Customization Options -

-
    - {content.map((item) => ( -
  • -
    - Check -
    - {item.title} -
    -
    - {item.text && ( -

    - {item.text} -

    - )} -
  • - ))} -
-
-
- ))} -
-
-
- ); -}; - -export default Features; diff --git a/src/templates/FeaturesPage/Features/index.tsx b/src/templates/FeaturesPage/Features/index.tsx deleted file mode 100644 index 4c415983..00000000 --- a/src/templates/FeaturesPage/Features/index.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import Section from "@/components/Section"; -import Image from "@/components/Image"; - -type FeaturesProps = {}; - -const Features = ({}: FeaturesProps) => { - const content = [ - { - id: "0", - title: "Seamless Integration", - text: "With smart automation and top-notch security, it's the perfect solution for teams looking to work smarter.", - }, - { - id: "1", - title: "Smart Automation", - }, - { - id: "2", - title: "Top-notch Security", - }, - ]; - - return ( -
-
-
- {[ - { id: "0", imageUrl: "/images/features/image-1.jpg" }, - { id: "1", imageUrl: "/images/features/image-1.jpg" }, - { id: "2", imageUrl: "/images/features/image-1.jpg" }, - ].map((item, index) => ( -
-
- Image -
-
-
-
-
-
-

- Customization Options -

-
    - {content.map((item) => ( -
  • -
    - Check -
    - {item.title} -
    -
    - {item.text && ( -

    - {item.text} -

    - )} -
  • - ))} -
-
-
- ))} -
-
-
- ); -}; - -export default Features; diff --git a/src/templates/FeaturesPage/Hero/index.js b/src/templates/FeaturesPage/Hero/index.js deleted file mode 100644 index a1ba963a..00000000 --- a/src/templates/FeaturesPage/Hero/index.js +++ /dev/null @@ -1,38 +0,0 @@ -import Heading from "@/components/Heading"; -import Image from "@/components/Image"; -import Section from "@/components/Section"; - -type HeroProps = {}; - -const Hero = ({}: HeroProps) => ( -
-
- -
- Features -
- Grid -
-
-
-
-); - -export default Hero; diff --git a/src/templates/FeaturesPage/Hero/index.tsx b/src/templates/FeaturesPage/Hero/index.tsx deleted file mode 100644 index a1ba963a..00000000 --- a/src/templates/FeaturesPage/Hero/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import Heading from "@/components/Heading"; -import Image from "@/components/Image"; -import Section from "@/components/Section"; - -type HeroProps = {}; - -const Hero = ({}: HeroProps) => ( -
-
- -
- Features -
- Grid -
-
-
-
-); - -export default Hero; diff --git a/src/templates/FeaturesPage/index.js b/src/templates/FeaturesPage/index.js deleted file mode 100644 index ed091efe..00000000 --- a/src/templates/FeaturesPage/index.js +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import Layout from "@/components/Layout"; -import Services from "@/components/Services"; -import Join from "@/components/Join"; -import Hero from "./Hero"; -import Benefits from "./Benefits"; -import Features from "./Features"; -import Community from "./Community"; - -const FeaturesPage = () => { - return ( - - - - - - - - - ); -}; - -export default FeaturesPage; diff --git a/src/templates/FeaturesPage/index.tsx b/src/templates/FeaturesPage/index.tsx deleted file mode 100644 index ed091efe..00000000 --- a/src/templates/FeaturesPage/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import Layout from "@/components/Layout"; -import Services from "@/components/Services"; -import Join from "@/components/Join"; -import Hero from "./Hero"; -import Benefits from "./Benefits"; -import Features from "./Features"; -import Community from "./Community"; - -const FeaturesPage = () => { - return ( - - - - - - - - - ); -}; - -export default FeaturesPage; diff --git a/src/templates/HomePage/Benefits/index.js b/src/templates/HomePage/Benefits/index.js deleted file mode 100644 index 5a81c0ad..00000000 --- a/src/templates/HomePage/Benefits/index.js +++ /dev/null @@ -1,165 +0,0 @@ -import { useRef, useState } from "react"; -import { Link } from "react-router-dom"; -// import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide"; -import Section from "../../../components/Section"; -import Heading from "../../../components/Heading/index.js"; -import Image from "../../../components/Image"; - -// 简化版数据,避免依赖外部mock文件 -const benefits = [ - { - id: "0", - title: "智能问答", - text: "让用户能够快速找到问题答案,无需在多个信息源中搜索,提升投研效率。", - backgroundUrl: "/images/benefits/card-1.svg", - iconUrl: "/images/benefits/icon-1.svg", - imageUrl: "/images/benefits/image-2.png", - light: true, - }, - { - id: "1", - title: "持续学习", - text: "系统采用自然语言处理技术理解用户查询,提供准确相关的投研分析结果。", - backgroundUrl: "/images/benefits/card-2.svg", - iconUrl: "/images/benefits/icon-2.svg", - imageUrl: "/images/benefits/image-2.png", - }, - { - id: "2", - title: "全域连接", - text: "随时随地连接AI投研助手,支持多设备访问,让专业分析更便捷。", - backgroundUrl: "/images/benefits/card-3.svg", - iconUrl: "/images/benefits/icon-3.svg", - imageUrl: "/images/benefits/image-2.png", - }, - { - id: "3", - title: "快速响应", - text: "毫秒级响应速度,让用户快速获得投研洞察,把握市场先机。", - backgroundUrl: "/images/benefits/card-4.svg", - iconUrl: "/images/benefits/icon-4.svg", - imageUrl: "/images/benefits/image-2.png", - light: true, - }, - { - id: "4", - title: "深度分析", - text: "基于海量数据训练的专业投研模型,提供超越传统分析工具的深度洞察。", - backgroundUrl: "/images/benefits/card-5.svg", - iconUrl: "/images/benefits/icon-1.svg", - imageUrl: "/images/benefits/image-2.png", - }, - { - id: "5", - title: "智能预测", - text: "结合机器学习算法,为投资决策提供智能预测和风险评估建议。", - backgroundUrl: "/images/benefits/card-6.svg", - iconUrl: "/images/benefits/icon-2.svg", - imageUrl: "/images/benefits/image-2.png", - }, -]; - -const Benefits = () => { - const [activeIndex, setActiveIndex] = useState(0); - - const handleClick = (index) => { - setActiveIndex(index); - }; - - return ( -
-
- - - {/* 简化版网格布局,暂时不使用Splide */} -
- {benefits.map((item, index) => ( -
- -
-
- {item.title} -
-

- {item.text} -

-
- {item.title} -
- 了解更多 -
- - - -
-
- {item.light && ( -
- )} -
-
- {item.imageUrl && ( - {item.title} - )} -
-
- -
- ))} -
- - {/* 指示器 */} -
- {benefits.map((item, index) => ( - - ))} -
-
-
- ); -}; - -export default Benefits; diff --git a/src/templates/HomePage/Collaboration/index.js b/src/templates/HomePage/Collaboration/index.js deleted file mode 100644 index 4d0624e4..00000000 --- a/src/templates/HomePage/Collaboration/index.js +++ /dev/null @@ -1,130 +0,0 @@ -import Section from "@/components/Section"; -import Button from "@/components/Button"; -import Image from "@/components/Image"; - -import { text, content, apps } from "@/mocks/collaboration"; - -type CollaborationProps = {}; - -const Collaboration = ({}: CollaborationProps) => { - return ( -
-
-
-

- AI chat app for seamless collaboration -

-
    - {content.map((item) => ( -
  • -
    - Check -
    - {item.title} -
    -
    - {item.text && ( -

    - {item.text} -

    - )} -
  • - ))} -
- -
-
-
-

- {text} -

-
-
-
-
- Brainwave -
-
-
-
    - {apps.map((app, index) => ( -
  • -
    - {app.title} -
    -
  • - ))} -
-
- Curve 1 -
-
- Curve 2 -
-
-
-
-
-
- ); -}; - -export default Collaboration; diff --git a/src/templates/HomePage/Collaboration/index.tsx b/src/templates/HomePage/Collaboration/index.tsx deleted file mode 100644 index 4d0624e4..00000000 --- a/src/templates/HomePage/Collaboration/index.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import Section from "@/components/Section"; -import Button from "@/components/Button"; -import Image from "@/components/Image"; - -import { text, content, apps } from "@/mocks/collaboration"; - -type CollaborationProps = {}; - -const Collaboration = ({}: CollaborationProps) => { - return ( -
-
-
-

- AI chat app for seamless collaboration -

-
    - {content.map((item) => ( -
  • -
    - Check -
    - {item.title} -
    -
    - {item.text && ( -

    - {item.text} -

    - )} -
  • - ))} -
- -
-
-
-

- {text} -

-
-
-
-
- Brainwave -
-
-
-
    - {apps.map((app, index) => ( -
  • -
    - {app.title} -
    -
  • - ))} -
-
- Curve 1 -
-
- Curve 2 -
-
-
-
-
-
- ); -}; - -export default Collaboration; diff --git a/src/templates/HomePage/Features/index.js b/src/templates/HomePage/Features/index.js deleted file mode 100644 index cfeeead9..00000000 --- a/src/templates/HomePage/Features/index.js +++ /dev/null @@ -1,99 +0,0 @@ -import { useRef, useState } from "react"; -import Section from "../../../components/Section"; -import Button from "../../../components/Button"; -import Image from "../../../components/Image"; -import Notification from "../../../components/Notification"; - -// 简化版特性数据 -const features = [ - { - id: "0", - title: "智能投研分析", - text: "利用先进的AI技术,为您提供全面的投资研究分析,包括市场趋势、公司基本面、技术指标等多维度分析,帮助您做出更明智的投资决策。", - imageUrl: "/images/features/features.png", - iconUrl: "/images/icons/recording-01.svg", - notification: "AI分析完成 - 发现3个潜在投资机会", - }, - { - id: "1", - title: "实时市场监控", - text: "24/7全天候监控全球金融市场动态,实时捕捉市场变化和投资机会。智能预警系统会在关键时刻及时提醒您,确保不错过任何重要的投资时机。", - imageUrl: "/images/features/image-1.jpg", - iconUrl: "/images/icons/chrome-cast.svg", - notification: "市场异动提醒 - 科技股出现上涨信号", - }, -]; - -const Features = () => { - const [currentFeature, setCurrentFeature] = useState(0); - - return ( -
-
- {features.map((item, index) => ( -
-
-
-

- {item.title} -

-

- {item.text} -

- -
- -
-
- Feature -
-
- -
-
- Icon -
-
-
-
-
- ))} - - {/* 简化版导航 */} -
- {features.map((_, index) => ( -
-
-
- ); -}; - -export default Features; diff --git a/src/templates/HomePage/Features/index.tsx b/src/templates/HomePage/Features/index.tsx deleted file mode 100644 index 1459358f..00000000 --- a/src/templates/HomePage/Features/index.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useRef } from "react"; -import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide"; -import Section from "@/components/Section"; -import Button from "@/components/Button"; -import Image from "@/components/Image"; -import Notification from "@/components/Notification"; - -import { features } from "@/mocks/features"; -import Arrows from "@/components/Arrows"; - -type FeaturesProps = {}; - -const Features = ({}: FeaturesProps) => { - const ref = useRef(null); - - return ( -
-
- - - {features.map((item) => ( - -
-
-

- {item.title} -

-

- {item.text} -

- -
- ref.current?.go("<")} - onNext={() => ref.current?.go(">")} - /> -
-
- Figure -
- -
- Icon -
-
-
-
-
- ))} -
- ref.current?.go("<")} - onNext={() => ref.current?.go(">")} - /> -
-
-
- ); -}; - -export default Features; diff --git a/src/templates/HomePage/Hero/index.js b/src/templates/HomePage/Hero/index.js deleted file mode 100644 index 0f42649c..00000000 --- a/src/templates/HomePage/Hero/index.js +++ /dev/null @@ -1,236 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -// import { MouseParallax, ScrollParallax } from "react-just-parallax"; -import Section from "../../../components/Section"; -import Button from "../../../components/Button"; -import Image from "../../../components/Image"; -import Generating from "../../../components/Generating"; -import Notification from "../../../components/Notification"; -import Logos from "../../../components/Logos"; - -const Hero = () => { - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - const parallaxRef = useRef(null); - - return ( -
- {/* 添加深色渐变背景 */} -
-
-
-
-

- 探索  - - AI投研 - -  的无限可能性 {" "} - - - 价值前沿 - - Curve - -

-

- 释放AI的力量,升级您的投研效率。 - 体验专业的开放式AI投研平台,超越传统分析工具。 -

- -
-
-
-
-
-
- AI -
- - - {/* 简化版本,暂时不使用ScrollParallax */} -
- {[ - "/images/icons/home-smile.svg", - "/images/icons/file-02.svg", - "/images/icons/search-md.svg", - "/images/icons/plus-square.svg", - ].map((icon, index) => ( -
- {`Icon -
- ))} -
- -
- -
-
-
-
-
-
- Hero -
-
-
-
-
-
- - {/* 浮动装饰点 */} -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- - - - - - -
- ); -}; - -export default Hero; diff --git a/src/templates/HomePage/HowItWorks/index.js b/src/templates/HomePage/HowItWorks/index.js deleted file mode 100644 index 7d9dac4c..00000000 --- a/src/templates/HomePage/HowItWorks/index.js +++ /dev/null @@ -1,152 +0,0 @@ -import { useRef, useState } from "react"; -import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide"; -import Section from "@/components/Section"; -import Image from "@/components/Image"; -import Button from "@/components/Button"; -import Tagline from "@/components/Tagline"; -import Arrows from "@/components/Arrows"; - -import { howItWorks } from "@/mocks/how-it-works"; - -type HowItWorksProps = {}; - -const HowItWorks = ({}: HowItWorksProps) => { - const [activeIndex, setActiveIndex] = useState(0); - - const ref = useRef(null); - - const handleClick = (index: number) => { - setActiveIndex(index); - ref.current?.go(index); - }; - - return ( -
-
- - - {howItWorks.map((item, index) => ( - -
-
- - How it work: 0{index + 1}. - -

- {item.title} -

-

- {item.text} -

- - ref.current?.go("<")} - onNext={() => ref.current?.go(">")} - /> -
-
-
-
- {item.title} -
-
- - - -
-
- Ask anything -
-
- Recording -
-
-
-
-
-
-
-
-
- ))} -
- ref.current?.go("<")} - onNext={() => ref.current?.go(">")} - /> -
- Gradient -
-
- {howItWorks.map((item, index) => ( -
handleClick(index)} - key={item.id} - > -
-
- 0{index + 1}. -
-

- {item.title} -

-

- {item.text} -

-
- ))} -
-
-
-
- ); -}; - -export default HowItWorks; diff --git a/src/templates/HomePage/HowItWorks/index.tsx b/src/templates/HomePage/HowItWorks/index.tsx deleted file mode 100644 index 7d9dac4c..00000000 --- a/src/templates/HomePage/HowItWorks/index.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { useRef, useState } from "react"; -import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide"; -import Section from "@/components/Section"; -import Image from "@/components/Image"; -import Button from "@/components/Button"; -import Tagline from "@/components/Tagline"; -import Arrows from "@/components/Arrows"; - -import { howItWorks } from "@/mocks/how-it-works"; - -type HowItWorksProps = {}; - -const HowItWorks = ({}: HowItWorksProps) => { - const [activeIndex, setActiveIndex] = useState(0); - - const ref = useRef(null); - - const handleClick = (index: number) => { - setActiveIndex(index); - ref.current?.go(index); - }; - - return ( -
-
- - - {howItWorks.map((item, index) => ( - -
-
- - How it work: 0{index + 1}. - -

- {item.title} -

-

- {item.text} -

- - ref.current?.go("<")} - onNext={() => ref.current?.go(">")} - /> -
-
-
-
- {item.title} -
-
- - - -
-
- Ask anything -
-
- Recording -
-
-
-
-
-
-
-
-
- ))} -
- ref.current?.go("<")} - onNext={() => ref.current?.go(">")} - /> -
- Gradient -
-
- {howItWorks.map((item, index) => ( -
handleClick(index)} - key={item.id} - > -
-
- 0{index + 1}. -
-

- {item.title} -

-

- {item.text} -

-
- ))} -
-
-
-
- ); -}; - -export default HowItWorks; diff --git a/src/templates/HomePage/Pricing/index.js b/src/templates/HomePage/Pricing/index.js deleted file mode 100644 index 2b11497a..00000000 --- a/src/templates/HomePage/Pricing/index.js +++ /dev/null @@ -1,69 +0,0 @@ -import Link from "next/link"; -import Section from "@/components/Section"; -import Image from "@/components/Image"; -import Heading from "@/components/Heading"; -import PricingList from "@/components/PricingList"; - -type PricingProps = {}; - -const Pricing = ({}: PricingProps) => { - return ( -
-
-
- Sphere -
- Stars -
-
- -
- -
- Lines -
-
- Lines -
-
-
- - See the full details - -
-
-
- ); -}; - -export default Pricing; diff --git a/src/templates/HomePage/Pricing/index.tsx b/src/templates/HomePage/Pricing/index.tsx deleted file mode 100644 index 2b11497a..00000000 --- a/src/templates/HomePage/Pricing/index.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import Link from "next/link"; -import Section from "@/components/Section"; -import Image from "@/components/Image"; -import Heading from "@/components/Heading"; -import PricingList from "@/components/PricingList"; - -type PricingProps = {}; - -const Pricing = ({}: PricingProps) => { - return ( -
-
-
- Sphere -
- Stars -
-
- -
- -
- Lines -
-
- Lines -
-
-
- - See the full details - -
-
-
- ); -}; - -export default Pricing; diff --git a/src/templates/HomePage/Roadmap/index.js b/src/templates/HomePage/Roadmap/index.js deleted file mode 100644 index 24d83b59..00000000 --- a/src/templates/HomePage/Roadmap/index.js +++ /dev/null @@ -1,97 +0,0 @@ -import Section from "@/components/Section"; -import Tagline from "@/components/Tagline"; -import Image from "@/components/Image"; - -import { roadmap } from "@/mocks/roadmap"; -import Button from "@/components/Button"; -import Heading from "@/components/Heading"; - -type RoadmapProps = {}; - -const Roadmap = ({}: RoadmapProps) => ( -
-
- -
- {roadmap.map((item, index) => ( -
-
-
- Grid -
-
-
- {item.date} -
- { -
- {item.status === "done" - ? "Done" - : "In progress"} -
-
-
-
-
- {item.title} -
-
-

{item.title}

-

{item.text}

-
-
-
- ))} -
-
- Gradient -
-
-
-
- -
-
-
-); - -export default Roadmap; diff --git a/src/templates/HomePage/Roadmap/index.tsx b/src/templates/HomePage/Roadmap/index.tsx deleted file mode 100644 index 24d83b59..00000000 --- a/src/templates/HomePage/Roadmap/index.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import Section from "@/components/Section"; -import Tagline from "@/components/Tagline"; -import Image from "@/components/Image"; - -import { roadmap } from "@/mocks/roadmap"; -import Button from "@/components/Button"; -import Heading from "@/components/Heading"; - -type RoadmapProps = {}; - -const Roadmap = ({}: RoadmapProps) => ( -
-
- -
- {roadmap.map((item, index) => ( -
-
-
- Grid -
-
-
- {item.date} -
- { -
- {item.status === "done" - ? "Done" - : "In progress"} -
-
-
-
-
- {item.title} -
-
-

{item.title}

-

{item.text}

-
-
-
- ))} -
-
- Gradient -
-
-
-
- -
-
-
-); - -export default Roadmap; diff --git a/src/templates/HomePage/Testimonials/index.js b/src/templates/HomePage/Testimonials/index.js deleted file mode 100644 index 847b75d8..00000000 --- a/src/templates/HomePage/Testimonials/index.js +++ /dev/null @@ -1,90 +0,0 @@ -import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide"; -import Section from "@/components/Section"; -import Tagline from "@/components/Tagline"; -import Button from "@/components/Button"; -import Image from "@/components/Image"; - -import { testimonials } from "@/mocks/testimonials"; -import Arrows from "@/components/Arrows"; -import Heading from "@/components/Heading"; - -type TestimonialsProps = {}; - -const Testimonials = ({}: TestimonialsProps) => ( -
-
- - - - {testimonials.map((item) => ( - -
-
-
-
- {item.name} -
-
-
-
-
- {item.name} -
-
{item.name}
-
- {item.role} -
-
-
-
-

- {item.text} -

- -
-
-
-
- ))} -
- -
-
-
-); - -export default Testimonials; diff --git a/src/templates/HomePage/Testimonials/index.tsx b/src/templates/HomePage/Testimonials/index.tsx deleted file mode 100644 index 847b75d8..00000000 --- a/src/templates/HomePage/Testimonials/index.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide"; -import Section from "@/components/Section"; -import Tagline from "@/components/Tagline"; -import Button from "@/components/Button"; -import Image from "@/components/Image"; - -import { testimonials } from "@/mocks/testimonials"; -import Arrows from "@/components/Arrows"; -import Heading from "@/components/Heading"; - -type TestimonialsProps = {}; - -const Testimonials = ({}: TestimonialsProps) => ( -
-
- - - - {testimonials.map((item) => ( - -
-
-
-
- {item.name} -
-
-
-
-
- {item.name} -
-
{item.name}
-
- {item.role} -
-
-
-
-

- {item.text} -

- -
-
-
-
- ))} -
- -
-
-
-); - -export default Testimonials; diff --git a/src/templates/HomePage/index.js b/src/templates/HomePage/index.js deleted file mode 100644 index c89749a0..00000000 --- a/src/templates/HomePage/index.js +++ /dev/null @@ -1,30 +0,0 @@ -import Layout from "../../components/Layout"; -import Hero from "./Hero/index.js"; -import Benefits from "./Benefits/index.js"; -import Features from "./Features/index.js"; -import Collaboration from "./Collaboration"; -import HowItWorks from "./HowItWorks"; -import Pricing from "./Pricing"; -import Testimonials from "./Testimonials"; -import Roadmap from "./Roadmap"; -import Services from "../../components/Services"; -import Join from "../../components/Join"; - -const HomePage = () => { - return ( - - - - - - - - - - - - - ); -}; - -export default HomePage; diff --git a/src/templates/HomePage/index.tsx b/src/templates/HomePage/index.tsx deleted file mode 100644 index 22256d0a..00000000 --- a/src/templates/HomePage/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import Layout from "../../components/Layout/index.js"; -import Hero from "./Hero/index.js"; -import Benefits from "./Benefits/index.js"; -import Features from "./Features/index.js"; -// import Collaboration from "./Collaboration"; -// import HowItWorks from "./HowItWorks"; -// import Pricing from "./Pricing"; -// import Testimonials from "./Testimonials"; -// import Roadmap from "./Roadmap"; -// import Services from "../../components/Services"; -// import Join from "../../components/Join"; - -const HomePage = () => { - return ( - - - - - {/* 其他组件将在后续逐步修复 */} - {/* - - - - - - */} - - ); -}; - -export default HomePage; diff --git a/src/templates/HowToUsePage/Help/index.js b/src/templates/HowToUsePage/Help/index.js deleted file mode 100644 index daa68b93..00000000 --- a/src/templates/HowToUsePage/Help/index.js +++ /dev/null @@ -1,62 +0,0 @@ -import Section from "@/components/Section"; -import Image from "@/components/Image"; -import Button from "@/components/Button"; - -type HelpProps = {}; - -const Help = ({}: HelpProps) => ( -
-
-
-
- Help -
-
-
-

Need help?

-

- Can’t find your answer, contact us -

-
    - {[ - { - id: "0", - title: "Join our community", - text: "Discuss anything with other users", - }, - { - id: "1", - title: "Email us", - text: "hello@brainwave.com", - }, - ].map((item) => ( -
  • -
    - Contact -
    -
    -
    {item.title}
    -

    {item.text}

    -
    -
  • - ))} -
-
-
-
-); - -export default Help; diff --git a/src/templates/HowToUsePage/Help/index.tsx b/src/templates/HowToUsePage/Help/index.tsx deleted file mode 100644 index daa68b93..00000000 --- a/src/templates/HowToUsePage/Help/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import Section from "@/components/Section"; -import Image from "@/components/Image"; -import Button from "@/components/Button"; - -type HelpProps = {}; - -const Help = ({}: HelpProps) => ( -
-
-
-
- Help -
-
-
-

Need help?

-

- Can’t find your answer, contact us -

-
    - {[ - { - id: "0", - title: "Join our community", - text: "Discuss anything with other users", - }, - { - id: "1", - title: "Email us", - text: "hello@brainwave.com", - }, - ].map((item) => ( -
  • -
    - Contact -
    -
    -
    {item.title}
    -

    {item.text}

    -
    -
  • - ))} -
-
-
-
-); - -export default Help; diff --git a/src/templates/HowToUsePage/HowToUse/index.js b/src/templates/HowToUsePage/HowToUse/index.js deleted file mode 100644 index 904bc443..00000000 --- a/src/templates/HowToUsePage/HowToUse/index.js +++ /dev/null @@ -1,261 +0,0 @@ -import { useState } from "react"; -import ScrollIntoView from "react-scroll-into-view"; -import Section from "@/components/Section"; -import Heading from "@/components/Heading"; -import Image from "@/components/Image"; -import Tagline from "@/components/Tagline"; -import Button from "@/components/Button"; - -import { navigation } from "@/mocks/how-to-use"; - -type HowToUseProps = {}; - -const HowToUse = ({}: HowToUseProps) => { - const [openNavigation, setOpenNavigation] = useState(false); - const [openGroupId, setOpenGroudId] = useState("g0"); - - return ( -
-
- -
- Search - -
-
-
-
setOpenNavigation(!openNavigation)} - > -
- Getting started -
- Arrow -
-
- {navigation.map((group) => ( -
- -
-
    - {group.items.map((item) => ( -
  • - - - {item.title} - - -
  • - ))} -
-
-
- ))} -
-
-
-

- Getting started -

-
-
-

Sign up

- 01 -
-
- Image 1 -
-
-

- {`To create an account with Brainwave - AI - chat app, all you need to do is provide - your name, email address, and password. - Once you have signed up, you will be - able to start exploring the app's - various features. Brainwave's AI chat - system is designed to provide you with - an intuitive, easy-to-use interface that - makes it simple to chat with friends and - family, or even with new acquaintances.`} -

-

- In addition, the app is constantly being - updated with new features and improvements, - so you can expect it to continue to evolve - and improve over time. Whether you are - looking for a simple chat app, or a more - advanced platform that can help you stay - connected with people from all over the - world, Brainwave is the perfect choice. -

-
-
-
-
-
-

- Connect with AI Chatbot -

- 02 -
-
- Image 2 -
-
-

- Connect with the AI chatbot to start the - conversation. The chatbot uses natural - language processing to understand your - queries and provide relevant responses. -

-
-
-
-
-
-

- Get Personalized Advices -

- 03 -
-
- Image 3 -
-
-

- Based on the conversation with the AI - chatbot, you will receive personalized - recommendations related to your queries. The - chatbot is trained to understand your - preferences and provide customized - suggestions. -

-
-
-
-
-
-

- Explore and Engage -

- 04 -
-
- Image 4 -
-
-

- Explore the recommendations provided by the - AI chatbot and engage with the app. You can - ask questions, provide feedback, and share - your experience with the chatbot. -

-
-
-
-
- -
-
-
-
-
- ); -}; - -export default HowToUse; diff --git a/src/templates/HowToUsePage/HowToUse/index.tsx b/src/templates/HowToUsePage/HowToUse/index.tsx deleted file mode 100644 index 904bc443..00000000 --- a/src/templates/HowToUsePage/HowToUse/index.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { useState } from "react"; -import ScrollIntoView from "react-scroll-into-view"; -import Section from "@/components/Section"; -import Heading from "@/components/Heading"; -import Image from "@/components/Image"; -import Tagline from "@/components/Tagline"; -import Button from "@/components/Button"; - -import { navigation } from "@/mocks/how-to-use"; - -type HowToUseProps = {}; - -const HowToUse = ({}: HowToUseProps) => { - const [openNavigation, setOpenNavigation] = useState(false); - const [openGroupId, setOpenGroudId] = useState("g0"); - - return ( -
-
- -
- Search - -
-
-
-
setOpenNavigation(!openNavigation)} - > -
- Getting started -
- Arrow -
-
- {navigation.map((group) => ( -
- -
-
    - {group.items.map((item) => ( -
  • - - - {item.title} - - -
  • - ))} -
-
-
- ))} -
-
-
-

- Getting started -

-
-
-

Sign up

- 01 -
-
- Image 1 -
-
-

- {`To create an account with Brainwave - AI - chat app, all you need to do is provide - your name, email address, and password. - Once you have signed up, you will be - able to start exploring the app's - various features. Brainwave's AI chat - system is designed to provide you with - an intuitive, easy-to-use interface that - makes it simple to chat with friends and - family, or even with new acquaintances.`} -

-

- In addition, the app is constantly being - updated with new features and improvements, - so you can expect it to continue to evolve - and improve over time. Whether you are - looking for a simple chat app, or a more - advanced platform that can help you stay - connected with people from all over the - world, Brainwave is the perfect choice. -

-
-
-
-
-
-

- Connect with AI Chatbot -

- 02 -
-
- Image 2 -
-
-

- Connect with the AI chatbot to start the - conversation. The chatbot uses natural - language processing to understand your - queries and provide relevant responses. -

-
-
-
-
-
-

- Get Personalized Advices -

- 03 -
-
- Image 3 -
-
-

- Based on the conversation with the AI - chatbot, you will receive personalized - recommendations related to your queries. The - chatbot is trained to understand your - preferences and provide customized - suggestions. -

-
-
-
-
-
-

- Explore and Engage -

- 04 -
-
- Image 4 -
-
-

- Explore the recommendations provided by the - AI chatbot and engage with the app. You can - ask questions, provide feedback, and share - your experience with the chatbot. -

-
-
-
-
- -
-
-
-
-
- ); -}; - -export default HowToUse; diff --git a/src/templates/HowToUsePage/index.js b/src/templates/HowToUsePage/index.js deleted file mode 100644 index e74a317e..00000000 --- a/src/templates/HowToUsePage/index.js +++ /dev/null @@ -1,16 +0,0 @@ -"use client"; - -import Layout from "@/components/Layout"; -import HowToUse from "./HowToUse"; -import Help from "./Help"; - -const HowToUsePage = () => { - return ( - - - - - ); -}; - -export default HowToUsePage; diff --git a/src/templates/HowToUsePage/index.tsx b/src/templates/HowToUsePage/index.tsx deleted file mode 100644 index e74a317e..00000000 --- a/src/templates/HowToUsePage/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -"use client"; - -import Layout from "@/components/Layout"; -import HowToUse from "./HowToUse"; -import Help from "./Help"; - -const HowToUsePage = () => { - return ( - - - - - ); -}; - -export default HowToUsePage; diff --git a/src/templates/LoginPage/index.js b/src/templates/LoginPage/index.js deleted file mode 100644 index 43fbcf82..00000000 --- a/src/templates/LoginPage/index.js +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -import { useSearchParams } from "next/navigation"; -import Button from "@/components/Button"; -import Image from "@/components/Image"; -import Layout from "@/components/Layout"; -import Section from "@/components/Section"; - -const LoginPage = ({}) => { - const searchParams = useSearchParams(); - const signUp = searchParams.has("new"); - - return ( - -
-
-
-

- Join the AI revolution with Brainwave -

-

- Get started with Brainwave - AI chat app today and - experience the power of AI in your conversations! -

-
-
-
- {signUp && ( -
- Mail - -
- )} -
- Mail - -
-
- Lock - -
- -
-
- Or start your Brainwave with -
- -
-
-
-
-
-
-
-
-
- - - - - - -
- Background -
-
-
- ); -}; - -export default LoginPage; diff --git a/src/templates/LoginPage/index.tsx b/src/templates/LoginPage/index.tsx deleted file mode 100644 index 43fbcf82..00000000 --- a/src/templates/LoginPage/index.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -import { useSearchParams } from "next/navigation"; -import Button from "@/components/Button"; -import Image from "@/components/Image"; -import Layout from "@/components/Layout"; -import Section from "@/components/Section"; - -const LoginPage = ({}) => { - const searchParams = useSearchParams(); - const signUp = searchParams.has("new"); - - return ( - -
-
-
-

- Join the AI revolution with Brainwave -

-

- Get started with Brainwave - AI chat app today and - experience the power of AI in your conversations! -

-
-
-
- {signUp && ( -
- Mail - -
- )} -
- Mail - -
-
- Lock - -
- -
-
- Or start your Brainwave with -
- -
-
-
-
-
-
-
-
-
- - - - - - -
- Background -
-
-
- ); -}; - -export default LoginPage; diff --git a/src/templates/PricingPage/Community/Carousel.js b/src/templates/PricingPage/Community/Carousel.js deleted file mode 100644 index 75fa2242..00000000 --- a/src/templates/PricingPage/Community/Carousel.js +++ /dev/null @@ -1,64 +0,0 @@ -import { useRef, useState } from "react"; -import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide"; -import Comment from "./Comment"; - -type CarouselProps = { - items: any; -}; - -const Carousel = ({ items }: CarouselProps) => { - const [activeIndex, setActiveIndex] = useState(0); - - const ref = useRef(null); - - const handleClick = (index: number) => { - setActiveIndex(index); - ref.current?.go(index); - }; - - return ( - setActiveIndex(newIndex)} - hasTrack={false} - ref={ref} - > - - {items.map((item: any) => ( - -
- -
-
- ))} -
-
- {items.map((item: any, index: number) => ( - - ))} -
-
- ); -}; - -export default Carousel; diff --git a/src/templates/PricingPage/Community/Carousel.tsx b/src/templates/PricingPage/Community/Carousel.tsx deleted file mode 100644 index 75fa2242..00000000 --- a/src/templates/PricingPage/Community/Carousel.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useRef, useState } from "react"; -import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide"; -import Comment from "./Comment"; - -type CarouselProps = { - items: any; -}; - -const Carousel = ({ items }: CarouselProps) => { - const [activeIndex, setActiveIndex] = useState(0); - - const ref = useRef(null); - - const handleClick = (index: number) => { - setActiveIndex(index); - ref.current?.go(index); - }; - - return ( - setActiveIndex(newIndex)} - hasTrack={false} - ref={ref} - > - - {items.map((item: any) => ( - -
- -
-
- ))} -
-
- {items.map((item: any, index: number) => ( - - ))} -
-
- ); -}; - -export default Carousel; diff --git a/src/templates/PricingPage/Community/Comment.js b/src/templates/PricingPage/Community/Comment.js deleted file mode 100644 index a37e4b02..00000000 --- a/src/templates/PricingPage/Community/Comment.js +++ /dev/null @@ -1,28 +0,0 @@ -import Image from "@/components/Image"; - -type CommentProps = { - comment: any; -}; - -const Comment = ({ comment }: CommentProps) => ( -
-
{comment.text}
-
-
-
{comment.name}
-
{comment.role}
-
-
- {comment.name} -
-
-
-); - -export default Comment; diff --git a/src/templates/PricingPage/Community/Comment.tsx b/src/templates/PricingPage/Community/Comment.tsx deleted file mode 100644 index a37e4b02..00000000 --- a/src/templates/PricingPage/Community/Comment.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Image from "@/components/Image"; - -type CommentProps = { - comment: any; -}; - -const Comment = ({ comment }: CommentProps) => ( -
-
{comment.text}
-
-
-
{comment.name}
-
{comment.role}
-
-
- {comment.name} -
-
-
-); - -export default Comment; diff --git a/src/templates/PricingPage/Community/Grid.js b/src/templates/PricingPage/Community/Grid.js deleted file mode 100644 index 5c1b3f1f..00000000 --- a/src/templates/PricingPage/Community/Grid.js +++ /dev/null @@ -1,25 +0,0 @@ -import Masonry, { ResponsiveMasonry } from "react-responsive-masonry"; -import Comment from "./Comment"; - -type GridProps = { - items: any; -}; - -const Grid = ({ items }: GridProps) => { - return ( - - - {items.map((item: any) => ( -
- -
- ))} -
-
- ); -}; - -export default Grid; diff --git a/src/templates/PricingPage/Community/Grid.tsx b/src/templates/PricingPage/Community/Grid.tsx deleted file mode 100644 index 5c1b3f1f..00000000 --- a/src/templates/PricingPage/Community/Grid.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import Masonry, { ResponsiveMasonry } from "react-responsive-masonry"; -import Comment from "./Comment"; - -type GridProps = { - items: any; -}; - -const Grid = ({ items }: GridProps) => { - return ( - - - {items.map((item: any) => ( -
- -
- ))} -
-
- ); -}; - -export default Grid; diff --git a/src/templates/PricingPage/Community/index.js b/src/templates/PricingPage/Community/index.js deleted file mode 100644 index 35e2408d..00000000 --- a/src/templates/PricingPage/Community/index.js +++ /dev/null @@ -1,50 +0,0 @@ -import dynamic from "next/dynamic"; -import { useMediaQuery } from "react-responsive"; -import Section from "@/components/Section"; -import Heading from "@/components/Heading"; -import Image from "@/components/Image"; -const Grid = dynamic(() => import("./Grid"), { ssr: false }); -const Carousel = dynamic(() => import("./Carousel"), { ssr: false }); - -import { community } from "@/mocks/community"; - -type CommunityProps = {}; - -const Community = ({}: CommunityProps) => { - const isTablet = useMediaQuery({ - query: "(min-width: 768px)", - }); - - return ( -
-
- -
- {isTablet ? ( - - ) : ( - - )} -
-
- Gradient -
-
-
-
-
- ); -}; - -export default Community; diff --git a/src/templates/PricingPage/Community/index.tsx b/src/templates/PricingPage/Community/index.tsx deleted file mode 100644 index 35e2408d..00000000 --- a/src/templates/PricingPage/Community/index.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import dynamic from "next/dynamic"; -import { useMediaQuery } from "react-responsive"; -import Section from "@/components/Section"; -import Heading from "@/components/Heading"; -import Image from "@/components/Image"; -const Grid = dynamic(() => import("./Grid"), { ssr: false }); -const Carousel = dynamic(() => import("./Carousel"), { ssr: false }); - -import { community } from "@/mocks/community"; - -type CommunityProps = {}; - -const Community = ({}: CommunityProps) => { - const isTablet = useMediaQuery({ - query: "(min-width: 768px)", - }); - - return ( -
-
- -
- {isTablet ? ( - - ) : ( - - )} -
-
- Gradient -
-
-
-
-
- ); -}; - -export default Community; diff --git a/src/templates/PricingPage/Comparison/index.js b/src/templates/PricingPage/Comparison/index.js deleted file mode 100644 index 3046ed3e..00000000 --- a/src/templates/PricingPage/Comparison/index.js +++ /dev/null @@ -1,101 +0,0 @@ -import Tippy from "@tippyjs/react"; -import Heading from "@/components/Heading"; -import Image from "@/components/Image"; -import Section from "@/components/Section"; - -import { comparison } from "@/mocks/comparison"; - -type ComparisonProps = {}; - -const Comparison = ({}: ComparisonProps) => { - const check = (value: any, enterprise?: boolean) => - typeof value === "boolean" ? ( - value === true ? ( - Check - ) : null - ) : ( - value - ); - - return ( -
-
- -
- - - - - - - - - {comparison.map((item) => ( - - - - - - - ))} - -
Features - Basic - - Premium - - Enterprise -
-
- {item.title} - -
- Help -
-
-
-
- {check( - item.pricing[0], - item.enterprise - )} - - {check( - item.pricing[1], - item.enterprise - )} - - {check( - item.pricing[2], - item.enterprise - )} -
-
-
-
- ); -}; - -export default Comparison; diff --git a/src/templates/PricingPage/Comparison/index.tsx b/src/templates/PricingPage/Comparison/index.tsx deleted file mode 100644 index 3046ed3e..00000000 --- a/src/templates/PricingPage/Comparison/index.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import Tippy from "@tippyjs/react"; -import Heading from "@/components/Heading"; -import Image from "@/components/Image"; -import Section from "@/components/Section"; - -import { comparison } from "@/mocks/comparison"; - -type ComparisonProps = {}; - -const Comparison = ({}: ComparisonProps) => { - const check = (value: any, enterprise?: boolean) => - typeof value === "boolean" ? ( - value === true ? ( - Check - ) : null - ) : ( - value - ); - - return ( -
-
- -
- - - - - - - - - {comparison.map((item) => ( - - - - - - - ))} - -
Features - Basic - - Premium - - Enterprise -
-
- {item.title} - -
- Help -
-
-
-
- {check( - item.pricing[0], - item.enterprise - )} - - {check( - item.pricing[1], - item.enterprise - )} - - {check( - item.pricing[2], - item.enterprise - )} -
-
-
-
- ); -}; - -export default Comparison; diff --git a/src/templates/PricingPage/Faq/index.js b/src/templates/PricingPage/Faq/index.js deleted file mode 100644 index 6b6d45fe..00000000 --- a/src/templates/PricingPage/Faq/index.js +++ /dev/null @@ -1,78 +0,0 @@ -import { useState } from "react"; -import Section from "@/components/Section"; -import Heading from "@/components/Heading"; - -import { faq } from "@/mocks/faq"; - -type FaqProps = {}; - -const Faq = ({}: FaqProps) => { - const [activeId, setActiveId] = useState(faq[0].id); - - return ( -
-
- - Haven’t found what you’re looking for?{" "} - - Contact us - - - } - /> -
- {faq.map((item) => ( -
-
- setActiveId( - activeId === item.id ? null : item.id - ) - } - > -
- {item.title} -
-
-
-
-
-
-
-
-
{item.text}
-
-
-
- ))} -
-
-
- ); -}; - -export default Faq; diff --git a/src/templates/PricingPage/Faq/index.tsx b/src/templates/PricingPage/Faq/index.tsx deleted file mode 100644 index 6b6d45fe..00000000 --- a/src/templates/PricingPage/Faq/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useState } from "react"; -import Section from "@/components/Section"; -import Heading from "@/components/Heading"; - -import { faq } from "@/mocks/faq"; - -type FaqProps = {}; - -const Faq = ({}: FaqProps) => { - const [activeId, setActiveId] = useState(faq[0].id); - - return ( -
-
- - Haven’t found what you’re looking for?{" "} - - Contact us - - - } - /> -
- {faq.map((item) => ( -
-
- setActiveId( - activeId === item.id ? null : item.id - ) - } - > -
- {item.title} -
-
-
-
-
-
-
-
-
{item.text}
-
-
-
- ))} -
-
-
- ); -}; - -export default Faq; diff --git a/src/templates/PricingPage/Pricing/index.js b/src/templates/PricingPage/Pricing/index.js deleted file mode 100644 index e6df7276..00000000 --- a/src/templates/PricingPage/Pricing/index.js +++ /dev/null @@ -1,50 +0,0 @@ -import Section from "@/components/Section"; -import Heading from "@/components/Heading"; -import PricingList from "@/components/PricingList"; -import { useState } from "react"; -import Logos from "@/components/Logos"; - -type PricingProps = {}; - -const Pricing = ({}: PricingProps) => { - const [monthly, setMonthly] = useState(false); - - return ( -
-
- -
-
- - -
-
- - -
-
- ); -}; - -export default Pricing; diff --git a/src/templates/PricingPage/Pricing/index.tsx b/src/templates/PricingPage/Pricing/index.tsx deleted file mode 100644 index e6df7276..00000000 --- a/src/templates/PricingPage/Pricing/index.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import Section from "@/components/Section"; -import Heading from "@/components/Heading"; -import PricingList from "@/components/PricingList"; -import { useState } from "react"; -import Logos from "@/components/Logos"; - -type PricingProps = {}; - -const Pricing = ({}: PricingProps) => { - const [monthly, setMonthly] = useState(false); - - return ( -
-
- -
-
- - -
-
- - -
-
- ); -}; - -export default Pricing; diff --git a/src/templates/PricingPage/index.js b/src/templates/PricingPage/index.js deleted file mode 100644 index 10fe6141..00000000 --- a/src/templates/PricingPage/index.js +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -import Layout from "@/components/Layout"; -import Pricing from "./Pricing"; -import Comparison from "./Comparison"; -import Community from "./Community"; -import Join from "@/components/Join"; -import Faq from "./Faq"; - -const PricingPage = () => { - return ( - - - - - - - - ); -}; - -export default PricingPage; diff --git a/src/templates/PricingPage/index.tsx b/src/templates/PricingPage/index.tsx deleted file mode 100644 index 10fe6141..00000000 --- a/src/templates/PricingPage/index.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -import Layout from "@/components/Layout"; -import Pricing from "./Pricing"; -import Comparison from "./Comparison"; -import Community from "./Community"; -import Join from "@/components/Join"; -import Faq from "./Faq"; - -const PricingPage = () => { - return ( - - - - - - - - ); -}; - -export default PricingPage; diff --git a/src/templates/RoadmapPage/Hero/index.js b/src/templates/RoadmapPage/Hero/index.js deleted file mode 100644 index 6a0e00c5..00000000 --- a/src/templates/RoadmapPage/Hero/index.js +++ /dev/null @@ -1,58 +0,0 @@ -import Section from "@/components/Section"; -import Heading from "@/components/Heading"; -import Button from "@/components/Button"; -import Generating from "@/components/Generating"; -import Image from "@/components/Image"; - -type HeroProps = {}; - -const Hero = ({}: HeroProps) => ( -
-
- - - -
-
-
-
-
- Hero -
-
- Coins -
- -
-
-
-
-
-
-
-); - -export default Hero; diff --git a/src/templates/RoadmapPage/Hero/index.tsx b/src/templates/RoadmapPage/Hero/index.tsx deleted file mode 100644 index 6a0e00c5..00000000 --- a/src/templates/RoadmapPage/Hero/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import Section from "@/components/Section"; -import Heading from "@/components/Heading"; -import Button from "@/components/Button"; -import Generating from "@/components/Generating"; -import Image from "@/components/Image"; - -type HeroProps = {}; - -const Hero = ({}: HeroProps) => ( -
-
- - - -
-
-
-
-
- Hero -
-
- Coins -
- -
-
-
-
-
-
-
-); - -export default Hero; diff --git a/src/templates/RoadmapPage/Roadmap/index.js b/src/templates/RoadmapPage/Roadmap/index.js deleted file mode 100644 index 8fd46d67..00000000 --- a/src/templates/RoadmapPage/Roadmap/index.js +++ /dev/null @@ -1,60 +0,0 @@ -import Image from "@/components/Image"; -import Section from "@/components/Section"; -import Tagline from "@/components/Tagline"; -import { roadmapFull } from "@/mocks/roadmap"; - -type RoadmapProps = {}; - -const Roadmap = ({}: RoadmapProps) => ( -
-
-
    - {roadmapFull.map((item) => ( -
  • -
    - {item.date} -
    -
    -
    -
    - Done -
    -
    -
    -
    {item.title}
    - {item.status === "progress" && ( -
    - In progress -
    WIP
    -
    - )} -
    -

    {item.text}

    -
    -
    -
  • - ))} -
-
-
-); - -export default Roadmap; diff --git a/src/templates/RoadmapPage/Roadmap/index.tsx b/src/templates/RoadmapPage/Roadmap/index.tsx deleted file mode 100644 index 8fd46d67..00000000 --- a/src/templates/RoadmapPage/Roadmap/index.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import Image from "@/components/Image"; -import Section from "@/components/Section"; -import Tagline from "@/components/Tagline"; -import { roadmapFull } from "@/mocks/roadmap"; - -type RoadmapProps = {}; - -const Roadmap = ({}: RoadmapProps) => ( -
-
-
    - {roadmapFull.map((item) => ( -
  • -
    - {item.date} -
    -
    -
    -
    - Done -
    -
    -
    -
    {item.title}
    - {item.status === "progress" && ( -
    - In progress -
    WIP
    -
    - )} -
    -

    {item.text}

    -
    -
    -
  • - ))} -
-
-
-); - -export default Roadmap; diff --git a/src/templates/RoadmapPage/index.js b/src/templates/RoadmapPage/index.js deleted file mode 100644 index a4b114cd..00000000 --- a/src/templates/RoadmapPage/index.js +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import Layout from "@/components/Layout"; -import Join from "@/components/Join"; -import Hero from "./Hero"; -import Roadmap from "./Roadmap"; - -const RoadmapPage = () => { - return ( - - - - - - ); -}; - -export default RoadmapPage; diff --git a/src/templates/RoadmapPage/index.tsx b/src/templates/RoadmapPage/index.tsx deleted file mode 100644 index a4b114cd..00000000 --- a/src/templates/RoadmapPage/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import Layout from "@/components/Layout"; -import Join from "@/components/Join"; -import Hero from "./Hero"; -import Roadmap from "./Roadmap"; - -const RoadmapPage = () => { - return ( - - - - - - ); -}; - -export default RoadmapPage; diff --git a/src/theme/forumTheme.js b/src/theme/forumTheme.js index d887d901..9b4cd366 100644 --- a/src/theme/forumTheme.js +++ b/src/theme/forumTheme.js @@ -53,6 +53,59 @@ export const forumColors = { info: '#2196F3', }, + // 扩展功能色系(类似 Chakra UI 的颜色阶梯) + success: { + 50: '#E8F5E9', + 100: '#C8E6C9', + 200: '#A5D6A7', + 300: '#81C784', + 400: '#66BB6A', + 500: '#4CAF50', // 主绿色 + 600: '#43A047', + 700: '#388E3C', + 800: '#2E7D32', + 900: '#1B5E20', + }, + + error: { + 50: '#FFEBEE', + 100: '#FFCDD2', + 200: '#EF9A9A', + 300: '#E57373', + 400: '#EF5350', + 500: '#F44336', // 主红色 + 600: '#E53935', + 700: '#D32F2F', + 800: '#C62828', + 900: '#B71C1C', + }, + + warning: { + 50: '#FFF3E0', + 100: '#FFE0B2', + 200: '#FFCC80', + 300: '#FFB74D', + 400: '#FFA726', + 500: '#FF9800', // 主橙色 + 600: '#FB8C00', + 700: '#F57C00', + 800: '#EF6C00', + 900: '#E65100', + }, + + info: { + 50: '#E3F2FD', + 100: '#BBDEFB', + 200: '#90CAF9', + 300: '#64B5F6', + 400: '#42A5F5', + 500: '#2196F3', // 主蓝色 + 600: '#1E88E5', + 700: '#1976D2', + 800: '#1565C0', + 900: '#0D47A1', + }, + // 金色渐变系列 gradients: { goldPrimary: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)', @@ -164,6 +217,32 @@ export const forumComponentStyles = { }, }, + // Select下拉框样式(修复白色文字问题) + Select: { + baseStyle: { + field: { + bg: forumColors.background.card, + color: forumColors.text.primary, + borderColor: forumColors.border.default, + _hover: { + borderColor: forumColors.border.light, + }, + _focus: { + borderColor: forumColors.border.gold, + boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`, + }, + // 修复下拉选项颜色 + option: { + bg: forumColors.background.card, + color: forumColors.text.primary, + }, + }, + icon: { + color: forumColors.text.primary, + }, + }, + }, + // 标签样式 Tag: { variants: { diff --git a/src/views/AgentChat/README.md b/src/views/AgentChat/README.md new file mode 100644 index 00000000..9f5e7377 --- /dev/null +++ b/src/views/AgentChat/README.md @@ -0,0 +1,313 @@ +# Agent Chat - 超炫酷 AI 投研助手 + +> 🎨 基于 **Hero UI** (NextUI) 构建的现代化 AI 聊天界面,模仿 Google AI Studio 风格 + +## ✨ 设计亮点 + +### 🚀 技术栈 + +- **Hero UI** - 现代化 React UI 组件库(NextUI 的继任者) +- **Framer Motion 12** - 物理动画引擎(已升级!) +- **Tailwind CSS** - 原子化 CSS 框架(Hero UI 内置) +- **Lucide Icons** - 现代化图标库 +- **Chakra UI Toast** - 通知提示(待迁移到 Hero UI Toast) + +### 🎨 视觉特性 + +1. **毛玻璃效果(Glassmorphism)** + - 侧边栏和顶栏采用半透明毛玻璃质感 + - `backdrop-blur-xl` + 渐变背景 + - 深色模式完美支持 + +2. **流畅动画(Framer Motion 12 物理引擎)** + - ✨ 侧边栏 **Spring 弹性动画**滑入/滑出(stiffness: 300, damping: 30) + - ✨ 消息 **淡入上移** + **错开延迟**(staggerChildren: 0.05) + - ✨ 按钮 **悬停缩放**(1.05x) + **点击压缩**(0.95x) + - ✨ AI 头像 **360度持续旋转**(duration: 3s, linear) + - ✨ 快捷问题卡片 **退出动画**(AnimatePresence) + +3. **渐变色设计** + - 标题:蓝到紫渐变 + - 用户消息气泡:蓝到紫渐变 + - 发送按钮:蓝到紫渐变 + - AI 头像:紫到粉渐变 + +4. **响应式布局** + - 三栏式设计(左侧历史 + 中间聊天 + 右侧配置) + - 侧边栏可折叠 + - 暗黑模式支持 + - 集成主导航栏(MainLayout) + +### 🎯 核心功能 + +#### 1. 左侧历史面板 +- ✅ 会话列表展示(带搜索) +- ✅ 新建对话 +- ✅ 切换会话 +- ✅ 会话元信息(时间、消息数) +- ✅ 用户信息展示 +- ✅ 折叠/展开动画 + +#### 2. 中间聊天区域 +- ✅ 消息流展示(用户/AI) +- ✅ AI 思考状态(脉冲动画) +- ✅ 消息操作(复制、点赞、点踩) +- ✅ 执行步骤详情(可折叠 Accordion) +- ✅ 快捷问题按钮(2x2 网格) +- ✅ 键盘快捷键(Enter 发送,Shift+Enter 换行) +- ✅ 自动滚动到底部 +- ✅ Hero UI ScrollShadow 组件 + +#### 3. 右侧配置面板 +- ✅ Tabs 切换(模型 / 工具 / 统计) +- ✅ 模型选择(3 个模型,卡片式) +- ✅ 工具选择(Checkbox 多选) +- ✅ 统计信息(会话数、消息数、工具数) + +## 🔧 使用方法 + +### 访问路径 +``` +/agent-chat +``` + +### 模型选择 + +| 模型 | 图标 | 描述 | 适用场景 | +|------|------|------|----------| +| **Kimi K2 Thinking** | 🧠 | 深度思考模型 | 复杂分析、深度研究 | +| **Kimi K2** | ⚡ | 快速响应模型 | 简单查询、快速问答 | +| **DeepMoney** | 📈 | 金融专业模型 | 金融数据分析 | + +### 工具选择 + +| 工具 | 功能 | +|------|------| +| 📰 新闻搜索 | 搜索最新财经新闻 | +| 📈 涨停分析 | 分析涨停股票 | +| 💾 概念板块 | 查询概念板块信息 | +| 📚 研报搜索 | 搜索研究报告 | +| 📊 路演信息 | 查询路演活动 | + +### 快捷键 + +| 快捷键 | 功能 | +|--------|------| +| `Enter` | 发送消息 | +| `Shift + Enter` | 换行 | + +## 📦 Hero UI 组件使用 + +### 核心组件 + +```javascript +import { + Button, // 按钮 + Card, // 卡片 + Input, // 输入框 + Avatar, // 头像 + Chip, // 标签 + Badge, // 徽章 + Spinner, // 加载器 + Tooltip, // 工具提示 + Checkbox, // 复选框 + Tabs, Tab, // 标签页 + ScrollShadow, // 滚动阴影 + Kbd, // 键盘按键 + Accordion, // 手风琴 +} from '@heroui/react'; +``` + +### 特色功能 + +1. **isPressable / isHoverable** + ```javascript + + 内容 + + ``` + +2. **ScrollShadow**(自动滚动阴影) + ```javascript + + 长内容... + + ``` + +3. **渐变背景(Tailwind)** + ```javascript +
+ 内容 +
+ ``` + +4. **毛玻璃效果** + ```javascript +
+ 内容 +
+ ``` + +## 🔌 API 集成 + +### 后端接口 + +#### 1. 获取会话列表 +```http +GET /mcp/agent/sessions?user_id={user_id}&limit=50 +``` + +#### 2. 获取会话历史 +```http +GET /mcp/agent/history/{session_id}?limit=100 +``` + +#### 3. 发送消息 +```http +POST /mcp/agent/chat +Content-Type: application/json + +{ + "message": "用户问题", + "conversation_history": [], + "user_id": "user_id", + "session_id": "uuid或null", + "model": "kimi-k2-thinking", + "tools": ["search_news", "search_limit_up"] +} +``` + +## 🎨 Hero UI 特性 + +### 为什么选择 Hero UI? + +1. **基于 Tailwind CSS** + - 编译时 CSS,零运行时开销 + - 原子化类名,易于定制 + - 深色模式内置支持 + +2. **基于 React Aria** + - 完整的无障碍支持 + - 键盘导航内置 + - ARIA 属性自动处理 + +3. **TypeScript 优先** + - 完整的类型支持 + - 智能提示 + +4. **物理动画** + - 集成 Framer Motion + - 性能优化 + +5. **模块化架构** + - npm 包分发(非复制粘贴) + - 按需引入 + - Tree-shaking 友好 + +### Hero UI vs Chakra UI + +| 特性 | Hero UI | Chakra UI | +|------|---------|-----------| +| CSS 方案 | Tailwind CSS(编译时) | Emotion(运行时) | +| 包大小 | 更小(Tree-shaking) | 较大 | +| 性能 | 更快(无运行时 CSS) | 较慢 | +| 定制性 | Tailwind 配置 | Theme 对象 | +| 学习曲线 | 需要熟悉 Tailwind | 纯 Props API | +| 组件数量 | 210+ | 100+ | +| 动画 | Framer Motion | Framer Motion | +| 无障碍 | React Aria | 自实现 | + +## 📁 文件结构 + +``` +src/views/AgentChat/ +├── index.js # Hero UI 版本(当前) +├── index_old_chakra.js # Chakra UI 旧版本(备份) +└── README.md # 本文档 +``` + +## 🎯 组件层次 + +``` +AgentChat +├── MotionDiv (背景渐变) +├── LeftSidebar (历史会话) +│ ├── SearchInput (Hero UI Input) +│ ├── SessionList (Hero UI Card) +│ └── UserInfo (Hero UI Avatar) +├── ChatArea (中间区域) +│ ├── ChatHeader (Hero UI) +│ ├── MessageList (Hero UI ScrollShadow) +│ │ └── MessageRenderer +│ ├── QuickQuestions (Hero UI Button) +│ └── InputBox (Hero UI Input + Button) +└── RightSidebar (配置面板) + ├── Tabs (Hero UI Tabs) + ├── ModelSelector (Hero UI Card) + ├── ToolSelector (Hero UI CheckboxGroup) + └── Statistics (Hero UI Badge) +``` + +## 🚀 性能优化 + +1. **代码分割** + - React.lazy() 懒加载 + - 路由级别分割 + +2. **动画优化** + - Framer Motion 硬件加速 + - AnimatePresence 动画退出 + +3. **Tailwind CSS** + - JIT 模式(即时编译,构建速度提升 50%) + - 编译时生成 CSS + - 零运行时开销 + - PurgeCSS 自动清理 + +4. **Hero UI** + - Tree-shaking 优化 + - 按需引入组件 + +5. **构建优化(craco.config.js)** + - 文件系统缓存(二次构建提速 50-80%) + - ESLint 插件移除(构建提速 20-30%) + - 生产环境禁用 source map(提速 40-60%) + - 激进的代码分割策略(按库分离) + - Babel 缓存启用 + +## 🐛 已知问题 + +- ~~深色模式下某些颜色对比度不足~~ ✅ 已修复 +- ~~会话删除功能需要后端 API 支持~~ ⏳ 待实现 + +## 📝 开发日志 + +### 2025-11-22 +- ✅ 完成 Hero UI 迁移 +- ✅ 实现三栏式布局 +- ✅ 添加毛玻璃效果 +- ✅ 集成 Framer Motion 动画 +- ✅ 添加模型和工具选择功能 +- ✅ 优化深色模式 + +## 🔮 未来计划 + +- [ ] 支持流式响应(SSE) +- [ ] Markdown 渲染(react-markdown) +- [ ] 代码高亮(Prism.js) +- [ ] 图片上传和分析 +- [ ] 语音输入/输出 +- [ ] 导出为 PDF/Word +- [ ] 分享对话链接 +- [ ] 对话模板功能 + +## 📖 参考资源 + +- [Hero UI 官方文档](https://www.heroui.com/docs) +- [Framer Motion 文档](https://www.framer.com/motion/) +- [Tailwind CSS 文档](https://tailwindcss.com/docs) +- [Lucide Icons](https://lucide.dev/) + +## 📄 许可证 + +本项目基于 Argon Dashboard Chakra PRO 模板开发。 diff --git a/src/views/AgentChat/components/ChatArea/ExecutionStepsDisplay.js b/src/views/AgentChat/components/ChatArea/ExecutionStepsDisplay.js new file mode 100644 index 00000000..6b9321c2 --- /dev/null +++ b/src/views/AgentChat/components/ChatArea/ExecutionStepsDisplay.js @@ -0,0 +1,117 @@ +// src/views/AgentChat/components/ChatArea/ExecutionStepsDisplay.js +// 执行步骤显示组件 + +import React from 'react'; +import { motion } from 'framer-motion'; +import { + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Card, + CardBody, + Badge, + HStack, + VStack, + Flex, + Text, +} from '@chakra-ui/react'; +import { Activity } from 'lucide-react'; + +/** + * ExecutionStepsDisplay - 执行步骤显示组件 + * + * @param {Object} props + * @param {Array} props.steps - 执行步骤列表 + * @param {Object} props.plan - 执行计划(可选) + * @returns {JSX.Element} + */ +const ExecutionStepsDisplay = ({ steps, plan }) => { + return ( + + + + + + + 执行详情 + + + {steps.length} 步骤 + + + + + + + {steps.map((result, idx) => ( + + + + + + 步骤 {idx + 1}: {result.tool_name} + + + {result.status} + + + + {result.execution_time?.toFixed(2)}s + + {result.error && ( + + ⚠️ {result.error} + + )} + + + + ))} + + + + + ); +}; + +export default ExecutionStepsDisplay; diff --git a/src/views/AgentChat/components/ChatArea/MessageRenderer.js b/src/views/AgentChat/components/ChatArea/MessageRenderer.js new file mode 100644 index 00000000..f056b9f3 --- /dev/null +++ b/src/views/AgentChat/components/ChatArea/MessageRenderer.js @@ -0,0 +1,248 @@ +// src/views/AgentChat/components/ChatArea/MessageRenderer.js +// 消息渲染器组件 + +import React from 'react'; +import { motion } from 'framer-motion'; +import { + Card, + CardBody, + Avatar, + Badge, + Spinner, + Tooltip, + IconButton, + HStack, + Flex, + Text, + Box, +} from '@chakra-ui/react'; +import { Cpu, User, Copy, ThumbsUp, ThumbsDown, File } from 'lucide-react'; +import { MessageTypes } from '../../constants/messageTypes'; +import ExecutionStepsDisplay from './ExecutionStepsDisplay'; + +/** + * MessageRenderer - 消息渲染器组件 + * + * @param {Object} props + * @param {Object} props.message - 消息对象 + * @param {string} props.userAvatar - 用户头像 URL + * @returns {JSX.Element|null} + */ +const MessageRenderer = ({ message, userAvatar }) => { + switch (message.type) { + case MessageTypes.USER: + return ( + + + + + + + {message.content} + + {message.files && message.files.length > 0 && ( + + {message.files.map((file, idx) => ( + + + {file.name} + + ))} + + )} + + + + } + size="sm" + bgGradient="linear(to-br, blue.500, purple.600)" + boxShadow="0 0 12px rgba(139, 92, 246, 0.4)" + /> + + + ); + + case MessageTypes.AGENT_THINKING: + return ( + + + } + size="sm" + bgGradient="linear(to-br, purple.500, pink.500)" + boxShadow="0 0 12px rgba(236, 72, 153, 0.4)" + /> + + + + + + + {message.content} + + + + + + + + ); + + case MessageTypes.AGENT_RESPONSE: + return ( + + + } + size="sm" + bgGradient="linear(to-br, purple.500, pink.500)" + boxShadow="0 0 12px rgba(236, 72, 153, 0.4)" + /> + + + + + {message.content} + + + {message.stepResults && message.stepResults.length > 0 && ( + + + + )} + + + + + } + onClick={() => navigator.clipboard.writeText(message.content)} + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + _hover={{ + color: 'white', + bg: 'rgba(255, 255, 255, 0.1)', + }} + /> + + + + + } + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + _hover={{ + color: 'green.400', + bg: 'rgba(16, 185, 129, 0.1)', + boxShadow: '0 0 12px rgba(16, 185, 129, 0.3)', + }} + /> + + + + + } + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + _hover={{ + color: 'red.400', + bg: 'rgba(239, 68, 68, 0.1)', + boxShadow: '0 0 12px rgba(239, 68, 68, 0.3)', + }} + /> + + + + {new Date(message.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + })} + + + + + + + + ); + + case MessageTypes.ERROR: + return ( + + + + + + {message.content} + + + + + + ); + + default: + return null; + } +}; + +export default MessageRenderer; diff --git a/src/views/AgentChat/components/ChatArea/index.js b/src/views/AgentChat/components/ChatArea/index.js new file mode 100644 index 00000000..71b48d87 --- /dev/null +++ b/src/views/AgentChat/components/ChatArea/index.js @@ -0,0 +1,477 @@ +// src/views/AgentChat/components/ChatArea/index.js +// 中间聊天区域组件 + +import React, { useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Box, + Button, + Input, + Avatar, + Badge, + Tooltip, + IconButton, + Kbd, + HStack, + VStack, + Flex, + Text, + Tag, + TagLabel, + TagCloseButton, +} from '@chakra-ui/react'; +import { + Send, + Menu, + RefreshCw, + Settings, + Cpu, + Zap, + Sparkles, + Paperclip, + Image as ImageIcon, +} from 'lucide-react'; +import { AVAILABLE_MODELS } from '../../constants/models'; +import { quickQuestions } from '../../constants/quickQuestions'; +import { animations } from '../../constants/animations'; +import MessageRenderer from './MessageRenderer'; + +/** + * ChatArea - 中间聊天区域组件 + * + * @param {Object} props + * @param {Array} props.messages - 消息列表 + * @param {string} props.inputValue - 输入框内容 + * @param {Function} props.onInputChange - 输入框变化回调 + * @param {boolean} props.isProcessing - 处理中状态 + * @param {Function} props.onSendMessage - 发送消息回调 + * @param {Function} props.onKeyPress - 键盘事件回调 + * @param {Array} props.uploadedFiles - 已上传文件列表 + * @param {Function} props.onFileSelect - 文件选择回调 + * @param {Function} props.onFileRemove - 文件删除回调 + * @param {string} props.selectedModel - 当前选中的模型 ID + * @param {boolean} props.isLeftSidebarOpen - 左侧栏是否展开 + * @param {boolean} props.isRightSidebarOpen - 右侧栏是否展开 + * @param {Function} props.onToggleLeftSidebar - 切换左侧栏回调 + * @param {Function} props.onToggleRightSidebar - 切换右侧栏回调 + * @param {Function} props.onNewSession - 新建会话回调 + * @param {string} props.userAvatar - 用户头像 URL + * @param {RefObject} props.inputRef - 输入框引用 + * @param {RefObject} props.fileInputRef - 文件上传输入引用 + * @returns {JSX.Element} + */ +const ChatArea = ({ + messages, + inputValue, + onInputChange, + isProcessing, + onSendMessage, + onKeyPress, + uploadedFiles, + onFileSelect, + onFileRemove, + selectedModel, + isLeftSidebarOpen, + isRightSidebarOpen, + onToggleLeftSidebar, + onToggleRightSidebar, + onNewSession, + userAvatar, + inputRef, + fileInputRef, +}) => { + // Auto-scroll 功能:当消息列表更新时,自动滚动到底部 + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + return ( + + {/* 顶部标题栏 - 深色毛玻璃 */} + + + + {!isLeftSidebarOpen && ( + + } + onClick={onToggleLeftSidebar} + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: 'rgba(255, 255, 255, 0.1)', + color: 'white', + }} + /> + + )} + + + } + bgGradient="linear(to-br, purple.500, pink.500)" + boxShadow="0 0 20px rgba(236, 72, 153, 0.5)" + /> + + + + + 价小前投研 AI + + + + + 智能分析 + + + {AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.name} + + + + + + + + + } + onClick={onNewSession} + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: 'rgba(255, 255, 255, 0.1)', + color: 'white', + borderColor: 'purple.400', + boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)', + }} + /> + + + {!isRightSidebarOpen && ( + + } + onClick={onToggleRightSidebar} + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: 'rgba(255, 255, 255, 0.1)', + color: 'white', + }} + /> + + )} + + + + + {/* 消息列表 */} + + + + + {messages.map((message) => ( + + + + ))} + +
+ + + + + {/* 快捷问题 */} + + {messages.length <= 2 && !isProcessing && ( + + + + + + 快速开始 + + + {quickQuestions.map((question, idx) => ( + + + + ))} + + + + + )} + + + {/* 输入栏 - 深色毛玻璃 */} + + + {/* 已上传文件预览 */} + {uploadedFiles.length > 0 && ( + + {uploadedFiles.map((file, idx) => ( + + + {file.name} + onFileRemove(idx)} color="gray.400" /> + + + ))} + + )} + + + + + + + } + onClick={() => fileInputRef.current?.click()} + bg="rgba(255, 255, 255, 0.05)" + color="gray.300" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: 'rgba(255, 255, 255, 0.1)', + borderColor: 'purple.400', + color: 'white', + boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)', + }} + /> + + + + + + } + onClick={() => { + fileInputRef.current?.setAttribute('accept', 'image/*'); + fileInputRef.current?.click(); + }} + bg="rgba(255, 255, 255, 0.05)" + color="gray.300" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: 'rgba(255, 255, 255, 0.1)', + borderColor: 'purple.400', + color: 'white', + boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)', + }} + /> + + + + onInputChange(e.target.value)} + onKeyDown={onKeyPress} + placeholder="输入你的问题... (Enter 发送, Shift+Enter 换行)" + isDisabled={isProcessing} + size="lg" + variant="outline" + borderWidth={2} + bg="rgba(255, 255, 255, 0.05)" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + color="white" + _placeholder={{ color: 'gray.500' }} + _hover={{ + borderColor: 'rgba(255, 255, 255, 0.2)', + }} + _focus={{ + borderColor: 'purple.400', + boxShadow: + '0 0 0 1px var(--chakra-colors-purple-400), 0 0 12px rgba(139, 92, 246, 0.3)', + bg: 'rgba(255, 255, 255, 0.08)', + }} + /> + + + } + onClick={onSendMessage} + isLoading={isProcessing} + isDisabled={!inputValue.trim() || isProcessing} + bgGradient="linear(to-r, blue.500, purple.600)" + color="white" + _hover={{ + bgGradient: 'linear(to-r, blue.600, purple.700)', + boxShadow: '0 8px 20px rgba(139, 92, 246, 0.4)', + }} + _active={{ + transform: 'translateY(0)', + boxShadow: '0 4px 12px rgba(139, 92, 246, 0.3)', + }} + /> + + + + + + + Enter + + 发送 + + + + Shift + + + + + Enter + + 换行 + + + + + + ); +}; + +export default ChatArea; diff --git a/src/views/AgentChat/components/LeftSidebar/SessionCard.js b/src/views/AgentChat/components/LeftSidebar/SessionCard.js new file mode 100644 index 00000000..63a4ef23 --- /dev/null +++ b/src/views/AgentChat/components/LeftSidebar/SessionCard.js @@ -0,0 +1,76 @@ +// src/views/AgentChat/components/LeftSidebar/SessionCard.js +// 会话卡片组件 + +import React from 'react'; +import { motion } from 'framer-motion'; +import { Card, CardBody, Flex, Box, Text, Badge } from '@chakra-ui/react'; + +/** + * SessionCard - 会话卡片组件 + * + * @param {Object} props + * @param {Object} props.session - 会话数据 + * @param {boolean} props.isActive - 是否为当前选中的会话 + * @param {Function} props.onPress - 点击回调函数 + * @returns {JSX.Element} + */ +const SessionCard = ({ session, isActive, onPress }) => { + return ( + + + + + + + {session.title || '新对话'} + + + {new Date(session.created_at || session.timestamp).toLocaleString('zh-CN', { + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} + + + {session.message_count && ( + + {session.message_count} + + )} + + + + + ); +}; + +export default SessionCard; diff --git a/src/views/AgentChat/components/LeftSidebar/index.js b/src/views/AgentChat/components/LeftSidebar/index.js new file mode 100644 index 00000000..a893021b --- /dev/null +++ b/src/views/AgentChat/components/LeftSidebar/index.js @@ -0,0 +1,314 @@ +// src/views/AgentChat/components/LeftSidebar/index.js +// 左侧栏组件 - 对话历史列表 + +import React, { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Box, + Text, + Input, + Avatar, + Badge, + Spinner, + Tooltip, + IconButton, + HStack, + VStack, + Flex, +} from '@chakra-ui/react'; +import { MessageSquare, Plus, Search, ChevronLeft } from 'lucide-react'; +import { animations } from '../../constants/animations'; +import { groupSessionsByDate } from '../../utils/sessionUtils'; +import SessionCard from './SessionCard'; + +/** + * LeftSidebar - 左侧栏组件 + * + * @param {Object} props + * @param {boolean} props.isOpen - 侧边栏是否展开 + * @param {Function} props.onClose - 关闭侧边栏回调 + * @param {Array} props.sessions - 会话列表 + * @param {string|null} props.currentSessionId - 当前选中的会话 ID + * @param {Function} props.onSessionSwitch - 切换会话回调 + * @param {Function} props.onNewSession - 新建会话回调 + * @param {boolean} props.isLoadingSessions - 会话加载中状态 + * @param {Object} props.user - 用户信息 + * @returns {JSX.Element|null} + */ +const LeftSidebar = ({ + isOpen, + onClose, + sessions, + currentSessionId, + onSessionSwitch, + onNewSession, + isLoadingSessions, + user, +}) => { + const [searchQuery, setSearchQuery] = useState(''); + + // 按日期分组会话 + const sessionGroups = groupSessionsByDate(sessions); + + // 搜索过滤 + const filteredSessions = searchQuery + ? sessions.filter( + (s) => + s.title?.toLowerCase().includes(searchQuery.toLowerCase()) || + s.session_id?.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : sessions; + + return ( + + {isOpen && ( + + + {/* 标题栏 */} + + + + + + 对话历史 + + + + + + } + onClick={onNewSession} + bg="rgba(255, 255, 255, 0.05)" + color="gray.300" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: 'rgba(59, 130, 246, 0.2)', + borderColor: 'blue.400', + color: 'blue.300', + boxShadow: '0 0 12px rgba(59, 130, 246, 0.3)', + }} + /> + + + + + } + onClick={onClose} + bg="rgba(255, 255, 255, 0.05)" + color="gray.300" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: 'rgba(255, 255, 255, 0.1)', + borderColor: 'purple.400', + color: 'white', + }} + /> + + + + + + {/* 搜索框 */} + + + + + setSearchQuery(e.target.value)} + size="sm" + variant="outline" + pl={10} + bg="rgba(255, 255, 255, 0.05)" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + color="white" + _placeholder={{ color: 'gray.500' }} + _hover={{ + borderColor: 'rgba(255, 255, 255, 0.2)', + }} + _focus={{ + borderColor: 'purple.400', + boxShadow: + '0 0 0 1px var(--chakra-colors-purple-400), 0 0 12px rgba(139, 92, 246, 0.3)', + bg: 'rgba(255, 255, 255, 0.08)', + }} + /> + + + + {/* 会话列表 */} + + {/* 按日期分组显示会话 */} + {sessionGroups.today.length > 0 && ( + + + 今天 + + + {sessionGroups.today.map((session, idx) => ( + + onSessionSwitch(session.session_id)} + /> + + ))} + + + )} + + {sessionGroups.yesterday.length > 0 && ( + + + 昨天 + + + {sessionGroups.yesterday.map((session) => ( + onSessionSwitch(session.session_id)} + /> + ))} + + + )} + + {sessionGroups.thisWeek.length > 0 && ( + + + 本周 + + + {sessionGroups.thisWeek.map((session) => ( + onSessionSwitch(session.session_id)} + /> + ))} + + + )} + + {sessionGroups.older.length > 0 && ( + + + 更早 + + + {sessionGroups.older.map((session) => ( + onSessionSwitch(session.session_id)} + /> + ))} + + + )} + + {/* 加载状态 */} + {isLoadingSessions && ( + + + + )} + + {/* 空状态 */} + {sessions.length === 0 && !isLoadingSessions && ( + + + 还没有对话历史 + 开始一个新对话吧! + + )} + + + {/* 用户信息卡片 */} + + + + + + {user?.nickname || '未登录'} + + + {user?.subscription_type || 'free'} + + + + + + + )} + + ); +}; + +export default LeftSidebar; diff --git a/src/views/AgentChat/components/RightSidebar/index.js b/src/views/AgentChat/components/RightSidebar/index.js new file mode 100644 index 00000000..0760af0a --- /dev/null +++ b/src/views/AgentChat/components/RightSidebar/index.js @@ -0,0 +1,499 @@ +// src/views/AgentChat/components/RightSidebar/index.js +// 右侧栏组件 - 配置中心 + +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Box, + Button, + Badge, + Checkbox, + CheckboxGroup, + Tooltip, + IconButton, + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Tabs, + TabList, + TabPanels, + Tab, + TabPanel, + Card, + CardBody, + HStack, + VStack, + Flex, + Text, +} from '@chakra-ui/react'; +import { + Settings, + ChevronRight, + Cpu, + Code, + BarChart3, + Check, + MessageSquare, + Activity, +} from 'lucide-react'; +import { animations } from '../../constants/animations'; +import { AVAILABLE_MODELS } from '../../constants/models'; +import { MCP_TOOLS, TOOL_CATEGORIES } from '../../constants/tools'; + +/** + * RightSidebar - 右侧栏组件(配置中心) + * + * @param {Object} props + * @param {boolean} props.isOpen - 侧边栏是否展开 + * @param {Function} props.onClose - 关闭侧边栏回调 + * @param {string} props.selectedModel - 当前选中的模型 ID + * @param {Function} props.onModelChange - 模型切换回调 + * @param {Array} props.selectedTools - 已选工具 ID 列表 + * @param {Function} props.onToolsChange - 工具选择变化回调 + * @param {number} props.sessionsCount - 会话总数 + * @param {number} props.messagesCount - 消息总数 + * @returns {JSX.Element|null} + */ +const RightSidebar = ({ + isOpen, + onClose, + selectedModel, + onModelChange, + selectedTools, + onToolsChange, + sessionsCount, + messagesCount, +}) => { + return ( + + {isOpen && ( + + + {/* 标题栏 */} + + + + + + 配置中心 + + + + + } + onClick={onClose} + bg="rgba(255, 255, 255, 0.05)" + color="gray.300" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: 'rgba(255, 255, 255, 0.1)', + borderColor: 'purple.400', + color: 'white', + }} + /> + + + + + + {/* Tab 面板 */} + + + + + + + 模型 + + + + + + 工具 + {selectedTools.length > 0 && ( + + {selectedTools.length} + + )} + + + + + + 统计 + + + + + + {/* 模型选择 */} + + + {AVAILABLE_MODELS.map((model, idx) => ( + + onModelChange(model.id)} + bg={ + selectedModel === model.id + ? 'rgba(139, 92, 246, 0.15)' + : 'rgba(255, 255, 255, 0.05)' + } + backdropFilter="blur(12px)" + borderWidth={2} + borderColor={ + selectedModel === model.id ? 'purple.400' : 'rgba(255, 255, 255, 0.1)' + } + _hover={{ + borderColor: + selectedModel === model.id ? 'purple.400' : 'rgba(255, 255, 255, 0.2)', + boxShadow: + selectedModel === model.id + ? '0 8px 20px rgba(139, 92, 246, 0.4)' + : '0 4px 12px rgba(0, 0, 0, 0.3)', + }} + transition="all 0.3s" + > + + + + {model.icon} + + + + {model.name} + + + {model.description} + + + {selectedModel === model.id && ( + + + + )} + + + + + ))} + + + + {/* 工具选择 */} + + + {Object.entries(TOOL_CATEGORIES).map(([category, tools], catIdx) => ( + + + + + + {category} + + + {tools.filter((t) => selectedTools.includes(t.id)).length}/{tools.length} + + + + + + + + {tools.map((tool) => ( + + + + + {tool.icon} + + + + {tool.name} + + + {tool.description} + + + + + + ))} + + + + + + ))} + + + + + + + + + + + + + {/* 统计信息 */} + + + + + + + + + 对话数 + + + {sessionsCount} + + + + + + + + + + + + + + + 消息数 + + + {messagesCount} + + + + + + + + + + + + + + + 已选工具 + + + {selectedTools.length} + + + + + + + + + + + + + + + )} + + ); +}; + +export default RightSidebar; diff --git a/src/views/AgentChat/constants/animations.ts b/src/views/AgentChat/constants/animations.ts new file mode 100644 index 00000000..5cd5e77a --- /dev/null +++ b/src/views/AgentChat/constants/animations.ts @@ -0,0 +1,101 @@ +// src/views/AgentChat/constants/animations.ts +// Framer Motion 动画变体配置 + +import { Variants } from 'framer-motion'; + +/** + * Framer Motion 动画变体配置 + * 用于 AgentChat 组件的各种动画效果 + */ +export const animations: Record = { + /** + * 左侧栏滑入动画(Spring 物理引擎) + * 从左侧滑入,使用弹性动画 + */ + slideInLeft: { + initial: { x: -320, opacity: 0 }, + animate: { + x: 0, + opacity: 1, + transition: { + type: 'spring', + stiffness: 300, + damping: 30, + }, + }, + exit: { + x: -320, + opacity: 0, + transition: { duration: 0.2 }, + }, + }, + + /** + * 右侧栏滑入动画(Spring 物理引擎) + * 从右侧滑入,使用弹性动画 + */ + slideInRight: { + initial: { x: 320, opacity: 0 }, + animate: { + x: 0, + opacity: 1, + transition: { + type: 'spring', + stiffness: 300, + damping: 30, + }, + }, + exit: { + x: 320, + opacity: 0, + transition: { duration: 0.2 }, + }, + }, + + /** + * 消息淡入上移动画 + * 用于新消息出现时的动画效果 + */ + fadeInUp: { + initial: { opacity: 0, y: 20 }, + animate: { + opacity: 1, + y: 0, + transition: { + type: 'spring', + stiffness: 400, + damping: 25, + }, + }, + }, + + /** + * 错开动画项 + * 用于列表项的错开出现效果 + */ + staggerItem: { + initial: { opacity: 0, y: 10 }, + animate: { opacity: 1, y: 0 }, + }, + + /** + * 错开动画容器 + * 用于包含多个子项的容器,使子项依次出现 + */ + staggerContainer: { + animate: { + transition: { + staggerChildren: 0.05, + }, + }, + }, + + /** + * 按压缩放动画 + * 用于按钮等交互元素的点击/悬停效果 + */ + pressScale: { + whileTap: { scale: 0.95 }, + whileHover: { scale: 1.05 }, + }, +}; diff --git a/src/views/AgentChat/constants/index.ts b/src/views/AgentChat/constants/index.ts new file mode 100644 index 00000000..35c32e62 --- /dev/null +++ b/src/views/AgentChat/constants/index.ts @@ -0,0 +1,22 @@ +// src/views/AgentChat/constants/index.ts +// 常量配置统一导出 + +/** + * 统一导出所有常量配置模块 + * + * 使用示例: + * ```typescript + * // 方式 1: 从子模块导入(推荐,按需引入) + * import { animations } from './constants/animations'; + * import { MessageTypes } from './constants/messageTypes'; + * + * // 方式 2: 从统一入口导入 + * import { animations, MessageTypes, AVAILABLE_MODELS } from './constants'; + * ``` + */ + +export * from './animations'; +export * from './messageTypes'; +export * from './models'; +export * from './tools'; +export * from './quickQuestions'; diff --git a/src/views/AgentChat/constants/messageTypes.ts b/src/views/AgentChat/constants/messageTypes.ts new file mode 100644 index 00000000..1d5de3c6 --- /dev/null +++ b/src/views/AgentChat/constants/messageTypes.ts @@ -0,0 +1,93 @@ +// src/views/AgentChat/constants/messageTypes.ts +// 消息类型定义 + +/** + * 消息类型枚举 + * 定义 Agent Chat 中所有可能的消息类型 + */ +export enum MessageTypes { + /** 用户消息 */ + USER = 'user', + /** Agent 思考中状态 */ + AGENT_THINKING = 'agent_thinking', + /** Agent 执行计划 */ + AGENT_PLAN = 'agent_plan', + /** Agent 执行步骤中 */ + AGENT_EXECUTING = 'agent_executing', + /** Agent 最终回复 */ + AGENT_RESPONSE = 'agent_response', + /** 错误消息 */ + ERROR = 'error', +} + +/** + * 文件附件接口 + */ +export interface MessageFile { + /** 文件名称 */ + name: string; + /** 文件大小(字节)*/ + size: number; + /** MIME 类型 */ + type: string; + /** 文件 URL(本地或远程)*/ + url: string; +} + +/** + * 消息基础接口 + */ +export interface BaseMessage { + /** 消息唯一标识 */ + id: string | number; + /** 消息类型 */ + type: MessageTypes; + /** 消息内容 */ + content: string; + /** 时间戳(ISO 8601 格式)*/ + timestamp: string; +} + +/** + * 用户消息接口 + */ +export interface UserMessage extends BaseMessage { + type: MessageTypes.USER; + /** 上传的文件附件 */ + files?: MessageFile[]; +} + +/** + * Agent 消息接口 + */ +export interface AgentMessage extends BaseMessage { + type: + | MessageTypes.AGENT_RESPONSE + | MessageTypes.AGENT_THINKING + | MessageTypes.AGENT_PLAN + | MessageTypes.AGENT_EXECUTING; + /** 执行计划(JSON 对象)*/ + plan?: any; + /** 执行步骤结果 */ + stepResults?: Array<{ + tool_name: string; + status: 'success' | 'error'; + execution_time?: number; + error?: string; + }>; + /** 额外元数据 */ + metadata?: any; +} + +/** + * 错误消息接口 + */ +export interface ErrorMessage extends BaseMessage { + type: MessageTypes.ERROR; +} + +/** + * 消息联合类型 + * 用于表示任意类型的消息 + */ +export type Message = UserMessage | AgentMessage | ErrorMessage; diff --git a/src/views/AgentChat/constants/models.ts b/src/views/AgentChat/constants/models.ts new file mode 100644 index 00000000..bb99afda --- /dev/null +++ b/src/views/AgentChat/constants/models.ts @@ -0,0 +1,63 @@ +// src/views/AgentChat/constants/models.ts +// 可用模型配置 + +import * as React from 'react'; +import { Brain, Zap, TrendingUp } from 'lucide-react'; + +/** + * 模型配置接口 + */ +export interface ModelConfig { + /** 模型唯一标识 */ + id: string; + /** 模型显示名称 */ + name: string; + /** 模型描述 */ + description: string; + /** 模型图标(React 元素)*/ + icon: React.ReactNode; + /** 颜色主题 */ + color: string; +} + +/** + * 可用模型配置列表 + * 包含所有可供用户选择的 AI 模型 + */ +export const AVAILABLE_MODELS: ModelConfig[] = [ + { + id: 'kimi-k2-thinking', + name: 'Kimi K2 Thinking', + description: '深度思考模型,适合复杂分析', + icon: React.createElement(Brain, { className: 'w-5 h-5' }), + color: 'purple', + }, + { + id: 'kimi-k2', + name: 'Kimi K2', + description: '快速响应模型,适合简单查询', + icon: React.createElement(Zap, { className: 'w-5 h-5' }), + color: 'blue', + }, + { + id: 'deepmoney', + name: 'DeepMoney', + description: '金融专业模型', + icon: React.createElement(TrendingUp, { className: 'w-5 h-5' }), + color: 'green', + }, +]; + +/** + * 默认选中的模型 ID + */ +export const DEFAULT_MODEL_ID = 'kimi-k2-thinking'; + +/** + * 根据 ID 查找模型配置 + * @param modelId 模型 ID + * @returns 模型配置对象,未找到则返回 undefined + */ +export const findModelById = (modelId: string): ModelConfig | undefined => { + return AVAILABLE_MODELS.find((model) => model.id === modelId); +}; diff --git a/src/views/AgentChat/constants/quickQuestions.ts b/src/views/AgentChat/constants/quickQuestions.ts new file mode 100644 index 00000000..e275051e --- /dev/null +++ b/src/views/AgentChat/constants/quickQuestions.ts @@ -0,0 +1,23 @@ +// src/views/AgentChat/constants/quickQuestions.ts +// 快捷问题配置 + +/** + * 快捷问题配置接口 + */ +export interface QuickQuestion { + /** 问题文本 */ + text: string; + /** 表情符号 */ + emoji: string; +} + +/** + * 预设快捷问题列表 + * 用于在聊天界面初始状态显示,帮助用户快速开始对话 + */ +export const quickQuestions: QuickQuestion[] = [ + { text: '今日涨停板块分析', emoji: '🔥' }, + { text: '新能源概念机会', emoji: '⚡' }, + { text: '半导体行业动态', emoji: '💾' }, + { text: '本周热门研报', emoji: '📊' }, +]; diff --git a/src/views/AgentChat/constants/tools.ts b/src/views/AgentChat/constants/tools.ts new file mode 100644 index 00000000..89930d60 --- /dev/null +++ b/src/views/AgentChat/constants/tools.ts @@ -0,0 +1,249 @@ +// src/views/AgentChat/constants/tools.ts +// MCP 工具配置 + +import * as React from 'react'; +import { + Globe, + Newspaper, + Activity, + PieChart, + FileText, + BarChart3, + LineChart, + TrendingUp, + Calendar, + BookOpen, + Briefcase, + DollarSign, + Search, + Users, +} from 'lucide-react'; + +/** + * 工具类别枚举 + */ +export enum ToolCategory { + NEWS = '新闻资讯', + CONCEPT = '概念板块', + LIMIT_UP = '涨停分析', + RESEARCH = '研报路演', + STOCK_DATA = '股票数据', + USER_DATA = '用户数据', +} + +/** + * MCP 工具配置接口 + */ +export interface MCPTool { + /** 工具唯一标识 */ + id: string; + /** 工具显示名称 */ + name: string; + /** 工具图标(React 元素)*/ + icon: React.ReactNode; + /** 工具类别 */ + category: ToolCategory; + /** 工具描述 */ + description: string; +} + +/** + * MCP 工具完整配置列表 + * 包含所有可供 Agent 调用的工具 + */ +export const MCP_TOOLS: MCPTool[] = [ + // ==================== 新闻搜索类 ==================== + { + id: 'search_news', + name: '全球新闻搜索', + icon: React.createElement(Globe, { className: 'w-4 h-4' }), + category: ToolCategory.NEWS, + description: '搜索全球新闻,支持关键词和日期过滤', + }, + { + id: 'search_china_news', + name: '中国新闻搜索', + icon: React.createElement(Newspaper, { className: 'w-4 h-4' }), + category: ToolCategory.NEWS, + description: 'KNN语义搜索中国新闻', + }, + { + id: 'search_medical_news', + name: '医疗健康新闻', + icon: React.createElement(Activity, { className: 'w-4 h-4' }), + category: ToolCategory.NEWS, + description: '医药、医疗设备、生物技术新闻', + }, + + // ==================== 概念板块类 ==================== + { + id: 'search_concepts', + name: '概念板块搜索', + icon: React.createElement(PieChart, { className: 'w-4 h-4' }), + category: ToolCategory.CONCEPT, + description: '搜索股票概念板块及相关股票', + }, + { + id: 'get_concept_details', + name: '概念详情', + icon: React.createElement(FileText, { className: 'w-4 h-4' }), + category: ToolCategory.CONCEPT, + description: '获取概念板块详细信息', + }, + { + id: 'get_stock_concepts', + name: '股票概念', + icon: React.createElement(BarChart3, { className: 'w-4 h-4' }), + category: ToolCategory.CONCEPT, + description: '查询股票相关概念板块', + }, + { + id: 'get_concept_statistics', + name: '概念统计', + icon: React.createElement(LineChart, { className: 'w-4 h-4' }), + category: ToolCategory.CONCEPT, + description: '涨幅榜、跌幅榜、活跃榜等', + }, + + // ==================== 涨停分析类 ==================== + { + id: 'search_limit_up_stocks', + name: '涨停股票搜索', + icon: React.createElement(TrendingUp, { className: 'w-4 h-4' }), + category: ToolCategory.LIMIT_UP, + description: '搜索涨停股票,支持多条件筛选', + }, + { + id: 'get_daily_stock_analysis', + name: '涨停日报', + icon: React.createElement(Calendar, { className: 'w-4 h-4' }), + category: ToolCategory.LIMIT_UP, + description: '每日涨停股票分析报告', + }, + + // ==================== 研报路演类 ==================== + { + id: 'search_research_reports', + name: '研报搜索', + icon: React.createElement(BookOpen, { className: 'w-4 h-4' }), + category: ToolCategory.RESEARCH, + description: '搜索研究报告,支持语义搜索', + }, + { + id: 'search_roadshows', + name: '路演活动', + icon: React.createElement(Briefcase, { className: 'w-4 h-4' }), + category: ToolCategory.RESEARCH, + description: '上市公司路演、投资者交流活动', + }, + + // ==================== 股票数据类 ==================== + { + id: 'get_stock_basic_info', + name: '股票基本信息', + icon: React.createElement(FileText, { className: 'w-4 h-4' }), + category: ToolCategory.STOCK_DATA, + description: '公司名称、行业、主营业务等', + }, + { + id: 'get_stock_financial_index', + name: '财务指标', + icon: React.createElement(DollarSign, { className: 'w-4 h-4' }), + category: ToolCategory.STOCK_DATA, + description: 'EPS、ROE、营收增长率等', + }, + { + id: 'get_stock_trade_data', + name: '交易数据', + icon: React.createElement(BarChart3, { className: 'w-4 h-4' }), + category: ToolCategory.STOCK_DATA, + description: '价格、成交量、涨跌幅等', + }, + { + id: 'get_stock_balance_sheet', + name: '资产负债表', + icon: React.createElement(PieChart, { className: 'w-4 h-4' }), + category: ToolCategory.STOCK_DATA, + description: '资产、负债、所有者权益', + }, + { + id: 'get_stock_cashflow', + name: '现金流量表', + icon: React.createElement(LineChart, { className: 'w-4 h-4' }), + category: ToolCategory.STOCK_DATA, + description: '经营、投资、筹资现金流', + }, + { + id: 'search_stocks_by_criteria', + name: '条件选股', + icon: React.createElement(Search, { className: 'w-4 h-4' }), + category: ToolCategory.STOCK_DATA, + description: '按行业、地区、市值筛选', + }, + { + id: 'get_stock_comparison', + name: '股票对比', + icon: React.createElement(BarChart3, { className: 'w-4 h-4' }), + category: ToolCategory.STOCK_DATA, + description: '多只股票财务指标对比', + }, + + // ==================== 用户数据类 ==================== + { + id: 'get_user_watchlist', + name: '自选股列表', + icon: React.createElement(Users, { className: 'w-4 h-4' }), + category: ToolCategory.USER_DATA, + description: '用户关注的股票及行情', + }, + { + id: 'get_user_following_events', + name: '关注事件', + icon: React.createElement(Activity, { className: 'w-4 h-4' }), + category: ToolCategory.USER_DATA, + description: '用户关注的重大事件', + }, +]; + +/** + * 按类别分组的工具配置 + * 用于在 UI 中按类别展示工具 + */ +export const TOOL_CATEGORIES: Record = { + [ToolCategory.NEWS]: MCP_TOOLS.filter((t) => t.category === ToolCategory.NEWS), + [ToolCategory.CONCEPT]: MCP_TOOLS.filter((t) => t.category === ToolCategory.CONCEPT), + [ToolCategory.LIMIT_UP]: MCP_TOOLS.filter((t) => t.category === ToolCategory.LIMIT_UP), + [ToolCategory.RESEARCH]: MCP_TOOLS.filter((t) => t.category === ToolCategory.RESEARCH), + [ToolCategory.STOCK_DATA]: MCP_TOOLS.filter((t) => t.category === ToolCategory.STOCK_DATA), + [ToolCategory.USER_DATA]: MCP_TOOLS.filter((t) => t.category === ToolCategory.USER_DATA), +}; + +/** + * 默认选中的工具 ID 列表 + * 这些工具在页面初始化时自动选中 + */ +export const DEFAULT_SELECTED_TOOLS: string[] = [ + 'search_news', + 'search_china_news', + 'search_concepts', + 'search_limit_up_stocks', + 'search_research_reports', +]; + +/** + * 根据 ID 查找工具配置 + * @param toolId 工具 ID + * @returns 工具配置对象,未找到则返回 undefined + */ +export const findToolById = (toolId: string): MCPTool | undefined => { + return MCP_TOOLS.find((tool) => tool.id === toolId); +}; + +/** + * 根据类别获取工具列表 + * @param category 工具类别 + * @returns 该类别下的所有工具 + */ +export const getToolsByCategory = (category: ToolCategory): MCPTool[] => { + return TOOL_CATEGORIES[category] || []; +}; diff --git a/src/views/AgentChat/hooks/index.ts b/src/views/AgentChat/hooks/index.ts new file mode 100644 index 00000000..bb28170e --- /dev/null +++ b/src/views/AgentChat/hooks/index.ts @@ -0,0 +1,34 @@ +// src/views/AgentChat/hooks/index.ts +// 自定义 Hooks 统一导出 + +/** + * 自定义 Hooks 统一入口 + * + * 使用示例: + * ```typescript + * // 方式 1: 从统一入口导入(推荐) + * import { useAgentChat, useAgentSessions, useFileUpload, useAutoScroll } from './hooks'; + * + * // 方式 2: 从单个文件导入 + * import { useAgentChat } from './hooks/useAgentChat'; + * ``` + */ + +export { useAutoScroll } from './useAutoScroll'; +export { useFileUpload } from './useFileUpload'; +export type { UploadedFile, UseFileUploadReturn } from './useFileUpload'; + +export { useAgentSessions } from './useAgentSessions'; +export type { + Session, + User, + UseAgentSessionsParams, + UseAgentSessionsReturn, +} from './useAgentSessions'; + +export { useAgentChat } from './useAgentChat'; +export type { + ToastFunction, + UseAgentChatParams, + UseAgentChatReturn, +} from './useAgentChat'; diff --git a/src/views/AgentChat/hooks/useAgentChat.ts b/src/views/AgentChat/hooks/useAgentChat.ts new file mode 100644 index 00000000..747b3489 --- /dev/null +++ b/src/views/AgentChat/hooks/useAgentChat.ts @@ -0,0 +1,289 @@ +// src/views/AgentChat/hooks/useAgentChat.ts +// 消息处理 Hook - 发送消息、处理响应、错误处理 + +import { useState, useCallback } from 'react'; +import type { Dispatch, SetStateAction, KeyboardEvent } from 'react'; +import axios from 'axios'; +import { logger } from '@utils/logger'; +import { MessageTypes, type Message } from '../constants/messageTypes'; +import type { UploadedFile } from './useFileUpload'; +import type { User } from './useAgentSessions'; + +/** + * Toast 通知函数类型(来自 Chakra UI) + */ +export interface ToastFunction { + (options: { + title: string; + description?: string; + status: 'success' | 'error' | 'warning' | 'info'; + duration?: number; + isClosable?: boolean; + }): void; +} + +/** + * useAgentChat Hook 参数 + */ +export interface UseAgentChatParams { + /** 当前用户信息 */ + user: User | null; + /** 当前会话 ID */ + currentSessionId: string | null; + /** 设置当前会话 ID */ + setCurrentSessionId: Dispatch>; + /** 选中的 AI 模型 */ + selectedModel: string; + /** 选中的工具列表 */ + selectedTools: string[]; + /** 已上传文件列表 */ + uploadedFiles: UploadedFile[]; + /** 清空已上传文件 */ + clearFiles: () => void; + /** Toast 通知函数 */ + toast: ToastFunction; + /** 重新加载会话列表(发送消息成功后调用) */ + loadSessions: () => Promise; +} + +/** + * useAgentChat Hook 返回值 + */ +export interface UseAgentChatReturn { + /** 消息列表 */ + messages: Message[]; + /** 设置消息列表 */ + setMessages: Dispatch>; + /** 输入框内容 */ + inputValue: string; + /** 设置输入框内容 */ + setInputValue: Dispatch>; + /** 是否正在处理消息 */ + isProcessing: boolean; + /** 发送消息 */ + handleSendMessage: () => Promise; + /** 键盘事件处理(Enter 发送) */ + handleKeyPress: (e: KeyboardEvent) => void; + /** 添加消息到列表 */ + addMessage: (message: Partial) => void; +} + +/** + * useAgentChat Hook + * + * 处理消息发送、AI 响应、错误处理逻辑 + * + * @param params - UseAgentChatParams + * @returns UseAgentChatReturn + * + * @example + * ```tsx + * const { + * messages, + * inputValue, + * setInputValue, + * isProcessing, + * handleSendMessage, + * handleKeyPress, + * } = useAgentChat({ + * user, + * currentSessionId, + * setCurrentSessionId, + * selectedModel, + * selectedTools, + * uploadedFiles, + * clearFiles, + * toast, + * loadSessions, + * }); + * ``` + */ +export const useAgentChat = ({ + user, + currentSessionId, + setCurrentSessionId, + selectedModel, + selectedTools, + uploadedFiles, + clearFiles, + toast, + loadSessions, +}: UseAgentChatParams): UseAgentChatReturn => { + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + + /** + * 添加消息到列表 + */ + const addMessage = useCallback((message: Partial) => { + setMessages((prev) => [ + ...prev, + { + id: Date.now() + Math.random(), + timestamp: new Date().toISOString(), + ...message, + } as Message, + ]); + }, []); + + /** + * 发送消息到后端 API + */ + const handleSendMessage = useCallback(async () => { + if (!inputValue.trim() || isProcessing) return; + + // 创建用户消息 + const userMessage: Partial = { + type: MessageTypes.USER, + content: inputValue, + timestamp: new Date().toISOString(), + files: uploadedFiles.length > 0 ? uploadedFiles : undefined, + }; + + addMessage(userMessage); + const userInput = inputValue; + + // 清空输入框和文件 + setInputValue(''); + clearFiles(); + setIsProcessing(true); + + try { + // 显示 "思考中" 状态 + addMessage({ + type: MessageTypes.AGENT_THINKING, + content: '正在分析你的问题...', + }); + + // 调用后端 API + const response = await axios.post('/mcp/agent/chat', { + message: userInput, + conversation_history: messages + .filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE) + .map((m) => ({ + isUser: m.type === MessageTypes.USER, + content: m.content, + })), + user_id: user?.id || 'anonymous', + user_nickname: user?.nickname || '匿名用户', + user_avatar: user?.avatar || '', + subscription_type: user?.subscription_type || 'free', + session_id: currentSessionId, + model: selectedModel, + tools: selectedTools, + files: uploadedFiles.length > 0 ? uploadedFiles : undefined, + }); + + // 移除 "思考中" 消息 + setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); + + if (response.data.success) { + const data = response.data; + + // 更新会话 ID(如果是新会话) + if (data.session_id && !currentSessionId) { + setCurrentSessionId(data.session_id); + } + + // 显示执行计划(如果有) + if (data.plan) { + addMessage({ + type: MessageTypes.AGENT_PLAN, + content: '已制定执行计划', + plan: data.plan, + }); + } + + // 显示执行步骤(如果有) + if (data.steps && data.steps.length > 0) { + addMessage({ + type: MessageTypes.AGENT_EXECUTING, + content: '正在执行步骤...', + plan: data.plan, + stepResults: data.steps, + }); + } + + // 移除 "执行中" 消息 + setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING)); + + // 显示最终回复 + addMessage({ + type: MessageTypes.AGENT_RESPONSE, + content: data.final_answer || data.message || '处理完成', + plan: data.plan, + stepResults: data.steps, + metadata: data.metadata, + }); + + // 重新加载会话列表 + loadSessions(); + } + } catch (error: any) { + logger.error('Agent chat error', error); + + // 移除 "思考中" 和 "执行中" 消息 + setMessages((prev) => + prev.filter( + (m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING + ) + ); + + // 显示错误消息 + const errorMessage = error.response?.data?.error || error.message || '处理失败'; + addMessage({ + type: MessageTypes.ERROR, + content: `处理失败:${errorMessage}`, + }); + + // 显示 Toast 通知 + toast({ + title: '处理失败', + description: errorMessage, + status: 'error', + duration: 5000, + }); + } finally { + setIsProcessing(false); + } + }, [ + inputValue, + isProcessing, + uploadedFiles, + messages, + user, + currentSessionId, + selectedModel, + selectedTools, + addMessage, + clearFiles, + setCurrentSessionId, + loadSessions, + toast, + ]); + + /** + * 键盘事件处理(Enter 发送,Shift+Enter 换行) + */ + const handleKeyPress = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }, + [handleSendMessage] + ); + + return { + messages, + setMessages, + inputValue, + setInputValue, + isProcessing, + handleSendMessage, + handleKeyPress, + addMessage, + }; +}; diff --git a/src/views/AgentChat/hooks/useAgentSessions.ts b/src/views/AgentChat/hooks/useAgentSessions.ts new file mode 100644 index 00000000..0228d97e --- /dev/null +++ b/src/views/AgentChat/hooks/useAgentSessions.ts @@ -0,0 +1,189 @@ +// src/views/AgentChat/hooks/useAgentSessions.ts +// 会话管理 Hook - 加载、切换、创建会话 + +import { useState, useEffect, useCallback } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import axios from 'axios'; +import { logger } from '@utils/logger'; +import { MessageTypes, type Message } from '../constants/messageTypes'; + +/** + * 会话数据结构 + */ +export interface Session { + session_id: string; + title?: string; + created_at?: string; + timestamp?: string; + message_count?: number; +} + +/** + * 用户信息(从 AuthContext 传入) + */ +export interface User { + id: string; + nickname?: string; + avatar?: string; + subscription_type?: string; +} + +/** + * useAgentSessions Hook 参数 + */ +export interface UseAgentSessionsParams { + /** 当前用户信息 */ + user: User | null; + /** 消息列表 setter(用于创建新会话时设置欢迎消息) */ + setMessages: Dispatch>; +} + +/** + * useAgentSessions Hook 返回值 + */ +export interface UseAgentSessionsReturn { + /** 会话列表 */ + sessions: Session[]; + /** 当前选中的会话 ID */ + currentSessionId: string | null; + /** 设置当前会话 ID */ + setCurrentSessionId: Dispatch>; + /** 是否正在加载会话列表 */ + isLoadingSessions: boolean; + /** 加载会话列表 */ + loadSessions: () => Promise; + /** 切换到指定会话 */ + switchSession: (sessionId: string) => void; + /** 创建新会话(显示欢迎消息) */ + createNewSession: () => void; + /** 加载指定会话的历史消息 */ + loadSessionHistory: (sessionId: string) => Promise; +} + +/** + * useAgentSessions Hook + * + * 管理会话列表、会话切换、新建会话逻辑 + * + * @param params - UseAgentSessionsParams + * @returns UseAgentSessionsReturn + * + * @example + * ```tsx + * const { + * sessions, + * currentSessionId, + * isLoadingSessions, + * switchSession, + * createNewSession, + * } = useAgentSessions({ user, setMessages }); + * ``` + */ +export const useAgentSessions = ({ + user, + setMessages, +}: UseAgentSessionsParams): UseAgentSessionsReturn => { + const [sessions, setSessions] = useState([]); + const [currentSessionId, setCurrentSessionId] = useState(null); + const [isLoadingSessions, setIsLoadingSessions] = useState(false); + + /** + * 加载用户的会话列表 + */ + const loadSessions = useCallback(async () => { + if (!user?.id) return; + + setIsLoadingSessions(true); + try { + const response = await axios.get('/mcp/agent/sessions', { + params: { user_id: user.id, limit: 50 }, + }); + + if (response.data.success) { + setSessions(response.data.data); + } + } catch (error) { + logger.error('加载会话列表失败', error); + } finally { + setIsLoadingSessions(false); + } + }, [user?.id]); + + /** + * 加载指定会话的历史消息 + */ + const loadSessionHistory = useCallback( + async (sessionId: string) => { + if (!sessionId) return; + + try { + const response = await axios.get(`/mcp/agent/history/${sessionId}`, { + params: { limit: 100 }, + }); + + if (response.data.success) { + const history = response.data.data; + const formattedMessages: Message[] = history.map((msg: any, idx: number) => ({ + id: `${sessionId}-${idx}`, + type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE, + content: msg.message, + plan: msg.plan ? JSON.parse(msg.plan) : null, + stepResults: msg.steps ? JSON.parse(msg.steps) : null, + timestamp: msg.timestamp, + })); + + setMessages(formattedMessages); + } + } catch (error) { + logger.error('加载会话历史失败', error); + } + }, + [setMessages] + ); + + /** + * 切换到指定会话 + */ + const switchSession = useCallback( + (sessionId: string) => { + setCurrentSessionId(sessionId); + loadSessionHistory(sessionId); + }, + [loadSessionHistory] + ); + + /** + * 创建新会话(清空消息,显示欢迎消息) + */ + const createNewSession = useCallback(() => { + setCurrentSessionId(null); + setMessages([ + { + id: Date.now(), + type: MessageTypes.AGENT_RESPONSE, + content: `你好${user?.nickname || ''}!👋\n\n我是**价小前**,你的 AI 投研助手。\n\n**我能做什么?**\n• 📊 全面分析股票基本面和技术面\n• 🔥 追踪市场热点和涨停板块\n• 📈 研究行业趋势和投资机会\n• 📰 汇总最新财经新闻和研报\n\n直接输入你的问题开始探索!`, + timestamp: new Date().toISOString(), + }, + ]); + }, [user?.nickname, setMessages]); + + /** + * 组件挂载时加载会话列表并创建新会话 + */ + useEffect(() => { + loadSessions(); + createNewSession(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user]); + + return { + sessions, + currentSessionId, + setCurrentSessionId, + isLoadingSessions, + loadSessions, + switchSession, + createNewSession, + loadSessionHistory, + }; +}; diff --git a/src/views/AgentChat/hooks/useAutoScroll.ts b/src/views/AgentChat/hooks/useAutoScroll.ts new file mode 100644 index 00000000..de9eb290 --- /dev/null +++ b/src/views/AgentChat/hooks/useAutoScroll.ts @@ -0,0 +1,38 @@ +// src/views/AgentChat/hooks/useAutoScroll.ts +// 自动滚动 Hook - 消息列表自动滚动到底部 + +import { useEffect, useRef } from 'react'; +import type { Message } from '../constants/messageTypes'; + +/** + * useAutoScroll Hook + * + * 监听消息列表变化,自动滚动到底部 + * + * @param messages - 消息列表 + * @returns messagesEndRef - 消息列表底部引用(需要绑定到消息列表末尾的 div) + * + * @example + * ```tsx + * const { messagesEndRef } = useAutoScroll(messages); + * + * return ( + * + * {messages.map(msg => )} + *
+ * + * ); + * ``` + */ +export const useAutoScroll = (messages: Message[]) => { + const messagesEndRef = useRef(null); + + useEffect(() => { + // 平滑滚动到底部 + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + return { + messagesEndRef, + }; +}; diff --git a/src/views/AgentChat/hooks/useFileUpload.ts b/src/views/AgentChat/hooks/useFileUpload.ts new file mode 100644 index 00000000..5fbcd0dc --- /dev/null +++ b/src/views/AgentChat/hooks/useFileUpload.ts @@ -0,0 +1,131 @@ +// src/views/AgentChat/hooks/useFileUpload.ts +// 文件上传 Hook - 处理文件选择、预览、删除 + +import { useState, useRef } from 'react'; +import type { ChangeEvent, RefObject } from 'react'; + +/** + * 上传文件数据结构 + */ +export interface UploadedFile { + /** 文件名 */ + name: string; + /** 文件大小(字节) */ + size: number; + /** 文件 MIME 类型 */ + type: string; + /** 文件预览 URL(使用 URL.createObjectURL 创建) */ + url: string; +} + +/** + * useFileUpload Hook 返回值 + */ +export interface UseFileUploadReturn { + /** 已上传文件列表 */ + uploadedFiles: UploadedFile[]; + /** 文件输入框引用(用于触发文件选择) */ + fileInputRef: RefObject; + /** 处理文件选择事件 */ + handleFileSelect: (event: ChangeEvent) => void; + /** 删除指定文件 */ + removeFile: (index: number) => void; + /** 清空所有文件 */ + clearFiles: () => void; +} + +/** + * useFileUpload Hook + * + * 处理文件上传相关逻辑(选择、预览、删除) + * + * @returns UseFileUploadReturn + * + * @example + * ```tsx + * const { uploadedFiles, fileInputRef, handleFileSelect, removeFile } = useFileUpload(); + * + * return ( + * <> + * + * + * {uploadedFiles.map((file, idx) => ( + * + * {file.name} + * removeFile(idx)} /> + * + * ))} + * + * ); + * ``` + */ +export const useFileUpload = (): UseFileUploadReturn => { + const [uploadedFiles, setUploadedFiles] = useState([]); + const fileInputRef = useRef(null); + + /** + * 处理文件选择事件 + */ + const handleFileSelect = (event: ChangeEvent) => { + const files = Array.from(event.target.files || []); + + const fileData: UploadedFile[] = files.map((file) => ({ + name: file.name, + size: file.size, + type: file.type, + // 创建本地预览 URL(实际上传时需要转换为 base64 或上传到服务器) + url: URL.createObjectURL(file), + })); + + setUploadedFiles((prev) => [...prev, ...fileData]); + + // 清空 input value,允许重复选择同一文件 + if (event.target) { + event.target.value = ''; + } + }; + + /** + * 删除指定索引的文件 + */ + const removeFile = (index: number) => { + setUploadedFiles((prev) => { + // 释放 URL.createObjectURL 创建的内存 + const file = prev[index]; + if (file?.url) { + URL.revokeObjectURL(file.url); + } + + return prev.filter((_, i) => i !== index); + }); + }; + + /** + * 清空所有文件 + */ + const clearFiles = () => { + // 释放所有 URL 内存 + uploadedFiles.forEach((file) => { + if (file.url) { + URL.revokeObjectURL(file.url); + } + }); + + setUploadedFiles([]); + }; + + return { + uploadedFiles, + fileInputRef, + handleFileSelect, + removeFile, + clearFiles, + }; +}; diff --git a/src/views/AgentChat/index.js b/src/views/AgentChat/index.js index d4f8afe6..e926d570 100644 --- a/src/views/AgentChat/index.js +++ b/src/views/AgentChat/index.js @@ -1,1522 +1,139 @@ -// src/views/AgentChat/index_v4.js -// Agent聊天页面 V4 - 黑金毛玻璃设计,带模型选择和工具选择 +// src/views/AgentChat/index.js +// 超炫酷的 AI 投研助手 - HeroUI v3 现代深色主题版本 +// 使用 Framer Motion 物理动画引擎 + 毛玻璃效果 -import React, { useState, useEffect, useRef } from 'react'; -import { - Box, - Flex, - VStack, - HStack, - Text, - Input, - IconButton, - Button, - Avatar, - Heading, - Divider, - Spinner, - Badge, - useToast, - Progress, - Fade, - Collapse, - InputGroup, - InputLeftElement, - Menu, - MenuButton, - MenuList, - MenuItem, - Tooltip, - Select, - Checkbox, - CheckboxGroup, - Stack, - Accordion, - AccordionItem, - AccordionButton, - AccordionPanel, - AccordionIcon, - useDisclosure, -} from '@chakra-ui/react'; -import { - FiSend, - FiSearch, - FiPlus, - FiMessageSquare, - FiTrash2, - FiMoreVertical, - FiRefreshCw, - FiDownload, - FiCpu, - FiUser, - FiZap, - FiClock, - FiSettings, - FiCheckCircle, - FiChevronRight, - FiTool, -} from 'react-icons/fi'; +import React, { useState } from 'react'; +import { Box, Flex, useToast } from '@chakra-ui/react'; import { useAuth } from '@contexts/AuthContext'; -import { PlanCard } from '@components/ChatBot/PlanCard'; -import { StepResultCard } from '@components/ChatBot/StepResultCard'; -import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts'; -import { logger } from '@utils/logger'; -import axios from 'axios'; + +// 常量配置 - 从 TypeScript 模块导入 +import { DEFAULT_MODEL_ID } from './constants/models'; +import { DEFAULT_SELECTED_TOOLS } from './constants/tools'; + +// 拆分后的子组件 +import LeftSidebar from './components/LeftSidebar'; +import ChatArea from './components/ChatArea'; +import RightSidebar from './components/RightSidebar'; + +// 自定义 Hooks +import { useAgentSessions, useAgentChat, useFileUpload } from './hooks'; /** - * Agent消息类型 + * Agent Chat - 主组件(HeroUI v3 深色主题) + * + * 架构说明: + * - Phase 1: 常量配置已提取到 constants/ 目录(TypeScript) + * - Phase 2: UI 组件已拆分到 components/ 目录 + * - Phase 3: 业务逻辑已提取到 hooks/ 目录(TypeScript) + * + * 主组件职责: + * 1. 组合各个自定义 Hooks + * 2. 管理 UI 状态(侧边栏开关、模型选择、工具选择) + * 3. 组合渲染子组件 */ -const MessageTypes = { - USER: 'user', - AGENT_THINKING: 'agent_thinking', - AGENT_PLAN: 'agent_plan', - AGENT_EXECUTING: 'agent_executing', - AGENT_RESPONSE: 'agent_response', - ERROR: 'error', -}; - -/** - * 可用模型配置 - */ -const AVAILABLE_MODELS = [ - { - id: 'kimi-k2', - name: 'Kimi K2', - description: '快速响应,适合日常对话', - icon: '🚀', - provider: 'Moonshot', - }, - { - id: 'kimi-k2-thinking', - name: 'Kimi K2 Thinking', - description: '深度思考,提供详细推理过程', - icon: '🧠', - provider: 'Moonshot', - recommended: true, - }, - { - id: 'glm-4.6', - name: 'GLM 4.6', - description: '智谱AI最新模型,性能强大', - icon: '⚡', - provider: 'ZhipuAI', - }, - { - id: 'deepmoney', - name: 'DeepMoney', - description: '金融专业模型,擅长财经分析', - icon: '💰', - provider: 'Custom', - }, - { - id: 'gemini-3', - name: 'Gemini 3', - description: 'Google最新多模态模型', - icon: '✨', - provider: 'Google', - }, -]; - -/** - * MCP工具分类配置 - */ -const MCP_TOOL_CATEGORIES = [ - { - name: '新闻搜索', - icon: '📰', - tools: [ - { id: 'search_news', name: '全球新闻搜索', description: '搜索国际新闻、行业动态' }, - { id: 'search_china_news', name: '中国新闻搜索', description: 'KNN语义搜索中国新闻' }, - { id: 'search_medical_news', name: '医疗新闻搜索', description: '医药、医疗设备、生物技术' }, - ], - }, - { - name: '股票分析', - icon: '📈', - tools: [ - { id: 'search_limit_up_stocks', name: '涨停股票搜索', description: '搜索涨停股票及原因分析' }, - { id: 'get_stock_analysis', name: '个股分析', description: '获取股票深度分析报告' }, - { id: 'get_stock_concepts', name: '股票概念查询', description: '查询股票相关概念板块' }, - ], - }, - { - name: '概念板块', - icon: '🏢', - tools: [ - { id: 'search_concepts', name: '概念搜索', description: '搜索股票概念板块' }, - { id: 'get_concept_details', name: '概念详情', description: '获取概念板块详细信息' }, - { id: 'get_concept_statistics', name: '概念统计', description: '涨幅榜、活跃榜、连涨榜' }, - ], - }, - { - name: '公司信息', - icon: '🏭', - tools: [ - { id: 'search_roadshows', name: '路演搜索', description: '搜索上市公司路演活动' }, - { id: 'get_company_info', name: '公司信息', description: '获取公司基本面信息' }, - ], - }, - { - name: '数据分析', - icon: '📊', - tools: [ - { id: 'query_database', name: '数据库查询', description: 'SQL查询金融数据' }, - { id: 'get_market_overview', name: '市场概况', description: '获取市场整体行情' }, - ], - }, -]; - -/** - * Agent聊天页面 V4 - 黑金毛玻璃设计 - */ -const AgentChatV4 = () => { +const AgentChat = () => { const { user } = useAuth(); const toast = useToast(); - // 会话相关状态 - const [sessions, setSessions] = useState([]); - const [currentSessionId, setCurrentSessionId] = useState(null); - const [isLoadingSessions, setIsLoadingSessions] = useState(true); + // ==================== UI 状态(主组件管理)==================== + const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_ID); + const [selectedTools, setSelectedTools] = useState(DEFAULT_SELECTED_TOOLS); + const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true); + const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(true); - // 消息相关状态 + // ==================== 自定义 Hooks ==================== + + // 文件上传 Hook + const { uploadedFiles, fileInputRef, handleFileSelect, removeFile, clearFiles } = useFileUpload(); + + // 会话管理 Hook(需要先创建 messages state) const [messages, setMessages] = useState([]); - const [inputValue, setInputValue] = useState(''); - const [isProcessing, setIsProcessing] = useState(false); - const [currentProgress, setCurrentProgress] = useState(0); - // 模型和工具配置状态 - const [selectedModel, setSelectedModel] = useState('kimi-k2-thinking'); - const [selectedTools, setSelectedTools] = useState(() => { - // 默认全选所有工具 - const allToolIds = MCP_TOOL_CATEGORIES.flatMap(cat => cat.tools.map(t => t.id)); - return allToolIds; + const { + sessions, + currentSessionId, + setCurrentSessionId, + isLoadingSessions, + loadSessions, + switchSession, + createNewSession, + } = useAgentSessions({ + user, + setMessages, }); - // UI 状态 - const [searchQuery, setSearchQuery] = useState(''); - const { isOpen: isSidebarOpen, onToggle: toggleSidebar } = useDisclosure({ defaultIsOpen: true }); - const { isOpen: isRightPanelOpen, onToggle: toggleRightPanel } = useDisclosure({ defaultIsOpen: true }); + // 消息处理 Hook + const { + inputValue, + setInputValue, + isProcessing, + handleSendMessage, + handleKeyPress, + } = useAgentChat({ + user, + currentSessionId, + setCurrentSessionId, + selectedModel, + selectedTools, + uploadedFiles, + clearFiles, + toast, + loadSessions, + }); - // Refs - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - - // 毛玻璃深灰金配色主题(类似编程工具的深色主题) - const glassBg = 'rgba(30, 35, 40, 0.85)'; // 深灰色毛玻璃 - const glassHoverBg = 'rgba(40, 45, 50, 0.9)'; - const goldAccent = '#FFD700'; - const goldGradient = 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)'; - const darkBg = '#1a1d23'; // VS Code 风格的深灰背景 - const borderGold = 'rgba(255, 215, 0, 0.3)'; - const textGold = '#FFD700'; - const textWhite = '#E8E8E8'; // 柔和的白色 - const textGray = '#9BA1A6'; // 柔和的灰色 - const cardBg = 'rgba(40, 45, 50, 0.6)'; // 卡片背景(深灰毛玻璃) - - // ==================== 会话管理函数 ==================== - - const loadSessions = async () => { - if (!user?.id) return; - - setIsLoadingSessions(true); - try { - const response = await axios.get('/mcp/agent/sessions', { - params: { user_id: String(user.id), limit: 50 }, - }); - - if (response.data.success) { - setSessions(response.data.data); - logger.info('会话列表加载成功', response.data.data); - } - } catch (error) { - logger.error('加载会话列表失败', error); - toast({ - title: '加载失败', - description: '无法加载会话列表', - status: 'error', - duration: 3000, - }); - } finally { - setIsLoadingSessions(false); - } - }; - - const loadSessionHistory = async (sessionId) => { - if (!sessionId) return; - - try { - const response = await axios.get(`/mcp/agent/history/${sessionId}`, { - params: { limit: 100 }, - }); - - if (response.data.success) { - const history = response.data.data; - const formattedMessages = history.map((msg, idx) => ({ - id: `${sessionId}-${idx}`, - type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE, - content: msg.message, - plan: msg.plan ? JSON.parse(msg.plan) : null, - stepResults: msg.steps ? JSON.parse(msg.steps) : null, - timestamp: msg.timestamp, - })); - - setMessages(formattedMessages); - logger.info('会话历史加载成功', formattedMessages); - } - } catch (error) { - logger.error('加载会话历史失败', error); - toast({ - title: '加载失败', - description: '无法加载会话历史', - status: 'error', - duration: 3000, - }); - } - }; - - const createNewSession = () => { - setCurrentSessionId(null); - setMessages([ - { - id: Date.now(), - type: MessageTypes.AGENT_RESPONSE, - content: `你好${user?.nickname || ''}!我是价小前,北京价值前沿科技公司的AI投研助手。\n\n我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我:\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现`, - timestamp: new Date().toISOString(), - }, - ]); - }; - - const switchSession = (sessionId) => { - setCurrentSessionId(sessionId); - loadSessionHistory(sessionId); - }; - - const deleteSession = async (sessionId) => { - toast({ - title: '删除会话', - description: '此功能尚未实现', - status: 'info', - duration: 2000, - }); - }; - - // ==================== 消息处理函数 ==================== - - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; - - useEffect(() => { - scrollToBottom(); - }, [messages]); - - const addMessage = (message) => { - setMessages((prev) => [...prev, { ...message, id: Date.now() }]); - }; - - const handleSendMessage = async () => { - if (!inputValue.trim() || isProcessing) return; - - const hasAccess = user?.subscription_type === 'max'; - - if (!hasAccess) { - logger.warn('AgentChat', '权限检查失败', { - userId: user?.id, - subscription_type: user?.subscription_type, - }); - - toast({ - title: '订阅升级', - description: '「价小前投研」功能需要 Max 订阅。请前往设置页面升级您的订阅。', - status: 'warning', - duration: 5000, - isClosable: true, - }); - return; - } - - const userMessage = { - type: MessageTypes.USER, - content: inputValue, - timestamp: new Date().toISOString(), - }; - - addMessage(userMessage); - const userInput = inputValue; - setInputValue(''); - setIsProcessing(true); - setCurrentProgress(0); - - let currentPlan = null; - let stepResults = []; - let executingMessageId = null; - - try { - addMessage({ - type: MessageTypes.AGENT_THINKING, - content: '正在分析你的问题...', - timestamp: new Date().toISOString(), - }); - - setCurrentProgress(10); - - const requestBody = { - message: userInput, - conversation_history: messages - .filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE) - .map((m) => ({ - isUser: m.type === MessageTypes.USER, - content: m.content, - })), - user_id: user?.id ? String(user.id) : 'anonymous', - user_nickname: user?.nickname || user?.username || '匿名用户', - user_avatar: user?.avatar || '', - subscription_type: user?.subscription_type || 'free', - session_id: currentSessionId, - model: selectedModel, // 传递选中的模型 - tools: selectedTools, // 传递选中的工具 - }; - - const response = await fetch('/mcp/agent/chat/stream', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - let thinkingMessageId = null; - let thinkingContent = ''; - let summaryMessageId = null; - let summaryContent = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop(); - - let currentEvent = null; - - for (const line of lines) { - if (!line.trim() || line.startsWith(':')) continue; - - if (line.startsWith('event:')) { - currentEvent = line.substring(6).trim(); - continue; - } - - if (line.startsWith('data:')) { - try { - const data = JSON.parse(line.substring(5).trim()); - - if (currentEvent === 'thinking') { - if (!thinkingMessageId) { - thinkingMessageId = Date.now(); - thinkingContent = ''; - setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); - addMessage({ - id: thinkingMessageId, - type: MessageTypes.AGENT_THINKING, - content: '', - timestamp: new Date().toISOString(), - }); - } - thinkingContent += data.content; - setMessages((prev) => - prev.map((m) => - m.id === thinkingMessageId - ? { ...m, content: thinkingContent } - : m - ) - ); - } else if (currentEvent === 'plan') { - currentPlan = data; - thinkingMessageId = null; - thinkingContent = ''; - setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); - addMessage({ - type: MessageTypes.AGENT_PLAN, - content: '已制定执行计划', - plan: data, - timestamp: new Date().toISOString(), - }); - setCurrentProgress(30); - } else if (currentEvent === 'step_complete') { - const stepResult = { - step_index: data.step_index, - tool: data.tool, - status: data.status, - result: data.result, - error: data.error, - execution_time: data.execution_time, - }; - stepResults.push(stepResult); - - setMessages((prev) => - prev.map((m) => - m.id === executingMessageId - ? { ...m, stepResults: [...stepResults] } - : m - ) - ); - - const progress = 40 + (stepResults.length / (currentPlan?.steps?.length || 5)) * 40; - setCurrentProgress(Math.min(progress, 80)); - } else if (currentEvent === 'summary_chunk') { - if (!summaryMessageId) { - summaryMessageId = Date.now(); - summaryContent = ''; - setMessages((prev) => - prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING) - ); - addMessage({ - id: summaryMessageId, - type: MessageTypes.AGENT_RESPONSE, - content: '', - plan: currentPlan, - stepResults: stepResults, - isStreaming: true, - timestamp: new Date().toISOString(), - }); - setCurrentProgress(85); - } - summaryContent += data.content; - setMessages((prev) => - prev.map((m) => - m.id === summaryMessageId - ? { ...m, content: summaryContent } - : m - ) - ); - } else if (currentEvent === 'summary') { - if (summaryMessageId) { - setMessages((prev) => - prev.map((m) => - m.id === summaryMessageId - ? { - ...m, - content: data.content || summaryContent, - metadata: data.metadata, - isStreaming: false, - } - : m - ) - ); - } else { - setMessages((prev) => - prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING) - ); - addMessage({ - type: MessageTypes.AGENT_RESPONSE, - content: data.content, - plan: currentPlan, - stepResults: stepResults, - metadata: data.metadata, - isStreaming: false, - timestamp: new Date().toISOString(), - }); - } - setCurrentProgress(100); - } else if (currentEvent === 'status') { - if (data.stage === 'planning') { - setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); - addMessage({ - type: MessageTypes.AGENT_THINKING, - content: data.message, - timestamp: new Date().toISOString(), - }); - setCurrentProgress(10); - } else if (data.stage === 'executing') { - const msgId = Date.now(); - executingMessageId = msgId; - addMessage({ - id: msgId, - type: MessageTypes.AGENT_EXECUTING, - content: data.message, - plan: currentPlan, - stepResults: [], - timestamp: new Date().toISOString(), - }); - setCurrentProgress(40); - } else if (data.stage === 'summarizing') { - setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING)); - addMessage({ - type: MessageTypes.AGENT_THINKING, - content: data.message, - timestamp: new Date().toISOString(), - }); - setCurrentProgress(80); - } - } - } catch (e) { - logger.error('解析 SSE 数据失败', e); - } - } - } - } - - loadSessions(); - - } catch (error) { - logger.error('Agent chat error', error); - - setMessages((prev) => - prev.filter( - (m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING - ) - ); - - const errorMessage = error.response?.data?.detail || error.message || '处理失败'; - - addMessage({ - type: MessageTypes.ERROR, - content: `处理失败:${errorMessage}`, - timestamp: new Date().toISOString(), - }); - - toast({ - title: '处理失败', - description: errorMessage, - status: 'error', - duration: 5000, - isClosable: true, - }); - } finally { - setIsProcessing(false); - setCurrentProgress(0); - inputRef.current?.focus(); - } - }; - - const handleKeyPress = (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSendMessage(); - } - }; - - const handleClearChat = () => { - createNewSession(); - }; - - const handleExportChat = () => { - const chatText = messages - .filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE) - .map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : '价小前'}] ${msg.content}`) - .join('\n\n'); - - const blob = new Blob([chatText], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`; - a.click(); - URL.revokeObjectURL(url); - }; - - // ==================== 工具选择处理 ==================== - - const handleToolToggle = (toolId, isChecked) => { - if (isChecked) { - setSelectedTools((prev) => [...prev, toolId]); - } else { - setSelectedTools((prev) => prev.filter((id) => id !== toolId)); - } - }; - - const handleCategoryToggle = (categoryTools, isAllSelected) => { - const toolIds = categoryTools.map((t) => t.id); - if (isAllSelected) { - // 全部取消选中 - setSelectedTools((prev) => prev.filter((id) => !toolIds.includes(id))); - } else { - // 全部选中 - setSelectedTools((prev) => { - const newTools = [...prev]; - toolIds.forEach((id) => { - if (!newTools.includes(id)) { - newTools.push(id); - } - }); - return newTools; - }); - } - }; - - // ==================== 初始化 ==================== - - useEffect(() => { - if (user) { - loadSessions(); - createNewSession(); - } - }, [user]); - - // ==================== 渲染 ==================== - - const quickQuestions = [ - '全面分析贵州茅台这只股票', - '今日涨停股票有哪些亮点', - '新能源概念板块的投资机会', - '半导体行业最新动态', - ]; - - const filteredSessions = sessions.filter( - (session) => - !searchQuery || - session.last_message?.toLowerCase().includes(searchQuery.toLowerCase()) - ); + // ==================== 输入框引用(保留在主组件)==================== + const inputRef = React.useRef(null); + // ==================== 渲染组件 ==================== return ( - - {/* 背景装饰 - 黄金光晕效果 */} - - + {/* 左侧栏 */} + setIsLeftSidebarOpen(false)} + sessions={sessions} + currentSessionId={currentSessionId} + onSessionSwitch={switchSession} + onNewSession={createNewSession} + isLoadingSessions={isLoadingSessions} + user={user} /> - {/* 左侧会话列表 */} - - - {/* 侧边栏头部 */} - - + {/* 中间聊天区 */} + setIsLeftSidebarOpen(true)} + onToggleRightSidebar={() => setIsRightSidebarOpen(true)} + onNewSession={createNewSession} + userAvatar={user?.avatar} + inputRef={inputRef} + fileInputRef={fileInputRef} + /> - - - - - setSearchQuery(e.target.value)} - bg="rgba(255, 255, 255, 0.05)" - border="1px solid" - borderColor={borderGold} - color={textWhite} - _placeholder={{ color: textGray }} - _hover={{ borderColor: goldAccent }} - _focus={{ borderColor: goldAccent, boxShadow: `0 0 0 1px ${goldAccent}` }} - /> - - - - {/* 会话列表 */} - - {isLoadingSessions ? ( - - - - ) : filteredSessions.length === 0 ? ( - - - - {searchQuery ? '没有找到匹配的对话' : '暂无对话记录'} - - - ) : ( - filteredSessions.map((session) => ( - switchSession(session.session_id)} - transition="all 0.2s" - > - - - - {session.last_message || '新对话'} - - - - - {new Date(session.last_timestamp).toLocaleDateString('zh-CN', { - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - })} - - - {session.message_count} 条 - - - - - - } - size="xs" - variant="ghost" - color={textGray} - _hover={{ color: goldAccent }} - onClick={(e) => e.stopPropagation()} - /> - - } - color="red.400" - bg="transparent" - _hover={{ bg: 'rgba(255, 0, 0, 0.1)' }} - onClick={(e) => { - e.stopPropagation(); - deleteSession(session.session_id); - }} - > - 删除对话 - - - - - - )) - )} - - - {/* 用户信息 */} - - - - - - {user?.nickname || '未登录'} - - - MAX 订阅 - - - - - - - - {/* 主聊天区域 */} - - {/* 聊天头部 */} - - - - } - size="sm" - variant="ghost" - color={goldAccent} - _hover={{ bg: 'rgba(255, 215, 0, 0.1)' }} - aria-label="切换侧边栏" - onClick={toggleSidebar} - /> - - - - - 价小前投研 - - - - - AI 深度分析 - - - - {AVAILABLE_MODELS.find(m => m.id === selectedModel)?.name || '智能模型'} - - - - - - - } - size="sm" - variant="ghost" - color={textGray} - _hover={{ color: goldAccent, bg: 'rgba(255, 215, 0, 0.1)' }} - aria-label="清空对话" - onClick={handleClearChat} - /> - } - size="sm" - variant="ghost" - color={textGray} - _hover={{ color: goldAccent, bg: 'rgba(255, 215, 0, 0.1)' }} - aria-label="导出对话" - onClick={handleExportChat} - /> - } - size="sm" - variant="ghost" - color={goldAccent} - _hover={{ bg: 'rgba(255, 215, 0, 0.1)' }} - aria-label="设置" - onClick={toggleRightPanel} - /> - - - - {/* 进度条 */} - {isProcessing && ( - div': { - background: goldGradient, - }, - }} - isAnimated - /> - )} - - - {/* 消息列表 */} - - - {messages.map((message) => ( - - - - ))} -
- - - - {/* 快捷问题 */} - {messages.length <= 2 && !isProcessing && ( - - - 💡 试试这些问题: - - - {quickQuestions.map((question, idx) => ( - - ))} - - - )} - - {/* 输入框 */} - - - setInputValue(e.target.value)} - onKeyPress={handleKeyPress} - placeholder="输入你的问题,我会进行深度分析..." - bg="rgba(255, 255, 255, 0.05)" - border="1px solid" - borderColor={borderGold} - color={textWhite} - _placeholder={{ color: textGray }} - _focus={{ borderColor: goldAccent, boxShadow: `0 0 0 1px ${goldAccent}` }} - mr={2} - disabled={isProcessing} - size="lg" - /> - - - - - - {/* 右侧配置面板 */} - - - - {/* 模型选择 */} - - - - - 选择模型 - - - - {AVAILABLE_MODELS.map((model) => ( - setSelectedModel(model.id)} - transition="all 0.2s" - _hover={{ - bg: 'rgba(255, 215, 0, 0.1)', - borderColor: goldAccent, - transform: 'translateX(4px)', - }} - position="relative" - > - {model.recommended && ( - - 推荐 - - )} - - {model.icon} - - - {model.name} - - - {model.description} - - - {selectedModel === model.id && ( - - )} - - - ))} - - - - - - {/* 工具选择 */} - - - - - - MCP 工具 - - - - {selectedTools.length} 个已选 - - - - - {MCP_TOOL_CATEGORIES.map((category, catIdx) => { - const categoryToolIds = category.tools.map((t) => t.id); - const selectedInCategory = categoryToolIds.filter((id) => selectedTools.includes(id)); - const isAllSelected = selectedInCategory.length === categoryToolIds.length; - const isSomeSelected = selectedInCategory.length > 0 && !isAllSelected; - - return ( - - - - {category.icon} - - {category.name} - - - {selectedInCategory.length}/{category.tools.length} - - - - - - - {/* 全选按钮 */} - - - {category.tools.map((tool) => ( - handleToolToggle(tool.id, e.target.checked)} - colorScheme="yellow" - size="sm" - sx={{ - '.chakra-checkbox__control': { - borderColor: borderGold, - bg: 'rgba(255, 255, 255, 0.05)', - _checked: { - bg: goldGradient, - borderColor: goldAccent, - }, - }, - }} - > - - - {tool.name} - - - {tool.description} - - - - ))} - - - - ); - })} - - - - - + {/* 右侧栏 */} + setIsRightSidebarOpen(false)} + selectedModel={selectedModel} + onModelChange={setSelectedModel} + selectedTools={selectedTools} + onToolsChange={setSelectedTools} + sessionsCount={sessions.length} + messagesCount={messages.length} + /> ); }; -/** - * 消息渲染器(深灰毛玻璃风格) - */ -const MessageRenderer = ({ message, userAvatar }) => { - const glassBg = 'rgba(30, 35, 40, 0.85)'; // 深灰色毛玻璃 - const cardBg = 'rgba(40, 45, 50, 0.6)'; // 卡片背景 - const goldAccent = '#FFD700'; - const goldGradient = 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)'; - const darkBg = '#1a1d23'; - const borderGold = 'rgba(255, 215, 0, 0.3)'; - const textWhite = '#E8E8E8'; // 柔和的白色 - const textGray = '#9BA1A6'; // 柔和的灰色 - - switch (message.type) { - case MessageTypes.USER: - return ( - - - - - {message.content} - - - } - border="2px solid" - borderColor={goldAccent} - /> - - - ); - - case MessageTypes.AGENT_THINKING: - return ( - - - - - - - - - - {message.content} - - - - - - ); - - case MessageTypes.AGENT_PLAN: - return ( - - - - - - - - - - - ); - - case MessageTypes.AGENT_EXECUTING: - return ( - - - - - - - - {message.stepResults?.map((result, idx) => ( - - ))} - - - - ); - - case MessageTypes.AGENT_RESPONSE: - return ( - - - - - - - {/* 最终总结 */} - - {message.isStreaming ? ( - - {message.content} - - ) : ( - - )} - - {message.metadata && ( - - 总步骤: {message.metadata.total_steps} - ✓ {message.metadata.successful_steps} - {message.metadata.failed_steps > 0 && ( - ✗ {message.metadata.failed_steps} - )} - 耗时: {message.metadata.total_execution_time?.toFixed(1)}s - - )} - - - {/* 执行详情(可选) */} - {message.plan && message.stepResults && message.stepResults.length > 0 && ( - - - - 📊 执行详情(点击展开查看) - - {message.stepResults.map((result, idx) => ( - - ))} - - )} - - - - ); - - case MessageTypes.ERROR: - return ( - - - - - - - {message.content} - - - - ); - - default: - return null; - } -}; - -export default AgentChatV4; +export default AgentChat; diff --git a/src/views/AgentChat/index.js.bak b/src/views/AgentChat/index.js.bak new file mode 100644 index 00000000..60a2ecf8 --- /dev/null +++ b/src/views/AgentChat/index.js.bak @@ -0,0 +1,1529 @@ +// src/views/AgentChat/index.js +// 超炫酷的 AI 投研助手 - HeroUI v3 现代深色主题版本 +// 使用 Framer Motion 物理动画引擎 + 毛玻璃效果 + +import React, { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Box, + Button, + Input, + Avatar, + Badge, + Divider, + Spinner, + Tooltip, + Checkbox, + CheckboxGroup, + Kbd, + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Tabs, + TabList, + TabPanels, + Tab, + TabPanel, + useToast, + VStack, + HStack, + Text, + Flex, + IconButton, + useColorMode, + Card, + CardBody, + Tag, + TagLabel, + TagCloseButton, +} from '@chakra-ui/react'; +import { useAuth } from '@contexts/AuthContext'; +import { logger } from '@utils/logger'; +import axios from 'axios'; + +// 图标 - 使用 Lucide Icons +import { + Send, + Plus, + Search, + MessageSquare, + Trash2, + MoreVertical, + RefreshCw, + Download, + Cpu, + User, + Zap, + Clock, + Settings, + ChevronLeft, + ChevronRight, + Activity, + Code, + Database, + TrendingUp, + FileText, + BookOpen, + Menu, + X, + Check, + Circle, + Maximize2, + Minimize2, + Copy, + ThumbsUp, + ThumbsDown, + Sparkles, + Brain, + Rocket, + Paperclip, + Image as ImageIcon, + File, + Calendar, + Globe, + DollarSign, + Newspaper, + BarChart3, + PieChart, + LineChart, + Briefcase, + Users, +} from 'lucide-react'; + +// 常量配置 - 从 TypeScript 模块导入 +import { MessageTypes } from './constants/messageTypes'; +import { DEFAULT_MODEL_ID } from './constants/models'; +import { DEFAULT_SELECTED_TOOLS } from './constants/tools'; + +// 拆分后的子组件 +import BackgroundEffects from './components/BackgroundEffects'; +import LeftSidebar from './components/LeftSidebar'; +import ChatArea from './components/ChatArea'; +import RightSidebar from './components/RightSidebar'; + +/** + * Agent Chat - 主组件(HeroUI v3 深色主题) + * + * 注意:所有常量配置已提取到 constants/ 目录: + * - animations: constants/animations.ts + * - MessageTypes: constants/messageTypes.ts + * - AVAILABLE_MODELS: constants/models.ts + * - MCP_TOOLS, TOOL_CATEGORIES: constants/tools.ts + * - quickQuestions: constants/quickQuestions.ts + */ +const AgentChat = () => { + const { user } = useAuth(); + const toast = useToast(); + const { setColorMode } = useColorMode(); + + // 会话管理 + const [sessions, setSessions] = useState([]); + const [currentSessionId, setCurrentSessionId] = useState(null); + const [isLoadingSessions, setIsLoadingSessions] = useState(false); + + // 消息管理 + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + + // UI 状态 + const [searchQuery, setSearchQuery] = useState(''); + const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_ID); + const [selectedTools, setSelectedTools] = useState(DEFAULT_SELECTED_TOOLS); + const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true); + const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(true); + + // 文件上传 + const [uploadedFiles, setUploadedFiles] = useState([]); + const fileInputRef = useRef(null); + + // Refs + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + // ==================== 启用深色模式 ==================== + useEffect(() => { + // 为 AgentChat 页面强制启用深色模式 + setColorMode('dark'); + document.documentElement.classList.add('dark'); + + return () => { + // 组件卸载时不移除,让其他页面自己控制 + // document.documentElement.classList.remove('dark'); + }; + }, [setColorMode]); + + // ==================== API 调用函数 ==================== + + const loadSessions = async () => { + if (!user?.id) return; + setIsLoadingSessions(true); + try { + const response = await axios.get('/mcp/agent/sessions', { + params: { user_id: user.id, limit: 50 }, + }); + if (response.data.success) { + setSessions(response.data.data); + } + } catch (error) { + logger.error('加载会话列表失败', error); + } finally { + setIsLoadingSessions(false); + } + }; + + const loadSessionHistory = async (sessionId) => { + if (!sessionId) return; + try { + const response = await axios.get(`/mcp/agent/history/${sessionId}`, { + params: { limit: 100 }, + }); + if (response.data.success) { + const history = response.data.data; + const formattedMessages = history.map((msg, idx) => ({ + id: `${sessionId}-${idx}`, + type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE, + content: msg.message, + plan: msg.plan ? JSON.parse(msg.plan) : null, + stepResults: msg.steps ? JSON.parse(msg.steps) : null, + timestamp: msg.timestamp, + })); + setMessages(formattedMessages); + } + } catch (error) { + logger.error('加载会话历史失败', error); + } + }; + + const createNewSession = () => { + setCurrentSessionId(null); + setMessages([ + { + id: Date.now(), + type: MessageTypes.AGENT_RESPONSE, + content: `你好${user?.nickname || ''}!👋\n\n我是**价小前**,你的 AI 投研助手。\n\n**我能做什么?**\n• 📊 全面分析股票基本面和技术面\n• 🔥 追踪市场热点和涨停板块\n• 📈 研究行业趋势和投资机会\n• 📰 汇总最新财经新闻和研报\n\n直接输入你的问题开始探索!`, + timestamp: new Date().toISOString(), + }, + ]); + }; + + const switchSession = (sessionId) => { + setCurrentSessionId(sessionId); + loadSessionHistory(sessionId); + }; + + const handleSendMessage = async () => { + if (!inputValue.trim() || isProcessing) return; + + const userMessage = { + type: MessageTypes.USER, + content: inputValue, + timestamp: new Date().toISOString(), + files: uploadedFiles.length > 0 ? uploadedFiles : undefined, + }; + + addMessage(userMessage); + const userInput = inputValue; + setInputValue(''); + setUploadedFiles([]); + setIsProcessing(true); + + try { + addMessage({ + type: MessageTypes.AGENT_THINKING, + content: '正在分析你的问题...', + timestamp: new Date().toISOString(), + }); + + const response = await axios.post('/mcp/agent/chat', { + message: userInput, + conversation_history: messages + .filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE) + .map((m) => ({ + isUser: m.type === MessageTypes.USER, + content: m.content, + })), + user_id: user?.id || 'anonymous', + user_nickname: user?.nickname || '匿名用户', + user_avatar: user?.avatar || '', + subscription_type: user?.subscription_type || 'free', + session_id: currentSessionId, + model: selectedModel, + tools: selectedTools, + files: uploadedFiles.length > 0 ? uploadedFiles : undefined, + }); + + setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); + + if (response.data.success) { + const data = response.data; + if (data.session_id && !currentSessionId) { + setCurrentSessionId(data.session_id); + } + + if (data.plan) { + addMessage({ + type: MessageTypes.AGENT_PLAN, + content: '已制定执行计划', + plan: data.plan, + timestamp: new Date().toISOString(), + }); + } + + if (data.steps && data.steps.length > 0) { + addMessage({ + type: MessageTypes.AGENT_EXECUTING, + content: '正在执行步骤...', + plan: data.plan, + stepResults: data.steps, + timestamp: new Date().toISOString(), + }); + } + + setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING)); + + addMessage({ + type: MessageTypes.AGENT_RESPONSE, + content: data.final_answer || data.message || '处理完成', + plan: data.plan, + stepResults: data.steps, + metadata: data.metadata, + timestamp: new Date().toISOString(), + }); + + loadSessions(); + } + } catch (error) { + logger.error('Agent chat error', error); + setMessages((prev) => + prev.filter( + (m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING + ) + ); + + const errorMessage = error.response?.data?.error || error.message || '处理失败'; + addMessage({ + type: MessageTypes.ERROR, + content: `处理失败:${errorMessage}`, + timestamp: new Date().toISOString(), + }); + + toast({ + title: '处理失败', + description: errorMessage, + status: 'error', + duration: 5000, + }); + } finally { + setIsProcessing(false); + } + }; + + // 文件上传处理 + const handleFileSelect = (event) => { + const files = Array.from(event.target.files || []); + const fileData = files.map(file => ({ + name: file.name, + size: file.size, + type: file.type, + // 实际上传时需要转换为 base64 或上传到服务器 + url: URL.createObjectURL(file), + })); + setUploadedFiles(prev => [...prev, ...fileData]); + }; + + const removeFile = (index) => { + setUploadedFiles(prev => prev.filter((_, i) => i !== index)); + }; + + const addMessage = (message) => { + setMessages((prev) => [ + ...prev, + { + id: Date.now() + Math.random(), + ...message, + }, + ]); + }; + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + useEffect(() => { + loadSessions(); + createNewSession(); + }, [user]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + return ( + + + {/* 背景渐变装饰 */} + + + {/* 左侧栏 */} + setIsLeftSidebarOpen(false)} + sessions={sessions} + currentSessionId={currentSessionId} + onSessionSwitch={switchSession} + onNewSession={createNewSession} + isLoadingSessions={isLoadingSessions} + user={user} + /> + + {/* 中间主聊天区域 */} + + {/* 顶部标题栏 - 深色毛玻璃 */} + + + + {!isLeftSidebarOpen && ( + + } + onClick={() => setIsLeftSidebarOpen(true)} + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: "rgba(255, 255, 255, 0.1)", + color: "white" + }} + /> + + )} + + + } + bgGradient="linear(to-br, purple.500, pink.500)" + boxShadow="0 0 20px rgba(236, 72, 153, 0.5)" + /> + + + + + 价小前投研 AI + + + + + 智能分析 + + + {AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.name} + + + + + + + + + } + onClick={createNewSession} + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: "rgba(255, 255, 255, 0.1)", + color: "white", + borderColor: "purple.400", + boxShadow: "0 0 12px rgba(139, 92, 246, 0.3)" + }} + /> + + + {!isRightSidebarOpen && ( + + } + onClick={() => setIsRightSidebarOpen(true)} + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: "rgba(255, 255, 255, 0.1)", + color: "white" + }} + /> + + )} + + + + + {/* 消息列表 */} + + + + + {messages.map((message) => ( + + + + ))} + +
+ + + + + {/* 快捷问题 */} + + {messages.length <= 2 && !isProcessing && ( + + + + + + 快速开始 + + + {quickQuestions.map((question, idx) => ( + + + + ))} + + + + + )} + + + {/* 输入栏 - 深色毛玻璃 */} + + + {/* 已上传文件预览 */} + {uploadedFiles.length > 0 && ( + + {uploadedFiles.map((file, idx) => ( + + + {file.name} + removeFile(idx)} color="gray.400" /> + + + ))} + + )} + + + + + + + } + onClick={() => fileInputRef.current?.click()} + bg="rgba(255, 255, 255, 0.05)" + color="gray.300" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: "rgba(255, 255, 255, 0.1)", + borderColor: "purple.400", + color: "white", + boxShadow: "0 0 12px rgba(139, 92, 246, 0.3)" + }} + /> + + + + + + } + onClick={() => { + fileInputRef.current?.setAttribute('accept', 'image/*'); + fileInputRef.current?.click(); + }} + bg="rgba(255, 255, 255, 0.05)" + color="gray.300" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: "rgba(255, 255, 255, 0.1)", + borderColor: "purple.400", + color: "white", + boxShadow: "0 0 12px rgba(139, 92, 246, 0.3)" + }} + /> + + + + setInputValue(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="输入你的问题... (Enter 发送, Shift+Enter 换行)" + isDisabled={isProcessing} + size="lg" + variant="outline" + borderWidth={2} + bg="rgba(255, 255, 255, 0.05)" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + color="white" + _placeholder={{ color: "gray.500" }} + _hover={{ + borderColor: "rgba(255, 255, 255, 0.2)" + }} + _focus={{ + borderColor: "purple.400", + boxShadow: "0 0 0 1px var(--chakra-colors-purple-400), 0 0 12px rgba(139, 92, 246, 0.3)", + bg: "rgba(255, 255, 255, 0.08)" + }} + /> + + + } + onClick={handleSendMessage} + isLoading={isProcessing} + isDisabled={!inputValue.trim() || isProcessing} + bgGradient="linear(to-r, blue.500, purple.600)" + color="white" + _hover={{ + bgGradient: "linear(to-r, blue.600, purple.700)", + boxShadow: "0 8px 20px rgba(139, 92, 246, 0.4)" + }} + _active={{ + transform: "translateY(0)", + boxShadow: "0 4px 12px rgba(139, 92, 246, 0.3)" + }} + /> + + + + + + Enter + 发送 + + + Shift + + + Enter + 换行 + + + + + + + {/* 右侧栏 - 深色配置中心 */} + + {isRightSidebarOpen && ( + + + + + + + + 配置中心 + + + + + } + onClick={() => setIsRightSidebarOpen(false)} + bg="rgba(255, 255, 255, 0.05)" + color="gray.300" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: "rgba(255, 255, 255, 0.1)", + borderColor: "purple.400", + color: "white" + }} + /> + + + + + + + + + + + + 模型 + + + + + + 工具 + {selectedTools.length > 0 && ( + + {selectedTools.length} + + )} + + + + + + 统计 + + + + + + {/* 模型选择 */} + + + {AVAILABLE_MODELS.map((model, idx) => ( + + setSelectedModel(model.id)} + bg={selectedModel === model.id + ? 'rgba(139, 92, 246, 0.15)' + : 'rgba(255, 255, 255, 0.05)'} + backdropFilter="blur(12px)" + borderWidth={2} + borderColor={selectedModel === model.id + ? 'purple.400' + : 'rgba(255, 255, 255, 0.1)'} + _hover={{ + borderColor: selectedModel === model.id + ? 'purple.400' + : 'rgba(255, 255, 255, 0.2)', + boxShadow: selectedModel === model.id + ? "0 8px 20px rgba(139, 92, 246, 0.4)" + : "0 4px 12px rgba(0, 0, 0, 0.3)" + }} + transition="all 0.3s" + > + + + + {model.icon} + + + + {model.name} + + + {model.description} + + + {selectedModel === model.id && ( + + + + )} + + + + + ))} + + + + {/* 工具选择 */} + + + {Object.entries(TOOL_CATEGORIES).map(([category, tools], catIdx) => ( + + + + + {category} + + {tools.filter(t => selectedTools.includes(t.id)).length}/{tools.length} + + + + + + + + {tools.map((tool) => ( + + + + {tool.icon} + + {tool.name} + {tool.description} + + + + + ))} + + + + + + ))} + + + + + + + + + + + + + {/* 统计信息 */} + + + + + + + + 对话数 + + {sessions.length} + + + + + + + + + + + + + + 消息数 + + {messages.length} + + + + + + + + + + + + + + 已选工具 + + {selectedTools.length} + + + + + + + + + + + + + + + )} + + + + ); +}; + +export default AgentChat; + return ( + + + + + + + {session.title || '新对话'} + + + {new Date(session.created_at || session.timestamp).toLocaleString('zh-CN', { + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} + + + {session.message_count && ( + + {session.message_count} + + )} + + + + + ); +}; + +/** + * 消息渲染器 + */ +const MessageRenderer = ({ message, userAvatar }) => { + switch (message.type) { + case MessageTypes.USER: + return ( + + + + + + + {message.content} + + {message.files && message.files.length > 0 && ( + + {message.files.map((file, idx) => ( + + + {file.name} + + ))} + + )} + + + + } + size="sm" + bgGradient="linear(to-br, blue.500, purple.600)" + boxShadow="0 0 12px rgba(139, 92, 246, 0.4)" + /> + + + ); + + case MessageTypes.AGENT_THINKING: + return ( + + + } + size="sm" + bgGradient="linear(to-br, purple.500, pink.500)" + boxShadow="0 0 12px rgba(236, 72, 153, 0.4)" + /> + + + + + + {message.content} + + + + + + + ); + + case MessageTypes.AGENT_RESPONSE: + return ( + + + } + size="sm" + bgGradient="linear(to-br, purple.500, pink.500)" + boxShadow="0 0 12px rgba(236, 72, 153, 0.4)" + /> + + + + + {message.content} + + + {message.stepResults && message.stepResults.length > 0 && ( + + + + )} + + + + + } + onClick={() => navigator.clipboard.writeText(message.content)} + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + _hover={{ + color: "white", + bg: "rgba(255, 255, 255, 0.1)" + }} + /> + + + + + } + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + _hover={{ + color: "green.400", + bg: "rgba(16, 185, 129, 0.1)", + boxShadow: "0 0 12px rgba(16, 185, 129, 0.3)" + }} + /> + + + + + } + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + _hover={{ + color: "red.400", + bg: "rgba(239, 68, 68, 0.1)", + boxShadow: "0 0 12px rgba(239, 68, 68, 0.3)" + }} + /> + + + + {new Date(message.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + })} + + + + + + + + ); + + case MessageTypes.ERROR: + return ( + + + + + {message.content} + + + + + ); + + default: + return null; + } +}; + +/** + * 执行步骤显示组件 + */ +const ExecutionStepsDisplay = ({ steps, plan }) => { + return ( + + + + + + 执行详情 + + {steps.length} 步骤 + + + + + + + {steps.map((result, idx) => ( + + + + + + 步骤 {idx + 1}: {result.tool_name} + + + {result.status} + + + + {result.execution_time?.toFixed(2)}s + + {result.error && ( + ⚠️ {result.error} + )} + + + + ))} + + + + + ); +}; diff --git a/src/views/AgentChat/index.js.bak2 b/src/views/AgentChat/index.js.bak2 new file mode 100644 index 00000000..071ce7a6 --- /dev/null +++ b/src/views/AgentChat/index.js.bak2 @@ -0,0 +1,1173 @@ +// src/views/AgentChat/index.js +// 超炫酷的 AI 投研助手 - HeroUI v3 现代深色主题版本 +// 使用 Framer Motion 物理动画引擎 + 毛玻璃效果 + +import React, { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Box, + Button, + Input, + Avatar, + Badge, + Divider, + Spinner, + Tooltip, + Checkbox, + CheckboxGroup, + Kbd, + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Tabs, + TabList, + TabPanels, + Tab, + TabPanel, + useToast, + VStack, + HStack, + Text, + Flex, + IconButton, + useColorMode, + Card, + CardBody, + Tag, + TagLabel, + TagCloseButton, +} from '@chakra-ui/react'; +import { useAuth } from '@contexts/AuthContext'; +import { logger } from '@utils/logger'; +import axios from 'axios'; + +// 图标 - 使用 Lucide Icons +import { + Send, + Plus, + Search, + MessageSquare, + Trash2, + MoreVertical, + RefreshCw, + Download, + Cpu, + User, + Zap, + Clock, + Settings, + ChevronLeft, + ChevronRight, + Activity, + Code, + Database, + TrendingUp, + FileText, + BookOpen, + Menu, + X, + Check, + Circle, + Maximize2, + Minimize2, + Copy, + ThumbsUp, + ThumbsDown, + Sparkles, + Brain, + Rocket, + Paperclip, + Image as ImageIcon, + File, + Calendar, + Globe, + DollarSign, + Newspaper, + BarChart3, + PieChart, + LineChart, + Briefcase, + Users, +} from 'lucide-react'; + +// 常量配置 - 从 TypeScript 模块导入 +import { MessageTypes } from './constants/messageTypes'; +import { DEFAULT_MODEL_ID } from './constants/models'; +import { DEFAULT_SELECTED_TOOLS } from './constants/tools'; + +// 拆分后的子组件 +import BackgroundEffects from './components/BackgroundEffects'; +import LeftSidebar from './components/LeftSidebar'; +import ChatArea from './components/ChatArea'; +import RightSidebar from './components/RightSidebar'; + +/** + * Agent Chat - 主组件(HeroUI v3 深色主题) + * + * 注意:所有常量配置已提取到 constants/ 目录: + * - animations: constants/animations.ts + * - MessageTypes: constants/messageTypes.ts + * - AVAILABLE_MODELS: constants/models.ts + * - MCP_TOOLS, TOOL_CATEGORIES: constants/tools.ts + * - quickQuestions: constants/quickQuestions.ts + */ +const AgentChat = () => { + const { user } = useAuth(); + const toast = useToast(); + const { setColorMode } = useColorMode(); + + // 会话管理 + const [sessions, setSessions] = useState([]); + const [currentSessionId, setCurrentSessionId] = useState(null); + const [isLoadingSessions, setIsLoadingSessions] = useState(false); + + // 消息管理 + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + + // UI 状态 + const [searchQuery, setSearchQuery] = useState(''); + const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_ID); + const [selectedTools, setSelectedTools] = useState(DEFAULT_SELECTED_TOOLS); + const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true); + const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(true); + + // 文件上传 + const [uploadedFiles, setUploadedFiles] = useState([]); + const fileInputRef = useRef(null); + + // Refs + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + // ==================== 启用深色模式 ==================== + useEffect(() => { + // 为 AgentChat 页面强制启用深色模式 + setColorMode('dark'); + document.documentElement.classList.add('dark'); + + return () => { + // 组件卸载时不移除,让其他页面自己控制 + // document.documentElement.classList.remove('dark'); + }; + }, [setColorMode]); + + // ==================== API 调用函数 ==================== + + const loadSessions = async () => { + if (!user?.id) return; + setIsLoadingSessions(true); + try { + const response = await axios.get('/mcp/agent/sessions', { + params: { user_id: user.id, limit: 50 }, + }); + if (response.data.success) { + setSessions(response.data.data); + } + } catch (error) { + logger.error('加载会话列表失败', error); + } finally { + setIsLoadingSessions(false); + } + }; + + const loadSessionHistory = async (sessionId) => { + if (!sessionId) return; + try { + const response = await axios.get(`/mcp/agent/history/${sessionId}`, { + params: { limit: 100 }, + }); + if (response.data.success) { + const history = response.data.data; + const formattedMessages = history.map((msg, idx) => ({ + id: `${sessionId}-${idx}`, + type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE, + content: msg.message, + plan: msg.plan ? JSON.parse(msg.plan) : null, + stepResults: msg.steps ? JSON.parse(msg.steps) : null, + timestamp: msg.timestamp, + })); + setMessages(formattedMessages); + } + } catch (error) { + logger.error('加载会话历史失败', error); + } + }; + + const createNewSession = () => { + setCurrentSessionId(null); + setMessages([ + { + id: Date.now(), + type: MessageTypes.AGENT_RESPONSE, + content: `你好${user?.nickname || ''}!👋\n\n我是**价小前**,你的 AI 投研助手。\n\n**我能做什么?**\n• 📊 全面分析股票基本面和技术面\n• 🔥 追踪市场热点和涨停板块\n• 📈 研究行业趋势和投资机会\n• 📰 汇总最新财经新闻和研报\n\n直接输入你的问题开始探索!`, + timestamp: new Date().toISOString(), + }, + ]); + }; + + const switchSession = (sessionId) => { + setCurrentSessionId(sessionId); + loadSessionHistory(sessionId); + }; + + const handleSendMessage = async () => { + if (!inputValue.trim() || isProcessing) return; + + const userMessage = { + type: MessageTypes.USER, + content: inputValue, + timestamp: new Date().toISOString(), + files: uploadedFiles.length > 0 ? uploadedFiles : undefined, + }; + + addMessage(userMessage); + const userInput = inputValue; + setInputValue(''); + setUploadedFiles([]); + setIsProcessing(true); + + try { + addMessage({ + type: MessageTypes.AGENT_THINKING, + content: '正在分析你的问题...', + timestamp: new Date().toISOString(), + }); + + const response = await axios.post('/mcp/agent/chat', { + message: userInput, + conversation_history: messages + .filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE) + .map((m) => ({ + isUser: m.type === MessageTypes.USER, + content: m.content, + })), + user_id: user?.id || 'anonymous', + user_nickname: user?.nickname || '匿名用户', + user_avatar: user?.avatar || '', + subscription_type: user?.subscription_type || 'free', + session_id: currentSessionId, + model: selectedModel, + tools: selectedTools, + files: uploadedFiles.length > 0 ? uploadedFiles : undefined, + }); + + setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); + + if (response.data.success) { + const data = response.data; + if (data.session_id && !currentSessionId) { + setCurrentSessionId(data.session_id); + } + + if (data.plan) { + addMessage({ + type: MessageTypes.AGENT_PLAN, + content: '已制定执行计划', + plan: data.plan, + timestamp: new Date().toISOString(), + }); + } + + if (data.steps && data.steps.length > 0) { + addMessage({ + type: MessageTypes.AGENT_EXECUTING, + content: '正在执行步骤...', + plan: data.plan, + stepResults: data.steps, + timestamp: new Date().toISOString(), + }); + } + + setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING)); + + addMessage({ + type: MessageTypes.AGENT_RESPONSE, + content: data.final_answer || data.message || '处理完成', + plan: data.plan, + stepResults: data.steps, + metadata: data.metadata, + timestamp: new Date().toISOString(), + }); + + loadSessions(); + } + } catch (error) { + logger.error('Agent chat error', error); + setMessages((prev) => + prev.filter( + (m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING + ) + ); + + const errorMessage = error.response?.data?.error || error.message || '处理失败'; + addMessage({ + type: MessageTypes.ERROR, + content: `处理失败:${errorMessage}`, + timestamp: new Date().toISOString(), + }); + + toast({ + title: '处理失败', + description: errorMessage, + status: 'error', + duration: 5000, + }); + } finally { + setIsProcessing(false); + } + }; + + // 文件上传处理 + const handleFileSelect = (event) => { + const files = Array.from(event.target.files || []); + const fileData = files.map(file => ({ + name: file.name, + size: file.size, + type: file.type, + // 实际上传时需要转换为 base64 或上传到服务器 + url: URL.createObjectURL(file), + })); + setUploadedFiles(prev => [...prev, ...fileData]); + }; + + const removeFile = (index) => { + setUploadedFiles(prev => prev.filter((_, i) => i !== index)); + }; + + const addMessage = (message) => { + setMessages((prev) => [ + ...prev, + { + id: Date.now() + Math.random(), + ...message, + }, + ]); + }; + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + useEffect(() => { + loadSessions(); + createNewSession(); + }, [user]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + return ( + + + {/* 背景渐变装饰 */} + + + {/* 左侧栏 */} + setIsLeftSidebarOpen(false)} + sessions={sessions} + currentSessionId={currentSessionId} + onSessionSwitch={switchSession} + onNewSession={createNewSession} + isLoadingSessions={isLoadingSessions} + user={user} + /> + + {/* 中间主聊天区域 */} + + {/* 顶部标题栏 - 深色毛玻璃 */} + + + + {!isLeftSidebarOpen && ( + + } + onClick={() => setIsLeftSidebarOpen(true)} + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: "rgba(255, 255, 255, 0.1)", + color: "white" + }} + /> + + )} + + + } + bgGradient="linear(to-br, purple.500, pink.500)" + boxShadow="0 0 20px rgba(236, 72, 153, 0.5)" + /> + + + + + 价小前投研 AI + + + + + 智能分析 + + + {AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.name} + + + + + + + + + } + onClick={createNewSession} + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: "rgba(255, 255, 255, 0.1)", + color: "white", + borderColor: "purple.400", + boxShadow: "0 0 12px rgba(139, 92, 246, 0.3)" + }} + /> + + + {!isRightSidebarOpen && ( + + } + onClick={() => setIsRightSidebarOpen(true)} + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: "rgba(255, 255, 255, 0.1)", + color: "white" + }} + /> + + )} + + + + + {/* 消息列表 */} + + + + + {messages.map((message) => ( + + + + ))} + +
+ + + + + {/* 快捷问题 */} + + {messages.length <= 2 && !isProcessing && ( + + + + + + 快速开始 + + + {quickQuestions.map((question, idx) => ( + + + + ))} + + + + + )} + + + {/* 输入栏 - 深色毛玻璃 */} + + + {/* 已上传文件预览 */} + {uploadedFiles.length > 0 && ( + + {uploadedFiles.map((file, idx) => ( + + + {file.name} + removeFile(idx)} color="gray.400" /> + + + ))} + + )} + + + + + + + } + onClick={() => fileInputRef.current?.click()} + bg="rgba(255, 255, 255, 0.05)" + color="gray.300" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: "rgba(255, 255, 255, 0.1)", + borderColor: "purple.400", + color: "white", + boxShadow: "0 0 12px rgba(139, 92, 246, 0.3)" + }} + /> + + + + + + } + onClick={() => { + fileInputRef.current?.setAttribute('accept', 'image/*'); + fileInputRef.current?.click(); + }} + bg="rgba(255, 255, 255, 0.05)" + color="gray.300" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: "rgba(255, 255, 255, 0.1)", + borderColor: "purple.400", + color: "white", + boxShadow: "0 0 12px rgba(139, 92, 246, 0.3)" + }} + /> + + + + setInputValue(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="输入你的问题... (Enter 发送, Shift+Enter 换行)" + isDisabled={isProcessing} + size="lg" + variant="outline" + borderWidth={2} + bg="rgba(255, 255, 255, 0.05)" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + color="white" + _placeholder={{ color: "gray.500" }} + _hover={{ + borderColor: "rgba(255, 255, 255, 0.2)" + }} + _focus={{ + borderColor: "purple.400", + boxShadow: "0 0 0 1px var(--chakra-colors-purple-400), 0 0 12px rgba(139, 92, 246, 0.3)", + bg: "rgba(255, 255, 255, 0.08)" + }} + /> + + + } + onClick={handleSendMessage} + isLoading={isProcessing} + isDisabled={!inputValue.trim() || isProcessing} + bgGradient="linear(to-r, blue.500, purple.600)" + color="white" + _hover={{ + bgGradient: "linear(to-r, blue.600, purple.700)", + boxShadow: "0 8px 20px rgba(139, 92, 246, 0.4)" + }} + _active={{ + transform: "translateY(0)", + boxShadow: "0 4px 12px rgba(139, 92, 246, 0.3)" + }} + /> + + + + + + Enter + 发送 + + + Shift + + + Enter + 换行 + + + + + + + {/* 右侧栏 - 深色配置中心 */} + + {isRightSidebarOpen && ( + + + + + + + + 配置中心 + + + + + } + onClick={() => setIsRightSidebarOpen(false)} + bg="rgba(255, 255, 255, 0.05)" + color="gray.300" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: "rgba(255, 255, 255, 0.1)", + borderColor: "purple.400", + color: "white" + }} + /> + + + + + + + + + + + + 模型 + + + + + + 工具 + {selectedTools.length > 0 && ( + + {selectedTools.length} + + )} + + + + + + 统计 + + + + + + {/* 模型选择 */} + + + {AVAILABLE_MODELS.map((model, idx) => ( + + setSelectedModel(model.id)} + bg={selectedModel === model.id + ? 'rgba(139, 92, 246, 0.15)' + : 'rgba(255, 255, 255, 0.05)'} + backdropFilter="blur(12px)" + borderWidth={2} + borderColor={selectedModel === model.id + ? 'purple.400' + : 'rgba(255, 255, 255, 0.1)'} + _hover={{ + borderColor: selectedModel === model.id + ? 'purple.400' + : 'rgba(255, 255, 255, 0.2)', + boxShadow: selectedModel === model.id + ? "0 8px 20px rgba(139, 92, 246, 0.4)" + : "0 4px 12px rgba(0, 0, 0, 0.3)" + }} + transition="all 0.3s" + > + + + + {model.icon} + + + + {model.name} + + + {model.description} + + + {selectedModel === model.id && ( + + + + )} + + + + + ))} + + + + {/* 工具选择 */} + + + {Object.entries(TOOL_CATEGORIES).map(([category, tools], catIdx) => ( + + + + + {category} + + {tools.filter(t => selectedTools.includes(t.id)).length}/{tools.length} + + + + + + + + {tools.map((tool) => ( + + + + {tool.icon} + + {tool.name} + {tool.description} + + + + + ))} + + + + + + ))} + + + + + + + + + + + + + {/* 统计信息 */} + + + + + + + + 对话数 + + {sessions.length} + + + + + + + + + + + + + + 消息数 + + {messages.length} + + + + + + + + + + + + + + 已选工具 + + {selectedTools.length} + + + + + + + + + + + + + + + )} + + + + ); +}; + +export default AgentChat; diff --git a/src/views/AgentChat/index.js.bak3 b/src/views/AgentChat/index.js.bak3 new file mode 100644 index 00000000..324d7229 --- /dev/null +++ b/src/views/AgentChat/index.js.bak3 @@ -0,0 +1,816 @@ +// src/views/AgentChat/index.js +// 超炫酷的 AI 投研助手 - HeroUI v3 现代深色主题版本 +// 使用 Framer Motion 物理动画引擎 + 毛玻璃效果 + +import React, { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Box, + Button, + Input, + Avatar, + Badge, + Divider, + Spinner, + Tooltip, + Checkbox, + CheckboxGroup, + Kbd, + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Tabs, + TabList, + TabPanels, + Tab, + TabPanel, + useToast, + VStack, + HStack, + Text, + Flex, + IconButton, + useColorMode, + Card, + CardBody, + Tag, + TagLabel, + TagCloseButton, +} from '@chakra-ui/react'; +import { useAuth } from '@contexts/AuthContext'; +import { logger } from '@utils/logger'; +import axios from 'axios'; + +// 图标 - 使用 Lucide Icons +import { + Send, + Plus, + Search, + MessageSquare, + Trash2, + MoreVertical, + RefreshCw, + Download, + Cpu, + User, + Zap, + Clock, + Settings, + ChevronLeft, + ChevronRight, + Activity, + Code, + Database, + TrendingUp, + FileText, + BookOpen, + Menu, + X, + Check, + Circle, + Maximize2, + Minimize2, + Copy, + ThumbsUp, + ThumbsDown, + Sparkles, + Brain, + Rocket, + Paperclip, + Image as ImageIcon, + File, + Calendar, + Globe, + DollarSign, + Newspaper, + BarChart3, + PieChart, + LineChart, + Briefcase, + Users, +} from 'lucide-react'; + +// 常量配置 - 从 TypeScript 模块导入 +import { MessageTypes } from './constants/messageTypes'; +import { DEFAULT_MODEL_ID } from './constants/models'; +import { DEFAULT_SELECTED_TOOLS } from './constants/tools'; + +// 拆分后的子组件 +import BackgroundEffects from './components/BackgroundEffects'; +import LeftSidebar from './components/LeftSidebar'; +import ChatArea from './components/ChatArea'; +import RightSidebar from './components/RightSidebar'; + +/** + * Agent Chat - 主组件(HeroUI v3 深色主题) + * + * 注意:所有常量配置已提取到 constants/ 目录: + * - animations: constants/animations.ts + * - MessageTypes: constants/messageTypes.ts + * - AVAILABLE_MODELS: constants/models.ts + * - MCP_TOOLS, TOOL_CATEGORIES: constants/tools.ts + * - quickQuestions: constants/quickQuestions.ts + */ +const AgentChat = () => { + const { user } = useAuth(); + const toast = useToast(); + const { setColorMode } = useColorMode(); + + // 会话管理 + const [sessions, setSessions] = useState([]); + const [currentSessionId, setCurrentSessionId] = useState(null); + const [isLoadingSessions, setIsLoadingSessions] = useState(false); + + // 消息管理 + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + + // UI 状态 + const [searchQuery, setSearchQuery] = useState(''); + const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_ID); + const [selectedTools, setSelectedTools] = useState(DEFAULT_SELECTED_TOOLS); + const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true); + const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(true); + + // 文件上传 + const [uploadedFiles, setUploadedFiles] = useState([]); + const fileInputRef = useRef(null); + + // Refs + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + // ==================== 启用深色模式 ==================== + useEffect(() => { + // 为 AgentChat 页面强制启用深色模式 + setColorMode('dark'); + document.documentElement.classList.add('dark'); + + return () => { + // 组件卸载时不移除,让其他页面自己控制 + // document.documentElement.classList.remove('dark'); + }; + }, [setColorMode]); + + // ==================== API 调用函数 ==================== + + const loadSessions = async () => { + if (!user?.id) return; + setIsLoadingSessions(true); + try { + const response = await axios.get('/mcp/agent/sessions', { + params: { user_id: user.id, limit: 50 }, + }); + if (response.data.success) { + setSessions(response.data.data); + } + } catch (error) { + logger.error('加载会话列表失败', error); + } finally { + setIsLoadingSessions(false); + } + }; + + const loadSessionHistory = async (sessionId) => { + if (!sessionId) return; + try { + const response = await axios.get(`/mcp/agent/history/${sessionId}`, { + params: { limit: 100 }, + }); + if (response.data.success) { + const history = response.data.data; + const formattedMessages = history.map((msg, idx) => ({ + id: `${sessionId}-${idx}`, + type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE, + content: msg.message, + plan: msg.plan ? JSON.parse(msg.plan) : null, + stepResults: msg.steps ? JSON.parse(msg.steps) : null, + timestamp: msg.timestamp, + })); + setMessages(formattedMessages); + } + } catch (error) { + logger.error('加载会话历史失败', error); + } + }; + + const createNewSession = () => { + setCurrentSessionId(null); + setMessages([ + { + id: Date.now(), + type: MessageTypes.AGENT_RESPONSE, + content: `你好${user?.nickname || ''}!👋\n\n我是**价小前**,你的 AI 投研助手。\n\n**我能做什么?**\n• 📊 全面分析股票基本面和技术面\n• 🔥 追踪市场热点和涨停板块\n• 📈 研究行业趋势和投资机会\n• 📰 汇总最新财经新闻和研报\n\n直接输入你的问题开始探索!`, + timestamp: new Date().toISOString(), + }, + ]); + }; + + const switchSession = (sessionId) => { + setCurrentSessionId(sessionId); + loadSessionHistory(sessionId); + }; + + const handleSendMessage = async () => { + if (!inputValue.trim() || isProcessing) return; + + const userMessage = { + type: MessageTypes.USER, + content: inputValue, + timestamp: new Date().toISOString(), + files: uploadedFiles.length > 0 ? uploadedFiles : undefined, + }; + + addMessage(userMessage); + const userInput = inputValue; + setInputValue(''); + setUploadedFiles([]); + setIsProcessing(true); + + try { + addMessage({ + type: MessageTypes.AGENT_THINKING, + content: '正在分析你的问题...', + timestamp: new Date().toISOString(), + }); + + const response = await axios.post('/mcp/agent/chat', { + message: userInput, + conversation_history: messages + .filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE) + .map((m) => ({ + isUser: m.type === MessageTypes.USER, + content: m.content, + })), + user_id: user?.id || 'anonymous', + user_nickname: user?.nickname || '匿名用户', + user_avatar: user?.avatar || '', + subscription_type: user?.subscription_type || 'free', + session_id: currentSessionId, + model: selectedModel, + tools: selectedTools, + files: uploadedFiles.length > 0 ? uploadedFiles : undefined, + }); + + setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); + + if (response.data.success) { + const data = response.data; + if (data.session_id && !currentSessionId) { + setCurrentSessionId(data.session_id); + } + + if (data.plan) { + addMessage({ + type: MessageTypes.AGENT_PLAN, + content: '已制定执行计划', + plan: data.plan, + timestamp: new Date().toISOString(), + }); + } + + if (data.steps && data.steps.length > 0) { + addMessage({ + type: MessageTypes.AGENT_EXECUTING, + content: '正在执行步骤...', + plan: data.plan, + stepResults: data.steps, + timestamp: new Date().toISOString(), + }); + } + + setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING)); + + addMessage({ + type: MessageTypes.AGENT_RESPONSE, + content: data.final_answer || data.message || '处理完成', + plan: data.plan, + stepResults: data.steps, + metadata: data.metadata, + timestamp: new Date().toISOString(), + }); + + loadSessions(); + } + } catch (error) { + logger.error('Agent chat error', error); + setMessages((prev) => + prev.filter( + (m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING + ) + ); + + const errorMessage = error.response?.data?.error || error.message || '处理失败'; + addMessage({ + type: MessageTypes.ERROR, + content: `处理失败:${errorMessage}`, + timestamp: new Date().toISOString(), + }); + + toast({ + title: '处理失败', + description: errorMessage, + status: 'error', + duration: 5000, + }); + } finally { + setIsProcessing(false); + } + }; + + // 文件上传处理 + const handleFileSelect = (event) => { + const files = Array.from(event.target.files || []); + const fileData = files.map(file => ({ + name: file.name, + size: file.size, + type: file.type, + // 实际上传时需要转换为 base64 或上传到服务器 + url: URL.createObjectURL(file), + })); + setUploadedFiles(prev => [...prev, ...fileData]); + }; + + const removeFile = (index) => { + setUploadedFiles(prev => prev.filter((_, i) => i !== index)); + }; + + const addMessage = (message) => { + setMessages((prev) => [ + ...prev, + { + id: Date.now() + Math.random(), + ...message, + }, + ]); + }; + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + useEffect(() => { + loadSessions(); + createNewSession(); + }, [user]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + return ( + + + {/* 背景渐变装饰 */} + + + {/* 左侧栏 */} + setIsLeftSidebarOpen(false)} + sessions={sessions} + currentSessionId={currentSessionId} + onSessionSwitch={switchSession} + onNewSession={createNewSession} + isLoadingSessions={isLoadingSessions} + user={user} + /> + + {/* 中间聊天区 */} + setIsLeftSidebarOpen(true)} + onToggleRightSidebar={() => setIsRightSidebarOpen(true)} + onNewSession={createNewSession} + userAvatar={user?.avatar} + messagesEndRef={messagesEndRef} + inputRef={inputRef} + fileInputRef={fileInputRef} + /> + + {/* 右侧栏 - 深色配置中心 */} + + {isRightSidebarOpen && ( + + + + + + + + 配置中心 + + + + + } + onClick={() => setIsRightSidebarOpen(false)} + bg="rgba(255, 255, 255, 0.05)" + color="gray.300" + backdropFilter="blur(10px)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + _hover={{ + bg: "rgba(255, 255, 255, 0.1)", + borderColor: "purple.400", + color: "white" + }} + /> + + + + + + + + + + + + 模型 + + + + + + 工具 + {selectedTools.length > 0 && ( + + {selectedTools.length} + + )} + + + + + + 统计 + + + + + + {/* 模型选择 */} + + + {AVAILABLE_MODELS.map((model, idx) => ( + + setSelectedModel(model.id)} + bg={selectedModel === model.id + ? 'rgba(139, 92, 246, 0.15)' + : 'rgba(255, 255, 255, 0.05)'} + backdropFilter="blur(12px)" + borderWidth={2} + borderColor={selectedModel === model.id + ? 'purple.400' + : 'rgba(255, 255, 255, 0.1)'} + _hover={{ + borderColor: selectedModel === model.id + ? 'purple.400' + : 'rgba(255, 255, 255, 0.2)', + boxShadow: selectedModel === model.id + ? "0 8px 20px rgba(139, 92, 246, 0.4)" + : "0 4px 12px rgba(0, 0, 0, 0.3)" + }} + transition="all 0.3s" + > + + + + {model.icon} + + + + {model.name} + + + {model.description} + + + {selectedModel === model.id && ( + + + + )} + + + + + ))} + + + + {/* 工具选择 */} + + + {Object.entries(TOOL_CATEGORIES).map(([category, tools], catIdx) => ( + + + + + {category} + + {tools.filter(t => selectedTools.includes(t.id)).length}/{tools.length} + + + + + + + + {tools.map((tool) => ( + + + + {tool.icon} + + {tool.name} + {tool.description} + + + + + ))} + + + + + + ))} + + + + + + + + + + + + + {/* 统计信息 */} + + + + + + + + 对话数 + + {sessions.length} + + + + + + + + + + + + + + 消息数 + + {messages.length} + + + + + + + + + + + + + + 已选工具 + + {selectedTools.length} + + + + + + + + + + + + + + + )} + + + + ); +}; + +export default AgentChat; diff --git a/src/views/AgentChat/index_backup.js b/src/views/AgentChat/index_backup.js deleted file mode 100644 index 5bb0ebd5..00000000 --- a/src/views/AgentChat/index_backup.js +++ /dev/null @@ -1,53 +0,0 @@ -// src/views/AgentChat/index.js -// Agent聊天页面 - -import React from 'react'; -import { - Box, - Container, - Heading, - Text, - VStack, - useColorModeValue, -} from '@chakra-ui/react'; -import { ChatInterfaceV2 } from '../../components/ChatBot'; - -/** - * Agent聊天页面 - * 提供基于MCP的AI助手对话功能 - */ -const AgentChat = () => { - const bgColor = useColorModeValue('gray.50', 'gray.900'); - const cardBg = useColorModeValue('white', 'gray.800'); - - return ( - - - - {/* 页面标题 */} - - AI投资助手 - - 基于MCP协议的智能投资顾问,支持股票查询、新闻搜索、概念分析等多种功能 - - - - {/* 聊天界面 */} - - - - - - - ); -}; - -export default AgentChat; diff --git a/src/views/AgentChat/index_v3.js b/src/views/AgentChat/index_v3.js deleted file mode 100644 index 29d7097c..00000000 --- a/src/views/AgentChat/index_v3.js +++ /dev/null @@ -1,857 +0,0 @@ -// src/views/AgentChat/index_v3.js -// Agent聊天页面 V3 - 带左侧会话列表和用户信息集成 - -import React, { useState, useEffect, useRef } from 'react'; -import { - Box, - Flex, - VStack, - HStack, - Text, - Input, - IconButton, - Button, - Avatar, - Heading, - Divider, - Spinner, - Badge, - useColorModeValue, - useToast, - Progress, - Fade, - Collapse, - useDisclosure, - InputGroup, - InputLeftElement, - Menu, - MenuButton, - MenuList, - MenuItem, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalCloseButton, - Tooltip, -} from '@chakra-ui/react'; -import { - FiSend, - FiSearch, - FiPlus, - FiMessageSquare, - FiTrash2, - FiMoreVertical, - FiRefreshCw, - FiDownload, - FiCpu, - FiUser, - FiZap, - FiClock, -} from 'react-icons/fi'; -import { useAuth } from '@contexts/AuthContext'; -import { PlanCard } from '@components/ChatBot/PlanCard'; -import { StepResultCard } from '@components/ChatBot/StepResultCard'; -import { logger } from '@utils/logger'; -import axios from 'axios'; - -/** - * Agent消息类型 - */ -const MessageTypes = { - USER: 'user', - AGENT_THINKING: 'agent_thinking', - AGENT_PLAN: 'agent_plan', - AGENT_EXECUTING: 'agent_executing', - AGENT_RESPONSE: 'agent_response', - ERROR: 'error', -}; - -/** - * Agent聊天页面 V3 - */ -const AgentChatV3 = () => { - const { user } = useAuth(); // 获取当前用户信息 - const toast = useToast(); - - // 会话相关状态 - const [sessions, setSessions] = useState([]); - const [currentSessionId, setCurrentSessionId] = useState(null); - const [isLoadingSessions, setIsLoadingSessions] = useState(true); - - // 消息相关状态 - const [messages, setMessages] = useState([]); - const [inputValue, setInputValue] = useState(''); - const [isProcessing, setIsProcessing] = useState(false); - const [currentProgress, setCurrentProgress] = useState(0); - - // UI 状态 - const [searchQuery, setSearchQuery] = useState(''); - const { isOpen: isSidebarOpen, onToggle: toggleSidebar } = useDisclosure({ defaultIsOpen: true }); - - // Refs - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - - // 颜色主题 - const bgColor = useColorModeValue('gray.50', 'gray.900'); - const sidebarBg = useColorModeValue('white', 'gray.800'); - const chatBg = useColorModeValue('white', 'gray.800'); - const inputBg = useColorModeValue('white', 'gray.700'); - const borderColor = useColorModeValue('gray.200', 'gray.600'); - const hoverBg = useColorModeValue('gray.100', 'gray.700'); - const activeBg = useColorModeValue('blue.50', 'blue.900'); - const userBubbleBg = useColorModeValue('blue.500', 'blue.600'); - const agentBubbleBg = useColorModeValue('white', 'gray.700'); - - // ==================== 会话管理函数 ==================== - - // 加载会话列表 - const loadSessions = async () => { - if (!user?.id) return; - - setIsLoadingSessions(true); - try { - const response = await axios.get('/mcp/agent/sessions', { - params: { user_id: user.id, limit: 50 }, - }); - - if (response.data.success) { - setSessions(response.data.data); - logger.info('会话列表加载成功', response.data.data); - } - } catch (error) { - logger.error('加载会话列表失败', error); - toast({ - title: '加载失败', - description: '无法加载会话列表', - status: 'error', - duration: 3000, - }); - } finally { - setIsLoadingSessions(false); - } - }; - - // 加载会话历史 - const loadSessionHistory = async (sessionId) => { - if (!sessionId) return; - - try { - const response = await axios.get(`/mcp/agent/history/${sessionId}`, { - params: { limit: 100 }, - }); - - if (response.data.success) { - const history = response.data.data; - - // 将历史记录转换为消息格式 - const formattedMessages = history.map((msg, idx) => ({ - id: `${sessionId}-${idx}`, - type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE, - content: msg.message, - plan: msg.plan ? JSON.parse(msg.plan) : null, - stepResults: msg.steps ? JSON.parse(msg.steps) : null, - timestamp: msg.timestamp, - })); - - setMessages(formattedMessages); - logger.info('会话历史加载成功', formattedMessages); - } - } catch (error) { - logger.error('加载会话历史失败', error); - toast({ - title: '加载失败', - description: '无法加载会话历史', - status: 'error', - duration: 3000, - }); - } - }; - - // 创建新会话 - const createNewSession = () => { - setCurrentSessionId(null); - setMessages([ - { - id: Date.now(), - type: MessageTypes.AGENT_RESPONSE, - content: `你好${user?.nickname || ''}!我是价小前,北京价值前沿科技公司的AI投研助手。\n\n我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我:\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现`, - timestamp: new Date().toISOString(), - }, - ]); - }; - - // 切换会话 - const switchSession = (sessionId) => { - setCurrentSessionId(sessionId); - loadSessionHistory(sessionId); - }; - - // 删除会话(需要后端API支持) - const deleteSession = async (sessionId) => { - // TODO: 实现删除会话的后端API - toast({ - title: '删除会话', - description: '此功能尚未实现', - status: 'info', - duration: 2000, - }); - }; - - // ==================== 消息处理函数 ==================== - - // 自动滚动到底部 - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; - - useEffect(() => { - scrollToBottom(); - }, [messages]); - - // 添加消息 - const addMessage = (message) => { - setMessages((prev) => [...prev, { ...message, id: Date.now() }]); - }; - - // 发送消息 - const handleSendMessage = async () => { - if (!inputValue.trim() || isProcessing) return; - - // 权限检查 - if (user?.id !== 'max') { - toast({ - title: '权限不足', - description: '「价小前投研」功能目前仅对特定用户开放。如需使用,请联系管理员。', - status: 'warning', - duration: 5000, - isClosable: true, - }); - return; - } - - const userMessage = { - type: MessageTypes.USER, - content: inputValue, - timestamp: new Date().toISOString(), - }; - - addMessage(userMessage); - const userInput = inputValue; - setInputValue(''); - setIsProcessing(true); - setCurrentProgress(0); - - let currentPlan = null; - let stepResults = []; - - try { - // 1. 显示思考状态 - addMessage({ - type: MessageTypes.AGENT_THINKING, - content: '正在分析你的问题...', - timestamp: new Date().toISOString(), - }); - - setCurrentProgress(10); - - // 2. 调用后端API(非流式) - const response = await axios.post('/mcp/agent/chat', { - message: userInput, - conversation_history: messages - .filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE) - .map((m) => ({ - isUser: m.type === MessageTypes.USER, - content: m.content, - })), - user_id: user?.id || 'anonymous', - user_nickname: user?.nickname || '匿名用户', - user_avatar: user?.avatar || '', - session_id: currentSessionId, - }); - - // 移除思考消息 - setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); - - if (response.data.success) { - const data = response.data; - - // 更新 session_id(如果是新会话) - if (data.session_id && !currentSessionId) { - setCurrentSessionId(data.session_id); - } - - // 显示执行计划 - if (data.plan) { - currentPlan = data.plan; - addMessage({ - type: MessageTypes.AGENT_PLAN, - content: '已制定执行计划', - plan: data.plan, - timestamp: new Date().toISOString(), - }); - setCurrentProgress(30); - } - - // 显示执行步骤 - if (data.steps && data.steps.length > 0) { - stepResults = data.steps; - addMessage({ - type: MessageTypes.AGENT_EXECUTING, - content: '正在执行步骤...', - plan: currentPlan, - stepResults: stepResults, - timestamp: new Date().toISOString(), - }); - setCurrentProgress(70); - } - - // 移除执行中消息 - setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING)); - - // 显示最终结果 - addMessage({ - type: MessageTypes.AGENT_RESPONSE, - content: data.final_answer || data.message || '处理完成', - plan: currentPlan, - stepResults: stepResults, - metadata: data.metadata, - timestamp: new Date().toISOString(), - }); - - setCurrentProgress(100); - - // 重新加载会话列表 - loadSessions(); - } - } catch (error) { - logger.error('Agent chat error', error); - - // 移除思考/执行中消息 - setMessages((prev) => - prev.filter( - (m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING - ) - ); - - const errorMessage = error.response?.data?.error || error.message || '处理失败'; - - addMessage({ - type: MessageTypes.ERROR, - content: `处理失败:${errorMessage}`, - timestamp: new Date().toISOString(), - }); - - toast({ - title: '处理失败', - description: errorMessage, - status: 'error', - duration: 5000, - isClosable: true, - }); - } finally { - setIsProcessing(false); - setCurrentProgress(0); - inputRef.current?.focus(); - } - }; - - // 处理键盘事件 - const handleKeyPress = (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSendMessage(); - } - }; - - // 清空对话 - const handleClearChat = () => { - createNewSession(); - }; - - // 导出对话 - const handleExportChat = () => { - const chatText = messages - .filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE) - .map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : '价小前'}] ${msg.content}`) - .join('\n\n'); - - const blob = new Blob([chatText], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`; - a.click(); - URL.revokeObjectURL(url); - }; - - // ==================== 初始化 ==================== - - useEffect(() => { - if (user) { - loadSessions(); - createNewSession(); - } - }, [user]); - - // ==================== 渲染 ==================== - - // 快捷问题 - const quickQuestions = [ - '全面分析贵州茅台这只股票', - '今日涨停股票有哪些亮点', - '新能源概念板块的投资机会', - '半导体行业最新动态', - ]; - - // 筛选会话 - const filteredSessions = sessions.filter( - (session) => - !searchQuery || - session.last_message?.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - return ( - - {/* 左侧会话列表 */} - - - {/* 侧边栏头部 */} - - - - {/* 搜索框 */} - - - - - setSearchQuery(e.target.value)} - /> - - - - {/* 会话列表 */} - - {isLoadingSessions ? ( - - - - ) : filteredSessions.length === 0 ? ( - - - - {searchQuery ? '没有找到匹配的对话' : '暂无对话记录'} - - - ) : ( - filteredSessions.map((session) => ( - switchSession(session.session_id)} - > - - - - {session.last_message || '新对话'} - - - - - {new Date(session.last_timestamp).toLocaleDateString('zh-CN', { - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - })} - - - {session.message_count} 条 - - - - - - } - size="xs" - variant="ghost" - onClick={(e) => e.stopPropagation()} - /> - - } - color="red.500" - onClick={(e) => { - e.stopPropagation(); - deleteSession(session.session_id); - }} - > - 删除对话 - - - - - - )) - )} - - - {/* 用户信息 */} - - - - - - {user?.nickname || '未登录'} - - - {user?.id || 'anonymous'} - - - - - - - - {/* 主聊天区域 */} - - {/* 聊天头部 */} - - - - } - size="sm" - variant="ghost" - aria-label="切换侧边栏" - onClick={toggleSidebar} - /> - } /> - - 价小前投研 - - - - - 智能分析 - - - - 多步骤深度研究 - - - - - - - } - size="sm" - variant="ghost" - aria-label="清空对话" - onClick={handleClearChat} - /> - } - size="sm" - variant="ghost" - aria-label="导出对话" - onClick={handleExportChat} - /> - - - - {/* 进度条 */} - {isProcessing && ( - - )} - - - {/* 消息列表 */} - - - {messages.map((message) => ( - - - - ))} -
- - - - {/* 快捷问题 */} - {messages.length <= 2 && !isProcessing && ( - - - 💡 试试这些问题: - - - {quickQuestions.map((question, idx) => ( - - ))} - - - )} - - {/* 输入框 */} - - - setInputValue(e.target.value)} - onKeyPress={handleKeyPress} - placeholder="输入你的问题,我会进行深度分析..." - bg={inputBg} - border="1px" - borderColor={borderColor} - _focus={{ borderColor: 'blue.500', boxShadow: '0 0 0 1px #3182CE' }} - mr={2} - disabled={isProcessing} - size="lg" - /> - : } - colorScheme="blue" - aria-label="发送" - onClick={handleSendMessage} - isLoading={isProcessing} - disabled={!inputValue.trim() || isProcessing} - size="lg" - /> - - - - - ); -}; - -/** - * 消息渲染器 - */ -const MessageRenderer = ({ message, userAvatar }) => { - const userBubbleBg = useColorModeValue('blue.500', 'blue.600'); - const agentBubbleBg = useColorModeValue('white', 'gray.700'); - const borderColor = useColorModeValue('gray.200', 'gray.600'); - - switch (message.type) { - case MessageTypes.USER: - return ( - - - - - {message.content} - - - } /> - - - ); - - case MessageTypes.AGENT_THINKING: - return ( - - - } /> - - - - - {message.content} - - - - - - ); - - case MessageTypes.AGENT_PLAN: - return ( - - - } /> - - - - - - ); - - case MessageTypes.AGENT_EXECUTING: - return ( - - - } /> - - - {message.stepResults?.map((result, idx) => ( - - ))} - - - - ); - - case MessageTypes.AGENT_RESPONSE: - return ( - - - } /> - - {/* 最终总结 */} - - - {message.content} - - - {/* 元数据 */} - {message.metadata && ( - - 总步骤: {message.metadata.total_steps} - ✓ {message.metadata.successful_steps} - {message.metadata.failed_steps > 0 && ( - ✗ {message.metadata.failed_steps} - )} - 耗时: {message.metadata.total_execution_time?.toFixed(1)}s - - )} - - - {/* 执行详情(可选) */} - {message.plan && message.stepResults && message.stepResults.length > 0 && ( - - - - 📊 执行详情(点击展开查看) - - {message.stepResults.map((result, idx) => ( - - ))} - - )} - - - - ); - - case MessageTypes.ERROR: - return ( - - - } /> - - {message.content} - - - - ); - - default: - return null; - } -}; - -export default AgentChatV3; diff --git a/src/views/AgentChat/index_v3_backup.js b/src/views/AgentChat/index_v3_backup.js deleted file mode 100644 index 40812bd7..00000000 --- a/src/views/AgentChat/index_v3_backup.js +++ /dev/null @@ -1,1039 +0,0 @@ -// src/views/AgentChat/index.js -// Agent聊天页面 - 黑金毛玻璃设计,V4版本(带模型选择和工具选择) -// 导出 V4 版本作为默认组件 - -import React, { useState, useEffect, useRef } from 'react'; -import { - Box, - Flex, - VStack, - HStack, - Text, - Input, - IconButton, - Button, - Avatar, - Heading, - Divider, - Spinner, - Badge, - useColorModeValue, - useToast, - Progress, - Fade, - Collapse, - useDisclosure, - InputGroup, - InputLeftElement, - Menu, - MenuButton, - MenuList, - MenuItem, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalCloseButton, - Tooltip, -} from '@chakra-ui/react'; -import { - FiSend, - FiSearch, - FiPlus, - FiMessageSquare, - FiTrash2, - FiMoreVertical, - FiRefreshCw, - FiDownload, - FiCpu, - FiUser, - FiZap, - FiClock, -} from 'react-icons/fi'; -import { useAuth } from '@contexts/AuthContext'; -import { PlanCard } from '@components/ChatBot/PlanCard'; -import { StepResultCard } from '@components/ChatBot/StepResultCard'; -import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts'; -import { logger } from '@utils/logger'; -import axios from 'axios'; - -/** - * Agent消息类型 - */ -const MessageTypes = { - USER: 'user', - AGENT_THINKING: 'agent_thinking', - AGENT_PLAN: 'agent_plan', - AGENT_EXECUTING: 'agent_executing', - AGENT_RESPONSE: 'agent_response', - ERROR: 'error', -}; - -/** - * Agent聊天页面 V3 - */ -const AgentChatV3 = () => { - const { user } = useAuth(); // 获取当前用户信息 - const toast = useToast(); - - // 会话相关状态 - const [sessions, setSessions] = useState([]); - const [currentSessionId, setCurrentSessionId] = useState(null); - const [isLoadingSessions, setIsLoadingSessions] = useState(true); - - // 消息相关状态 - const [messages, setMessages] = useState([]); - const [inputValue, setInputValue] = useState(''); - const [isProcessing, setIsProcessing] = useState(false); - const [currentProgress, setCurrentProgress] = useState(0); - - // UI 状态 - const [searchQuery, setSearchQuery] = useState(''); - const { isOpen: isSidebarOpen, onToggle: toggleSidebar } = useDisclosure({ defaultIsOpen: true }); - - // Refs - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - - // 颜色主题 - const bgColor = useColorModeValue('gray.50', 'gray.900'); - const sidebarBg = useColorModeValue('white', 'gray.800'); - const chatBg = useColorModeValue('white', 'gray.800'); - const inputBg = useColorModeValue('white', 'gray.700'); - const borderColor = useColorModeValue('gray.200', 'gray.600'); - const hoverBg = useColorModeValue('gray.100', 'gray.700'); - const activeBg = useColorModeValue('blue.50', 'blue.900'); - const userBubbleBg = useColorModeValue('blue.500', 'blue.600'); - const agentBubbleBg = useColorModeValue('white', 'gray.700'); - - // ==================== 会话管理函数 ==================== - - // 加载会话列表 - const loadSessions = async () => { - if (!user?.id) return; - - setIsLoadingSessions(true); - try { - const response = await axios.get('/mcp/agent/sessions', { - params: { user_id: String(user.id), limit: 50 }, - }); - - if (response.data.success) { - setSessions(response.data.data); - logger.info('会话列表加载成功', response.data.data); - } - } catch (error) { - logger.error('加载会话列表失败', error); - toast({ - title: '加载失败', - description: '无法加载会话列表', - status: 'error', - duration: 3000, - }); - } finally { - setIsLoadingSessions(false); - } - }; - - // 加载会话历史 - const loadSessionHistory = async (sessionId) => { - if (!sessionId) return; - - try { - const response = await axios.get(`/mcp/agent/history/${sessionId}`, { - params: { limit: 100 }, - }); - - if (response.data.success) { - const history = response.data.data; - - // 将历史记录转换为消息格式 - const formattedMessages = history.map((msg, idx) => ({ - id: `${sessionId}-${idx}`, - type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE, - content: msg.message, - plan: msg.plan ? JSON.parse(msg.plan) : null, - stepResults: msg.steps ? JSON.parse(msg.steps) : null, - timestamp: msg.timestamp, - })); - - setMessages(formattedMessages); - logger.info('会话历史加载成功', formattedMessages); - } - } catch (error) { - logger.error('加载会话历史失败', error); - toast({ - title: '加载失败', - description: '无法加载会话历史', - status: 'error', - duration: 3000, - }); - } - }; - - // 创建新会话 - const createNewSession = () => { - setCurrentSessionId(null); - setMessages([ - { - id: Date.now(), - type: MessageTypes.AGENT_RESPONSE, - content: `你好${user?.nickname || ''}!我是价小前,北京价值前沿科技公司的AI投研助手。\n\n我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我:\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现`, - timestamp: new Date().toISOString(), - }, - ]); - }; - - // 切换会话 - const switchSession = (sessionId) => { - setCurrentSessionId(sessionId); - loadSessionHistory(sessionId); - }; - - // 删除会话(需要后端API支持) - const deleteSession = async (sessionId) => { - // TODO: 实现删除会话的后端API - toast({ - title: '删除会话', - description: '此功能尚未实现', - status: 'info', - duration: 2000, - }); - }; - - // ==================== 消息处理函数 ==================== - - // 自动滚动到底部 - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; - - useEffect(() => { - scrollToBottom(); - }, [messages]); - - // 添加消息 - const addMessage = (message) => { - setMessages((prev) => [...prev, { ...message, id: Date.now() }]); - }; - - // 发送消息(流式 SSE 版本) - const handleSendMessage = async () => { - if (!inputValue.trim() || isProcessing) return; - - // 权限检查 - 只允许 max 用户访问(与传导链分析权限保持一致) - const hasAccess = user?.subscription_type === 'max'; - - if (!hasAccess) { - logger.warn('AgentChat', '权限检查失败', { - userId: user?.id, - username: user?.username, - subscription_type: user?.subscription_type, - userObject: user - }); - - toast({ - title: '订阅升级', - description: '「价小前投研」功能需要 Max 订阅。请前往设置页面升级您的订阅。', - status: 'warning', - duration: 5000, - isClosable: true, - }); - return; - } - - logger.info('AgentChat', '权限检查通过', { - userId: user?.id, - username: user?.username, - subscription_type: user?.subscription_type - }); - - const userMessage = { - type: MessageTypes.USER, - content: inputValue, - timestamp: new Date().toISOString(), - }; - - addMessage(userMessage); - const userInput = inputValue; - setInputValue(''); - setIsProcessing(true); - setCurrentProgress(0); - - let currentPlan = null; - let stepResults = []; - let executingMessageId = null; - - try { - // 1. 显示思考状态 - addMessage({ - type: MessageTypes.AGENT_THINKING, - content: '正在分析你的问题...', - timestamp: new Date().toISOString(), - }); - - setCurrentProgress(10); - - // 2. 使用 EventSource 接收 SSE 流式数据 - const requestBody = { - message: userInput, - conversation_history: messages - .filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE) - .map((m) => ({ - isUser: m.type === MessageTypes.USER, - content: m.content, - })), - user_id: user?.id ? String(user.id) : 'anonymous', - user_nickname: user?.nickname || user?.username || '匿名用户', - user_avatar: user?.avatar || '', - subscription_type: user?.subscription_type || 'free', - session_id: currentSessionId, - }; - - // 使用 fetch API 进行 SSE 请求 - const response = await fetch('/mcp/agent/chat/stream', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - // 流式状态变量 - let thinkingMessageId = null; - let thinkingContent = ''; - let summaryMessageId = null; - let summaryContent = ''; - - // 读取流式数据 - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop(); // 保留不完整的行 - - let currentEvent = null; - - for (const line of lines) { - if (!line.trim() || line.startsWith(':')) continue; - - if (line.startsWith('event:')) { - // 提取事件类型 - currentEvent = line.substring(6).trim(); - continue; - } - - if (line.startsWith('data:')) { - try { - const data = JSON.parse(line.substring(5).trim()); - - // 根据事件类型处理数据 - if (currentEvent === 'thinking') { - // Kimi 流式思考过程 - if (!thinkingMessageId) { - thinkingMessageId = Date.now(); - thinkingContent = ''; - setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); - addMessage({ - id: thinkingMessageId, - type: MessageTypes.AGENT_THINKING, - content: '', - timestamp: new Date().toISOString(), - }); - } - thinkingContent += data.content; - // 实时更新思考内容 - setMessages((prev) => - prev.map((m) => - m.id === thinkingMessageId - ? { ...m, content: thinkingContent } - : m - ) - ); - } else if (currentEvent === 'reasoning') { - // Kimi 推理过程(可选显示) - logger.info('Kimi reasoning:', data.content); - } else if (currentEvent === 'plan') { - // 收到执行计划 - currentPlan = data; - thinkingMessageId = null; - thinkingContent = ''; - setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); - addMessage({ - type: MessageTypes.AGENT_PLAN, - content: '已制定执行计划', - plan: data, - timestamp: new Date().toISOString(), - }); - setCurrentProgress(30); - } else if (currentEvent === 'step_complete') { - // 收到步骤完成事件 - const stepResult = { - step_index: data.step_index, - tool: data.tool, - status: data.status, - result: data.result, - error: data.error, - execution_time: data.execution_time, - }; - stepResults.push(stepResult); - - // 更新执行中的消息 - setMessages((prev) => - prev.map((m) => - m.id === executingMessageId - ? { ...m, stepResults: [...stepResults] } - : m - ) - ); - - // 更新进度 - const progress = 40 + (stepResults.length / (currentPlan?.steps?.length || 5)) * 40; - setCurrentProgress(Math.min(progress, 80)); - } else if (currentEvent === 'summary_chunk') { - // 流式总结内容 - if (!summaryMessageId) { - summaryMessageId = Date.now(); - summaryContent = ''; - setMessages((prev) => - prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING) - ); - addMessage({ - id: summaryMessageId, - type: MessageTypes.AGENT_RESPONSE, - content: '', - plan: currentPlan, - stepResults: stepResults, - isStreaming: true, // 标记为流式输出中 - timestamp: new Date().toISOString(), - }); - setCurrentProgress(85); - } - summaryContent += data.content; - // 实时更新总结内容 - setMessages((prev) => - prev.map((m) => - m.id === summaryMessageId - ? { ...m, content: summaryContent } - : m - ) - ); - } else if (currentEvent === 'summary') { - // 收到完整总结(包含元数据) - if (summaryMessageId) { - // 更新已有消息的元数据和内容,并标记流式输出完成 - setMessages((prev) => - prev.map((m) => - m.id === summaryMessageId - ? { - ...m, - content: data.content || summaryContent, // ✅ 使用后端返回的完整内容,如果没有则使用累积内容 - metadata: data.metadata, - isStreaming: false, // ✅ 标记流式输出完成 - } - : m - ) - ); - } else { - // 如果没有流式片段,直接显示完整总结 - setMessages((prev) => - prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING) - ); - addMessage({ - type: MessageTypes.AGENT_RESPONSE, - content: data.content, - plan: currentPlan, - stepResults: stepResults, - metadata: data.metadata, - isStreaming: false, // 非流式,直接标记完成 - timestamp: new Date().toISOString(), - }); - } - setCurrentProgress(100); - } else if (currentEvent === 'status') { - // 状态更新 - if (data.stage === 'planning') { - setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); - addMessage({ - type: MessageTypes.AGENT_THINKING, - content: data.message, - timestamp: new Date().toISOString(), - }); - setCurrentProgress(10); - } else if (data.stage === 'executing') { - const msgId = Date.now(); - executingMessageId = msgId; - addMessage({ - id: msgId, - type: MessageTypes.AGENT_EXECUTING, - content: data.message, - plan: currentPlan, - stepResults: [], - timestamp: new Date().toISOString(), - }); - setCurrentProgress(40); - } else if (data.stage === 'summarizing') { - setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING)); - addMessage({ - type: MessageTypes.AGENT_THINKING, - content: data.message, - timestamp: new Date().toISOString(), - }); - setCurrentProgress(80); - } - } - } catch (e) { - logger.error('解析 SSE 数据失败', e); - } - } - } - } - - // 重新加载会话列表 - loadSessions(); - - } catch (error) { - logger.error('Agent chat error', error); - - // 移除思考/执行中消息 - setMessages((prev) => - prev.filter( - (m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING - ) - ); - - const errorMessage = error.response?.data?.detail || error.message || '处理失败'; - - addMessage({ - type: MessageTypes.ERROR, - content: `处理失败:${errorMessage}`, - timestamp: new Date().toISOString(), - }); - - toast({ - title: '处理失败', - description: errorMessage, - status: 'error', - duration: 5000, - isClosable: true, - }); - } finally { - setIsProcessing(false); - setCurrentProgress(0); - inputRef.current?.focus(); - } - }; - - // 处理键盘事件 - const handleKeyPress = (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSendMessage(); - } - }; - - // 清空对话 - const handleClearChat = () => { - createNewSession(); - }; - - // 导出对话 - const handleExportChat = () => { - const chatText = messages - .filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE) - .map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : '价小前'}] ${msg.content}`) - .join('\n\n'); - - const blob = new Blob([chatText], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`; - a.click(); - URL.revokeObjectURL(url); - }; - - // ==================== 初始化 ==================== - - useEffect(() => { - if (user) { - loadSessions(); - createNewSession(); - } - }, [user]); - - // ==================== 渲染 ==================== - - // 快捷问题 - const quickQuestions = [ - '全面分析贵州茅台这只股票', - '今日涨停股票有哪些亮点', - '新能源概念板块的投资机会', - '半导体行业最新动态', - ]; - - // 筛选会话 - const filteredSessions = sessions.filter( - (session) => - !searchQuery || - session.last_message?.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - return ( - - {/* 左侧会话列表 */} - - - {/* 侧边栏头部 */} - - - - {/* 搜索框 */} - - - - - setSearchQuery(e.target.value)} - /> - - - - {/* 会话列表 */} - - {isLoadingSessions ? ( - - - - ) : filteredSessions.length === 0 ? ( - - - - {searchQuery ? '没有找到匹配的对话' : '暂无对话记录'} - - - ) : ( - filteredSessions.map((session) => ( - switchSession(session.session_id)} - > - - - - {session.last_message || '新对话'} - - - - - {new Date(session.last_timestamp).toLocaleDateString('zh-CN', { - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - })} - - - {session.message_count} 条 - - - - - - } - size="xs" - variant="ghost" - onClick={(e) => e.stopPropagation()} - /> - - } - color="red.500" - onClick={(e) => { - e.stopPropagation(); - deleteSession(session.session_id); - }} - > - 删除对话 - - - - - - )) - )} - - - {/* 用户信息 */} - - - - - - {user?.nickname || '未登录'} - - - {user?.id || 'anonymous'} - - - - - - - - {/* 主聊天区域 */} - - {/* 聊天头部 */} - - - - } - size="sm" - variant="ghost" - aria-label="切换侧边栏" - onClick={toggleSidebar} - /> - } /> - - 价小前投研 - - - - - 智能分析 - - - - 多步骤深度研究 - - - - - - - } - size="sm" - variant="ghost" - aria-label="清空对话" - onClick={handleClearChat} - /> - } - size="sm" - variant="ghost" - aria-label="导出对话" - onClick={handleExportChat} - /> - - - - {/* 进度条 */} - {isProcessing && ( - - )} - - - {/* 消息列表 */} - - - {messages.map((message) => ( - - - - ))} -
- - - - {/* 快捷问题 */} - {messages.length <= 2 && !isProcessing && ( - - - 💡 试试这些问题: - - - {quickQuestions.map((question, idx) => ( - - ))} - - - )} - - {/* 输入框 */} - - - setInputValue(e.target.value)} - onKeyPress={handleKeyPress} - placeholder="输入你的问题,我会进行深度分析..." - bg={inputBg} - border="1px" - borderColor={borderColor} - _focus={{ borderColor: 'blue.500', boxShadow: '0 0 0 1px #3182CE' }} - mr={2} - disabled={isProcessing} - size="lg" - /> - : } - colorScheme="blue" - aria-label="发送" - onClick={handleSendMessage} - isLoading={isProcessing} - disabled={!inputValue.trim() || isProcessing} - size="lg" - /> - - - - - ); -}; - -/** - * 消息渲染器 - */ -const MessageRenderer = ({ message, userAvatar }) => { - const userBubbleBg = useColorModeValue('blue.500', 'blue.600'); - const agentBubbleBg = useColorModeValue('white', 'gray.700'); - const borderColor = useColorModeValue('gray.200', 'gray.600'); - - switch (message.type) { - case MessageTypes.USER: - return ( - - - - - {message.content} - - - } /> - - - ); - - case MessageTypes.AGENT_THINKING: - return ( - - - } /> - - - - - {message.content} - - - - - - ); - - case MessageTypes.AGENT_PLAN: - return ( - - - } /> - - - - - - ); - - case MessageTypes.AGENT_EXECUTING: - return ( - - - } /> - - - {message.stepResults?.map((result, idx) => ( - - ))} - - - - ); - - case MessageTypes.AGENT_RESPONSE: - return ( - - - } /> - - {/* 最终总结(支持 Markdown + ECharts) */} - - {/* 流式输出中显示纯文本,完成后才渲染 Markdown + 图表 */} - {message.isStreaming ? ( - - {message.content} - - ) : ( - - )} - - {/* 元数据 */} - {message.metadata && ( - - 总步骤: {message.metadata.total_steps} - ✓ {message.metadata.successful_steps} - {message.metadata.failed_steps > 0 && ( - ✗ {message.metadata.failed_steps} - )} - 耗时: {message.metadata.total_execution_time?.toFixed(1)}s - - )} - - - {/* 执行详情(可选) */} - {message.plan && message.stepResults && message.stepResults.length > 0 && ( - - - - 📊 执行详情(点击展开查看) - - {message.stepResults.map((result, idx) => ( - - ))} - - )} - - - - ); - - case MessageTypes.ERROR: - return ( - - - } /> - - {message.content} - - - - ); - - default: - return null; - } -}; - -export default AgentChatV3; diff --git a/src/views/AgentChat/index_v4.js b/src/views/AgentChat/index_v4.js deleted file mode 100644 index d2ff7e2d..00000000 --- a/src/views/AgentChat/index_v4.js +++ /dev/null @@ -1,1519 +0,0 @@ -// src/views/AgentChat/index_v4.js -// Agent聊天页面 V4 - 黑金毛玻璃设计,带模型选择和工具选择 - -import React, { useState, useEffect, useRef } from 'react'; -import { - Box, - Flex, - VStack, - HStack, - Text, - Input, - IconButton, - Button, - Avatar, - Heading, - Divider, - Spinner, - Badge, - useToast, - Progress, - Fade, - Collapse, - InputGroup, - InputLeftElement, - Menu, - MenuButton, - MenuList, - MenuItem, - Tooltip, - Select, - Checkbox, - CheckboxGroup, - Stack, - Accordion, - AccordionItem, - AccordionButton, - AccordionPanel, - AccordionIcon, - useDisclosure, -} from '@chakra-ui/react'; -import { - FiSend, - FiSearch, - FiPlus, - FiMessageSquare, - FiTrash2, - FiMoreVertical, - FiRefreshCw, - FiDownload, - FiCpu, - FiUser, - FiZap, - FiClock, - FiSettings, - FiCheckCircle, - FiChevronRight, - FiTool, -} from 'react-icons/fi'; -import { useAuth } from '@contexts/AuthContext'; -import { PlanCard } from '@components/ChatBot/PlanCard'; -import { StepResultCard } from '@components/ChatBot/StepResultCard'; -import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts'; -import { logger } from '@utils/logger'; -import axios from 'axios'; - -/** - * Agent消息类型 - */ -const MessageTypes = { - USER: 'user', - AGENT_THINKING: 'agent_thinking', - AGENT_PLAN: 'agent_plan', - AGENT_EXECUTING: 'agent_executing', - AGENT_RESPONSE: 'agent_response', - ERROR: 'error', -}; - -/** - * 可用模型配置 - */ -const AVAILABLE_MODELS = [ - { - id: 'kimi-k2', - name: 'Kimi K2', - description: '快速响应,适合日常对话', - icon: '🚀', - provider: 'Moonshot', - }, - { - id: 'kimi-k2-thinking', - name: 'Kimi K2 Thinking', - description: '深度思考,提供详细推理过程', - icon: '🧠', - provider: 'Moonshot', - recommended: true, - }, - { - id: 'glm-4.6', - name: 'GLM 4.6', - description: '智谱AI最新模型,性能强大', - icon: '⚡', - provider: 'ZhipuAI', - }, - { - id: 'deepmoney', - name: 'DeepMoney', - description: '金融专业模型,擅长财经分析', - icon: '💰', - provider: 'Custom', - }, - { - id: 'gemini-3', - name: 'Gemini 3', - description: 'Google最新多模态模型', - icon: '✨', - provider: 'Google', - }, -]; - -/** - * MCP工具分类配置 - */ -const MCP_TOOL_CATEGORIES = [ - { - name: '新闻搜索', - icon: '📰', - tools: [ - { id: 'search_news', name: '全球新闻搜索', description: '搜索国际新闻、行业动态' }, - { id: 'search_china_news', name: '中国新闻搜索', description: 'KNN语义搜索中国新闻' }, - { id: 'search_medical_news', name: '医疗新闻搜索', description: '医药、医疗设备、生物技术' }, - ], - }, - { - name: '股票分析', - icon: '📈', - tools: [ - { id: 'search_limit_up_stocks', name: '涨停股票搜索', description: '搜索涨停股票及原因分析' }, - { id: 'get_stock_analysis', name: '个股分析', description: '获取股票深度分析报告' }, - { id: 'get_stock_concepts', name: '股票概念查询', description: '查询股票相关概念板块' }, - ], - }, - { - name: '概念板块', - icon: '🏢', - tools: [ - { id: 'search_concepts', name: '概念搜索', description: '搜索股票概念板块' }, - { id: 'get_concept_details', name: '概念详情', description: '获取概念板块详细信息' }, - { id: 'get_concept_statistics', name: '概念统计', description: '涨幅榜、活跃榜、连涨榜' }, - ], - }, - { - name: '公司信息', - icon: '🏭', - tools: [ - { id: 'search_roadshows', name: '路演搜索', description: '搜索上市公司路演活动' }, - { id: 'get_company_info', name: '公司信息', description: '获取公司基本面信息' }, - ], - }, - { - name: '数据分析', - icon: '📊', - tools: [ - { id: 'query_database', name: '数据库查询', description: 'SQL查询金融数据' }, - { id: 'get_market_overview', name: '市场概况', description: '获取市场整体行情' }, - ], - }, -]; - -/** - * Agent聊天页面 V4 - 黑金毛玻璃设计 - */ -const AgentChatV4 = () => { - const { user } = useAuth(); - const toast = useToast(); - - // 会话相关状态 - const [sessions, setSessions] = useState([]); - const [currentSessionId, setCurrentSessionId] = useState(null); - const [isLoadingSessions, setIsLoadingSessions] = useState(true); - - // 消息相关状态 - const [messages, setMessages] = useState([]); - const [inputValue, setInputValue] = useState(''); - const [isProcessing, setIsProcessing] = useState(false); - const [currentProgress, setCurrentProgress] = useState(0); - - // 模型和工具配置状态 - const [selectedModel, setSelectedModel] = useState('kimi-k2-thinking'); - const [selectedTools, setSelectedTools] = useState(() => { - // 默认全选所有工具 - const allToolIds = MCP_TOOL_CATEGORIES.flatMap(cat => cat.tools.map(t => t.id)); - return allToolIds; - }); - - // UI 状态 - const [searchQuery, setSearchQuery] = useState(''); - const { isOpen: isSidebarOpen, onToggle: toggleSidebar } = useDisclosure({ defaultIsOpen: true }); - const { isOpen: isRightPanelOpen, onToggle: toggleRightPanel } = useDisclosure({ defaultIsOpen: true }); - - // Refs - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - - // 毛玻璃黑金配色主题 - const glassBg = 'rgba(20, 20, 20, 0.7)'; - const glassHoverBg = 'rgba(30, 30, 30, 0.8)'; - const goldAccent = '#FFD700'; - const goldGradient = 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)'; - const darkBg = '#0A0A0A'; - const borderGold = 'rgba(255, 215, 0, 0.3)'; - const textGold = '#FFD700'; - const textWhite = '#FFFFFF'; - const textGray = '#CCCCCC'; - - // ==================== 会话管理函数 ==================== - - const loadSessions = async () => { - if (!user?.id) return; - - setIsLoadingSessions(true); - try { - const response = await axios.get('/mcp/agent/sessions', { - params: { user_id: String(user.id), limit: 50 }, - }); - - if (response.data.success) { - setSessions(response.data.data); - logger.info('会话列表加载成功', response.data.data); - } - } catch (error) { - logger.error('加载会话列表失败', error); - toast({ - title: '加载失败', - description: '无法加载会话列表', - status: 'error', - duration: 3000, - }); - } finally { - setIsLoadingSessions(false); - } - }; - - const loadSessionHistory = async (sessionId) => { - if (!sessionId) return; - - try { - const response = await axios.get(`/mcp/agent/history/${sessionId}`, { - params: { limit: 100 }, - }); - - if (response.data.success) { - const history = response.data.data; - const formattedMessages = history.map((msg, idx) => ({ - id: `${sessionId}-${idx}`, - type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE, - content: msg.message, - plan: msg.plan ? JSON.parse(msg.plan) : null, - stepResults: msg.steps ? JSON.parse(msg.steps) : null, - timestamp: msg.timestamp, - })); - - setMessages(formattedMessages); - logger.info('会话历史加载成功', formattedMessages); - } - } catch (error) { - logger.error('加载会话历史失败', error); - toast({ - title: '加载失败', - description: '无法加载会话历史', - status: 'error', - duration: 3000, - }); - } - }; - - const createNewSession = () => { - setCurrentSessionId(null); - setMessages([ - { - id: Date.now(), - type: MessageTypes.AGENT_RESPONSE, - content: `你好${user?.nickname || ''}!我是价小前,北京价值前沿科技公司的AI投研助手。\n\n我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我:\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现`, - timestamp: new Date().toISOString(), - }, - ]); - }; - - const switchSession = (sessionId) => { - setCurrentSessionId(sessionId); - loadSessionHistory(sessionId); - }; - - const deleteSession = async (sessionId) => { - toast({ - title: '删除会话', - description: '此功能尚未实现', - status: 'info', - duration: 2000, - }); - }; - - // ==================== 消息处理函数 ==================== - - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; - - useEffect(() => { - scrollToBottom(); - }, [messages]); - - const addMessage = (message) => { - setMessages((prev) => [...prev, { ...message, id: Date.now() }]); - }; - - const handleSendMessage = async () => { - if (!inputValue.trim() || isProcessing) return; - - const hasAccess = user?.subscription_type === 'max'; - - if (!hasAccess) { - logger.warn('AgentChat', '权限检查失败', { - userId: user?.id, - subscription_type: user?.subscription_type, - }); - - toast({ - title: '订阅升级', - description: '「价小前投研」功能需要 Max 订阅。请前往设置页面升级您的订阅。', - status: 'warning', - duration: 5000, - isClosable: true, - }); - return; - } - - const userMessage = { - type: MessageTypes.USER, - content: inputValue, - timestamp: new Date().toISOString(), - }; - - addMessage(userMessage); - const userInput = inputValue; - setInputValue(''); - setIsProcessing(true); - setCurrentProgress(0); - - let currentPlan = null; - let stepResults = []; - let executingMessageId = null; - - try { - addMessage({ - type: MessageTypes.AGENT_THINKING, - content: '正在分析你的问题...', - timestamp: new Date().toISOString(), - }); - - setCurrentProgress(10); - - const requestBody = { - message: userInput, - conversation_history: messages - .filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE) - .map((m) => ({ - isUser: m.type === MessageTypes.USER, - content: m.content, - })), - user_id: user?.id ? String(user.id) : 'anonymous', - user_nickname: user?.nickname || user?.username || '匿名用户', - user_avatar: user?.avatar || '', - subscription_type: user?.subscription_type || 'free', - session_id: currentSessionId, - model: selectedModel, // 传递选中的模型 - tools: selectedTools, // 传递选中的工具 - }; - - const response = await fetch('/mcp/agent/chat/stream', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - let thinkingMessageId = null; - let thinkingContent = ''; - let summaryMessageId = null; - let summaryContent = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop(); - - let currentEvent = null; - - for (const line of lines) { - if (!line.trim() || line.startsWith(':')) continue; - - if (line.startsWith('event:')) { - currentEvent = line.substring(6).trim(); - continue; - } - - if (line.startsWith('data:')) { - try { - const data = JSON.parse(line.substring(5).trim()); - - if (currentEvent === 'thinking') { - if (!thinkingMessageId) { - thinkingMessageId = Date.now(); - thinkingContent = ''; - setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); - addMessage({ - id: thinkingMessageId, - type: MessageTypes.AGENT_THINKING, - content: '', - timestamp: new Date().toISOString(), - }); - } - thinkingContent += data.content; - setMessages((prev) => - prev.map((m) => - m.id === thinkingMessageId - ? { ...m, content: thinkingContent } - : m - ) - ); - } else if (currentEvent === 'plan') { - currentPlan = data; - thinkingMessageId = null; - thinkingContent = ''; - setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); - addMessage({ - type: MessageTypes.AGENT_PLAN, - content: '已制定执行计划', - plan: data, - timestamp: new Date().toISOString(), - }); - setCurrentProgress(30); - } else if (currentEvent === 'step_complete') { - const stepResult = { - step_index: data.step_index, - tool: data.tool, - status: data.status, - result: data.result, - error: data.error, - execution_time: data.execution_time, - }; - stepResults.push(stepResult); - - setMessages((prev) => - prev.map((m) => - m.id === executingMessageId - ? { ...m, stepResults: [...stepResults] } - : m - ) - ); - - const progress = 40 + (stepResults.length / (currentPlan?.steps?.length || 5)) * 40; - setCurrentProgress(Math.min(progress, 80)); - } else if (currentEvent === 'summary_chunk') { - if (!summaryMessageId) { - summaryMessageId = Date.now(); - summaryContent = ''; - setMessages((prev) => - prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING) - ); - addMessage({ - id: summaryMessageId, - type: MessageTypes.AGENT_RESPONSE, - content: '', - plan: currentPlan, - stepResults: stepResults, - isStreaming: true, - timestamp: new Date().toISOString(), - }); - setCurrentProgress(85); - } - summaryContent += data.content; - setMessages((prev) => - prev.map((m) => - m.id === summaryMessageId - ? { ...m, content: summaryContent } - : m - ) - ); - } else if (currentEvent === 'summary') { - if (summaryMessageId) { - setMessages((prev) => - prev.map((m) => - m.id === summaryMessageId - ? { - ...m, - content: data.content || summaryContent, - metadata: data.metadata, - isStreaming: false, - } - : m - ) - ); - } else { - setMessages((prev) => - prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING) - ); - addMessage({ - type: MessageTypes.AGENT_RESPONSE, - content: data.content, - plan: currentPlan, - stepResults: stepResults, - metadata: data.metadata, - isStreaming: false, - timestamp: new Date().toISOString(), - }); - } - setCurrentProgress(100); - } else if (currentEvent === 'status') { - if (data.stage === 'planning') { - setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); - addMessage({ - type: MessageTypes.AGENT_THINKING, - content: data.message, - timestamp: new Date().toISOString(), - }); - setCurrentProgress(10); - } else if (data.stage === 'executing') { - const msgId = Date.now(); - executingMessageId = msgId; - addMessage({ - id: msgId, - type: MessageTypes.AGENT_EXECUTING, - content: data.message, - plan: currentPlan, - stepResults: [], - timestamp: new Date().toISOString(), - }); - setCurrentProgress(40); - } else if (data.stage === 'summarizing') { - setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING)); - addMessage({ - type: MessageTypes.AGENT_THINKING, - content: data.message, - timestamp: new Date().toISOString(), - }); - setCurrentProgress(80); - } - } - } catch (e) { - logger.error('解析 SSE 数据失败', e); - } - } - } - } - - loadSessions(); - - } catch (error) { - logger.error('Agent chat error', error); - - setMessages((prev) => - prev.filter( - (m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING - ) - ); - - const errorMessage = error.response?.data?.detail || error.message || '处理失败'; - - addMessage({ - type: MessageTypes.ERROR, - content: `处理失败:${errorMessage}`, - timestamp: new Date().toISOString(), - }); - - toast({ - title: '处理失败', - description: errorMessage, - status: 'error', - duration: 5000, - isClosable: true, - }); - } finally { - setIsProcessing(false); - setCurrentProgress(0); - inputRef.current?.focus(); - } - }; - - const handleKeyPress = (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSendMessage(); - } - }; - - const handleClearChat = () => { - createNewSession(); - }; - - const handleExportChat = () => { - const chatText = messages - .filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE) - .map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : '价小前'}] ${msg.content}`) - .join('\n\n'); - - const blob = new Blob([chatText], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`; - a.click(); - URL.revokeObjectURL(url); - }; - - // ==================== 工具选择处理 ==================== - - const handleToolToggle = (toolId, isChecked) => { - if (isChecked) { - setSelectedTools((prev) => [...prev, toolId]); - } else { - setSelectedTools((prev) => prev.filter((id) => id !== toolId)); - } - }; - - const handleCategoryToggle = (categoryTools, isAllSelected) => { - const toolIds = categoryTools.map((t) => t.id); - if (isAllSelected) { - // 全部取消选中 - setSelectedTools((prev) => prev.filter((id) => !toolIds.includes(id))); - } else { - // 全部选中 - setSelectedTools((prev) => { - const newTools = [...prev]; - toolIds.forEach((id) => { - if (!newTools.includes(id)) { - newTools.push(id); - } - }); - return newTools; - }); - } - }; - - // ==================== 初始化 ==================== - - useEffect(() => { - if (user) { - loadSessions(); - createNewSession(); - } - }, [user]); - - // ==================== 渲染 ==================== - - const quickQuestions = [ - '全面分析贵州茅台这只股票', - '今日涨停股票有哪些亮点', - '新能源概念板块的投资机会', - '半导体行业最新动态', - ]; - - const filteredSessions = sessions.filter( - (session) => - !searchQuery || - session.last_message?.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - return ( - - {/* 背景装饰 - 黄金光晕效果 */} - - - - {/* 左侧会话列表 */} - - - {/* 侧边栏头部 */} - - - - - - - - setSearchQuery(e.target.value)} - bg="rgba(255, 255, 255, 0.05)" - border="1px solid" - borderColor={borderGold} - color={textWhite} - _placeholder={{ color: textGray }} - _hover={{ borderColor: goldAccent }} - _focus={{ borderColor: goldAccent, boxShadow: `0 0 0 1px ${goldAccent}` }} - /> - - - - {/* 会话列表 */} - - {isLoadingSessions ? ( - - - - ) : filteredSessions.length === 0 ? ( - - - - {searchQuery ? '没有找到匹配的对话' : '暂无对话记录'} - - - ) : ( - filteredSessions.map((session) => ( - switchSession(session.session_id)} - transition="all 0.2s" - > - - - - {session.last_message || '新对话'} - - - - - {new Date(session.last_timestamp).toLocaleDateString('zh-CN', { - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - })} - - - {session.message_count} 条 - - - - - - } - size="xs" - variant="ghost" - color={textGray} - _hover={{ color: goldAccent }} - onClick={(e) => e.stopPropagation()} - /> - - } - color="red.400" - bg="transparent" - _hover={{ bg: 'rgba(255, 0, 0, 0.1)' }} - onClick={(e) => { - e.stopPropagation(); - deleteSession(session.session_id); - }} - > - 删除对话 - - - - - - )) - )} - - - {/* 用户信息 */} - - - - - - {user?.nickname || '未登录'} - - - MAX 订阅 - - - - - - - - {/* 主聊天区域 */} - - {/* 聊天头部 */} - - - - } - size="sm" - variant="ghost" - color={goldAccent} - _hover={{ bg: 'rgba(255, 215, 0, 0.1)' }} - aria-label="切换侧边栏" - onClick={toggleSidebar} - /> - - - - - 价小前投研 - - - - - AI 深度分析 - - - - {AVAILABLE_MODELS.find(m => m.id === selectedModel)?.name || '智能模型'} - - - - - - - } - size="sm" - variant="ghost" - color={textGray} - _hover={{ color: goldAccent, bg: 'rgba(255, 215, 0, 0.1)' }} - aria-label="清空对话" - onClick={handleClearChat} - /> - } - size="sm" - variant="ghost" - color={textGray} - _hover={{ color: goldAccent, bg: 'rgba(255, 215, 0, 0.1)' }} - aria-label="导出对话" - onClick={handleExportChat} - /> - } - size="sm" - variant="ghost" - color={goldAccent} - _hover={{ bg: 'rgba(255, 215, 0, 0.1)' }} - aria-label="设置" - onClick={toggleRightPanel} - /> - - - - {/* 进度条 */} - {isProcessing && ( - div': { - background: goldGradient, - }, - }} - isAnimated - /> - )} - - - {/* 消息列表 */} - - - {messages.map((message) => ( - - - - ))} -
- - - - {/* 快捷问题 */} - {messages.length <= 2 && !isProcessing && ( - - - 💡 试试这些问题: - - - {quickQuestions.map((question, idx) => ( - - ))} - - - )} - - {/* 输入框 */} - - - setInputValue(e.target.value)} - onKeyPress={handleKeyPress} - placeholder="输入你的问题,我会进行深度分析..." - bg="rgba(255, 255, 255, 0.05)" - border="1px solid" - borderColor={borderGold} - color={textWhite} - _placeholder={{ color: textGray }} - _focus={{ borderColor: goldAccent, boxShadow: `0 0 0 1px ${goldAccent}` }} - mr={2} - disabled={isProcessing} - size="lg" - /> - - - - - - {/* 右侧配置面板 */} - - - - {/* 模型选择 */} - - - - - 选择模型 - - - - {AVAILABLE_MODELS.map((model) => ( - setSelectedModel(model.id)} - transition="all 0.2s" - _hover={{ - bg: 'rgba(255, 215, 0, 0.1)', - borderColor: goldAccent, - transform: 'translateX(4px)', - }} - position="relative" - > - {model.recommended && ( - - 推荐 - - )} - - {model.icon} - - - {model.name} - - - {model.description} - - - {selectedModel === model.id && ( - - )} - - - ))} - - - - - - {/* 工具选择 */} - - - - - - MCP 工具 - - - - {selectedTools.length} 个已选 - - - - - {MCP_TOOL_CATEGORIES.map((category, catIdx) => { - const categoryToolIds = category.tools.map((t) => t.id); - const selectedInCategory = categoryToolIds.filter((id) => selectedTools.includes(id)); - const isAllSelected = selectedInCategory.length === categoryToolIds.length; - const isSomeSelected = selectedInCategory.length > 0 && !isAllSelected; - - return ( - - - - {category.icon} - - {category.name} - - - {selectedInCategory.length}/{category.tools.length} - - - - - - - {/* 全选按钮 */} - - - {category.tools.map((tool) => ( - handleToolToggle(tool.id, e.target.checked)} - colorScheme="yellow" - size="sm" - sx={{ - '.chakra-checkbox__control': { - borderColor: borderGold, - bg: 'rgba(255, 255, 255, 0.05)', - _checked: { - bg: goldGradient, - borderColor: goldAccent, - }, - }, - }} - > - - - {tool.name} - - - {tool.description} - - - - ))} - - - - ); - })} - - - - - - - ); -}; - -/** - * 消息渲染器(黑金毛玻璃风格) - */ -const MessageRenderer = ({ message, userAvatar }) => { - const glassBg = 'rgba(20, 20, 20, 0.7)'; - const goldAccent = '#FFD700'; - const goldGradient = 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)'; - const darkBg = '#0A0A0A'; - const borderGold = 'rgba(255, 215, 0, 0.3)'; - const textWhite = '#FFFFFF'; - const textGray = '#CCCCCC'; - - switch (message.type) { - case MessageTypes.USER: - return ( - - - - - {message.content} - - - } - border="2px solid" - borderColor={goldAccent} - /> - - - ); - - case MessageTypes.AGENT_THINKING: - return ( - - - - - - - - - - {message.content} - - - - - - ); - - case MessageTypes.AGENT_PLAN: - return ( - - - - - - - - - - - ); - - case MessageTypes.AGENT_EXECUTING: - return ( - - - - - - - - {message.stepResults?.map((result, idx) => ( - - ))} - - - - ); - - case MessageTypes.AGENT_RESPONSE: - return ( - - - - - - - {/* 最终总结 */} - - {message.isStreaming ? ( - - {message.content} - - ) : ( - - )} - - {message.metadata && ( - - 总步骤: {message.metadata.total_steps} - ✓ {message.metadata.successful_steps} - {message.metadata.failed_steps > 0 && ( - ✗ {message.metadata.failed_steps} - )} - 耗时: {message.metadata.total_execution_time?.toFixed(1)}s - - )} - - - {/* 执行详情(可选) */} - {message.plan && message.stepResults && message.stepResults.length > 0 && ( - - - - 📊 执行详情(点击展开查看) - - {message.stepResults.map((result, idx) => ( - - ))} - - )} - - - - ); - - case MessageTypes.ERROR: - return ( - - - - - - - {message.content} - - - - ); - - default: - return null; - } -}; - -export default AgentChatV4; diff --git a/src/views/AgentChat/utils/sessionUtils.js b/src/views/AgentChat/utils/sessionUtils.js new file mode 100644 index 00000000..588f0ab5 --- /dev/null +++ b/src/views/AgentChat/utils/sessionUtils.js @@ -0,0 +1,45 @@ +// src/views/AgentChat/utils/sessionUtils.js +// 会话管理工具函数 + +/** + * 按日期分组会话列表 + * + * @param {Array} sessions - 会话列表 + * @returns {Object} 分组后的会话对象 { today, yesterday, thisWeek, older } + * + * @example + * const groups = groupSessionsByDate(sessions); + * console.log(groups.today); // 今天的会话 + * console.log(groups.yesterday); // 昨天的会话 + */ +export const groupSessionsByDate = (sessions) => { + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + + const groups = { + today: [], + yesterday: [], + thisWeek: [], + older: [], + }; + + sessions.forEach((session) => { + const sessionDate = new Date(session.created_at || session.timestamp); + const daysDiff = Math.floor((today - sessionDate) / (1000 * 60 * 60 * 24)); + + if (daysDiff === 0) { + groups.today.push(session); + } else if (daysDiff === 1) { + groups.yesterday.push(session); + } else if (daysDiff <= 7) { + groups.thisWeek.push(session); + } else { + groups.older.push(session); + } + }); + + return groups; +}; diff --git a/src/views/Community/components/DynamicNewsDetail/StockListItem.js b/src/views/Community/components/DynamicNewsDetail/StockListItem.js index f5a361e4..e91de2e6 100644 --- a/src/views/Community/components/DynamicNewsDetail/StockListItem.js +++ b/src/views/Community/components/DynamicNewsDetail/StockListItem.js @@ -52,6 +52,7 @@ const StockListItem = ({ const [isDescExpanded, setIsDescExpanded] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); + const [modalChartType, setModalChartType] = useState('timeline'); // 跟踪用户点击的图表类型 const handleViewDetail = () => { const stockCode = stock.stock_code.split('.')[0]; @@ -203,6 +204,7 @@ const StockListItem = ({ bg="rgba(59, 130, 246, 0.1)" onClick={(e) => { e.stopPropagation(); + setModalChartType('timeline'); // 设置为分时图 setIsModalOpen(true); }} cursor="pointer" @@ -245,6 +247,7 @@ const StockListItem = ({ bg="rgba(168, 85, 247, 0.1)" onClick={(e) => { e.stopPropagation(); + setModalChartType('daily'); // 设置为日K线 setIsModalOpen(true); }} cursor="pointer" @@ -385,6 +388,7 @@ const StockListItem = ({ stock={stock} eventTime={eventTime} size="6xl" + initialChartType={modalChartType} // 传递用户点击的图表类型 /> )} diff --git a/src/views/Community/components/MidjourneyHeroSection.js b/src/views/Community/components/MidjourneyHeroSection.js index dfb5c035..46db5c52 100644 --- a/src/views/Community/components/MidjourneyHeroSection.js +++ b/src/views/Community/components/MidjourneyHeroSection.js @@ -1,7 +1,7 @@ import React, { useRef, useMemo, useState, useEffect } from 'react'; import { motion } from 'framer-motion'; -import Particles from 'react-tsparticles'; -import { loadSlim } from 'tsparticles-slim'; +import Particles from '@tsparticles/react'; +import { loadSlim } from '@tsparticles/slim'; import { Box, Container, diff --git a/src/views/Company/CompanyOverview.js b/src/views/Company/CompanyOverview.js index 83e80fff..a1fb9d55 100644 --- a/src/views/Company/CompanyOverview.js +++ b/src/views/Company/CompanyOverview.js @@ -15,18 +15,18 @@ import { chakra } from '@chakra-ui/react'; -import { +import { FaBuilding, FaMapMarkerAlt, FaChartLine, FaLightbulb, FaRocket, - FaNetworkWired, FaChevronDown, FaChevronUp, FaCog, FaTrophy, - FaShieldAlt, FaBrain, FaChartPie, FaHistory, FaCheckCircle, - FaExclamationCircle, FaArrowUp, FaArrowDown, FaLink, FaStar, - FaUserTie, FaIndustry, FaDollarSign, FaBalanceScale, FaChartBar, + FaNetworkWired, FaChevronDown, FaChevronUp, FaChevronLeft, FaChevronRight, + FaCog, FaTrophy, FaShieldAlt, FaBrain, FaChartPie, FaHistory, FaCheckCircle, + FaExclamationCircle, FaArrowUp, FaArrowDown, FaArrowRight, FaArrowLeft, + FaLink, FaStar, FaUserTie, FaIndustry, FaDollarSign, FaBalanceScale, FaChartBar, FaEye, FaFlask, FaHandshake, FaUsers, FaClock, FaCalendarAlt, FaCircle, FaGlobe, FaEnvelope, FaPhone, FaFax, FaBriefcase, FaUniversity, FaGraduationCap, FaVenusMars, FaPassport, FaFileAlt, FaNewspaper, FaBullhorn, FaUserShield, FaShareAlt, FaSitemap, FaSearch, FaDownload, FaExternalLinkAlt, FaInfoCircle, FaCrown, - FaCertificate, FaAward, FaExpandAlt, FaCompressAlt + FaCertificate, FaAward, FaExpandAlt, FaCompressAlt, FaGavel, FaFire } from 'react-icons/fa'; import { @@ -240,18 +240,21 @@ const BusinessTreeItem = ({ business, depth = 0 }) => { // 产业链节点卡片 const ValueChainNodeCard = ({ node, isCompany = false, level = 0 }) => { const { isOpen, onOpen, onClose } = useDisclosure(); - + const [relatedCompanies, setRelatedCompanies] = useState([]); + const [loadingRelated, setLoadingRelated] = useState(false); + const toast = useToast(); + const getColorScheme = () => { if (isCompany) return 'blue'; if (level < 0) return 'orange'; if (level > 0) return 'green'; return 'gray'; }; - + const colorScheme = getColorScheme(); const bgColor = useColorModeValue(`${colorScheme}.50`, `${colorScheme}.900`); const borderColor = useColorModeValue(`${colorScheme}.200`, `${colorScheme}.600`); - + const getNodeTypeIcon = (type) => { const icons = { 'company': FaBuilding, @@ -264,13 +267,53 @@ const ValueChainNodeCard = ({ node, isCompany = false, level = 0 }) => { }; return icons[type] || FaBuilding; }; - + const getImportanceColor = (score) => { if (score >= 80) return 'red'; if (score >= 60) return 'orange'; if (score >= 40) return 'yellow'; return 'green'; }; + + // 获取相关公司 + const fetchRelatedCompanies = async () => { + setLoadingRelated(true); + try { + const response = await fetch( + `${API_BASE_URL}/api/company/value-chain/related-companies?node_name=${encodeURIComponent(node.node_name)}` + ); + const data = await response.json(); + if (data.success) { + setRelatedCompanies(data.data || []); + } else { + toast({ + title: '获取相关公司失败', + description: data.message, + status: 'error', + duration: 3000, + isClosable: true, + }); + } + } catch (error) { + logger.error('ValueChainNodeCard', 'fetchRelatedCompanies', error, { node_name: node.node_name }); + toast({ + title: '获取相关公司失败', + description: error.message, + status: 'error', + duration: 3000, + isClosable: true, + }); + } finally { + setLoadingRelated(false); + } + }; + + const handleCardClick = () => { + onOpen(); + if (relatedCompanies.length === 0) { + fetchRelatedCompanies(); + } + }; return ( <> @@ -281,9 +324,9 @@ const ValueChainNodeCard = ({ node, isCompany = false, level = 0 }) => { borderWidth={isCompany ? 3 : 1} shadow={isCompany ? 'lg' : 'sm'} cursor="pointer" - onClick={onOpen} - _hover={{ - shadow: 'xl', + onClick={handleCardClick} + _hover={{ + shadow: 'xl', transform: 'translateY(-4px)', borderColor: `${colorScheme}.400` }} @@ -350,7 +393,7 @@ const ValueChainNodeCard = ({ node, isCompany = false, level = 0 }) => { - + @@ -374,35 +417,35 @@ const ValueChainNodeCard = ({ node, isCompany = false, level = 0 }) => { {node.node_description} )} - + 重要度评分 {node.importance_score || 0} - - + {node.market_share && ( 市场份额 {node.market_share}% )} - + {node.dependency_degree && ( 依赖程度 {node.dependency_degree}% - 50 ? 'orange' : 'green'} borderRadius="full" @@ -411,6 +454,150 @@ const ValueChainNodeCard = ({ node, isCompany = false, level = 0 }) => { )} + + + + {/* 相关公司列表 */} + + + 相关公司 + {loadingRelated && } + + {loadingRelated ? ( +
+ +
+ ) : relatedCompanies.length > 0 ? ( + + {relatedCompanies.map((company, idx) => { + // 获取节点层级标签 + const getLevelLabel = (level) => { + if (level < 0) return { text: '上游', color: 'orange' }; + if (level === 0) return { text: '核心', color: 'blue' }; + if (level > 0) return { text: '下游', color: 'green' }; + return { text: '未知', color: 'gray' }; + }; + + const levelInfo = getLevelLabel(company.node_info?.node_level); + + return ( + + + + {/* 公司基本信息 */} + + + + {company.stock_name} + {company.stock_code} + + {levelInfo.text} + + {company.node_info?.node_type && ( + + {company.node_info.node_type} + + )} + + {company.company_name && ( + + {company.company_name} + + )} + + } + variant="ghost" + colorScheme="blue" + onClick={() => { + window.location.href = `/company?stock_code=${company.stock_code}`; + }} + aria-label="查看公司详情" + /> + + + {/* 节点描述 */} + {company.node_info?.node_description && ( + + {company.node_info.node_description} + + )} + + {/* 节点指标 */} + {(company.node_info?.importance_score || company.node_info?.market_share || company.node_info?.dependency_degree) && ( + + {company.node_info.importance_score && ( + + 重要度: + {company.node_info.importance_score} + + )} + {company.node_info.market_share && ( + + 市场份额: + {company.node_info.market_share}% + + )} + {company.node_info.dependency_degree && ( + + 依赖度: + {company.node_info.dependency_degree}% + + )} + + )} + + {/* 流向关系 */} + {company.relationships && company.relationships.length > 0 && ( + + + 产业链关系: + + + {company.relationships.map((rel, ridx) => ( + + + + {rel.role === 'source' ? '流向' : '来自'} + + {rel.connected_node} + + + {rel.relationship_desc && ( + + {rel.relationship_desc} + + )} + {rel.flow_ratio && ( + + {rel.flow_ratio}% + + )} + + ))} + + + )} + + + + ); + })} + + ) : ( +
+ + + 暂无相关公司 + +
+ )} +
@@ -697,7 +884,20 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => { const [branches, setBranches] = useState([]); const [announcements, setAnnouncements] = useState([]); const [disclosureSchedule, setDisclosureSchedule] = useState([]); - + + // 新闻动态数据 + const [newsEvents, setNewsEvents] = useState([]); + const [newsLoading, setNewsLoading] = useState(false); + const [newsSearchQuery, setNewsSearchQuery] = useState(''); + const [newsPagination, setNewsPagination] = useState({ + page: 1, + per_page: 10, + total: 0, + pages: 0, + has_next: false, + has_prev: false + }); + const [error, setError] = useState(null); const toast = useToast(); @@ -785,6 +985,78 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => { } }, [stockCode]); + // 加载新闻事件 + const loadNewsEvents = async (page = 1, searchQuery = '') => { + setNewsLoading(true); + try { + // 构建查询参数 + const params = new URLSearchParams({ + page: page.toString(), + per_page: '10', + sort: 'new', + include_creator: 'true', + include_stats: 'true' + }); + + // 搜索关键词优先级: + // 1. 用户输入的搜索关键词 + // 2. 股票简称 + const queryText = searchQuery || basicInfo?.SECNAME || ''; + if (queryText) { + params.append('q', queryText); + } + + const response = await fetch(`${API_BASE_URL}/api/events?${params.toString()}`); + const data = await response.json(); + + // API返回 data.data.events + const events = data.data?.events || data.events || []; + const pagination = data.data?.pagination || { + page: 1, + per_page: 10, + total: 0, + pages: 0, + has_next: false, + has_prev: false + }; + + setNewsEvents(events); + setNewsPagination(pagination); + } catch (err) { + logger.error('CompanyOverview', 'loadNewsEvents', err, { stockCode, searchQuery, page }); + setNewsEvents([]); + setNewsPagination({ + page: 1, + per_page: 10, + total: 0, + pages: 0, + has_next: false, + has_prev: false + }); + } finally { + setNewsLoading(false); + } + }; + + // 当基本信息加载完成后,加载新闻事件 + useEffect(() => { + if (basicInfo) { + loadNewsEvents(1); + } + }, [basicInfo]); + + // 处理搜索 + const handleNewsSearch = () => { + loadNewsEvents(1, newsSearchQuery); + }; + + // 处理分页 + const handleNewsPageChange = (newPage) => { + loadNewsEvents(newPage, newsSearchQuery); + // 滚动到新闻列表顶部 + document.getElementById('news-list-top')?.scrollIntoView({ behavior: 'smooth' }); + }; + // 管理层职位分类 const getManagementByCategory = () => { const categories = { @@ -1060,15 +1332,476 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => { )} - {/* 主要内容区 - 分为企业概览和深度分析 */} - + {/* 主要内容区 - 分为深度分析、基本信息和新闻动态 */} + - 企业概览 深度分析 + 基本信息 + 新闻动态 - {/* 企业概览标签页 */} + {/* 深度分析标签页 */} + + + {/* 核心定位卡片 */} + {comprehensiveData?.qualitative_analysis && ( + + + + + 核心定位 + + + + + + {comprehensiveData.qualitative_analysis.core_positioning?.one_line_intro && ( + + + {comprehensiveData.qualitative_analysis.core_positioning.one_line_intro} + + )} + + + + + 投资亮点 + + + {comprehensiveData.qualitative_analysis.core_positioning?.investment_highlights || '暂无数据'} + + + + + + + + 商业模式 + + + {comprehensiveData.qualitative_analysis.core_positioning?.business_model_desc || '暂无数据'} + + + + + + + + + )} + + {/* 竞争地位分析 */} + {comprehensiveData?.competitive_position && ( + + + + + 竞争地位分析 + {comprehensiveData.competitive_position.ranking && ( + + 行业排名 {comprehensiveData.competitive_position.ranking.industry_rank}/{comprehensiveData.competitive_position.ranking.total_companies} + + )} + + + + + {comprehensiveData.competitive_position.analysis?.main_competitors && ( + + 主要竞争对手 + + {comprehensiveData.competitive_position.analysis.main_competitors + .split(',') + .map((competitor, idx) => ( + + + {competitor.trim()} + + ))} + + + )} + + + + + + + + + + + + + + + + + {getRadarChartOption() && ( + + )} + + + + + + + + 竞争优势 + + {comprehensiveData.competitive_position.analysis?.competitive_advantages || '暂无数据'} + + + + 竞争劣势 + + {comprehensiveData.competitive_position.analysis?.competitive_disadvantages || '暂无数据'} + + + + + + )} + + {/* 业务结构分析 */} + {comprehensiveData?.business_structure && comprehensiveData.business_structure.length > 0 && ( + + + + + 业务结构分析 + {comprehensiveData.business_structure[0]?.report_period} + + + + + + {comprehensiveData.business_structure.map((business, idx) => ( + + ))} + + + + )} + + {/* 产业链分析 */} + {valueChainData && ( + + + + + 产业链分析 + + + 上游 {valueChainData.analysis_summary?.upstream_nodes || 0} + + + 核心 {valueChainData.analysis_summary?.company_nodes || 0} + + + 下游 {valueChainData.analysis_summary?.downstream_nodes || 0} + + + + + + + + + 层级视图 + 流向关系 + + + + + + {(valueChainData.value_chain_structure?.nodes_by_level?.['level_-2'] || + valueChainData.value_chain_structure?.nodes_by_level?.['level_-1']) && ( + + + 上游供应链 + 原材料与供应商 + + + {[ + ...(valueChainData.value_chain_structure?.nodes_by_level?.['level_-2'] || []), + ...(valueChainData.value_chain_structure?.nodes_by_level?.['level_-1'] || []) + ].map((node, idx) => ( + + ))} + + + )} + + {valueChainData.value_chain_structure?.nodes_by_level?.['level_0'] && ( + + + 核心企业 + 公司主体与产品 + + + {valueChainData.value_chain_structure.nodes_by_level['level_0'].map((node, idx) => ( + + ))} + + + )} + + {(valueChainData.value_chain_structure?.nodes_by_level?.['level_1'] || + valueChainData.value_chain_structure?.nodes_by_level?.['level_2']) && ( + + + 下游客户 + 客户与终端市场 + + + {[ + ...(valueChainData.value_chain_structure?.nodes_by_level?.['level_1'] || []), + ...(valueChainData.value_chain_structure?.nodes_by_level?.['level_2'] || []) + ].map((node, idx) => ( + + ))} + + + )} + + + + + {getSankeyChartOption() ? ( + + ) : ( +
+ 暂无流向数据 +
+ )} +
+
+
+
+
+ )} + + {/* 关键因素与发展时间线 */} + + + {keyFactorsData?.key_factors && ( + + + + + 关键因素 + {keyFactorsData.key_factors.total_factors} 项 + + + + + + {keyFactorsData.key_factors.categories.map((category, idx) => ( + + + + + {category.category_name} + + {category.factors.length} + + + + + + + + {category.factors.map((factor, fidx) => ( + + ))} + + + + ))} + + + + )} + + + + {keyFactorsData?.development_timeline && ( + + + + + 发展时间线 + + + 正面 {keyFactorsData.development_timeline.statistics?.positive_events || 0} + + + 负面 {keyFactorsData.development_timeline.statistics?.negative_events || 0} + + + + + + + + + + + + )} + + + + {/* 业务板块详情 */} + {comprehensiveData?.business_segments && comprehensiveData.business_segments.length > 0 && ( + + + + + 业务板块详情 + {comprehensiveData.business_segments.length} 个板块 + + + + + + {comprehensiveData.business_segments.map((segment, idx) => { + const isExpanded = expandedSegments[idx]; + + return ( + + + + + {segment.segment_name} + + + + + 业务描述 + + {segment.segment_description || '暂无描述'} + + + + + 竞争地位 + + {segment.competitive_position || '暂无数据'} + + + + + 未来潜力 + + {segment.future_potential || '暂无数据'} + + + + {isExpanded && segment.key_products && ( + + 主要产品 + + {segment.key_products} + + + )} + + {isExpanded && segment.market_share && ( + + 市场份额 + + + {segment.market_share}% + + + + )} + + {isExpanded && segment.revenue_contribution && ( + + 营收贡献 + + + {segment.revenue_contribution}% + + + + )} + + + + ); + })} + + + + )} + + {/* 战略分析 */} + {comprehensiveData?.qualitative_analysis?.strategy && ( + + + + + 战略分析 + + + + + + + + 战略方向 + + + {comprehensiveData.qualitative_analysis.strategy.strategy_description || '暂无数据'} + + + + + + + + 战略举措 + + + {comprehensiveData.qualitative_analysis.strategy.strategic_initiatives || '暂无数据'} + + + + + + + + )} +
+
+ + {/* 基本信息标签页 */} @@ -1077,9 +1810,8 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => { 股权结构 管理团队 公司公告 - 新闻动态 分支机构 - 基本信息 + 工商信息 @@ -1449,28 +2181,6 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => { - {/* 新闻动态标签页 */} - - - - - - - - - -
- - - 新闻接口待接入 - - 即将展示最新的公司新闻动态 - - -
-
-
- {/* 分支机构标签页 */} {branches.length > 0 ? ( @@ -1529,7 +2239,7 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => { )} - {/* 基本信息标签页 */} + {/* 工商信息标签页 */} {basicInfo && ( @@ -1593,463 +2303,320 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => { - {/* 深度分析标签页 */} + {/* 新闻动态标签页 */} - - {/* 核心定位卡片 */} - {comprehensiveData?.qualitative_analysis && ( - - - - - 核心定位 - - - - - - {comprehensiveData.qualitative_analysis.core_positioning?.one_line_intro && ( - - - {comprehensiveData.qualitative_analysis.core_positioning.one_line_intro} - - )} - - - - - 投资亮点 - - - {comprehensiveData.qualitative_analysis.core_positioning?.investment_highlights || '暂无数据'} - - - - - - - - 商业模式 - - - {comprehensiveData.qualitative_analysis.core_positioning?.business_model_desc || '暂无数据'} - - - - - - - - - )} - - {/* 竞争地位分析 */} - {comprehensiveData?.competitive_position && ( - - - - - 竞争地位分析 - {comprehensiveData.competitive_position.ranking && ( - - 行业排名 {comprehensiveData.competitive_position.ranking.industry_rank}/{comprehensiveData.competitive_position.ranking.total_companies} - - )} - - - - - {comprehensiveData.competitive_position.analysis?.main_competitors && ( - - 主要竞争对手 - - {comprehensiveData.competitive_position.analysis.main_competitors - .split(',') - .map((competitor, idx) => ( - - - {competitor.trim()} - - ))} - - - )} - - - - - - - - - - - - - - - - - {getRadarChartOption() && ( - + + + + {/* 搜索框和统计信息 */} + + + + + + + setNewsSearchQuery(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleNewsSearch()} /> - )} - - - - - - - - 竞争优势 - - {comprehensiveData.competitive_position.analysis?.competitive_advantages || '暂无数据'} - - - - 竞争劣势 - - {comprehensiveData.competitive_position.analysis?.competitive_disadvantages || '暂无数据'} - - - - - - )} - - {/* 业务结构分析 */} - {comprehensiveData?.business_structure && comprehensiveData.business_structure.length > 0 && ( - - - - - 业务结构分析 - {comprehensiveData.business_structure[0]?.report_period} - - - - - - {comprehensiveData.business_structure.map((business, idx) => ( - - ))} - - - - )} - - {/* 产业链分析 */} - {valueChainData && ( - - - - - 产业链分析 - - - 上游 {valueChainData.analysis_summary?.upstream_nodes || 0} - - - 核心 {valueChainData.analysis_summary?.company_nodes || 0} - - - 下游 {valueChainData.analysis_summary?.downstream_nodes || 0} - + + - - - - - - - 层级视图 - 流向关系 - - - - - - {(valueChainData.value_chain_structure?.nodes_by_level?.['level_-2'] || - valueChainData.value_chain_structure?.nodes_by_level?.['level_-1']) && ( - - - 上游供应链 - 原材料与供应商 - - - {[ - ...(valueChainData.value_chain_structure?.nodes_by_level?.['level_-2'] || []), - ...(valueChainData.value_chain_structure?.nodes_by_level?.['level_-1'] || []) - ].map((node, idx) => ( - - ))} - - - )} - - {valueChainData.value_chain_structure?.nodes_by_level?.['level_0'] && ( - - - 核心企业 - 公司主体与产品 - - - {valueChainData.value_chain_structure.nodes_by_level['level_0'].map((node, idx) => ( - - ))} - - - )} - - {(valueChainData.value_chain_structure?.nodes_by_level?.['level_1'] || - valueChainData.value_chain_structure?.nodes_by_level?.['level_2']) && ( - - - 下游客户 - 客户与终端市场 - - - {[ - ...(valueChainData.value_chain_structure?.nodes_by_level?.['level_1'] || []), - ...(valueChainData.value_chain_structure?.nodes_by_level?.['level_2'] || []) - ].map((node, idx) => ( - - ))} - - - )} - - - - - {getSankeyChartOption() ? ( - - ) : ( -
- 暂无流向数据 -
- )} -
-
-
-
-
- )} - - {/* 关键因素与发展时间线 */} - - - {keyFactorsData?.key_factors && ( - - - - - 关键因素 - {keyFactorsData.key_factors.total_factors} 项 + + {newsPagination.total > 0 && ( + + + + 共找到 {newsPagination.total} 条新闻 + - - - - - {keyFactorsData.key_factors.categories.map((category, idx) => ( - - - - - {category.category_name} - - {category.factors.length} - - - - - - - - {category.factors.map((factor, fidx) => ( - - ))} - - - - ))} - - - - )} - - - - {keyFactorsData?.development_timeline && ( - - - - - 发展时间线 - - - 正面 {keyFactorsData.development_timeline.statistics?.positive_events || 0} - - - 负面 {keyFactorsData.development_timeline.statistics?.negative_events || 0} - - - - - - - - - - - - )} - - - - {/* 业务板块详情 */} - {comprehensiveData?.business_segments && comprehensiveData.business_segments.length > 0 && ( - - - - - 业务板块详情 - {comprehensiveData.business_segments.length} 个板块 + )} - - - - - {comprehensiveData.business_segments.map((segment, idx) => { - const isExpanded = expandedSegments[idx]; - - return ( - - - - - {segment.segment_name} - - - - - 业务描述 - - {segment.segment_description || '暂无描述'} - - - - - 竞争地位 - - {segment.competitive_position || '暂无数据'} - - - - - 未来潜力 - - {segment.future_potential || '暂无数据'} - - - - {isExpanded && segment.key_products && ( - - 主要产品 - - {segment.key_products} - - - )} - - {isExpanded && segment.market_share && ( - - 市场份额 - - - {segment.market_share}% - - - - )} - - {isExpanded && segment.revenue_contribution && ( - - 营收贡献 - - - {segment.revenue_contribution}% - - - - )} - - - - ); - })} - - - - )} - - {/* 战略分析 */} - {comprehensiveData?.qualitative_analysis?.strategy && ( - - - - - 战略分析 - - - - - - - - 战略方向 - - - {comprehensiveData.qualitative_analysis.strategy.strategy_description || '暂无数据'} - - + +
+ + {/* 新闻列表 */} + {newsLoading ? ( +
+ + + 正在加载新闻... - - - - - 战略举措 - - - {comprehensiveData.qualitative_analysis.strategy.strategic_initiatives || '暂无数据'} - - +
+ ) : newsEvents.length > 0 ? ( + <> + + {newsEvents.map((event, idx) => { + const importanceColor = { + 'S': 'red', + 'A': 'orange', + 'B': 'yellow', + 'C': 'green' + }[event.importance] || 'gray'; + + const eventTypeIcon = { + '企业公告': FaBullhorn, + '政策': FaGavel, + '技术突破': FaFlask, + '企业融资': FaDollarSign, + '政策监管': FaShieldAlt, + '政策动态': FaFileAlt, + '行业事件': FaIndustry + }[event.event_type] || FaNewspaper; + + return ( + + + + {/* 标题栏 */} + + + + + + {event.title} + + + + {/* 标签栏 */} + + {event.importance && ( + + {event.importance}级 + + )} + {event.event_type && ( + + {event.event_type} + + )} + {event.invest_score && ( + + 投资分: {event.invest_score} + + )} + {event.keywords && event.keywords.length > 0 && ( + <> + {event.keywords.slice(0, 4).map((keyword, kidx) => ( + + {keyword} + + ))} + + )} + + + + {/* 右侧信息栏 */} + + + {event.created_at ? new Date(event.created_at).toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }) : ''} + + + {event.view_count !== undefined && ( + + + {event.view_count} + + )} + {event.hot_score !== undefined && ( + + + {event.hot_score.toFixed(1)} + + )} + + {event.creator && ( + + @{event.creator.username} + + )} + + + + {/* 描述 */} + {event.description && ( + + {event.description} + + )} + + {/* 收益率数据 */} + {(event.related_avg_chg !== null || event.related_max_chg !== null || event.related_week_chg !== null) && ( + + + + + 相关涨跌: + + {event.related_avg_chg !== null && event.related_avg_chg !== undefined && ( + + 平均 + 0 ? 'red.500' : 'green.500'} + > + {event.related_avg_chg > 0 ? '+' : ''}{event.related_avg_chg.toFixed(2)}% + + + )} + {event.related_max_chg !== null && event.related_max_chg !== undefined && ( + + 最大 + 0 ? 'red.500' : 'green.500'} + > + {event.related_max_chg > 0 ? '+' : ''}{event.related_max_chg.toFixed(2)}% + + + )} + {event.related_week_chg !== null && event.related_week_chg !== undefined && ( + + + 0 ? 'red.500' : 'green.500'} + > + {event.related_week_chg > 0 ? '+' : ''}{event.related_week_chg.toFixed(2)}% + + + )} + + + )} + + + + ); + })} - - - - - )} + + {/* 分页控件 */} + {newsPagination.pages > 1 && ( + + + {/* 分页信息 */} + + 第 {newsPagination.page} / {newsPagination.pages} 页 + + + {/* 分页按钮 */} + + + + + {/* 页码按钮 */} + {(() => { + const currentPage = newsPagination.page; + const totalPages = newsPagination.pages; + const pageButtons = []; + + // 显示当前页及前后各2页 + let startPage = Math.max(1, currentPage - 2); + let endPage = Math.min(totalPages, currentPage + 2); + + // 如果开始页大于1,显示省略号 + if (startPage > 1) { + pageButtons.push( + ... + ); + } + + for (let i = startPage; i <= endPage; i++) { + pageButtons.push( + + ); + } + + // 如果结束页小于总页数,显示省略号 + if (endPage < totalPages) { + pageButtons.push( + ... + ); + } + + return pageButtons; + })()} + + + + + + + )} + + ) : ( +
+ + + 暂无相关新闻 + + {newsSearchQuery ? '尝试修改搜索关键词' : '该公司暂无新闻动态'} + + +
+ )} + + + @@ -2095,4 +2662,4 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => { ); }; -export default CompanyAnalysisComplete; \ No newline at end of file +export default CompanyAnalysisComplete; diff --git a/src/views/Concept/ConceptTimelineModal.js b/src/views/Concept/ConceptTimelineModal.js index 51c35ec6..ac271ee7 100644 --- a/src/views/Concept/ConceptTimelineModal.js +++ b/src/views/Concept/ConceptTimelineModal.js @@ -1,7 +1,12 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { logger } from '../../utils/logger'; import { useConceptTimelineEvents } from './hooks/useConceptTimelineEvents'; import RiskDisclaimer from '../../components/RiskDisclaimer'; +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import dayjs from 'dayjs'; +import 'dayjs/locale/zh-cn'; import { Modal, ModalOverlay, @@ -24,21 +29,29 @@ import { Divider, useToast, useDisclosure, + SimpleGrid, + Tooltip, } from '@chakra-ui/react'; import { ChevronDownIcon, ChevronRightIcon, ExternalLinkIcon, - ViewIcon + ViewIcon, + CalendarIcon, } from '@chakra-ui/icons'; import { FaChartLine, FaArrowUp, FaArrowDown, - FaHistory + FaHistory, + FaNewspaper, + FaFileAlt, + FaClock, } from 'react-icons/fa'; import { keyframes } from '@emotion/react'; +dayjs.locale('zh-cn'); + // 动画定义 const pulseAnimation = keyframes` 0% { transform: translate(-50%, -50%) scale(1); opacity: 0.5; } @@ -46,6 +59,11 @@ const pulseAnimation = keyframes` 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.5; } `; +const shimmerAnimation = keyframes` + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +`; + // API配置 - 与主文件保持一致 const API_BASE_URL = process.env.NODE_ENV === 'production' ? '/concept-api' @@ -79,7 +97,11 @@ const ConceptTimelineModal = ({ const [timelineData, setTimelineData] = useState([]); const [loading, setLoading] = useState(true); - const [expandedDates, setExpandedDates] = useState({}); + const [selectedDate, setSelectedDate] = useState(null); + const [selectedDateData, setSelectedDateData] = useState(null); + + // 日期详情Modal + const { isOpen: isDateDetailOpen, onOpen: onDateDetailOpen, onClose: onDateDetailClose } = useDisclosure(); // 研报全文Modal相关状态 const [selectedReport, setSelectedReport] = useState(null); @@ -89,6 +111,178 @@ const ConceptTimelineModal = ({ const [selectedNews, setSelectedNews] = useState(null); const [isNewsModalOpen, setIsNewsModalOpen] = useState(false); + // 辅助函数:格式化日期显示(包含年份) + const formatDateDisplay = (dateStr) => { + const date = new Date(dateStr); + const today = new Date(); + const diffTime = today - date; + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const formatted = `${year}-${month}-${day}`; + + if (diffDays === 0) return `今天 ${formatted}`; + if (diffDays === 1) return `昨天 ${formatted}`; + if (diffDays < 7) return `${diffDays}天前 ${formatted}`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)}周前 ${formatted}`; + if (diffDays < 365) return `${Math.floor(diffDays / 30)}月前 ${formatted}`; + return formatted; + }; + + // 辅助函数:格式化完整时间(YYYY-MM-DD HH:mm) + const formatDateTime = (dateTimeStr) => { + if (!dateTimeStr) return '-'; + const normalized = typeof dateTimeStr === 'string' ? dateTimeStr.replace(' ', 'T') : dateTimeStr; + const dt = new Date(normalized); + if (isNaN(dt.getTime())) return '-'; + const y = dt.getFullYear(); + const m = String(dt.getMonth() + 1).padStart(2, '0'); + const d = String(dt.getDate()).padStart(2, '0'); + const hh = String(dt.getHours()).padStart(2, '0'); + const mm = String(dt.getMinutes()).padStart(2, '0'); + return `${y}-${m}-${d} ${hh}:${mm}`; + }; + + // 辅助函数:获取涨跌幅颜色和图标 + const getPriceInfo = (price) => { + if (!price || price.avg_change_pct === null) { + return { color: 'gray', icon: null, text: '无数据' }; + } + + const value = price.avg_change_pct; + if (value > 0) { + return { + color: 'red', + icon: FaArrowUp, + text: `+${value.toFixed(2)}%` + }; + } else if (value < 0) { + return { + color: 'green', + icon: FaArrowDown, + text: `${value.toFixed(2)}%` + }; + } else { + return { + color: 'gray', + icon: null, + text: '0.00%' + }; + } + }; + + // 转换时间轴数据为日历事件格式(一天拆分为多个独立事件) + const calendarEvents = useMemo(() => { + const events = []; + + timelineData.forEach(item => { + const priceInfo = getPriceInfo(item.price); + const newsCount = (item.events || []).filter(e => e.type === 'news').length; + const reportCount = (item.events || []).filter(e => e.type === 'report').length; + const hasPriceData = item.price && item.price.avg_change_pct !== null; + + // 如果有新闻,添加新闻事件 + if (newsCount > 0) { + events.push({ + id: `${item.date}-news`, + title: `📰 ${newsCount} 条新闻`, + date: item.date, + start: item.date, + backgroundColor: '#9F7AEA', + borderColor: '#9F7AEA', + extendedProps: { + eventType: 'news', + count: newsCount, + originalData: item, + } + }); + } + + // 如果有研报,添加研报事件 + if (reportCount > 0) { + events.push({ + id: `${item.date}-report`, + title: `📊 ${reportCount} 篇研报`, + date: item.date, + start: item.date, + backgroundColor: '#805AD5', + borderColor: '#805AD5', + extendedProps: { + eventType: 'report', + count: reportCount, + originalData: item, + } + }); + } + + // 如果有价格数据,添加价格事件 + if (hasPriceData) { + const changePercent = item.price.avg_change_pct; + const isSignificantRise = changePercent >= 3; // 涨幅 >= 3% 为重大利好 + let bgColor = '#e2e8f0'; + let title = priceInfo.text; + + if (priceInfo.color === 'red') { + if (isSignificantRise) { + // 涨幅 >= 3%,使用醒目的橙红色 + 火焰图标 + bgColor = '#F56565'; // 更深的红色 + title = `🔥 ${priceInfo.text}`; + } else { + bgColor = '#FC8181'; // 普通红色(上涨) + } + } else if (priceInfo.color === 'green') { + bgColor = '#68D391'; // 绿色(下跌) + } + + events.push({ + id: `${item.date}-price`, + title: title, + date: item.date, + start: item.date, + backgroundColor: bgColor, + borderColor: isSignificantRise ? '#C53030' : bgColor, // 深红色边框强调 + extendedProps: { + eventType: 'price', + priceInfo, + originalData: item, + isSignificantRise, // 标记重大涨幅 + } + }); + } + }); + + return events; + }, [timelineData]); + + // 处理日期点击 + const handleDateClick = (info) => { + const clickedDate = info.dateStr; + const dateData = timelineData.find(item => item.date === clickedDate); + + if (dateData) { + setSelectedDate(clickedDate); + setSelectedDateData(dateData); + onDateDetailOpen(); + + // 追踪日期点击 + trackDateToggled(clickedDate, true); + } + }; + + // 处理事件点击 + const handleEventClick = (info) => { + // 从事件的 extendedProps 中获取原始数据 + const dateData = info.event.extendedProps?.originalData; + + if (dateData) { + setSelectedDate(dateData.date); + setSelectedDateData(dateData); + onDateDetailOpen(); + } + }; + // 获取时间轴数据 const fetchTimelineData = async () => { setLoading(true); @@ -137,58 +331,130 @@ const ConceptTimelineModal = ({ ); // 获取新闻(精确匹配,最近100天,最多100条) - const newsParams = new URLSearchParams({ - query: conceptName, - exact_match: 1, - start_date: startDateStr, - end_date: endDateStr, - top_k: 100 - }); + // 🔄 添加回退逻辑:如果结果不足30条,去掉 exact_match 参数重新搜索 + const fetchNews = async () => { + try { + // 第一次尝试:使用精确匹配 + const newsParams = new URLSearchParams({ + query: conceptName, + exact_match: 1, + start_date: startDateStr, + end_date: endDateStr, + top_k: 100 + }); - const newsUrl = `${NEWS_API_URL}/search_china_news?${newsParams}`; + const newsUrl = `${NEWS_API_URL}/search_china_news?${newsParams}`; + const res = await fetch(newsUrl); - promises.push( - fetch(newsUrl) - .then(async res => { - if (!res.ok) { - const text = await res.text(); - logger.error('ConceptTimelineModal', 'fetchTimelineData - News API', new Error(`HTTP ${res.status}`), { conceptName, status: res.status, response: text.substring(0, 200) }); - return []; - } - return res.json(); - }) - .catch(err => { - logger.error('ConceptTimelineModal', 'fetchTimelineData - News API', err, { conceptName }); + if (!res.ok) { + const text = await res.text(); + logger.error('ConceptTimelineModal', 'fetchTimelineData - News API (exact_match=1)', new Error(`HTTP ${res.status}`), { conceptName, status: res.status, response: text.substring(0, 200) }); return []; - }) - ); + } + + const newsResult = await res.json(); + const newsArray = Array.isArray(newsResult) ? newsResult : []; + + // 检查结果数量,如果不足30条则进行回退搜索 + if (newsArray.length < 30) { + logger.info('ConceptTimelineModal', `新闻精确搜索结果不足30条 (${newsArray.length}),尝试模糊搜索`, { conceptName }); + + // 第二次尝试:去掉精确匹配参数 + const fallbackParams = new URLSearchParams({ + query: conceptName, + start_date: startDateStr, + end_date: endDateStr, + top_k: 100 + }); + + const fallbackUrl = `${NEWS_API_URL}/search_china_news?${fallbackParams}`; + const fallbackRes = await fetch(fallbackUrl); + + if (!fallbackRes.ok) { + logger.warn('ConceptTimelineModal', '新闻模糊搜索失败,使用精确搜索结果', { conceptName }); + return newsArray; + } + + const fallbackResult = await fallbackRes.json(); + const fallbackArray = Array.isArray(fallbackResult) ? fallbackResult : []; + + logger.info('ConceptTimelineModal', `新闻模糊搜索成功,获取 ${fallbackArray.length} 条结果`, { conceptName }); + return fallbackArray; + } + + return newsArray; + } catch (err) { + logger.error('ConceptTimelineModal', 'fetchTimelineData - News API', err, { conceptName }); + return []; + } + }; + + promises.push(fetchNews()); // 获取研报(文本模式、精确匹配,最近100天,最多30条) - const reportParams = new URLSearchParams({ - query: conceptName, - mode: 'text', - exact_match: 1, - size: 30, - start_date: startDateStr - }); + // 🔄 添加回退逻辑:如果结果不足10条,去掉 exact_match 参数重新搜索 + const fetchReports = async () => { + try { + // 第一次尝试:使用精确匹配 + const reportParams = new URLSearchParams({ + query: conceptName, + mode: 'text', + exact_match: 1, + size: 30, + start_date: startDateStr + }); - const reportUrl = `${REPORT_API_URL}/search?${reportParams}`; + const reportUrl = `${REPORT_API_URL}/search?${reportParams}`; + const res = await fetch(reportUrl); - promises.push( - fetch(reportUrl) - .then(async res => { - if (!res.ok) { - const text = await res.text(); - logger.error('ConceptTimelineModal', 'fetchTimelineData - Report API', new Error(`HTTP ${res.status}`), { conceptName, status: res.status, response: text.substring(0, 200) }); - return { results: [] }; - } - return res.json(); - }) - .catch(err => { - logger.error('ConceptTimelineModal', 'fetchTimelineData - Report API', err, { conceptName }); + if (!res.ok) { + const text = await res.text(); + logger.error('ConceptTimelineModal', 'fetchTimelineData - Report API (exact_match=1)', new Error(`HTTP ${res.status}`), { conceptName, status: res.status, response: text.substring(0, 200) }); return { results: [] }; - }) - ); + } + + const reportResult = await res.json(); + const reports = (reportResult.data && Array.isArray(reportResult.data.results)) + ? reportResult.data.results + : (Array.isArray(reportResult.results) ? reportResult.results : []); + + // 检查结果数量,如果不足10条则进行回退搜索 + if (reports.length < 10) { + logger.info('ConceptTimelineModal', `研报精确搜索结果不足10条 (${reports.length}),尝试模糊搜索`, { conceptName }); + + // 第二次尝试:去掉精确匹配参数 + const fallbackParams = new URLSearchParams({ + query: conceptName, + mode: 'text', + size: 30, + start_date: startDateStr + }); + + const fallbackUrl = `${REPORT_API_URL}/search?${fallbackParams}`; + const fallbackRes = await fetch(fallbackUrl); + + if (!fallbackRes.ok) { + logger.warn('ConceptTimelineModal', '研报模糊搜索失败,使用精确搜索结果', { conceptName }); + return { results: reports }; + } + + const fallbackResult = await fallbackRes.json(); + const fallbackReports = (fallbackResult.data && Array.isArray(fallbackResult.data.results)) + ? fallbackResult.data.results + : (Array.isArray(fallbackResult.results) ? fallbackResult.results : []); + + logger.info('ConceptTimelineModal', `研报模糊搜索成功,获取 ${fallbackReports.length} 条结果`, { conceptName }); + return { results: fallbackReports }; + } + + return { results: reports }; + } catch (err) { + logger.error('ConceptTimelineModal', 'fetchTimelineData - Report API', err, { conceptName }); + return { results: [] }; + } + }; + + promises.push(fetchReports()); const [priceResult, newsResult, reportResult] = await Promise.all(promises); @@ -325,85 +591,9 @@ const ConceptTimelineModal = ({ useEffect(() => { if (isOpen && conceptId) { fetchTimelineData(); - setExpandedDates({}); // 重置展开状态 } }, [isOpen, conceptId]); - // 切换日期展开状态 - const toggleDateExpand = (date) => { - const willExpand = !expandedDates[date]; - - // 🎯 追踪日期展开/折叠 - trackDateToggled(date, willExpand); - - setExpandedDates(prev => ({ - ...prev, - [date]: !prev[date] - })); - }; - - // 格式化日期显示(包含年份) - const formatDateDisplay = (dateStr) => { - const date = new Date(dateStr); - const today = new Date(); - const diffTime = today - date; - const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); - - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const formatted = `${year}-${month}-${day}`; - - if (diffDays === 0) return `今天 ${formatted}`; - if (diffDays === 1) return `昨天 ${formatted}`; - if (diffDays < 7) return `${diffDays}天前 ${formatted}`; - if (diffDays < 30) return `${Math.floor(diffDays / 7)}周前 ${formatted}`; - if (diffDays < 365) return `${Math.floor(diffDays / 30)}月前 ${formatted}`; - return formatted; - }; - - // 格式化完整时间(YYYY-MM-DD HH:mm) - const formatDateTime = (dateTimeStr) => { - if (!dateTimeStr) return '-'; - const normalized = typeof dateTimeStr === 'string' ? dateTimeStr.replace(' ', 'T') : dateTimeStr; - const dt = new Date(normalized); - if (isNaN(dt.getTime())) return '-'; - const y = dt.getFullYear(); - const m = String(dt.getMonth() + 1).padStart(2, '0'); - const d = String(dt.getDate()).padStart(2, '0'); - const hh = String(dt.getHours()).padStart(2, '0'); - const mm = String(dt.getMinutes()).padStart(2, '0'); - return `${y}-${m}-${d} ${hh}:${mm}`; - }; - - // 获取涨跌幅颜色和图标 - const getPriceInfo = (price) => { - if (!price || price.avg_change_pct === null) { - return { color: 'gray', icon: null, text: '无数据' }; - } - - const value = price.avg_change_pct; - if (value > 0) { - return { - color: 'red', - icon: FaArrowUp, - text: `+${value.toFixed(2)}%` - }; - } else if (value < 0) { - return { - color: 'green', - icon: FaArrowDown, - text: `${value.toFixed(2)}%` - }; - } else { - return { - color: 'gray', - icon: null, - text: '0.00%' - }; - } - }; - return ( <> {isOpen && ( @@ -416,19 +606,46 @@ const ConceptTimelineModal = ({ - - - {conceptName} - 历史时间轴 - + + + + {conceptName} - 历史时间轴 + + 最近100天 - + 🔥 Max版功能 @@ -463,367 +680,203 @@ const ConceptTimelineModal = ({ ) : timelineData.length > 0 ? ( - - {/* 时间轴主线 */} - - - - {timelineData.map((item, index) => { - const priceInfo = getPriceInfo(item.price); - const hasEvents = item.events.length > 0; - const isExpanded = expandedDates[item.date]; - - return ( - - - {/* 左侧 - 涨跌幅信息 */} - - - - {formatDateDisplay(item.date)} - - - {item.price && ( - - - {priceInfo.icon && ( - - )} - - {priceInfo.text} - - - - {item.price.stock_count && ( - - 统计股票: {item.price.stock_count} 只 - - )} - - )} - - {!item.price && !hasEvents && ( - - 当日无数据 - - )} - - - - {/* 中间 - 时间轴节点 */} - - hasEvents && toggleDateExpand(item.date)} - _hover={hasEvents ? { - transform: 'scale(1.2)', - bg: 'pink.500' - } : {}} - transition="all 0.2s" - > - {hasEvents && ( - <> - - {item.events.length} - - {/* 动画点击提示 */} - - - )} - - - - {/* 右侧 - 事件信息 */} - - {hasEvents ? ( - - - - - - {item.events.map((event, eventIdx) => ( - - - - - {event.type === 'news' ? '新闻' : '研报'} - - {event.source && ( - - {event.source} - - )} - {event.publisher && ( - - {event.publisher} - - )} - {event.rating && ( - - {event.rating} - - )} - - - - {event.title} - - - - {event.content || '暂无内容'} - - - - - - ))} - - - - ) : ( - - 当日无相关资讯 - - )} - - - - {/* 分隔线 */} - {index < timelineData.length - 1 && ( - - )} - - ); - })} - - - {/* 时间轴结束标记 */} - - + {/* 图例说明 */} + + - 时间轴起始点 - + + 📰 新闻 + + + + 📊 研报 + + + + 上涨 + + + + 下跌 + + + 🔥 + 涨3%+ + + + + {/* FullCalendar 日历组件 */} + + ) : ( -
- - - - 暂无历史数据 - +
+ + + + + 暂无历史数据 + + + 该概念在最近100天内没有相关事件记录 + +
)} @@ -834,8 +887,24 @@ const ConceptTimelineModal = ({ - - @@ -843,6 +912,350 @@ const ConceptTimelineModal = ({ )} + {/* 日期详情 Modal */} + {isDateDetailOpen && selectedDateData && ( + + + + + + + + + {formatDateDisplay(selectedDate)} + + + {selectedDateData.price && ( + + + + {getPriceInfo(selectedDateData.price).icon && ( + + )} + + {getPriceInfo(selectedDateData.price).text} + + + + {selectedDateData.price.stock_count && ( + + 📊 统计股票: + {selectedDateData.price.stock_count} 只 + + )} + + )} + + + + + + {selectedDateData.events && selectedDateData.events.length > 0 ? ( + + {/* 统计卡片 */} + + + + + + {selectedDateData.events.filter(e => e.type === 'news').length} + + 条新闻 + + + + + + + {selectedDateData.events.filter(e => e.type === 'report').length} + + 篇研报 + + + + + {/* 事件列表 */} + + {selectedDateData.events.map((event, eventIdx) => ( + + + + + {event.type === 'news' ? '📰 新闻' : '📊 研报'} + + {event.publisher && ( + + {event.publisher} + + )} + {event.rating && ( + + ⭐ {event.rating} + + )} + {event.security_name && ( + + 🏢 {event.security_name} + + )} + + + + {event.title} + + + + {event.content || '暂无内容'} + + + + {event.time && ( + + + + {formatDateTime(event.time)} + + + )} + + + + + + ))} + + + ) : ( +
+ + + + + 当日无新闻或研报 + + {selectedDateData.price && ( + + 仅有涨跌幅数据 + + )} + + +
+ )} +
+ + + + +
+
+ )} + {/* 研报全文Modal */} {isReportModalOpen && ( { ); }; - // 格式化日期显示 - const formatHappenedTimes = (times) => { - if (!times || times.length === 0) return null; + // 格式化添加日期显示 + const formatAddedDate = (concept) => { + // 优先使用 created_at 或 added_date 字段 + const addedDate = concept.created_at || concept.added_date || concept.happened_times?.[0]; - if (times.length === 1) { - return ( - - - {new Date(times[0]).toLocaleDateString('zh-CN')} - - ); - } - - const sortedTimes = [...times].sort((a, b) => new Date(b) - new Date(a)); - const latestDate = new Date(sortedTimes[0]).toLocaleDateString('zh-CN'); + if (!addedDate) return null; return ( - - 历史爆发日期: - {sortedTimes.map((time, idx) => ( - - {new Date(time).toLocaleDateString('zh-CN')} - - ))} -
- } - bg="purple.600" - color="white" - borderRadius="md" - p={3} - > - - - - {latestDate} (共{times.length}次) - - - + + + + 添加于 {new Date(addedDate).toLocaleDateString('zh-CN')} + + ); }; @@ -693,56 +667,141 @@ const ConceptCenter = () => { init(); }, []); - // 概念卡片组件 - 优化版 + // 概念卡片组件 - 科幻毛玻璃版 const ConceptCard = ({ concept, position = 0 }) => { const changePercent = concept.price_info?.avg_change_pct; const changeColor = getChangeColor(changePercent); const hasChange = changePercent !== null && changePercent !== undefined; + // 生成随机涨幅数字背景 + const generateNumbersBackground = () => { + const numbers = []; + for (let i = 0; i < 30; i++) { + const isPositive = Math.random() > 0.5; + const value = (Math.random() * 15).toFixed(2); + const sign = isPositive ? '+' : '-'; + numbers.push(`${sign}${value}%`); + } + return numbers; + }; + + const backgroundNumbers = generateNumbersBackground(); + return ( handleConceptClick(concept.concept_id, concept.concept, concept, position)} bg="white" borderWidth="1px" - borderColor="gray.200" + borderColor="transparent" overflow="hidden" _hover={{ transform: 'translateY(-8px)', - boxShadow: 'xl', - borderColor: 'purple.300', + boxShadow: '0 20px 40px rgba(139, 92, 246, 0.3)', + borderColor: 'purple.400', }} transition="all 0.3s" position="relative" + boxShadow="0 4px 12px rgba(0, 0, 0, 0.1)" > + {/* 毛玻璃涨幅数字背景 */} - {concept.concept} - - - } - /> + {/* 渐变背景层 */} 0 + ? "linear(135deg, #667eea 0%, #764ba2 100%)" + : hasChange && changePercent < 0 + ? "linear(135deg, #f093fb 0%, #f5576c 100%)" + : "linear(135deg, #4facfe 0%, #00f2fe 100%)" + } /> + {/* 数字矩阵层 */} + + {backgroundNumbers.map((num, idx) => ( + + {num} + + ))} + + + {/* 公司 Logo 层 */} + + Company Logo + + + {/* 毛玻璃层 */} + + + {/* 渐变遮罩 */} + + + {/* 发光边框效果 */} + + + {/* 左上角涨跌幅 Badge */} {hasChange && ( { bg={changeColor === 'red' ? 'red.500' : changeColor === 'green' ? 'green.500' : 'gray.500'} color="white" fontSize="sm" - px={2} + px={3} py={1} borderRadius="full" fontWeight="bold" - boxShadow="lg" + boxShadow="0 4px 12px rgba(0, 0, 0, 0.3)" display="flex" alignItems="center" gap={1} @@ -769,18 +828,22 @@ const ConceptCenter = () => { )} + {/* 右上角股票数量徽章 */} {concept.stock_count || 0} 只股票 @@ -788,44 +851,54 @@ const ConceptCenter = () => { - + {/* 概念名称 */} + {concept.concept} + {/* 描述信息 */} {concept.description || '暂无描述信息'} - {hasChange && concept.price_info?.trade_date && ( - - 交易日期: {new Date(concept.price_info.trade_date).toLocaleDateString('zh-CN')} - - - {formatChangePercent(changePercent)} - - - )} - {concept.stocks && concept.stocks.length > 0 && ( handleViewStocks(e, concept)} - _hover={{ bg: 'gray.100' }} - transition="background 0.2s" + _hover={{ + bg: 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(168, 85, 247, 0.1) 100%)', + transform: 'translateX(2px)', + }} + transition="all 0.2s" + border="1px solid" + borderColor="purple.100" > - - + + + 热门个股 {!hasFeatureAccess('hot_stocks') && ( - - 🔒需Pro + + 🔒Pro )} @@ -833,13 +906,20 @@ const ConceptCenter = () => { {hasFeatureAccess('hot_stocks') ? ( <> {concept.stocks.slice(0, 2).map((stock, idx) => ( - + {stock.stock_name} ))} {concept.stocks.length > 2 && ( - - +{concept.stocks.length - 2}更多 + + +{concept.stocks.length - 2} )} @@ -862,36 +942,41 @@ const ConceptCenter = () => { )} - + - {formatHappenedTimes(concept.happened_times)} + {formatAddedDate(concept)} + {/* 顶部发光条 */} { )} - {formatHappenedTimes(concept.happened_times)} + {formatAddedDate(concept)} @@ -1078,225 +1163,255 @@ const ConceptCenter = () => { ); - // 日期选择组件 + // 日期选择组件 - 科幻风格 const DateSelector = () => ( - - - 交易日期: + + + + + 交易日期: + - + - - - - - - + + + + + + - {latestTradeDate && ( - - - - - 最新数据: {latestTradeDate.toLocaleDateString('zh-CN')} - - - - )} - + {latestTradeDate && ( + + + + + 最新: {latestTradeDate.toLocaleDateString('zh-CN')} + + + + )} + + ); return ( - + {/* 导航栏已由 MainLayout 提供 */} - {/* Hero Section */} + {/* Hero Section - 精简版 */} + {/* 科幻网格背景 */} + {/* 发光球体 */} - - - - - + + + {/* 标题区域 */} + + + - + 概念中心 - - - 约下午4点更新 + + + 数据约下午4点更新 - - 大模型辅助的信息整理与呈现平台 - - - 以大模型协助汇聚与清洗多源信息,结合自主训练的领域知识图谱, -
- 并由资深分析师进行人工整合与校准,提供结构化参考信息 -
+ + + AI驱动的概念板块分析平台 · 实时追踪市场热点 · 智能挖掘投资机会 +
- - - - 实时更新 - - 毫秒级数据同步 - - - - - - 智能追踪 - - 算法智能追踪 - - - - - - 深度分析 - - 多维度数据挖掘 - - - - - - 专业可靠 - - 权威数据源保障 - - - - - }> - - 500+ - 概念板块 - - - 5000+ - 相关个股 - - - 24/7 - 全天候监控 - + {/* 核心数据展示 */} + } + bg="whiteAlpha.100" + backdropFilter="blur(10px)" + px={8} + py={3} + borderRadius="full" + border="1px solid" + borderColor="whiteAlpha.300" + boxShadow="0 8px 32px rgba(0, 0, 0, 0.3)" + > + + + + 500+ + 概念板块 + + + + + + 5000+ + 相关个股 + + + + + + 24/7 + 实时监控 + + + {/* 搜索框 */} - + - - + + { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} onKeyPress={handleKeyPress} - pr={searchQuery ? "40px" : "16px"} + pr={searchQuery ? "50px" : "16px"} /> {searchQuery && ( { icon={} variant="ghost" color="gray.500" - _hover={{ color: 'gray.700', bg: 'gray.100' }} + borderRadius="full" + _hover={{ color: 'gray.700', bg: 'gray.200' }} onClick={handleClearSearch} zIndex={1} /> @@ -1326,30 +1442,36 @@ const ConceptCenter = () => { - + {searchQuery && sortBy === '_score' && ( - + 正在搜索 "{searchQuery}",已自动切换到相关度排序 )} @@ -1370,7 +1492,14 @@ const ConceptCenter = () => { {/* 左侧概念卡片区域 */} - + { gap={4} > - 排序方式: + + 排序方式: {searchQuery && sortBy === '_score' && ( - - - + + + 智能排序 @@ -1412,10 +1554,13 @@ const ConceptCenter = () => { setViewMode('grid'); } }} - bg={viewMode === 'grid' ? 'purple.500' : 'transparent'} + bg={viewMode === 'grid' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'} color={viewMode === 'grid' ? 'white' : 'purple.500'} borderColor="purple.500" - _hover={{ bg: viewMode === 'grid' ? 'purple.600' : 'purple.50' }} + _hover={{ + bg: viewMode === 'grid' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50', + boxShadow: viewMode === 'grid' ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none', + }} aria-label="网格视图" /> { setViewMode('list'); } }} - bg={viewMode === 'list' ? 'purple.500' : 'transparent'} + bg={viewMode === 'list' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'} color={viewMode === 'list' ? 'white' : 'purple.500'} borderColor="purple.500" - _hover={{ bg: viewMode === 'list' ? 'purple.600' : 'purple.50' }} + _hover={{ + bg: viewMode === 'list' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50', + boxShadow: viewMode === 'list' ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none', + }} aria-label="列表视图" /> @@ -1474,18 +1622,39 @@ const ConceptCenter = () => { )}
- + - + {[...Array(Math.min(5, totalPages))].map((_, i) => { const pageNum = currentPage <= 3 ? i + 1 : currentPage >= totalPages - 2 ? totalPages - 4 + i : @@ -1496,8 +1665,16 @@ const ConceptCenter = () => { key={pageNum} size="sm" onClick={() => handlePageChange(pageNum)} - colorScheme="purple" - variant={pageNum === currentPage ? 'solid' : 'outline'} + bg={pageNum === currentPage ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'} + color={pageNum === currentPage ? 'white' : 'purple.600'} + variant={pageNum === currentPage ? 'solid' : 'ghost'} + borderRadius="full" + minW="40px" + fontWeight="bold" + _hover={{ + bg: pageNum === currentPage ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50', + boxShadow: pageNum === currentPage ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none', + }} > {pageNum} @@ -1509,8 +1686,19 @@ const ConceptCenter = () => { size="sm" onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))} isDisabled={currentPage === totalPages} - colorScheme="purple" - variant="outline" + bgGradient="linear(to-r, purple.500, pink.500)" + color="white" + variant="solid" + borderRadius="full" + _hover={{ + bgGradient: 'linear(to-r, purple.600, pink.600)', + boxShadow: '0 0 15px rgba(139, 92, 246, 0.4)', + }} + _disabled={{ + bg: 'gray.200', + color: 'gray.400', + cursor: 'not-allowed', + }} > 下一页 diff --git a/src/views/EventDetail/components/LimitAnalyse.js b/src/views/EventDetail/components/LimitAnalyse.js index 0c180f9f..6732efdb 100644 --- a/src/views/EventDetail/components/LimitAnalyse.js +++ b/src/views/EventDetail/components/LimitAnalyse.js @@ -819,7 +819,9 @@ const StockCard = ({ stock, idx }) => { - (\s*)/g, '\n').replace(/\n{2,}/g, '\n').replace(/\n/g, '
') }} /> + + {(stock.summary || '').replace(//gi, '\n')} +
diff --git a/src/views/LimitAnalyse/components/DataVisualizationComponents.js b/src/views/LimitAnalyse/components/DataVisualizationComponents.js index 63163f5d..d2c8ca7a 100644 --- a/src/views/LimitAnalyse/components/DataVisualizationComponents.js +++ b/src/views/LimitAnalyse/components/DataVisualizationComponents.js @@ -50,21 +50,24 @@ import { Treemap, Area, AreaChart, } from 'recharts'; +// 词云库 - 支持两种实现 import { Wordcloud } from '@visx/wordcloud'; import { scaleLog } from '@visx/scale'; import { Text as VisxText } from '@visx/text'; - +import ReactECharts from 'echarts-for-react'; +import 'echarts-wordcloud'; // 颜色配置 const CHART_COLORS = [ '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD', '#D4A5A5', '#9B6B6B', '#E9967A', '#B19CD9', '#87CEEB' ]; -// 词云颜色 +// 词云颜色常量 const WORDCLOUD_COLORS = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD']; -// 词云图组件(使用 @visx/wordcloud,兼容 React 18) -const WordCloud = ({ data }) => { +// ==================== 词云组件实现 1: @visx/wordcloud ==================== +// 使用 SVG 渲染,React 18 原生支持,配置灵活 +const VisxWordCloud = ({ data }) => { const [dimensions, setDimensions] = useState({ width: 0, height: 400 }); const containerRef = useRef(null); @@ -99,7 +102,7 @@ const WordCloud = ({ data }) => { } const words = data.slice(0, 100).map(item => ({ - text: item.name || item.text, + name: item.name || item.text, value: item.value || item.count || 1 })); @@ -151,6 +154,85 @@ const WordCloud = ({ data }) => { ); }; +// ==================== 词云组件实现 2: ECharts Wordcloud ==================== +// 使用 Canvas 渲染,内置交互效果(tooltip、emphasis),配置简单 +const EChartsWordCloud = ({ data }) => { + if (!data || data.length === 0) { + return ( +
+ + 暂无词云数据 + +
+ ); + } + + const words = data.slice(0, 100).map(item => ({ + name: item.name || item.text, + value: item.value || item.count || 1 + })); + + const option = { + tooltip: { + show: true + }, + series: [{ + type: 'wordCloud', + shape: 'circle', + left: 'center', + top: 'center', + width: '100%', + height: '100%', + sizeRange: [16, 80], + rotationRange: [-90, 0], + rotationStep: 90, + gridSize: 8, + drawOutOfBound: false, + layoutAnimation: true, + textStyle: { + fontFamily: 'Microsoft YaHei, sans-serif', + fontWeight: 'bold', + color: function () { + return WORDCLOUD_COLORS[Math.floor(Math.random() * WORDCLOUD_COLORS.length)]; + } + }, + emphasis: { + focus: 'self', + textStyle: { + textShadowBlur: 10, + textShadowColor: '#333' + } + }, + data: words + }] + }; + + return ( + + ); +}; + +// ==================== 词云组件包装器 ==================== +// 统一接口,支持切换两种实现方式 +const WordCloud = ({ data, engine = 'echarts' }) => { + if (!data || data.length === 0) { + return ( +
+ + 暂无词云数据 + +
+ ); + } + + // 根据 engine 参数选择实现方式 + return engine === 'visx' ? : ; +}; + // 板块热力图组件 const SectorHeatMap = ({ data }) => { if (!data) return null; diff --git a/src/views/Profile/index.js b/src/views/Profile/index.js new file mode 100644 index 00000000..336fcbf3 --- /dev/null +++ b/src/views/Profile/index.js @@ -0,0 +1,383 @@ +/** + * 个人中心页面 + * 包含用户信息、积分系统、交易记录等 + */ + +import React, { useState, useEffect } from 'react'; +import { + Box, + Container, + Grid, + GridItem, + Heading, + Text, + VStack, + HStack, + Avatar, + Button, + Card, + CardBody, + CardHeader, + Stat, + StatLabel, + StatNumber, + StatHelpText, + StatArrow, + Badge, + Tabs, + TabList, + TabPanels, + Tab, + TabPanel, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + Icon, + useToast, + Spinner, + Divider, +} from '@chakra-ui/react'; +import { + Wallet, + TrendingUp, + Gift, + History, + Award, + Calendar, + DollarSign, + Activity, +} from 'lucide-react'; +import { useAuth } from '@contexts/AuthContext'; +import { getUserAccount, claimDailyBonus } from '@services/predictionMarketService.api'; +import { forumColors } from '@theme/forumTheme'; + +const ProfilePage = () => { + const toast = useToast(); + const { user } = useAuth(); + + // 状态管理 + const [account, setAccount] = useState(null); + const [loading, setLoading] = useState(true); + const [claiming, setClaiming] = useState(false); + + // 加载用户积分账户 + useEffect(() => { + const fetchAccount = async () => { + if (!user) return; + + try { + setLoading(true); + const response = await getUserAccount(); + if (response.success) { + setAccount(response.data); + } + } catch (error) { + console.error('获取账户失败:', error); + toast({ + title: '加载失败', + description: '无法加载账户信息', + status: 'error', + duration: 3000, + }); + } finally { + setLoading(false); + } + }; + + fetchAccount(); + }, [user, toast]); + + // 领取每日奖励 + const handleClaimDailyBonus = async () => { + try { + setClaiming(true); + const response = await claimDailyBonus(); + + if (response.success) { + toast({ + title: '领取成功!', + description: `获得 ${response.data.bonus_amount} 积分`, + status: 'success', + duration: 3000, + }); + + // 刷新账户数据 + const accountResponse = await getUserAccount(); + if (accountResponse.success) { + setAccount(accountResponse.data); + } + } + } catch (error) { + toast({ + title: '领取失败', + description: error.response?.data?.error || '今日奖励已领取或系统错误', + status: 'error', + duration: 3000, + }); + } finally { + setClaiming(false); + } + }; + + if (loading) { + return ( + + + + 加载中... + + + ); + } + + return ( + + + {/* 用户信息头部 */} + + + + + + + + {user?.nickname || user?.username} + + + + + 会员 + + + {user?.email} + + + + + + + + {/* 积分概览 */} + + {/* 总余额 */} + + + + + + + 总余额 + + + {account?.balance?.toFixed(0) || 0} + + 积分 + + + + + + {/* 可用余额 */} + + + + + + + 可用余额 + + + {account?.available_balance?.toFixed(0) || 0} + + 积分 + + + + + + {/* 累计收益 */} + + + + + + + 累计收益 + + + +{account?.total_earned?.toFixed(0) || 0} + + + + 积分 + + + + + + + {/* 累计消费 */} + + + + + + + 累计消费 + + + {account?.total_spent?.toFixed(0) || 0} + + 积分 + + + + + + + {/* 每日签到 */} + + + + + + + 每日签到 + + + + + + + + + + + 每日登录可领取 100 积分奖励 + + + {account?.last_daily_bonus_at && ( + + 上次领取时间:{new Date(account.last_daily_bonus_at).toLocaleString('zh-CN')} + + )} + + + + + {/* 详细信息标签页 */} + + + + + + + 交易记录 + + + + 积分明细 + + + + + {/* 交易记录 */} + + + + 暂无交易记录 + + + + + {/* 积分明细 */} + + + + 暂无积分明细 + + + + + + + + + + ); +}; + +export default ProfilePage; diff --git a/src/views/ValueForum/PostDetail.js b/src/views/ValueForum/PostDetail.js index 1e9bd5e7..23350bfb 100644 --- a/src/views/ValueForum/PostDetail.js +++ b/src/views/ValueForum/PostDetail.js @@ -40,6 +40,7 @@ import { } from '@services/elasticsearchService'; import EventTimeline from './components/EventTimeline'; import CommentSection from './components/CommentSection'; +import ImagePreviewModal from '@components/ImagePreviewModal'; const MotionBox = motion(Box); @@ -53,6 +54,10 @@ const PostDetail = () => { const [isLiked, setIsLiked] = useState(false); const [likes, setLikes] = useState(0); + // 图片预览相关状态 + const [isImagePreviewOpen, setIsImagePreviewOpen] = useState(false); + const [previewImageIndex, setPreviewImageIndex] = useState(0); + // 加载帖子数据 useEffect(() => { const loadPostData = async () => { @@ -91,6 +96,12 @@ const PostDetail = () => { } }; + // 打开图片预览 + const handleImageClick = (index) => { + setPreviewImageIndex(index); + setIsImagePreviewOpen(true); + }; + // 格式化时间 const formatTime = (dateString) => { const date = new Date(dateString); @@ -272,6 +283,7 @@ const PostDetail = () => { border="1px solid" borderColor={forumColors.border.default} cursor="pointer" + onClick={() => handleImageClick(index)} _hover={{ transform: 'scale(1.05)', boxShadow: forumColors.shadows.gold, @@ -363,6 +375,14 @@ const PostDetail = () => { + + {/* 图片预览弹窗 */} + setIsImagePreviewOpen(false)} + images={post?.images || []} + initialIndex={previewImageIndex} + /> ); }; diff --git a/src/views/ValueForum/PredictionTopicDetail.js b/src/views/ValueForum/PredictionTopicDetail.js new file mode 100644 index 00000000..54c1dd7d --- /dev/null +++ b/src/views/ValueForum/PredictionTopicDetail.js @@ -0,0 +1,652 @@ +/** + * 预测话题详情页 + * 展示预测市场的完整信息、交易、评论等 + */ + +import React, { useState, useEffect } from 'react'; +import { + Box, + Container, + Heading, + Text, + Button, + HStack, + VStack, + Flex, + Badge, + Avatar, + Icon, + Progress, + Divider, + useDisclosure, + useToast, + SimpleGrid, +} from '@chakra-ui/react'; +import { + TrendingUp, + TrendingDown, + Crown, + Users, + Clock, + DollarSign, + ShoppingCart, + ArrowLeftRight, + CheckCircle2, +} from 'lucide-react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { forumColors } from '@theme/forumTheme'; +import { getTopicDetail, getUserAccount } from '@services/predictionMarketService.api'; +import { useAuth } from '@contexts/AuthContext'; +import TradeModal from './components/TradeModal'; +import PredictionCommentSection from './components/PredictionCommentSection'; +import CommentInvestModal from './components/CommentInvestModal'; + +const MotionBox = motion(Box); + +const PredictionTopicDetail = () => { + const { topicId } = useParams(); + const navigate = useNavigate(); + const toast = useToast(); + const { user } = useAuth(); + + // 状态 + const [topic, setTopic] = useState(null); + const [userAccount, setUserAccount] = useState(null); + const [tradeMode, setTradeMode] = useState('buy'); + const [selectedComment, setSelectedComment] = useState(null); + const [commentSectionKey, setCommentSectionKey] = useState(0); + + // 模态框 + const { + isOpen: isTradeModalOpen, + onOpen: onTradeModalOpen, + onClose: onTradeModalClose, + } = useDisclosure(); + + const { + isOpen: isInvestModalOpen, + onOpen: onInvestModalOpen, + onClose: onInvestModalClose, + } = useDisclosure(); + + // 加载话题数据 + useEffect(() => { + const loadTopic = async () => { + try { + const response = await getTopicDetail(topicId); + if (response.success) { + setTopic(response.data); + } else { + toast({ + title: '话题不存在', + status: 'error', + duration: 3000, + }); + navigate('/value-forum'); + } + } catch (error) { + console.error('获取话题详情失败:', error); + toast({ + title: '加载失败', + description: error.message, + status: 'error', + duration: 3000, + }); + navigate('/value-forum'); + } + }; + + const loadAccount = async () => { + if (!user) return; + try { + const response = await getUserAccount(); + if (response.success) { + setUserAccount(response.data); + } + } catch (error) { + console.error('获取账户失败:', error); + } + }; + + loadTopic(); + loadAccount(); + }, [topicId, user, toast, navigate]); + + // 打开交易弹窗 + const handleOpenTrade = (mode) => { + if (!user) { + toast({ + title: '请先登录', + status: 'warning', + duration: 3000, + }); + return; + } + + setTradeMode(mode); + onTradeModalOpen(); + }; + + // 交易成功回调 + const handleTradeSuccess = async () => { + // 刷新话题数据 + try { + const topicResponse = await getTopicDetail(topicId); + if (topicResponse.success) { + setTopic(topicResponse.data); + } + + const accountResponse = await getUserAccount(); + if (accountResponse.success) { + setUserAccount(accountResponse.data); + } + } catch (error) { + console.error('刷新数据失败:', error); + } + }; + + // 打开投资弹窗 + const handleOpenInvest = (comment) => { + if (!user) { + toast({ + title: '请先登录', + status: 'warning', + duration: 3000, + }); + return; + } + + setSelectedComment(comment); + onInvestModalOpen(); + }; + + // 投资成功回调 + const handleInvestSuccess = async () => { + // 刷新账户数据 + try { + const accountResponse = await getUserAccount(); + if (accountResponse.success) { + setUserAccount(accountResponse.data); + } + + // 刷新评论区(通过更新key触发重新加载) + setCommentSectionKey((prev) => prev + 1); + + toast({ + title: '投资成功', + description: '评论列表已刷新', + status: 'success', + duration: 2000, + }); + } catch (error) { + console.error('刷新数据失败:', error); + } + }; + + if (!topic) { + return null; + } + + // 获取选项数据(从后端扁平结构映射到前端使用的嵌套结构) + const yesData = { + total_shares: topic.yes_total_shares || 0, + current_price: topic.yes_price || 500, + lord_id: topic.yes_lord_id || null, + }; + const noData = { + total_shares: topic.no_total_shares || 0, + current_price: topic.no_price || 500, + lord_id: topic.no_lord_id || null, + }; + + // 计算总份额 + const totalShares = yesData.total_shares + noData.total_shares; + + // 计算百分比 + const yesPercent = totalShares > 0 ? (yesData.total_shares / totalShares) * 100 : 50; + const noPercent = totalShares > 0 ? (noData.total_shares / totalShares) * 100 : 50; + + // 格式化时间 + const formatTime = (dateString) => { + const date = new Date(dateString); + const now = new Date(); + const diff = date - now; + + const days = Math.floor(diff / 86400000); + const hours = Math.floor(diff / 3600000); + + if (days > 0) return `${days}天后`; + if (hours > 0) return `${hours}小时后`; + return '即将截止'; + }; + + return ( + + + {/* 头部:返回按钮 */} + + + + {/* 左侧:主要内容 */} + + + {/* 话题信息卡片 */} + + {/* 头部 */} + + + + {topic.category} + + + + + + {formatTime(topic.deadline)} 截止 + + + + + + {topic.title} + + + + {topic.description} + + + {/* 作者信息 */} + + + + + {topic.author_name} + + + 发起者 + + + + + + {/* 市场数据 */} + + + {/* Yes 方 */} + + {yesData.lord_id && ( + + + + 领主 + + + )} + + + + + + 看涨 / Yes + + + + + + 当前价格 + + + + {Math.round(yesData.current_price)} + + + 积分/份 + + + + + + + + + 总份额 + + + {yesData.total_shares}份 + + + + + + 市场占比 + + + {yesPercent.toFixed(1)}% + + + + + + {/* No 方 */} + + {noData.lord_id && ( + + + + 领主 + + + )} + + + + + + 看跌 / No + + + + + + 当前价格 + + + + {Math.round(noData.current_price)} + + + 积分/份 + + + + + + + + + 总份额 + + + {noData.total_shares}份 + + + + + + 市场占比 + + + {noPercent.toFixed(1)}% + + + + + + + {/* 市场情绪进度条 */} + + + + 市场情绪分布 + + + {yesPercent.toFixed(1)}% vs {noPercent.toFixed(1)}% + + + div': { + bg: 'linear-gradient(90deg, #48BB78 0%, #38A169 100%)', + }, + }} + /> + + + + + + + {/* 右侧:操作面板 */} + + + {/* 奖池信息 */} + + + + + + 当前奖池 + + + + + {topic.total_pool} + + + + 积分 + + + + + + 参与人数 + + + + {topic.participants_count || 0} + + + + + + 总交易量 + + {Math.round((topic.yes_total_shares || 0) + (topic.no_total_shares || 0))} + + + + + + {/* 交易按钮 */} + {topic.status === 'active' && ( + + + + + + )} + + {/* 用户余额 */} + {user && userAccount && ( + + + + 可用余额 + + {userAccount.balance} 积分 + + + + 冻结积分 + + {userAccount.frozen} 积分 + + + + + )} + + + + {/* 评论区 - 占据全宽 */} + + + + + + + + + {/* 交易模态框 */} + + + {/* 观点投资模态框 */} + + + ); +}; + +export default PredictionTopicDetail; diff --git a/src/views/ValueForum/components/CommentInvestModal.js b/src/views/ValueForum/components/CommentInvestModal.js new file mode 100644 index 00000000..f643cb1b --- /dev/null +++ b/src/views/ValueForum/components/CommentInvestModal.js @@ -0,0 +1,464 @@ +/** + * 观点IPO投资弹窗组件 + * 用于投资评论观点 + */ + +import React, { useState, useEffect } from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + ModalCloseButton, + Button, + VStack, + HStack, + Text, + Box, + Icon, + Slider, + SliderTrack, + SliderFilledTrack, + SliderThumb, + Flex, + useToast, + Avatar, + Badge, + Divider, +} from '@chakra-ui/react'; +import { TrendingUp, DollarSign, AlertCircle, Lightbulb, Crown } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { forumColors } from '@theme/forumTheme'; +import { + investComment, + getUserAccount, +} from '@services/predictionMarketService.api'; +import { useAuth } from '@contexts/AuthContext'; + +const MotionBox = motion(Box); + +const CommentInvestModal = ({ isOpen, onClose, comment, topic, onInvestSuccess }) => { + const toast = useToast(); + const { user } = useAuth(); + + const [shares, setShares] = useState(1); + const [isSubmitting, setIsSubmitting] = useState(false); + const [userAccount, setUserAccount] = useState(null); + + // 异步获取用户账户 + useEffect(() => { + const fetchAccount = async () => { + if (!user || !isOpen) return; + try { + const response = await getUserAccount(); + if (response.success) { + setUserAccount(response.data); + } + } catch (error) { + console.error('获取账户失败:', error); + } + }; + fetchAccount(); + }, [user, isOpen]); + + // 重置状态 + useEffect(() => { + if (isOpen) { + setShares(1); + } + }, [isOpen]); + + if (!comment || !userAccount) return null; + + // 计算投资成本(后端算法:基础价格100 + 已有投资额/10) + const basePrice = 100; + const priceIncrease = (comment.total_investment || 0) / 10; + const pricePerShare = basePrice + priceIncrease; + const totalCost = Math.round(pricePerShare * shares); + + // 预期收益(如果预测正确,获得1.5倍回报) + const expectedReturn = Math.round(totalCost * 1.5); + const expectedProfit = expectedReturn - totalCost; + + // 检查是否可以投资 + const canInvest = () => { + // 检查余额 + if (userAccount.balance < totalCost) { + return { ok: false, reason: '积分不足' }; + } + + // 不能投资自己的评论 + if (comment.user?.id === user?.id) { + return { ok: false, reason: '不能投资自己的评论' }; + } + + // 检查是否已结算 + if (comment.is_verified) { + return { ok: false, reason: '该评论已结算' }; + } + + return { ok: true }; + }; + + const investCheck = canInvest(); + + // 处理投资 + const handleInvest = async () => { + try { + setIsSubmitting(true); + + const response = await investComment(comment.id, shares); + + if (response.success) { + toast({ + title: '投资成功!', + description: `投资${totalCost}积分,剩余 ${response.data.new_balance} 积分`, + status: 'success', + duration: 3000, + }); + + // 刷新账户数据 + const accountResponse = await getUserAccount(); + if (accountResponse.success) { + setUserAccount(accountResponse.data); + } + + // 通知父组件刷新 + if (onInvestSuccess) { + onInvestSuccess(response.data); + } + + onClose(); + } + } catch (error) { + console.error('投资失败:', error); + toast({ + title: '投资失败', + description: error.response?.data?.error || error.message, + status: 'error', + duration: 3000, + }); + } finally { + setIsSubmitting(false); + } + }; + + // 判断是否是庄主 + const isYesLord = comment.user?.id === topic?.yes_lord_id; + const isNoLord = comment.user?.id === topic?.no_lord_id; + const isLord = isYesLord || isNoLord; + + return ( + + + + + + + + 投资观点 + + + + + + + + {/* 评论作者信息 */} + + + + + + + {comment.user?.nickname || comment.user?.username || '匿名用户'} + + {isLord && ( + + + {isYesLord ? 'YES庄' : 'NO庄'} + + )} + + + + + {/* 评论内容 */} + + {comment.content} + + + + {/* 当前投资统计 */} + + + + 已有投资人数 + + {comment.investor_count || 0} 人 + + + + 总投资额 + + {comment.total_investment || 0} 积分 + + + + 当前价格 + + {Math.round(pricePerShare)} 积分/份 + + + + + + {/* 投资份额 */} + + + + 投资份额 + + + {shares} 份 + + + + + + + + + + + + + + 1份 + 10份 (最大) + + + + + + {/* 费用明细 */} + + + + 单价 + + {Math.round(pricePerShare)} 积分/份 + + + + + 份额 + + {shares} 份 + + + + + + + 投资总额 + + + + + {totalCost} + + + 积分 + + + + + + {/* 预期收益 */} + + + + + 预测正确收益(1.5倍) + + + +{expectedProfit} 积分 + + + + 预测正确将获得 {expectedReturn} 积分(含本金) + + + + + + {/* 余额提示 */} + + + 你的余额: + + {userAccount.balance} 积分 + + + + 投资后: + = totalCost + ? forumColors.success[500] + : forumColors.error[500] + } + > + {userAccount.balance - totalCost} 积分 + + + + + {/* 警告提示 */} + {!investCheck.ok && ( + + + + + {investCheck.reason} + + + + )} + + {/* 风险提示 */} + + + + + 投资风险提示 + + + + 观点预测存在不确定性,预测错误将损失全部投资。请谨慎评估后再投资。 + + + + + + + + + + + + + + ); +}; + +export default CommentInvestModal; diff --git a/src/views/ValueForum/components/CreatePostModal.js b/src/views/ValueForum/components/CreatePostModal.js index 81d1953e..2362e720 100644 --- a/src/views/ValueForum/components/CreatePostModal.js +++ b/src/views/ValueForum/components/CreatePostModal.js @@ -70,22 +70,146 @@ const CreatePostModal = ({ isOpen, onClose, onPostCreated }) => { return Object.keys(newErrors).length === 0; }; - // 处理图片上传 - const handleImageUpload = (e) => { - const files = Array.from(e.target.files); + // 压缩图片 + const compressImage = (file) => { + return new Promise((resolve, reject) => { + // 检查文件类型 + if (!file.type.startsWith('image/')) { + reject(new Error('请选择图片文件')); + return; + } + + // 检查文件大小(10MB 限制,给压缩留空间) + if (file.size > 10 * 1024 * 1024) { + reject(new Error('图片大小不能超过 10MB')); + return; + } - files.forEach((file) => { const reader = new FileReader(); - reader.onloadend = () => { - setFormData((prev) => ({ - ...prev, - images: [...prev.images, reader.result], - })); + + reader.onload = function(e) { + const img = document.createElement('img'); + + img.onload = function() { + try { + // 创建 canvas 进行压缩 + const canvas = document.createElement('canvas'); + let width = img.width; + let height = img.height; + + // 如果图片尺寸过大,等比缩放到最大 1920px + const maxDimension = 1920; + if (width > maxDimension || height > maxDimension) { + if (width > height) { + height = Math.round((height * maxDimension) / width); + width = maxDimension; + } else { + width = Math.round((width * maxDimension) / height); + height = maxDimension; + } + } + + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, width, height); + + // 转换为 Data URL(JPEG 格式,质量 0.8) + try { + const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.8); + + // 计算压缩率 + const originalSize = file.size; + const compressedSize = Math.round((compressedDataUrl.length * 3) / 4); // Base64 解码后的大小 + + console.log( + `图片压缩: ${(originalSize / 1024).toFixed(2)}KB -> ${(compressedSize / 1024).toFixed(2)}KB` + ); + + resolve(compressedDataUrl); + } catch (error) { + reject(new Error('图片压缩失败')); + } + } catch (error) { + reject(new Error(`图片处理失败: ${error.message}`)); + } + }; + + img.onerror = function() { + reject(new Error('图片加载失败')); + }; + + img.src = e.target.result; }; + + reader.onerror = function() { + reject(new Error('文件读取失败')); + }; + reader.readAsDataURL(file); }); }; + // 处理图片上传 + const handleImageUpload = async (e) => { + const files = Array.from(e.target.files); + + // 清空 input 以支持重复上传同一文件 + e.target.value = ''; + + if (files.length === 0) return; + + // 检查总数量限制 + if (formData.images.length + files.length > 9) { + toast({ + title: '图片数量超限', + description: '最多只能上传 9 张图片', + status: 'warning', + duration: 3000, + }); + return; + } + + // 逐个处理图片,而不是使用 Promise.all + const compressedImages = []; + let hasError = false; + + for (let i = 0; i < files.length; i++) { + try { + const compressed = await compressImage(files[i]); + compressedImages.push(compressed); + } catch (error) { + console.error('图片压缩失败:', error); + hasError = true; + toast({ + title: '图片处理失败', + description: error.message || `第 ${i + 1} 张图片处理失败`, + status: 'error', + duration: 3000, + }); + break; // 遇到错误就停止 + } + } + + // 如果有成功压缩的图片,添加到表单 + if (compressedImages.length > 0) { + setFormData((prev) => ({ + ...prev, + images: [...prev.images, ...compressedImages], + })); + + if (!hasError) { + toast({ + title: '上传成功', + description: `成功添加 ${compressedImages.length} 张图片`, + status: 'success', + duration: 2000, + }); + } + } + }; + // 移除图片 const removeImage = (index) => { setFormData((prev) => ({ diff --git a/src/views/ValueForum/components/CreatePredictionModal.js b/src/views/ValueForum/components/CreatePredictionModal.js new file mode 100644 index 00000000..f5f173a1 --- /dev/null +++ b/src/views/ValueForum/components/CreatePredictionModal.js @@ -0,0 +1,401 @@ +/** + * 创建预测话题模态框 + * 用户可以发起新的预测市场话题 + */ + +import React, { useState, useEffect } from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + ModalCloseButton, + Button, + VStack, + FormControl, + FormLabel, + Input, + Textarea, + Select, + HStack, + Text, + Box, + Icon, + Alert, + AlertIcon, + useToast, +} from '@chakra-ui/react'; +import { Zap, Calendar, DollarSign } from 'lucide-react'; +import { forumColors } from '@theme/forumTheme'; +import { createTopic, getUserAccount } from '@services/predictionMarketService.api'; +import { CREDIT_CONFIG } from '@services/creditSystemService'; +import { useAuth } from '@contexts/AuthContext'; + +const CreatePredictionModal = ({ isOpen, onClose, onTopicCreated }) => { + const toast = useToast(); + const { user } = useAuth(); + + // 表单状态 + const [formData, setFormData] = useState({ + title: '', + description: '', + category: 'stock', + deadline_days: 7, + }); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [userAccount, setUserAccount] = useState(null); + + // 异步获取用户余额 + useEffect(() => { + const fetchAccount = async () => { + if (!user || !isOpen) return; + try { + const response = await getUserAccount(); + if (response.success) { + setUserAccount(response.data); + } + } catch (error) { + console.error('获取账户失败:', error); + } + }; + fetchAccount(); + }, [user, isOpen]); + + // 处理表单变化 + const handleChange = (field, value) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + // 提交表单 + const handleSubmit = async () => { + try { + setIsSubmitting(true); + + // 验证 + if (!formData.title.trim()) { + toast({ + title: '请填写话题标题', + status: 'warning', + duration: 3000, + }); + return; + } + + if (!formData.description.trim()) { + toast({ + title: '请填写话题描述', + status: 'warning', + duration: 3000, + }); + return; + } + + // 检查余额 + if (!userAccount || userAccount.balance < CREDIT_CONFIG.CREATE_TOPIC_COST) { + toast({ + title: '积分不足', + description: `创建话题需要${CREDIT_CONFIG.CREATE_TOPIC_COST}积分`, + status: 'error', + duration: 3000, + }); + return; + } + + // 计算截止时间 + const deadline = new Date(); + deadline.setDate(deadline.getDate() + parseInt(formData.deadline_days)); + + // 调用 API 创建话题 + const response = await createTopic({ + title: formData.title, + description: formData.description, + category: formData.category, + deadline: deadline.toISOString(), + }); + + if (response.success) { + toast({ + title: '创建成功!', + description: `话题已发布,剩余 ${response.data.new_balance} 积分`, + status: 'success', + duration: 3000, + }); + + // 重置表单 + setFormData({ + title: '', + description: '', + category: 'stock', + deadline_days: 7, + }); + + // 通知父组件 + if (onTopicCreated) { + onTopicCreated(response.data); + } + + onClose(); + + // 刷新账户数据 + const accountResponse = await getUserAccount(); + if (accountResponse.success) { + setUserAccount(accountResponse.data); + } + } + } catch (error) { + console.error('创建话题失败:', error); + toast({ + title: '创建失败', + description: error.message, + status: 'error', + duration: 3000, + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + + + 发起预测话题 + + + + + + + {/* 提示信息 */} + + + + + 创建预测话题 + + + • 创建费用:{CREDIT_CONFIG.CREATE_TOPIC_COST}积分(进入奖池) + + + • 作者不能参与自己发起的话题 + + + • 截止后由作者提交结果进行结算 + + + + + {/* 话题标题 */} + + + 话题标题 + + handleChange('title', e.target.value)} + bg={forumColors.background.main} + border="1px solid" + borderColor={forumColors.border.default} + color={forumColors.text.primary} + _placeholder={{ color: forumColors.text.tertiary }} + _hover={{ borderColor: forumColors.border.light }} + _focus={{ + borderColor: forumColors.border.gold, + boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`, + }} + /> + + + {/* 话题描述 */} + + + 话题描述 + +