diff --git a/src/routes/lazy-components.js b/src/routes/lazy-components.js index 48353be6..8e124a1a 100644 --- a/src/routes/lazy-components.js +++ b/src/routes/lazy-components.js @@ -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 4f3911f1..3c0fe14a 100644 --- a/src/routes/routeConfig.js +++ b/src/routes/routeConfig.js @@ -181,6 +181,16 @@ export const routeConfig = [ description: '论坛帖子详细内容' } }, + { + path: 'value-forum/prediction/:topicId', + component: lazyComponents.PredictionTopicDetail, + protection: PROTECTION_MODES.MODAL, + layout: 'main', + meta: { + title: '预测话题详情', + description: '预测市场话题详细信息' + } + }, // ==================== Agent模块 ==================== { 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.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/views/ValueForum/PredictionTopicDetail.js b/src/views/ValueForum/PredictionTopicDetail.js new file mode 100644 index 00000000..3c031869 --- /dev/null +++ b/src/views/ValueForum/PredictionTopicDetail.js @@ -0,0 +1,532 @@ +/** + * 预测话题详情页 + * 展示预测市场的完整信息、交易、评论等 + */ + +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 { getTopic } from '@services/predictionMarketService'; +import { getUserAccount } from '@services/creditSystemService'; +import { useAuth } from '@contexts/AuthContext'; +import TradeModal from './components/TradeModal'; + +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 { + isOpen: isTradeModalOpen, + onOpen: onTradeModalOpen, + onClose: onTradeModalClose, + } = useDisclosure(); + + // 加载话题数据 + useEffect(() => { + const loadTopic = () => { + const topicData = getTopic(topicId); + if (topicData) { + setTopic(topicData); + } else { + toast({ + title: '话题不存在', + status: 'error', + duration: 3000, + }); + navigate('/value-forum'); + } + }; + + loadTopic(); + + if (user) { + setUserAccount(getUserAccount(user.id)); + } + }, [topicId, user]); + + // 打开交易弹窗 + const handleOpenTrade = (mode) => { + if (!user) { + toast({ + title: '请先登录', + status: 'warning', + duration: 3000, + }); + return; + } + + setTradeMode(mode); + onTradeModalOpen(); + }; + + // 交易成功回调 + const handleTradeSuccess = () => { + // 刷新话题数据 + setTopic(getTopic(topicId)); + setUserAccount(getUserAccount(user.id)); + }; + + if (!topic) { + return null; + } + + // 获取选项数据 + const yesData = topic.positions?.yes || { total_shares: 0, current_price: 500, lord_id: null }; + const noData = topic.positions?.no || { total_shares: 0, current_price: 500, 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.stats.unique_traders.size} + + + + + 总交易量 + {Math.round(topic.stats.total_volume)} + + + + + {/* 交易按钮 */} + {topic.status === 'active' && ( + + + + + + )} + + {/* 用户余额 */} + {user && userAccount && ( + + + + 可用余额 + + {userAccount.balance} 积分 + + + + 冻结积分 + + {userAccount.frozen} 积分 + + + + + )} + + + + + + {/* 交易模态框 */} + + + ); +}; + +export default PredictionTopicDetail; diff --git a/src/views/ValueForum/components/CreatePredictionModal.js b/src/views/ValueForum/components/CreatePredictionModal.js new file mode 100644 index 00000000..4fd3ee6c --- /dev/null +++ b/src/views/ValueForum/components/CreatePredictionModal.js @@ -0,0 +1,386 @@ +/** + * 创建预测话题模态框 + * 用户可以发起新的预测市场话题 + */ + +import React, { useState } 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 } from '@services/predictionMarketService'; +import { getUserAccount, 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 = user ? getUserAccount(user.id) : null; + + // 处理表单变化 + 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.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)); + + const settlement_date = new Date(deadline); + settlement_date.setDate(settlement_date.getDate() + 1); + + // 创建话题 + const newTopic = createTopic({ + author_id: user.id, + author_name: user.name || user.username, + author_avatar: user.avatar, + title: formData.title, + description: formData.description, + category: formData.category, + deadline: deadline.toISOString(), + settlement_date: settlement_date.toISOString(), + }); + + toast({ + title: '创建成功!', + description: `话题已发布,扣除${CREDIT_CONFIG.CREATE_TOPIC_COST}积分`, + status: 'success', + duration: 3000, + }); + + // 重置表单 + setFormData({ + title: '', + description: '', + category: 'stock', + deadline_days: 7, + }); + + // 通知父组件 + if (onTopicCreated) { + onTopicCreated(newTopic); + } + + onClose(); + } 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}`, + }} + /> + + + {/* 话题描述 */} + + + 话题描述 + +