update pay function

This commit is contained in:
2025-11-24 08:05:19 +08:00
parent c594650aa4
commit b4dcbd1db9
11 changed files with 1063 additions and 1871 deletions

198
README.md
View File

@@ -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): 使用组件化架构替换内联渲染函数`
---

View File

@@ -1,218 +0,0 @@
-- 预测市场数据库表创建脚本
-- 数据库: stock (222.128.1.157:33060)
-- 执行时间: 2025-11-23
-- 1. 用户积分账户表
CREATE TABLE IF NOT EXISTS user_credit_account (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL UNIQUE,
balance FLOAT DEFAULT 10000.0 NOT NULL COMMENT '积分余额',
frozen_balance FLOAT DEFAULT 0.0 NOT NULL COMMENT '冻结积分',
total_earned FLOAT DEFAULT 0.0 NOT NULL COMMENT '累计获得',
total_spent FLOAT DEFAULT 0.0 NOT NULL COMMENT '累计消费',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_daily_bonus_at DATETIME COMMENT '最后一次领取每日奖励时间',
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE,
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户积分账户';
-- 2. 预测话题表
CREATE TABLE IF NOT EXISTS prediction_topic (
id INT AUTO_INCREMENT PRIMARY KEY,
creator_id INT NOT NULL,
title VARCHAR(200) NOT NULL COMMENT '话题标题',
description TEXT COMMENT '话题描述',
category VARCHAR(50) DEFAULT 'stock' COMMENT '分类: stock/index/concept/policy/event/other',
-- 市场数据
yes_total_shares INT DEFAULT 0 NOT NULL COMMENT 'YES方总份额',
no_total_shares INT DEFAULT 0 NOT NULL COMMENT 'NO方总份额',
yes_price FLOAT DEFAULT 500.0 NOT NULL COMMENT 'YES方价格0-1000',
no_price FLOAT DEFAULT 500.0 NOT NULL COMMENT 'NO方价格0-1000',
-- 奖池
total_pool FLOAT DEFAULT 0.0 NOT NULL COMMENT '总奖池2%交易税累积)',
-- 领主信息
yes_lord_id INT COMMENT 'YES方领主',
no_lord_id INT COMMENT 'NO方领主',
-- 状态
status VARCHAR(20) DEFAULT 'active' NOT NULL COMMENT '状态: active/settled/cancelled',
result VARCHAR(10) COMMENT '结算结果: yes/no/draw',
-- 时间
deadline DATETIME NOT NULL COMMENT '截止时间',
settled_at DATETIME COMMENT '结算时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 统计
views_count INT DEFAULT 0,
comments_count INT DEFAULT 0,
participants_count INT DEFAULT 0,
FOREIGN KEY (creator_id) REFERENCES user(id),
FOREIGN KEY (yes_lord_id) REFERENCES user(id),
FOREIGN KEY (no_lord_id) REFERENCES user(id),
INDEX idx_creator_id (creator_id),
INDEX idx_status (status),
INDEX idx_category (category),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预测话题';
-- 3. 用户持仓表
CREATE TABLE IF NOT EXISTS prediction_position (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
topic_id INT NOT NULL,
direction VARCHAR(3) NOT NULL COMMENT '方向: yes/no',
shares INT DEFAULT 0 NOT NULL COMMENT '持有份额',
avg_cost FLOAT DEFAULT 0.0 NOT NULL COMMENT '平均成本',
total_invested FLOAT DEFAULT 0.0 NOT NULL COMMENT '总投入',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE,
FOREIGN KEY (topic_id) REFERENCES prediction_topic(id) ON DELETE CASCADE,
UNIQUE KEY unique_position (user_id, topic_id, direction),
INDEX idx_user_topic (user_id, topic_id),
INDEX idx_topic (topic_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户持仓';
-- 4. 交易记录表
CREATE TABLE IF NOT EXISTS prediction_transaction (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
topic_id INT NOT NULL,
direction VARCHAR(3) NOT NULL COMMENT '方向: yes/no',
trade_type VARCHAR(10) NOT NULL COMMENT '交易类型: buy/sell',
shares INT NOT NULL COMMENT '交易份额',
price FLOAT NOT NULL COMMENT '交易价格',
amount FLOAT NOT NULL COMMENT '交易金额',
tax FLOAT DEFAULT 0.0 NOT NULL COMMENT '交易税',
total_cost FLOAT NOT NULL COMMENT '总成本(含税)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id),
FOREIGN KEY (topic_id) REFERENCES prediction_topic(id),
INDEX idx_user_id (user_id),
INDEX idx_topic_id (topic_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易记录';
-- 5. 话题评论表
CREATE TABLE IF NOT EXISTS topic_comment (
id INT AUTO_INCREMENT PRIMARY KEY,
topic_id INT NOT NULL,
user_id INT NOT NULL,
content TEXT NOT NULL COMMENT '评论内容',
direction VARCHAR(3) COMMENT '预测方向: yes/no',
is_published BOOLEAN DEFAULT FALSE NOT NULL COMMENT '是否已发布',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 观点IPO 相关
total_investment INT DEFAULT 0 NOT NULL COMMENT '总投资额',
investor_count INT DEFAULT 0 NOT NULL COMMENT '投资人数',
is_verified BOOLEAN DEFAULT FALSE NOT NULL COMMENT '是否已验证',
verification_result VARCHAR(20) COMMENT '验证结果: correct/incorrect',
position_rank INT COMMENT '评论位置排名',
FOREIGN KEY (topic_id) REFERENCES prediction_topic(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_topic_id (topic_id),
INDEX idx_user_id (user_id),
INDEX idx_position_rank (position_rank)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='话题评论';
-- 6. 评论投资记录表观点IPO
CREATE TABLE IF NOT EXISTS comment_investment (
id INT AUTO_INCREMENT PRIMARY KEY,
comment_id INT NOT NULL,
user_id INT NOT NULL,
shares INT NOT NULL COMMENT '投资份额',
amount INT NOT NULL COMMENT '投资金额',
avg_price FLOAT NOT NULL COMMENT '平均价格',
status VARCHAR(20) DEFAULT 'active' NOT NULL COMMENT '状态: active/settled',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (comment_id) REFERENCES topic_comment(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_comment_id (comment_id),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论投资记录';
-- 7. 评论位置竞拍记录(首发权拍卖)
CREATE TABLE IF NOT EXISTS comment_position_bid (
id INT AUTO_INCREMENT PRIMARY KEY,
topic_id INT NOT NULL,
user_id INT NOT NULL,
position INT NOT NULL COMMENT '位置1/2/3',
bid_amount INT NOT NULL COMMENT '竞拍金额',
status VARCHAR(20) DEFAULT 'pending' NOT NULL COMMENT '状态: pending/won/outbid',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL COMMENT '过期时间',
FOREIGN KEY (topic_id) REFERENCES prediction_topic(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_topic_id (topic_id),
INDEX idx_user_id (user_id),
INDEX idx_position (topic_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论位置竞拍记录';
-- 8. 时间胶囊话题表
CREATE TABLE IF NOT EXISTS time_capsule_topic (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(200) NOT NULL COMMENT '话题标题',
description TEXT COMMENT '话题描述',
encrypted_content TEXT COMMENT '加密的预测内容',
encryption_key VARCHAR(500) COMMENT '加密密钥(后端存储)',
start_year INT NOT NULL COMMENT '开始年份',
end_year INT NOT NULL COMMENT '结束年份',
status VARCHAR(20) DEFAULT 'active' NOT NULL COMMENT '状态: active/settled/cancelled',
is_decrypted BOOLEAN DEFAULT FALSE NOT NULL COMMENT '是否已解密',
actual_happened_year INT COMMENT '实际发生年份',
total_pool INT DEFAULT 0 NOT NULL COMMENT '总奖池',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_year_range (start_year, end_year)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='时间胶囊话题';
-- 9. 时间胶囊时间段表
CREATE TABLE IF NOT EXISTS time_capsule_time_slot (
id INT AUTO_INCREMENT PRIMARY KEY,
topic_id INT NOT NULL,
year_start INT NOT NULL COMMENT '年份区间开始',
year_end INT NOT NULL COMMENT '年份区间结束',
current_holder_id INT COMMENT '当前持有者',
current_price INT DEFAULT 100 NOT NULL COMMENT '当前价格',
total_bids INT DEFAULT 0 NOT NULL COMMENT '总竞拍次数',
status VARCHAR(20) DEFAULT 'active' NOT NULL COMMENT '状态: active/settled',
FOREIGN KEY (topic_id) REFERENCES time_capsule_topic(id) ON DELETE CASCADE,
FOREIGN KEY (current_holder_id) REFERENCES user(id),
INDEX idx_topic_id (topic_id),
INDEX idx_holder (current_holder_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='时间胶囊时间段';
-- 10. 时间段竞拍记录表
CREATE TABLE IF NOT EXISTS time_slot_bid (
id INT AUTO_INCREMENT PRIMARY KEY,
slot_id INT NOT NULL,
user_id INT NOT NULL,
bid_amount INT NOT NULL COMMENT '竞拍金额',
status VARCHAR(20) DEFAULT 'outbid' NOT NULL COMMENT '状态: current/outbid/won',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (slot_id) REFERENCES time_capsule_time_slot(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_slot_id (slot_id),
INDEX idx_user_id (user_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='时间段竞拍记录';

View File

@@ -1,134 +0,0 @@
-- 修复预测市场表结构 - 完整版
-- 添加所有缺失的字段
-- 如果字段已存在会报错,可以忽略
-- 执行前请备份数据库!
USE stock;
-- ==================== 完整修复 prediction_topic 表 ====================
-- 基本信息字段
ALTER TABLE prediction_topic ADD COLUMN creator_id INT NOT NULL COMMENT '创建者ID' AFTER id;
ALTER TABLE prediction_topic ADD COLUMN title VARCHAR(200) NOT NULL COMMENT '话题标题' AFTER creator_id;
ALTER TABLE prediction_topic ADD COLUMN description TEXT COMMENT '话题描述' AFTER title;
ALTER TABLE prediction_topic ADD COLUMN category VARCHAR(50) DEFAULT 'stock' COMMENT '分类' AFTER description;
-- 市场数据字段
ALTER TABLE prediction_topic ADD COLUMN yes_total_shares INT DEFAULT 0 NOT NULL COMMENT 'YES方总份额' AFTER category;
ALTER TABLE prediction_topic ADD COLUMN no_total_shares INT DEFAULT 0 NOT NULL COMMENT 'NO方总份额' AFTER yes_total_shares;
ALTER TABLE prediction_topic ADD COLUMN yes_price FLOAT DEFAULT 500.0 NOT NULL COMMENT 'YES方价格0-1000' AFTER no_total_shares;
ALTER TABLE prediction_topic ADD COLUMN no_price FLOAT DEFAULT 500.0 NOT NULL COMMENT 'NO方价格0-1000' AFTER yes_price;
-- 奖池
ALTER TABLE prediction_topic ADD COLUMN total_pool FLOAT DEFAULT 0.0 NOT NULL COMMENT '总奖池2%交易税累积)' AFTER no_price;
-- 领主信息
ALTER TABLE prediction_topic ADD COLUMN yes_lord_id INT COMMENT 'YES方领主' AFTER total_pool;
ALTER TABLE prediction_topic ADD COLUMN no_lord_id INT COMMENT 'NO方领主' AFTER yes_lord_id;
-- 状态
ALTER TABLE prediction_topic ADD COLUMN status VARCHAR(20) DEFAULT 'active' NOT NULL COMMENT '状态: active/settled/cancelled' AFTER no_lord_id;
ALTER TABLE prediction_topic ADD COLUMN result VARCHAR(10) COMMENT '结算结果: yes/no/draw' AFTER status;
-- 时间
ALTER TABLE prediction_topic ADD COLUMN deadline DATETIME NOT NULL COMMENT '截止时间' AFTER result;
ALTER TABLE prediction_topic ADD COLUMN settled_at DATETIME COMMENT '结算时间' AFTER deadline;
ALTER TABLE prediction_topic ADD COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER settled_at;
ALTER TABLE prediction_topic ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at;
-- 统计
ALTER TABLE prediction_topic ADD COLUMN views_count INT DEFAULT 0 AFTER updated_at;
ALTER TABLE prediction_topic ADD COLUMN comments_count INT DEFAULT 0 AFTER views_count;
ALTER TABLE prediction_topic ADD COLUMN participants_count INT DEFAULT 0 AFTER comments_count;
-- 添加外键约束
ALTER TABLE prediction_topic ADD CONSTRAINT fk_prediction_creator FOREIGN KEY (creator_id) REFERENCES user(id);
ALTER TABLE prediction_topic ADD CONSTRAINT fk_prediction_yes_lord FOREIGN KEY (yes_lord_id) REFERENCES user(id);
ALTER TABLE prediction_topic ADD CONSTRAINT fk_prediction_no_lord FOREIGN KEY (no_lord_id) REFERENCES user(id);
-- 添加索引
ALTER TABLE prediction_topic ADD INDEX idx_creator_id (creator_id);
ALTER TABLE prediction_topic ADD INDEX idx_status (status);
ALTER TABLE prediction_topic ADD INDEX idx_category (category);
ALTER TABLE prediction_topic ADD INDEX idx_created_at (created_at);
-- ==================== 完整修复 prediction_position 表(用户持仓)====================
ALTER TABLE prediction_position ADD COLUMN id INT AUTO_INCREMENT PRIMARY KEY FIRST;
ALTER TABLE prediction_position ADD COLUMN user_id INT NOT NULL AFTER id;
ALTER TABLE prediction_position ADD COLUMN topic_id INT NOT NULL AFTER user_id;
ALTER TABLE prediction_position ADD COLUMN direction VARCHAR(3) NOT NULL COMMENT '方向: yes/no' AFTER topic_id;
ALTER TABLE prediction_position ADD COLUMN shares INT DEFAULT 0 NOT NULL COMMENT '持有份额' AFTER direction;
ALTER TABLE prediction_position ADD COLUMN avg_cost FLOAT DEFAULT 0.0 NOT NULL COMMENT '平均成本' AFTER shares;
ALTER TABLE prediction_position ADD COLUMN total_invested FLOAT DEFAULT 0.0 NOT NULL COMMENT '总投入' AFTER avg_cost;
ALTER TABLE prediction_position ADD COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER total_invested;
ALTER TABLE prediction_position ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at;
-- 外键和唯一约束
ALTER TABLE prediction_position ADD CONSTRAINT fk_position_user FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE;
ALTER TABLE prediction_position ADD CONSTRAINT fk_position_topic FOREIGN KEY (topic_id) REFERENCES prediction_topic(id) ON DELETE CASCADE;
ALTER TABLE prediction_position ADD UNIQUE KEY unique_position (user_id, topic_id, direction);
ALTER TABLE prediction_position ADD INDEX idx_user_topic (user_id, topic_id);
ALTER TABLE prediction_position ADD INDEX idx_topic (topic_id);
-- ==================== 完整修复 prediction_transaction 表(交易记录)====================
ALTER TABLE prediction_transaction ADD COLUMN id INT AUTO_INCREMENT PRIMARY KEY FIRST;
ALTER TABLE prediction_transaction ADD COLUMN user_id INT NOT NULL AFTER id;
ALTER TABLE prediction_transaction ADD COLUMN topic_id INT NOT NULL AFTER user_id;
ALTER TABLE prediction_transaction ADD COLUMN direction VARCHAR(3) NOT NULL COMMENT '方向: yes/no' AFTER topic_id;
ALTER TABLE prediction_transaction ADD COLUMN trade_type VARCHAR(10) NOT NULL COMMENT '交易类型: buy/sell' AFTER direction;
ALTER TABLE prediction_transaction ADD COLUMN shares INT NOT NULL COMMENT '交易份额' AFTER trade_type;
ALTER TABLE prediction_transaction ADD COLUMN price FLOAT NOT NULL COMMENT '交易价格' AFTER shares;
ALTER TABLE prediction_transaction ADD COLUMN amount FLOAT NOT NULL COMMENT '交易金额' AFTER price;
ALTER TABLE prediction_transaction ADD COLUMN tax FLOAT DEFAULT 0.0 NOT NULL COMMENT '交易税' AFTER amount;
ALTER TABLE prediction_transaction ADD COLUMN total_cost FLOAT NOT NULL COMMENT '总成本(含税)' AFTER tax;
ALTER TABLE prediction_transaction ADD COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER total_cost;
-- 外键和索引
ALTER TABLE prediction_transaction ADD CONSTRAINT fk_transaction_user FOREIGN KEY (user_id) REFERENCES user(id);
ALTER TABLE prediction_transaction ADD CONSTRAINT fk_transaction_topic FOREIGN KEY (topic_id) REFERENCES prediction_topic(id);
ALTER TABLE prediction_transaction ADD INDEX idx_user_id (user_id);
ALTER TABLE prediction_transaction ADD INDEX idx_topic_id (topic_id);
ALTER TABLE prediction_transaction ADD INDEX idx_created_at (created_at);
-- ==================== 完整修复 topic_comment 表(话题评论 + 观点IPO====================
ALTER TABLE topic_comment ADD COLUMN id INT AUTO_INCREMENT PRIMARY KEY FIRST;
ALTER TABLE topic_comment ADD COLUMN topic_id INT NOT NULL AFTER id;
ALTER TABLE topic_comment ADD COLUMN user_id INT NOT NULL AFTER topic_id;
ALTER TABLE topic_comment ADD COLUMN content TEXT NOT NULL COMMENT '评论内容' AFTER user_id;
ALTER TABLE topic_comment ADD COLUMN direction VARCHAR(3) COMMENT '预测方向: yes/no' AFTER content;
ALTER TABLE topic_comment ADD COLUMN is_published BOOLEAN DEFAULT FALSE NOT NULL COMMENT '是否已发布' AFTER direction;
ALTER TABLE topic_comment ADD COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER is_published;
ALTER TABLE topic_comment ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at;
-- 观点IPO相关字段
ALTER TABLE topic_comment ADD COLUMN total_investment INT DEFAULT 0 NOT NULL COMMENT '总投资额' AFTER updated_at;
ALTER TABLE topic_comment ADD COLUMN investor_count INT DEFAULT 0 NOT NULL COMMENT '投资人数' AFTER total_investment;
ALTER TABLE topic_comment ADD COLUMN is_verified BOOLEAN DEFAULT FALSE NOT NULL COMMENT '是否已验证' AFTER investor_count;
ALTER TABLE topic_comment ADD COLUMN verification_result VARCHAR(20) COMMENT '验证结果: correct/incorrect' AFTER is_verified;
ALTER TABLE topic_comment ADD COLUMN position_rank INT COMMENT '评论位置排名' AFTER verification_result;
-- 外键和索引
ALTER TABLE topic_comment ADD CONSTRAINT fk_comment_topic FOREIGN KEY (topic_id) REFERENCES prediction_topic(id) ON DELETE CASCADE;
ALTER TABLE topic_comment ADD CONSTRAINT fk_comment_user FOREIGN KEY (user_id) REFERENCES user(id);
ALTER TABLE topic_comment ADD INDEX idx_topic_id (topic_id);
ALTER TABLE topic_comment ADD INDEX idx_user_id (user_id);
ALTER TABLE topic_comment ADD INDEX idx_position_rank (position_rank);
-- ==================== 执行说明 ====================
--
-- 重要提示:
-- 1. 这个脚本会尝试添加所有字段,如果字段已存在会报错
-- 2. 报错是正常的,可以忽略继续执行
-- 3. 建议使用MySQL客户端逐条执行跳过已存在字段的语句
--
-- 执行方式:
-- mysql -h 222.128.1.157 -P 33060 -u your_username -p stock < fix_prediction_tables.sql
--
-- 或者在MySQL客户端中逐条复制执行

View File

@@ -1,836 +0,0 @@
# ===========================
# 预测市场 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'))
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
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': topic.deadline.isoformat(),
'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
}
})
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:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/prediction/topics/<int:topic_id>', methods=['GET'])
def get_prediction_topic_detail(topic_id):
"""获取预测话题详情"""
try:
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/<int:topic_id>/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,
transaction_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/<int:topic_id>/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/<int:topic_id>/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/<int:comment_id>/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

View File

@@ -1,261 +0,0 @@
-- 完整重建预测市场所有表
-- 会删除旧表并重新创建
-- ⚠️ 警告:会删除所有现有数据!
-- 执行前请确认是测试环境或数据可以丢失
USE stock;
-- ==================== 删除所有旧表 ====================
-- 按依赖关系倒序删除
DROP TABLE IF EXISTS time_slot_bid;
DROP TABLE IF EXISTS time_capsule_time_slot;
DROP TABLE IF EXISTS time_capsule_topic;
DROP TABLE IF EXISTS comment_position_bid;
DROP TABLE IF EXISTS comment_investment;
DROP TABLE IF EXISTS topic_comment;
DROP TABLE IF EXISTS prediction_transaction;
DROP TABLE IF EXISTS prediction_position;
DROP TABLE IF EXISTS prediction_topic;
DROP TABLE IF EXISTS user_credit_account;
-- ==================== 重新创建所有表 ====================
-- 按依赖关系正序创建
-- 1. 用户积分账户表
CREATE TABLE user_credit_account (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL UNIQUE COMMENT '用户ID',
-- 积分余额
balance FLOAT DEFAULT 10000.0 NOT NULL COMMENT '积分余额初始10000',
frozen_balance FLOAT DEFAULT 0.0 NOT NULL COMMENT '冻结积分',
total_earned FLOAT DEFAULT 0.0 NOT NULL COMMENT '累计获得',
total_spent FLOAT DEFAULT 0.0 NOT NULL COMMENT '累计消费',
-- 时间
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_daily_bonus_at DATETIME COMMENT '最后一次领取每日奖励时间',
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE,
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户积分账户';
-- 2. 预测话题表
CREATE TABLE prediction_topic (
id INT AUTO_INCREMENT PRIMARY KEY,
creator_id INT NOT NULL COMMENT '创建者ID',
-- 基本信息
title VARCHAR(200) NOT NULL COMMENT '话题标题',
description TEXT COMMENT '话题描述',
category VARCHAR(50) DEFAULT 'stock' COMMENT '分类: stock/index/concept/policy/event/other',
-- 市场数据
yes_total_shares INT DEFAULT 0 NOT NULL COMMENT 'YES方总份额',
no_total_shares INT DEFAULT 0 NOT NULL COMMENT 'NO方总份额',
yes_price FLOAT DEFAULT 500.0 NOT NULL COMMENT 'YES方价格0-1000',
no_price FLOAT DEFAULT 500.0 NOT NULL COMMENT 'NO方价格0-1000',
-- 奖池
total_pool FLOAT DEFAULT 0.0 NOT NULL COMMENT '总奖池2%交易税累积)',
-- 领主信息
yes_lord_id INT COMMENT 'YES方领主持有最多份额的用户',
no_lord_id INT COMMENT 'NO方领主持有最多份额的用户',
-- 状态
status VARCHAR(20) DEFAULT 'active' NOT NULL COMMENT '状态: active/settled/cancelled',
result VARCHAR(10) COMMENT '结算结果: yes/no/draw',
-- 时间
deadline DATETIME NOT NULL COMMENT '截止时间',
settled_at DATETIME COMMENT '结算时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 统计
views_count INT DEFAULT 0 COMMENT '浏览次数',
comments_count INT DEFAULT 0 COMMENT '评论数',
participants_count INT DEFAULT 0 COMMENT '参与人数',
FOREIGN KEY (creator_id) REFERENCES user(id),
FOREIGN KEY (yes_lord_id) REFERENCES user(id),
FOREIGN KEY (no_lord_id) REFERENCES user(id),
INDEX idx_creator_id (creator_id),
INDEX idx_status (status),
INDEX idx_category (category),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预测话题';
-- 3. 用户持仓表
CREATE TABLE prediction_position (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID',
topic_id INT NOT NULL COMMENT '话题ID',
direction VARCHAR(3) NOT NULL COMMENT '方向: yes/no',
shares INT DEFAULT 0 NOT NULL COMMENT '持有份额',
avg_cost FLOAT DEFAULT 0.0 NOT NULL COMMENT '平均成本',
total_invested FLOAT DEFAULT 0.0 NOT NULL COMMENT '总投入',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE,
FOREIGN KEY (topic_id) REFERENCES prediction_topic(id) ON DELETE CASCADE,
UNIQUE KEY unique_position (user_id, topic_id, direction),
INDEX idx_user_topic (user_id, topic_id),
INDEX idx_topic (topic_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户持仓';
-- 4. 交易记录表
CREATE TABLE prediction_transaction (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID',
topic_id INT NOT NULL COMMENT '话题ID',
direction VARCHAR(3) NOT NULL COMMENT '方向: yes/no',
trade_type VARCHAR(10) NOT NULL COMMENT '交易类型: buy/sell',
shares INT NOT NULL COMMENT '交易份额',
price FLOAT NOT NULL COMMENT '交易价格',
amount FLOAT NOT NULL COMMENT '交易金额',
tax FLOAT DEFAULT 0.0 NOT NULL COMMENT '交易税2%',
total_cost FLOAT NOT NULL COMMENT '总成本(含税)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id),
FOREIGN KEY (topic_id) REFERENCES prediction_topic(id),
INDEX idx_user_id (user_id),
INDEX idx_topic_id (topic_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易记录';
-- 5. 话题评论表
CREATE TABLE topic_comment (
id INT AUTO_INCREMENT PRIMARY KEY,
topic_id INT NOT NULL COMMENT '话题ID',
user_id INT NOT NULL COMMENT '用户ID',
content TEXT NOT NULL COMMENT '评论内容',
direction VARCHAR(3) COMMENT '预测方向: yes/no',
is_published BOOLEAN DEFAULT FALSE NOT NULL COMMENT '是否已发布',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 观点IPO相关字段
total_investment INT DEFAULT 0 NOT NULL COMMENT '总投资额',
investor_count INT DEFAULT 0 NOT NULL COMMENT '投资人数',
is_verified BOOLEAN DEFAULT FALSE NOT NULL COMMENT '是否已验证',
verification_result VARCHAR(20) COMMENT '验证结果: correct/incorrect',
position_rank INT COMMENT '评论位置排名1/2/3',
FOREIGN KEY (topic_id) REFERENCES prediction_topic(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_topic_id (topic_id),
INDEX idx_user_id (user_id),
INDEX idx_position_rank (position_rank)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='话题评论';
-- 6. 评论投资记录表观点IPO
CREATE TABLE comment_investment (
id INT AUTO_INCREMENT PRIMARY KEY,
comment_id INT NOT NULL COMMENT '评论ID',
user_id INT NOT NULL COMMENT '投资者ID',
shares INT NOT NULL COMMENT '投资份额',
amount INT NOT NULL COMMENT '投资金额',
avg_price FLOAT NOT NULL COMMENT '平均价格',
status VARCHAR(20) DEFAULT 'active' NOT NULL COMMENT '状态: active/settled',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (comment_id) REFERENCES topic_comment(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_comment_id (comment_id),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论投资记录观点IPO';
-- 7. 评论位置竞拍记录表(首发权拍卖)
CREATE TABLE comment_position_bid (
id INT AUTO_INCREMENT PRIMARY KEY,
topic_id INT NOT NULL COMMENT '话题ID',
user_id INT NOT NULL COMMENT '竞拍者ID',
position INT NOT NULL COMMENT '位置1/2/3',
bid_amount INT NOT NULL COMMENT '竞拍金额',
status VARCHAR(20) DEFAULT 'pending' NOT NULL COMMENT '状态: pending/won/outbid',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL COMMENT '过期时间',
FOREIGN KEY (topic_id) REFERENCES prediction_topic(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_topic_id (topic_id),
INDEX idx_user_id (user_id),
INDEX idx_position (topic_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论位置竞拍记录(首发权拍卖)';
-- 8. 时间胶囊话题表
CREATE TABLE time_capsule_topic (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '创建者ID',
title VARCHAR(200) NOT NULL COMMENT '话题标题',
description TEXT COMMENT '话题描述',
encrypted_content TEXT COMMENT '加密的预测内容前端AES加密',
encryption_key VARCHAR(500) COMMENT '加密密钥(后端存储)',
start_year INT NOT NULL COMMENT '开始年份',
end_year INT NOT NULL COMMENT '结束年份',
status VARCHAR(20) DEFAULT 'active' NOT NULL COMMENT '状态: active/settled/cancelled',
is_decrypted BOOLEAN DEFAULT FALSE NOT NULL COMMENT '是否已解密',
actual_happened_year INT COMMENT '实际发生年份',
total_pool INT DEFAULT 0 NOT NULL COMMENT '总奖池',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_year_range (start_year, end_year)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='时间胶囊话题(长期预测)';
-- 9. 时间胶囊时间段表
CREATE TABLE time_capsule_time_slot (
id INT AUTO_INCREMENT PRIMARY KEY,
topic_id INT NOT NULL COMMENT '话题ID',
year_start INT NOT NULL COMMENT '年份区间开始',
year_end INT NOT NULL COMMENT '年份区间结束',
current_holder_id INT COMMENT '当前持有者ID',
current_price INT DEFAULT 100 NOT NULL COMMENT '当前价格',
total_bids INT DEFAULT 0 NOT NULL COMMENT '总竞拍次数',
status VARCHAR(20) DEFAULT 'active' NOT NULL COMMENT '状态: active/settled',
FOREIGN KEY (topic_id) REFERENCES time_capsule_topic(id) ON DELETE CASCADE,
FOREIGN KEY (current_holder_id) REFERENCES user(id),
INDEX idx_topic_id (topic_id),
INDEX idx_holder (current_holder_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='时间胶囊时间段';
-- 10. 时间段竞拍记录表
CREATE TABLE time_slot_bid (
id INT AUTO_INCREMENT PRIMARY KEY,
slot_id INT NOT NULL COMMENT '时间段ID',
user_id INT NOT NULL COMMENT '竞拍者ID',
bid_amount INT NOT NULL COMMENT '竞拍金额',
status VARCHAR(20) DEFAULT 'outbid' NOT NULL COMMENT '状态: current/outbid/won',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (slot_id) REFERENCES time_capsule_time_slot(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_slot_id (slot_id),
INDEX idx_user_id (user_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='时间段竞拍记录';
-- ==================== 执行完成提示 ====================
SELECT '✅ 所有表创建成功!' AS status;
SELECT '📊 共创建了 10 个表' AS info;
SELECT '💰 用户注册时将自动获得 10000 初始积分' AS credit_info;
SELECT '🎮 预测市场系统已就绪' AS market_status;

View File

@@ -1,221 +0,0 @@
-- 重建预测市场表(完全删除后重新创建)
-- 警告:会删除所有现有数据!
-- 仅在测试环境或数据可以丢失时使用
-- 执行前请备份数据库!
USE stock;
-- 删除旧表(按依赖关系倒序删除)
DROP TABLE IF EXISTS time_slot_bid;
DROP TABLE IF EXISTS time_capsule_time_slot;
DROP TABLE IF EXISTS time_capsule_topic;
DROP TABLE IF EXISTS comment_position_bid;
DROP TABLE IF EXISTS comment_investment;
DROP TABLE IF EXISTS topic_comment;
DROP TABLE IF EXISTS prediction_transaction;
DROP TABLE IF EXISTS prediction_position;
DROP TABLE IF EXISTS prediction_topic;
-- 重新创建表(按依赖关系正序创建)
-- 1. 预测话题表
CREATE TABLE prediction_topic (
id INT AUTO_INCREMENT PRIMARY KEY,
creator_id INT NOT NULL,
-- 基本信息
title VARCHAR(200) NOT NULL COMMENT '话题标题',
description TEXT COMMENT '话题描述',
category VARCHAR(50) DEFAULT 'stock' COMMENT '分类: stock/index/concept/policy/event/other',
-- 市场数据
yes_total_shares INT DEFAULT 0 NOT NULL COMMENT 'YES方总份额',
no_total_shares INT DEFAULT 0 NOT NULL COMMENT 'NO方总份额',
yes_price FLOAT DEFAULT 500.0 NOT NULL COMMENT 'YES方价格0-1000',
no_price FLOAT DEFAULT 500.0 NOT NULL COMMENT 'NO方价格0-1000',
-- 奖池
total_pool FLOAT DEFAULT 0.0 NOT NULL COMMENT '总奖池2%交易税累积)',
-- 领主信息
yes_lord_id INT COMMENT 'YES方领主',
no_lord_id INT COMMENT 'NO方领主',
-- 状态
status VARCHAR(20) DEFAULT 'active' NOT NULL COMMENT '状态: active/settled/cancelled',
result VARCHAR(10) COMMENT '结算结果: yes/no/draw',
-- 时间
deadline DATETIME NOT NULL COMMENT '截止时间',
settled_at DATETIME COMMENT '结算时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 统计
views_count INT DEFAULT 0,
comments_count INT DEFAULT 0,
participants_count INT DEFAULT 0,
FOREIGN KEY (creator_id) REFERENCES user(id),
FOREIGN KEY (yes_lord_id) REFERENCES user(id),
FOREIGN KEY (no_lord_id) REFERENCES user(id),
INDEX idx_creator_id (creator_id),
INDEX idx_status (status),
INDEX idx_category (category),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预测话题';
-- 2. 用户持仓表
CREATE TABLE prediction_position (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
topic_id INT NOT NULL,
direction VARCHAR(3) NOT NULL COMMENT '方向: yes/no',
shares INT DEFAULT 0 NOT NULL COMMENT '持有份额',
avg_cost FLOAT DEFAULT 0.0 NOT NULL COMMENT '平均成本',
total_invested FLOAT DEFAULT 0.0 NOT NULL COMMENT '总投入',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE,
FOREIGN KEY (topic_id) REFERENCES prediction_topic(id) ON DELETE CASCADE,
UNIQUE KEY unique_position (user_id, topic_id, direction),
INDEX idx_user_topic (user_id, topic_id),
INDEX idx_topic (topic_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户持仓';
-- 3. 交易记录表
CREATE TABLE prediction_transaction (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
topic_id INT NOT NULL,
direction VARCHAR(3) NOT NULL COMMENT '方向: yes/no',
trade_type VARCHAR(10) NOT NULL COMMENT '交易类型: buy/sell',
shares INT NOT NULL COMMENT '交易份额',
price FLOAT NOT NULL COMMENT '交易价格',
amount FLOAT NOT NULL COMMENT '交易金额',
tax FLOAT DEFAULT 0.0 NOT NULL COMMENT '交易税',
total_cost FLOAT NOT NULL COMMENT '总成本(含税)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id),
FOREIGN KEY (topic_id) REFERENCES prediction_topic(id),
INDEX idx_user_id (user_id),
INDEX idx_topic_id (topic_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易记录';
-- 4. 话题评论表
CREATE TABLE topic_comment (
id INT AUTO_INCREMENT PRIMARY KEY,
topic_id INT NOT NULL,
user_id INT NOT NULL,
content TEXT NOT NULL COMMENT '评论内容',
direction VARCHAR(3) COMMENT '预测方向: yes/no',
is_published BOOLEAN DEFAULT FALSE NOT NULL COMMENT '是否已发布',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 观点IPO相关
total_investment INT DEFAULT 0 NOT NULL COMMENT '总投资额',
investor_count INT DEFAULT 0 NOT NULL COMMENT '投资人数',
is_verified BOOLEAN DEFAULT FALSE NOT NULL COMMENT '是否已验证',
verification_result VARCHAR(20) COMMENT '验证结果: correct/incorrect',
position_rank INT COMMENT '评论位置排名',
FOREIGN KEY (topic_id) REFERENCES prediction_topic(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_topic_id (topic_id),
INDEX idx_user_id (user_id),
INDEX idx_position_rank (position_rank)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='话题评论';
-- 5. 评论投资记录表观点IPO
CREATE TABLE comment_investment (
id INT AUTO_INCREMENT PRIMARY KEY,
comment_id INT NOT NULL,
user_id INT NOT NULL,
shares INT NOT NULL COMMENT '投资份额',
amount INT NOT NULL COMMENT '投资金额',
avg_price FLOAT NOT NULL COMMENT '平均价格',
status VARCHAR(20) DEFAULT 'active' NOT NULL COMMENT '状态: active/settled',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (comment_id) REFERENCES topic_comment(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_comment_id (comment_id),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论投资记录';
-- 6. 评论位置竞拍记录(首发权拍卖)
CREATE TABLE comment_position_bid (
id INT AUTO_INCREMENT PRIMARY KEY,
topic_id INT NOT NULL,
user_id INT NOT NULL,
position INT NOT NULL COMMENT '位置1/2/3',
bid_amount INT NOT NULL COMMENT '竞拍金额',
status VARCHAR(20) DEFAULT 'pending' NOT NULL COMMENT '状态: pending/won/outbid',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL COMMENT '过期时间',
FOREIGN KEY (topic_id) REFERENCES prediction_topic(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_topic_id (topic_id),
INDEX idx_user_id (user_id),
INDEX idx_position (topic_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论位置竞拍记录';
-- 7. 时间胶囊话题表
CREATE TABLE time_capsule_topic (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(200) NOT NULL COMMENT '话题标题',
description TEXT COMMENT '话题描述',
encrypted_content TEXT COMMENT '加密的预测内容',
encryption_key VARCHAR(500) COMMENT '加密密钥(后端存储)',
start_year INT NOT NULL COMMENT '开始年份',
end_year INT NOT NULL COMMENT '结束年份',
status VARCHAR(20) DEFAULT 'active' NOT NULL COMMENT '状态: active/settled/cancelled',
is_decrypted BOOLEAN DEFAULT FALSE NOT NULL COMMENT '是否已解密',
actual_happened_year INT COMMENT '实际发生年份',
total_pool INT DEFAULT 0 NOT NULL COMMENT '总奖池',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_year_range (start_year, end_year)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='时间胶囊话题';
-- 8. 时间胶囊时间段表
CREATE TABLE time_capsule_time_slot (
id INT AUTO_INCREMENT PRIMARY KEY,
topic_id INT NOT NULL,
year_start INT NOT NULL COMMENT '年份区间开始',
year_end INT NOT NULL COMMENT '年份区间结束',
current_holder_id INT COMMENT '当前持有者',
current_price INT DEFAULT 100 NOT NULL COMMENT '当前价格',
total_bids INT DEFAULT 0 NOT NULL COMMENT '总竞拍次数',
status VARCHAR(20) DEFAULT 'active' NOT NULL COMMENT '状态: active/settled',
FOREIGN KEY (topic_id) REFERENCES time_capsule_topic(id) ON DELETE CASCADE,
FOREIGN KEY (current_holder_id) REFERENCES user(id),
INDEX idx_topic_id (topic_id),
INDEX idx_holder (current_holder_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='时间胶囊时间段';
-- 9. 时间段竞拍记录表
CREATE TABLE time_slot_bid (
id INT AUTO_INCREMENT PRIMARY KEY,
slot_id INT NOT NULL,
user_id INT NOT NULL,
bid_amount INT NOT NULL COMMENT '竞拍金额',
status VARCHAR(20) DEFAULT 'outbid' NOT NULL COMMENT '状态: current/outbid/won',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (slot_id) REFERENCES time_capsule_time_slot(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES user(id),
INDEX idx_slot_id (slot_id),
INDEX idx_user_id (user_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='时间段竞拍记录';

View File

@@ -1,3 +0,0 @@
INFO Accepting connections at http://localhost:58321
INFO Gracefully shutting down. Please wait...

View File

@@ -174,6 +174,52 @@ export const likeComment = async (commentId) => {
} }
}; };
// ==================== 观点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 = { export const MARKET_CONFIG = {
@@ -266,6 +312,11 @@ export default {
getComments, getComments,
likeComment, likeComment,
// 观点IPO
investComment,
getCommentInvestments,
verifyComment,
// 工具函数 // 工具函数
calculatePrice, calculatePrice,
calculateTax, calculateTax,

View File

@@ -39,6 +39,8 @@ import { forumColors } from '@theme/forumTheme';
import { getTopicDetail, getUserAccount } from '@services/predictionMarketService.api'; import { getTopicDetail, getUserAccount } from '@services/predictionMarketService.api';
import { useAuth } from '@contexts/AuthContext'; import { useAuth } from '@contexts/AuthContext';
import TradeModal from './components/TradeModal'; import TradeModal from './components/TradeModal';
import PredictionCommentSection from './components/PredictionCommentSection';
import CommentInvestModal from './components/CommentInvestModal';
const MotionBox = motion(Box); const MotionBox = motion(Box);
@@ -52,6 +54,8 @@ const PredictionTopicDetail = () => {
const [topic, setTopic] = useState(null); const [topic, setTopic] = useState(null);
const [userAccount, setUserAccount] = useState(null); const [userAccount, setUserAccount] = useState(null);
const [tradeMode, setTradeMode] = useState('buy'); const [tradeMode, setTradeMode] = useState('buy');
const [selectedComment, setSelectedComment] = useState(null);
const [commentSectionKey, setCommentSectionKey] = useState(0);
// 模态框 // 模态框
const { const {
@@ -60,6 +64,12 @@ const PredictionTopicDetail = () => {
onClose: onTradeModalClose, onClose: onTradeModalClose,
} = useDisclosure(); } = useDisclosure();
const {
isOpen: isInvestModalOpen,
onOpen: onInvestModalOpen,
onClose: onInvestModalClose,
} = useDisclosure();
// 加载话题数据 // 加载话题数据
useEffect(() => { useEffect(() => {
const loadTopic = async () => { const loadTopic = async () => {
@@ -136,6 +146,44 @@ const PredictionTopicDetail = () => {
} }
}; };
// 打开投资弹窗
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) { if (!topic) {
return null; return null;
} }
@@ -561,6 +609,22 @@ const PredictionTopicDetail = () => {
)} )}
</VStack> </VStack>
</Box> </Box>
{/* 评论区 - 占据全宽 */}
<Box gridColumn={{ base: "1", lg: "1 / -1" }}>
<MotionBox
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<PredictionCommentSection
key={commentSectionKey}
topicId={topicId}
topic={topic}
onInvest={handleOpenInvest}
/>
</MotionBox>
</Box>
</SimpleGrid> </SimpleGrid>
</Container> </Container>
@@ -572,6 +636,15 @@ const PredictionTopicDetail = () => {
mode={tradeMode} mode={tradeMode}
onTradeSuccess={handleTradeSuccess} onTradeSuccess={handleTradeSuccess}
/> />
{/* 观点投资模态框 */}
<CommentInvestModal
isOpen={isInvestModalOpen}
onClose={onInvestModalClose}
comment={selectedComment}
topic={topic}
onInvestSuccess={handleInvestSuccess}
/>
</Box> </Box>
); );
}; };

View File

@@ -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 (
<Modal isOpen={isOpen} onClose={onClose} size={{ base: "full", sm: "lg" }} isCentered>
<ModalOverlay backdropFilter="blur(4px)" />
<ModalContent
bg={forumColors.background.card}
borderRadius={{ base: "0", sm: "xl" }}
border="1px solid"
borderColor={forumColors.border.default}
maxH={{ base: "100vh", sm: "90vh" }}
m={{ base: "0", sm: "4" }}
>
<ModalHeader
bg={forumColors.gradients.goldSubtle}
borderTopRadius={{ base: "0", sm: "xl" }}
borderBottom="1px solid"
borderColor={forumColors.border.default}
py={{ base: "4", sm: "3" }}
>
<HStack spacing="2">
<Icon
as={Lightbulb}
boxSize={{ base: "18px", sm: "20px" }}
color={forumColors.primary[500]}
/>
<Text color={forumColors.text.primary} fontSize={{ base: "md", sm: "lg" }}>
投资观点
</Text>
</HStack>
</ModalHeader>
<ModalCloseButton color={forumColors.text.primary} />
<ModalBody py={{ base: "4", sm: "6" }} px={{ base: "4", sm: "6" }}>
<VStack spacing="5" align="stretch">
{/* 评论作者信息 */}
<Box
bg={forumColors.background.hover}
borderRadius="lg"
p={{ base: "3", sm: "4" }}
border="1px solid"
borderColor={forumColors.border.default}
>
<HStack spacing="3" mb="2">
<Avatar
size="sm"
name={comment.user?.nickname || comment.user?.username}
src={comment.user?.avatar_url}
bg={forumColors.gradients.goldPrimary}
/>
<VStack align="start" spacing="0" flex="1">
<HStack spacing="2">
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
{comment.user?.nickname || comment.user?.username || '匿名用户'}
</Text>
{isLord && (
<Badge
bg={isYesLord ? 'green.500' : 'red.500'}
color="white"
fontSize="2xs"
px="2"
py="0.5"
borderRadius="full"
display="flex"
alignItems="center"
gap="1"
>
<Crown size={10} />
{isYesLord ? 'YES庄' : 'NO庄'}
</Badge>
)}
</HStack>
</VStack>
</HStack>
{/* 评论内容 */}
<Text fontSize={{ base: "sm", sm: "md" }} color={forumColors.text.secondary} lineHeight="1.6">
{comment.content}
</Text>
</Box>
{/* 当前投资统计 */}
<Box
bg={forumColors.gradients.goldSubtle}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="lg"
p={{ base: "3", sm: "4" }}
>
<VStack spacing="2" align="stretch">
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>已有投资人数</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{comment.investor_count || 0}
</Text>
</Flex>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>总投资额</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{comment.total_investment || 0} 积分
</Text>
</Flex>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>当前价格</Text>
<Text fontWeight="600" color={forumColors.primary[500]}>
{Math.round(pricePerShare)} 积分/
</Text>
</Flex>
</VStack>
</Box>
{/* 投资份额 */}
<Box>
<Flex justify="space-between" mb={{ base: "2", sm: "3" }}>
<Text fontSize={{ base: "sm", sm: "sm" }} fontWeight="600" color={forumColors.text.primary}>
投资份额
</Text>
<Text fontSize={{ base: "sm", sm: "sm" }} color={forumColors.text.secondary}>
{shares}
</Text>
</Flex>
<Slider
value={shares}
onChange={setShares}
min={1}
max={10}
step={1}
focusThumbOnChange={false}
>
<SliderTrack bg={forumColors.background.hover} h={{ base: "2", sm: "1.5" }}>
<SliderFilledTrack bg={forumColors.gradients.goldPrimary} />
</SliderTrack>
<SliderThumb boxSize={{ base: "7", sm: "6" }} bg={forumColors.primary[500]}>
<Box as={Icon} as={DollarSign} boxSize={{ base: "14px", sm: "12px" }} color="white" />
</SliderThumb>
</Slider>
<HStack justify="space-between" mt="2" fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.tertiary}>
<Text>1</Text>
<Text>10 (最大)</Text>
</HStack>
</Box>
<Divider borderColor={forumColors.border.default} />
{/* 费用明细 */}
<Box
bg={forumColors.background.hover}
border="1px solid"
borderColor={forumColors.border.default}
borderRadius="lg"
p={{ base: "3", sm: "4" }}
>
<VStack spacing={{ base: "1.5", sm: "2" }} align="stretch">
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>单价</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{Math.round(pricePerShare)} 积分/
</Text>
</Flex>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>份额</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{shares}
</Text>
</Flex>
<Box borderTop="1px solid" borderColor={forumColors.border.default} pt={{ base: "1.5", sm: "2" }} mt="1">
<Flex justify="space-between">
<Text fontWeight="bold" color={forumColors.text.primary} fontSize={{ base: "sm", sm: "md" }}>
投资总额
</Text>
<HStack spacing="1">
<Icon as={DollarSign} boxSize={{ base: "16px", sm: "20px" }} color={forumColors.primary[500]} />
<Text fontSize={{ base: "xl", sm: "2xl" }} fontWeight="bold" color={forumColors.primary[500]}>
{totalCost}
</Text>
<Text fontSize={{ base: "xs", sm: "sm" }} color={forumColors.text.secondary}>
积分
</Text>
</HStack>
</Flex>
</Box>
{/* 预期收益 */}
<Box borderTop="1px solid" borderColor={forumColors.border.default} pt={{ base: "1.5", sm: "2" }}>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color="green.600" fontWeight="600">
<Icon as={TrendingUp} size={14} display="inline" mr="1" />
预测正确收益1.5
</Text>
<Text fontWeight="600" color="green.600">
+{expectedProfit} 积分
</Text>
</Flex>
<Text fontSize="2xs" color={forumColors.text.muted} mt="1">
预测正确将获得 {expectedReturn} 积分含本金
</Text>
</Box>
</VStack>
</Box>
{/* 余额提示 */}
<Box
bg={forumColors.background.hover}
borderRadius="lg"
p={{ base: "2.5", sm: "3" }}
>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>你的余额</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{userAccount.balance} 积分
</Text>
</Flex>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }} mt="1">
<Text color={forumColors.text.secondary}>投资后</Text>
<Text
fontWeight="600"
color={
userAccount.balance >= totalCost
? forumColors.success[500]
: forumColors.error[500]
}
>
{userAccount.balance - totalCost} 积分
</Text>
</Flex>
</Box>
{/* 警告提示 */}
{!investCheck.ok && (
<Box
bg="red.50"
border="1px solid"
borderColor="red.200"
borderRadius="lg"
p={{ base: "2.5", sm: "3" }}
>
<HStack spacing="2">
<Icon as={AlertCircle} boxSize={{ base: "14px", sm: "16px" }} color="red.500" />
<Text fontSize={{ base: "xs", sm: "sm" }} color="red.600" fontWeight="600">
{investCheck.reason}
</Text>
</HStack>
</Box>
)}
{/* 风险提示 */}
<Box
bg="orange.50"
border="1px solid"
borderColor="orange.200"
borderRadius="lg"
p={{ base: "2.5", sm: "3" }}
>
<HStack spacing="2" mb="1">
<Icon as={AlertCircle} boxSize="14px" color="orange.500" />
<Text fontSize="xs" color="orange.700" fontWeight="600">
投资风险提示
</Text>
</HStack>
<Text fontSize="2xs" color="orange.600" lineHeight="1.5">
观点预测存在不确定性预测错误将损失全部投资请谨慎评估后再投资
</Text>
</Box>
</VStack>
</ModalBody>
<ModalFooter
borderTop="1px solid"
borderColor={forumColors.border.default}
py={{ base: "3", sm: "4" }}
px={{ base: "4", sm: "6" }}
>
<HStack spacing={{ base: "2", sm: "3" }} w="full" justify="flex-end">
<Button
variant="ghost"
onClick={onClose}
color={forumColors.text.secondary}
_hover={{ bg: forumColors.background.hover }}
h={{ base: "10", sm: "auto" }}
fontSize={{ base: "sm", sm: "md" }}
>
取消
</Button>
<Button
bg={forumColors.gradients.goldPrimary}
color="white"
fontWeight="bold"
onClick={handleInvest}
isLoading={isSubmitting}
loadingText="投资中..."
isDisabled={!investCheck.ok}
_hover={{
opacity: 0.9,
transform: 'translateY(-2px)',
}}
_active={{ transform: 'translateY(0)' }}
h={{ base: "11", sm: "auto" }}
fontSize={{ base: "sm", sm: "md" }}
px={{ base: "6", sm: "4" }}
>
投资 {shares}
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default CommentInvestModal;

View File

@@ -0,0 +1,475 @@
/**
* 预测话题评论区组件
* 支持发布评论、嵌套回复、点赞、庄主标识、观点IPO投资
*/
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Avatar,
Textarea,
Button,
Flex,
IconButton,
Divider,
useToast,
Badge,
Tooltip,
} from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion';
import { Heart, MessageCircle, Send, TrendingUp, Crown, Pin } from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
import {
createComment,
getComments,
likeComment,
} from '@services/predictionMarketService.api';
import { useAuth } from '@contexts/AuthContext';
const MotionBox = motion(Box);
const CommentItem = ({ comment, topicId, topic, onReply, onInvest }) => {
const [isLiked, setIsLiked] = useState(false);
const [likes, setLikes] = useState(comment.likes_count || 0);
const [showReply, setShowReply] = useState(false);
// 处理点赞
const handleLike = async () => {
try {
const response = await likeComment(comment.id);
if (response.success) {
setLikes(response.likes_count);
setIsLiked(response.action === 'like');
}
} catch (error) {
console.error('点赞失败:', error);
}
};
// 格式化时间
const formatTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
});
};
// 判断是否是庄主
const isYesLord = comment.user?.id === topic?.yes_lord_id;
const isNoLord = comment.user?.id === topic?.no_lord_id;
const isLord = isYesLord || isNoLord;
return (
<MotionBox
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Flex gap={{ base: "2", sm: "3" }} py={{ base: "3", sm: "4" }}>
{/* 头像 */}
<Avatar
size={{ base: "sm", sm: "md" }}
name={comment.user?.nickname || comment.user?.username}
src={comment.user?.avatar_url}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
/>
{/* 评论内容 */}
<VStack align="stretch" flex="1" spacing={{ base: "1.5", sm: "2" }}>
{/* 用户名和时间 */}
<HStack justify="space-between" flexWrap="wrap">
<HStack spacing="2">
<Text fontSize={{ base: "sm", sm: "md" }} fontWeight="600" color={forumColors.text.primary}>
{comment.user?.nickname || comment.user?.username || '匿名用户'}
</Text>
{/* 庄主标识 */}
{isLord && (
<Tooltip label={isYesLord ? 'YES方庄主' : 'NO方庄主'} placement="top">
<Badge
bg={isYesLord ? 'green.500' : 'red.500'}
color="white"
fontSize="2xs"
px="2"
py="0.5"
borderRadius="full"
display="flex"
alignItems="center"
gap="1"
>
<Crown size={10} />
{isYesLord ? 'YES庄' : 'NO庄'}
</Badge>
</Tooltip>
)}
{/* 置顶标识 */}
{comment.is_pinned && (
<Badge
bg={forumColors.primary[500]}
color="white"
fontSize="2xs"
px="2"
py="0.5"
borderRadius="full"
display="flex"
alignItems="center"
gap="1"
>
<Pin size={10} />
置顶
</Badge>
)}
<Text fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.muted}>
{formatTime(comment.created_at)}
</Text>
</HStack>
</HStack>
{/* 评论正文 */}
<Text fontSize={{ base: "sm", sm: "md" }} color={forumColors.text.secondary} lineHeight="1.6">
{comment.content}
</Text>
{/* 观点IPO投资统计 */}
{comment.total_investment > 0 && (
<Box
bg={forumColors.gradients.goldSubtle}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="md"
px="3"
py="2"
mt="1"
>
<HStack spacing="4" fontSize="xs">
<HStack spacing="1">
<TrendingUp size={12} color={forumColors.primary[500]} />
<Text color={forumColors.text.secondary}>
{comment.investor_count || 0}人投资
</Text>
</HStack>
<Text color={forumColors.text.secondary}>
总投资<Text as="span" fontWeight="600" color={forumColors.primary[500]}>
{comment.total_investment}
</Text>
</Text>
{comment.is_verified && (
<Badge
colorScheme={comment.verification_result === 'correct' ? 'green' : 'red'}
fontSize="2xs"
>
{comment.verification_result === 'correct' ? '✓ 预测正确' : '✗ 预测错误'}
</Badge>
)}
</HStack>
</Box>
)}
{/* 操作按钮 */}
<HStack spacing={{ base: "3", sm: "4" }} fontSize="xs" color={forumColors.text.tertiary} flexWrap="wrap">
<HStack
spacing="1"
cursor="pointer"
onClick={handleLike}
_hover={{ color: forumColors.primary[500] }}
color={isLiked ? forumColors.primary[500] : forumColors.text.tertiary}
>
<Heart size={14} fill={isLiked ? 'currentColor' : 'none'} />
<Text>{likes > 0 ? likes : '点赞'}</Text>
</HStack>
<HStack
spacing="1"
cursor="pointer"
onClick={() => setShowReply(!showReply)}
_hover={{ color: forumColors.primary[500] }}
>
<MessageCircle size={14} />
<Text>回复</Text>
</HStack>
{/* 投资观点按钮 */}
{!comment.is_verified && onInvest && (
<Button
size="xs"
variant="ghost"
leftIcon={<TrendingUp size={14} />}
color={forumColors.primary[500]}
_hover={{ bg: forumColors.gradients.goldSubtle }}
onClick={() => onInvest(comment)}
>
投资观点
</Button>
)}
</HStack>
{/* 回复输入框 */}
{showReply && (
<MotionBox
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
mt="2"
>
<ReplyInput
topicId={topicId}
parentId={comment.id}
placeholder={`回复 @${comment.user?.nickname || comment.user?.username || '匿名用户'}`}
onSubmit={() => {
setShowReply(false);
if (onReply) onReply();
}}
/>
</MotionBox>
)}
{/* 回复列表 */}
{comment.replies && comment.replies.length > 0 && (
<VStack
align="stretch"
spacing="2"
pl={{ base: "3", sm: "4" }}
mt="2"
borderLeft="2px solid"
borderColor={forumColors.border.default}
>
{comment.replies.map((reply) => (
<Box key={reply.id}>
<HStack spacing="2" mb="1">
<Avatar
size="xs"
name={reply.user?.nickname || reply.user?.username}
src={reply.user?.avatar_url}
/>
<Text fontSize="xs" fontWeight="600" color={forumColors.text.primary}>
{reply.user?.nickname || reply.user?.username || '匿名用户'}
</Text>
<Text fontSize="2xs" color={forumColors.text.muted}>
{formatTime(reply.created_at)}
</Text>
</HStack>
<Text fontSize="sm" color={forumColors.text.secondary} pl="6">
{reply.content}
</Text>
</Box>
))}
</VStack>
)}
</VStack>
</Flex>
</MotionBox>
);
};
const ReplyInput = ({ topicId, parentId = null, placeholder, onSubmit }) => {
const toast = useToast();
const { user } = useAuth();
const [content, setContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
if (!content.trim()) {
toast({
title: '请输入评论内容',
status: 'warning',
duration: 2000,
});
return;
}
setIsSubmitting(true);
try {
const response = await createComment(topicId, {
content: content.trim(),
parent_id: parentId,
});
if (response.success) {
toast({
title: '评论成功',
status: 'success',
duration: 2000,
});
setContent('');
if (onSubmit) onSubmit();
}
} catch (error) {
console.error('评论失败:', error);
toast({
title: '评论失败',
description: error.response?.data?.error || error.message,
status: 'error',
duration: 3000,
});
} finally {
setIsSubmitting(false);
}
};
return (
<Flex gap="2" align="end">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder || '写下你的评论...'}
size="sm"
bg={forumColors.background.secondary}
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}`,
}}
minH={{ base: "70px", sm: "80px" }}
resize="vertical"
fontSize={{ base: "sm", sm: "md" }}
/>
<IconButton
icon={<Send size={18} />}
onClick={handleSubmit}
isLoading={isSubmitting}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
_hover={{ opacity: 0.9 }}
size="sm"
h={{ base: "9", sm: "10" }}
/>
</Flex>
);
};
const PredictionCommentSection = ({ topicId, topic, onInvest }) => {
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
// 加载评论
const loadComments = async (pageNum = 1) => {
try {
setLoading(true);
const response = await getComments(topicId, { page: pageNum, per_page: 20 });
if (response.success) {
if (pageNum === 1) {
setComments(response.data);
} else {
setComments((prev) => [...prev, ...response.data]);
}
setTotal(response.pagination?.total || response.data.length);
setHasMore(response.pagination?.has_next || false);
setPage(pageNum);
}
} catch (error) {
console.error('加载评论失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadComments();
}, [topicId]);
return (
<Box
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.default}
p={{ base: "4", sm: "6" }}
>
{/* 标题 */}
<Flex justify="space-between" align="center" mb={{ base: "4", sm: "6" }}>
<HStack spacing="2">
<MessageCircle size={20} color={forumColors.primary[500]} />
<Text fontSize={{ base: "md", sm: "lg" }} fontWeight="bold" color={forumColors.text.primary}>
评论
</Text>
</HStack>
<Text fontSize="sm" color={forumColors.text.tertiary}>
{total}
</Text>
</Flex>
{/* 发表评论 */}
<Box mb={{ base: "4", sm: "6" }}>
<ReplyInput topicId={topicId} onSubmit={() => loadComments(1)} />
</Box>
<Divider borderColor={forumColors.border.default} mb="4" />
{/* 评论列表 */}
{loading && page === 1 ? (
<Text color={forumColors.text.secondary} textAlign="center" py="8">
加载中...
</Text>
) : comments.length === 0 ? (
<Text color={forumColors.text.secondary} textAlign="center" py="8">
暂无评论快来抢沙发吧
</Text>
) : (
<>
<VStack align="stretch" spacing="0" divider={<Divider borderColor={forumColors.border.default} />}>
<AnimatePresence>
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
topicId={topicId}
topic={topic}
onReply={() => loadComments(1)}
onInvest={onInvest}
/>
))}
</AnimatePresence>
</VStack>
{/* 加载更多 */}
{hasMore && (
<Flex justify="center" mt="6">
<Button
variant="ghost"
onClick={() => loadComments(page + 1)}
isLoading={loading}
color={forumColors.text.secondary}
_hover={{ bg: forumColors.background.hover }}
>
加载更多
</Button>
</Flex>
)}
</>
)}
</Box>
);
};
export default PredictionCommentSection;