update pay function
This commit is contained in:
@@ -42,6 +42,7 @@ export const lazyComponents = {
|
|||||||
// 价值论坛模块
|
// 价值论坛模块
|
||||||
ValueForum: React.lazy(() => import('../views/ValueForum')),
|
ValueForum: React.lazy(() => import('../views/ValueForum')),
|
||||||
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
|
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
|
||||||
|
PredictionTopicDetail: React.lazy(() => import('../views/ValueForum/PredictionTopicDetail')),
|
||||||
|
|
||||||
// 数据浏览器模块
|
// 数据浏览器模块
|
||||||
DataBrowser: React.lazy(() => import('../views/DataBrowser')),
|
DataBrowser: React.lazy(() => import('../views/DataBrowser')),
|
||||||
|
|||||||
@@ -181,6 +181,16 @@ export const routeConfig = [
|
|||||||
description: '论坛帖子详细内容'
|
description: '论坛帖子详细内容'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'value-forum/prediction/:topicId',
|
||||||
|
component: lazyComponents.PredictionTopicDetail,
|
||||||
|
protection: PROTECTION_MODES.MODAL,
|
||||||
|
layout: 'main',
|
||||||
|
meta: {
|
||||||
|
title: '预测话题详情',
|
||||||
|
description: '预测市场话题详细信息'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ==================== Agent模块 ====================
|
// ==================== Agent模块 ====================
|
||||||
{
|
{
|
||||||
|
|||||||
492
src/services/creditSystemService.js
Normal file
492
src/services/creditSystemService.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
738
src/services/predictionMarketService.js
Normal file
738
src/services/predictionMarketService.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
532
src/views/ValueForum/PredictionTopicDetail.js
Normal file
532
src/views/ValueForum/PredictionTopicDetail.js
Normal file
@@ -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 (
|
||||||
|
<Box minH="100vh" bg={forumColors.background.main} pt="80px" pb="20">
|
||||||
|
<Container maxW="container.xl">
|
||||||
|
{/* 头部:返回按钮 */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate('/value-forum')}
|
||||||
|
mb="6"
|
||||||
|
color={forumColors.text.secondary}
|
||||||
|
_hover={{ bg: forumColors.background.hover }}
|
||||||
|
>
|
||||||
|
← 返回论坛
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<SimpleGrid columns={{ base: 1, lg: 3 }} spacing="6">
|
||||||
|
{/* 左侧:主要内容 */}
|
||||||
|
<Box gridColumn={{ base: '1', lg: '1 / 3' }}>
|
||||||
|
<VStack spacing="6" align="stretch">
|
||||||
|
{/* 话题信息卡片 */}
|
||||||
|
<MotionBox
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
bg={forumColors.background.card}
|
||||||
|
borderRadius="xl"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
{/* 头部 */}
|
||||||
|
<Box
|
||||||
|
bg={forumColors.gradients.goldSubtle}
|
||||||
|
px="6"
|
||||||
|
py="4"
|
||||||
|
borderBottom="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Badge
|
||||||
|
bg={forumColors.primary[500]}
|
||||||
|
color="white"
|
||||||
|
px="3"
|
||||||
|
py="1"
|
||||||
|
borderRadius="full"
|
||||||
|
fontSize="sm"
|
||||||
|
>
|
||||||
|
{topic.category}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<HStack spacing="3">
|
||||||
|
<Icon as={Clock} boxSize="16px" color={forumColors.text.secondary} />
|
||||||
|
<Text fontSize="sm" color={forumColors.text.secondary}>
|
||||||
|
{formatTime(topic.deadline)} 截止
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Heading
|
||||||
|
as="h1"
|
||||||
|
fontSize="2xl"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
mt="4"
|
||||||
|
>
|
||||||
|
{topic.title}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text fontSize="md" color={forumColors.text.secondary} mt="3">
|
||||||
|
{topic.description}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 作者信息 */}
|
||||||
|
<HStack mt="4" spacing="3">
|
||||||
|
<Avatar
|
||||||
|
size="sm"
|
||||||
|
name={topic.author_name}
|
||||||
|
src={topic.author_avatar}
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
/>
|
||||||
|
<VStack align="start" spacing="0">
|
||||||
|
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
|
||||||
|
{topic.author_name}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.tertiary}>
|
||||||
|
发起者
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 市场数据 */}
|
||||||
|
<Box p="6">
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2 }} spacing="6">
|
||||||
|
{/* Yes 方 */}
|
||||||
|
<Box
|
||||||
|
bg="linear-gradient(135deg, rgba(72, 187, 120, 0.1) 0%, rgba(72, 187, 120, 0.05) 100%)"
|
||||||
|
border="2px solid"
|
||||||
|
borderColor="green.400"
|
||||||
|
borderRadius="xl"
|
||||||
|
p="6"
|
||||||
|
position="relative"
|
||||||
|
>
|
||||||
|
{yesData.lord_id && (
|
||||||
|
<HStack
|
||||||
|
position="absolute"
|
||||||
|
top="3"
|
||||||
|
right="3"
|
||||||
|
spacing="1"
|
||||||
|
bg="yellow.400"
|
||||||
|
px="2"
|
||||||
|
py="1"
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
<Icon as={Crown} boxSize="12px" color="white" />
|
||||||
|
<Text fontSize="xs" fontWeight="bold" color="white">
|
||||||
|
领主
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VStack align="start" spacing="4">
|
||||||
|
<HStack spacing="2">
|
||||||
|
<Icon as={TrendingUp} boxSize="20px" color="green.500" />
|
||||||
|
<Text fontSize="lg" fontWeight="700" color="green.600">
|
||||||
|
看涨 / Yes
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<VStack align="start" spacing="1">
|
||||||
|
<Text fontSize="xs" color={forumColors.text.secondary}>
|
||||||
|
当前价格
|
||||||
|
</Text>
|
||||||
|
<HStack spacing="1">
|
||||||
|
<Text fontSize="3xl" fontWeight="bold" color="green.600">
|
||||||
|
{Math.round(yesData.current_price)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color={forumColors.text.secondary}>
|
||||||
|
积分/份
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<HStack justify="space-between" w="full">
|
||||||
|
<Text fontSize="sm" color={forumColors.text.secondary}>
|
||||||
|
总份额
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="md" fontWeight="600" color="green.600">
|
||||||
|
{yesData.total_shares}份
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack justify="space-between" w="full">
|
||||||
|
<Text fontSize="sm" color={forumColors.text.secondary}>
|
||||||
|
市场占比
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="md" fontWeight="600" color="green.600">
|
||||||
|
{yesPercent.toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* No 方 */}
|
||||||
|
<Box
|
||||||
|
bg="linear-gradient(135deg, rgba(245, 101, 101, 0.1) 0%, rgba(245, 101, 101, 0.05) 100%)"
|
||||||
|
border="2px solid"
|
||||||
|
borderColor="red.400"
|
||||||
|
borderRadius="xl"
|
||||||
|
p="6"
|
||||||
|
position="relative"
|
||||||
|
>
|
||||||
|
{noData.lord_id && (
|
||||||
|
<HStack
|
||||||
|
position="absolute"
|
||||||
|
top="3"
|
||||||
|
right="3"
|
||||||
|
spacing="1"
|
||||||
|
bg="yellow.400"
|
||||||
|
px="2"
|
||||||
|
py="1"
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
<Icon as={Crown} boxSize="12px" color="white" />
|
||||||
|
<Text fontSize="xs" fontWeight="bold" color="white">
|
||||||
|
领主
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VStack align="start" spacing="4">
|
||||||
|
<HStack spacing="2">
|
||||||
|
<Icon as={TrendingDown} boxSize="20px" color="red.500" />
|
||||||
|
<Text fontSize="lg" fontWeight="700" color="red.600">
|
||||||
|
看跌 / No
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<VStack align="start" spacing="1">
|
||||||
|
<Text fontSize="xs" color={forumColors.text.secondary}>
|
||||||
|
当前价格
|
||||||
|
</Text>
|
||||||
|
<HStack spacing="1">
|
||||||
|
<Text fontSize="3xl" fontWeight="bold" color="red.600">
|
||||||
|
{Math.round(noData.current_price)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color={forumColors.text.secondary}>
|
||||||
|
积分/份
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<HStack justify="space-between" w="full">
|
||||||
|
<Text fontSize="sm" color={forumColors.text.secondary}>
|
||||||
|
总份额
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="md" fontWeight="600" color="red.600">
|
||||||
|
{noData.total_shares}份
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack justify="space-between" w="full">
|
||||||
|
<Text fontSize="sm" color={forumColors.text.secondary}>
|
||||||
|
市场占比
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="md" fontWeight="600" color="red.600">
|
||||||
|
{noPercent.toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* 市场情绪进度条 */}
|
||||||
|
<Box mt="6">
|
||||||
|
<Flex justify="space-between" mb="2">
|
||||||
|
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
|
||||||
|
市场情绪分布
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color={forumColors.text.secondary}>
|
||||||
|
{yesPercent.toFixed(1)}% vs {noPercent.toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Progress
|
||||||
|
value={yesPercent}
|
||||||
|
size="lg"
|
||||||
|
borderRadius="full"
|
||||||
|
bg="red.200"
|
||||||
|
sx={{
|
||||||
|
'& > div': {
|
||||||
|
bg: 'linear-gradient(90deg, #48BB78 0%, #38A169 100%)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</MotionBox>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 右侧:操作面板 */}
|
||||||
|
<Box gridColumn={{ base: '1', lg: '3' }}>
|
||||||
|
<VStack spacing="6" align="stretch" position="sticky" top="90px">
|
||||||
|
{/* 奖池信息 */}
|
||||||
|
<Box
|
||||||
|
bg={forumColors.background.card}
|
||||||
|
borderRadius="xl"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.gold}
|
||||||
|
p="6"
|
||||||
|
>
|
||||||
|
<VStack spacing="4" align="stretch">
|
||||||
|
<HStack justify="center" spacing="2">
|
||||||
|
<Icon as={DollarSign} boxSize="24px" color={forumColors.primary[500]} />
|
||||||
|
<Text fontSize="sm" fontWeight="600" color={forumColors.text.secondary}>
|
||||||
|
当前奖池
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
fontSize="4xl"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={forumColors.primary[500]}
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
{topic.total_pool}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fontSize="sm" color={forumColors.text.secondary} textAlign="center">
|
||||||
|
积分
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<HStack justify="space-between" fontSize="sm">
|
||||||
|
<Text color={forumColors.text.secondary}>参与人数</Text>
|
||||||
|
<HStack spacing="1">
|
||||||
|
<Icon as={Users} boxSize="14px" />
|
||||||
|
<Text fontWeight="600">{topic.stats.unique_traders.size}</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack justify="space-between" fontSize="sm">
|
||||||
|
<Text color={forumColors.text.secondary}>总交易量</Text>
|
||||||
|
<Text fontWeight="600">{Math.round(topic.stats.total_volume)}</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 交易按钮 */}
|
||||||
|
{topic.status === 'active' && (
|
||||||
|
<VStack spacing="3">
|
||||||
|
<Button
|
||||||
|
leftIcon={<ShoppingCart size={18} />}
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
size="lg"
|
||||||
|
w="full"
|
||||||
|
fontWeight="bold"
|
||||||
|
onClick={() => handleOpenTrade('buy')}
|
||||||
|
_hover={{
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: forumColors.shadows.goldHover,
|
||||||
|
}}
|
||||||
|
_active={{ transform: 'translateY(0)' }}
|
||||||
|
>
|
||||||
|
购买席位
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
leftIcon={<ArrowLeftRight size={18} />}
|
||||||
|
variant="outline"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
size="lg"
|
||||||
|
w="full"
|
||||||
|
fontWeight="bold"
|
||||||
|
onClick={() => handleOpenTrade('sell')}
|
||||||
|
_hover={{
|
||||||
|
bg: forumColors.background.hover,
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
卖出席位
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 用户余额 */}
|
||||||
|
{user && userAccount && (
|
||||||
|
<Box
|
||||||
|
bg={forumColors.background.hover}
|
||||||
|
borderRadius="lg"
|
||||||
|
p="4"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
>
|
||||||
|
<VStack spacing="2" align="stretch" fontSize="sm">
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Text color={forumColors.text.secondary}>可用余额</Text>
|
||||||
|
<Text fontWeight="600" color={forumColors.text.primary}>
|
||||||
|
{userAccount.balance} 积分
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Text color={forumColors.text.secondary}>冻结积分</Text>
|
||||||
|
<Text fontWeight="600" color={forumColors.text.primary}>
|
||||||
|
{userAccount.frozen} 积分
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{/* 交易模态框 */}
|
||||||
|
<TradeModal
|
||||||
|
isOpen={isTradeModalOpen}
|
||||||
|
onClose={onTradeModalClose}
|
||||||
|
topic={topic}
|
||||||
|
mode={tradeMode}
|
||||||
|
onTradeSuccess={handleTradeSuccess}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PredictionTopicDetail;
|
||||||
386
src/views/ValueForum/components/CreatePredictionModal.js
Normal file
386
src/views/ValueForum/components/CreatePredictionModal.js
Normal file
@@ -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 (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} size="xl" isCentered>
|
||||||
|
<ModalOverlay backdropFilter="blur(4px)" />
|
||||||
|
<ModalContent
|
||||||
|
bg={forumColors.background.card}
|
||||||
|
borderRadius="xl"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
>
|
||||||
|
<ModalHeader
|
||||||
|
bg={forumColors.gradients.goldSubtle}
|
||||||
|
borderTopRadius="xl"
|
||||||
|
borderBottom="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
>
|
||||||
|
<HStack spacing="2">
|
||||||
|
<Icon as={Zap} boxSize="20px" color={forumColors.primary[500]} />
|
||||||
|
<Text color={forumColors.text.primary}>发起预测话题</Text>
|
||||||
|
</HStack>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalCloseButton color={forumColors.text.primary} />
|
||||||
|
|
||||||
|
<ModalBody py="6">
|
||||||
|
<VStack spacing="5" align="stretch">
|
||||||
|
{/* 提示信息 */}
|
||||||
|
<Alert
|
||||||
|
status="info"
|
||||||
|
bg={forumColors.background.hover}
|
||||||
|
borderRadius="lg"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
>
|
||||||
|
<AlertIcon color={forumColors.primary[500]} />
|
||||||
|
<VStack align="start" spacing="1" flex="1">
|
||||||
|
<Text fontSize="sm" color={forumColors.text.primary} fontWeight="600">
|
||||||
|
创建预测话题
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.secondary}>
|
||||||
|
• 创建费用:{CREDIT_CONFIG.CREATE_TOPIC_COST}积分(进入奖池)
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.secondary}>
|
||||||
|
• 作者不能参与自己发起的话题
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.secondary}>
|
||||||
|
• 截止后由作者提交结果进行结算
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* 话题标题 */}
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel fontSize="sm" color={forumColors.text.primary}>
|
||||||
|
话题标题
|
||||||
|
</FormLabel>
|
||||||
|
<Input
|
||||||
|
placeholder="例如:贵州茅台下周会涨吗?"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => 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}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* 话题描述 */}
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel fontSize="sm" color={forumColors.text.primary}>
|
||||||
|
话题描述
|
||||||
|
</FormLabel>
|
||||||
|
<Textarea
|
||||||
|
placeholder="详细描述预测的内容、判断标准、数据来源等..."
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => handleChange('description', e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
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}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* 分类 */}
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel fontSize="sm" color={forumColors.text.primary}>
|
||||||
|
分类
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={formData.category}
|
||||||
|
onChange={(e) => handleChange('category', e.target.value)}
|
||||||
|
bg={forumColors.background.main}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
_hover={{ borderColor: forumColors.border.light }}
|
||||||
|
_focus={{
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="stock">股票行情</option>
|
||||||
|
<option value="index">指数走势</option>
|
||||||
|
<option value="concept">概念板块</option>
|
||||||
|
<option value="policy">政策影响</option>
|
||||||
|
<option value="event">事件预测</option>
|
||||||
|
<option value="other">其他</option>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* 截止时间 */}
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel fontSize="sm" color={forumColors.text.primary}>
|
||||||
|
<HStack spacing="2">
|
||||||
|
<Icon as={Calendar} boxSize="16px" />
|
||||||
|
<Text>交易截止时间</Text>
|
||||||
|
</HStack>
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={formData.deadline_days}
|
||||||
|
onChange={(e) => handleChange('deadline_days', e.target.value)}
|
||||||
|
bg={forumColors.background.main}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
_hover={{ borderColor: forumColors.border.light }}
|
||||||
|
_focus={{
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="1">1天后</option>
|
||||||
|
<option value="3">3天后</option>
|
||||||
|
<option value="7">7天后(推荐)</option>
|
||||||
|
<option value="14">14天后</option>
|
||||||
|
<option value="30">30天后</option>
|
||||||
|
</Select>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.tertiary} mt="2">
|
||||||
|
截止后次日可提交结果进行结算
|
||||||
|
</Text>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* 费用说明 */}
|
||||||
|
<Box
|
||||||
|
bg={forumColors.gradients.goldSubtle}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.gold}
|
||||||
|
borderRadius="lg"
|
||||||
|
p="4"
|
||||||
|
>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<VStack align="start" spacing="1">
|
||||||
|
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
|
||||||
|
创建费用
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.secondary}>
|
||||||
|
将进入奖池,奖励给获胜者
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<HStack spacing="1">
|
||||||
|
<Icon as={DollarSign} boxSize="20px" color={forumColors.primary[500]} />
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color={forumColors.primary[500]}>
|
||||||
|
{CREDIT_CONFIG.CREATE_TOPIC_COST}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color={forumColors.text.secondary}>
|
||||||
|
积分
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Box mt="3" pt="3" borderTop="1px solid" borderColor={forumColors.border.default}>
|
||||||
|
<HStack justify="space-between" fontSize="sm">
|
||||||
|
<Text color={forumColors.text.secondary}>你的余额:</Text>
|
||||||
|
<Text fontWeight="600" color={forumColors.text.primary}>
|
||||||
|
{userAccount?.balance || 0} 积分
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack justify="space-between" fontSize="sm" mt="1">
|
||||||
|
<Text color={forumColors.text.secondary}>创建后:</Text>
|
||||||
|
<Text
|
||||||
|
fontWeight="600"
|
||||||
|
color={
|
||||||
|
(userAccount?.balance || 0) >= CREDIT_CONFIG.CREATE_TOPIC_COST
|
||||||
|
? forumColors.success[500]
|
||||||
|
: forumColors.error[500]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(userAccount?.balance || 0) - CREDIT_CONFIG.CREATE_TOPIC_COST} 积分
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter borderTop="1px solid" borderColor={forumColors.border.default}>
|
||||||
|
<HStack spacing="3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
color={forumColors.text.secondary}
|
||||||
|
_hover={{ bg: forumColors.background.hover }}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
fontWeight="bold"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
loadingText="创建中..."
|
||||||
|
isDisabled={(userAccount?.balance || 0) < CREDIT_CONFIG.CREATE_TOPIC_COST}
|
||||||
|
_hover={{
|
||||||
|
opacity: 0.9,
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
}}
|
||||||
|
_active={{ transform: 'translateY(0)' }}
|
||||||
|
>
|
||||||
|
发布话题
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreatePredictionModal;
|
||||||
327
src/views/ValueForum/components/PredictionTopicCard.js
Normal file
327
src/views/ValueForum/components/PredictionTopicCard.js
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
/**
|
||||||
|
* 预测话题卡片组件
|
||||||
|
* 展示预测市场的话题概览
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
HStack,
|
||||||
|
VStack,
|
||||||
|
Badge,
|
||||||
|
Progress,
|
||||||
|
Flex,
|
||||||
|
Avatar,
|
||||||
|
Icon,
|
||||||
|
useColorModeValue,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Crown,
|
||||||
|
Users,
|
||||||
|
Clock,
|
||||||
|
DollarSign,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { forumColors } from '@theme/forumTheme';
|
||||||
|
|
||||||
|
const MotionBox = motion(Box);
|
||||||
|
|
||||||
|
const PredictionTopicCard = ({ topic }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 处理卡片点击
|
||||||
|
const handleCardClick = () => {
|
||||||
|
navigate(`/value-forum/prediction/${topic.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化数字
|
||||||
|
const formatNumber = (num) => {
|
||||||
|
if (num >= 10000) return `${(num / 10000).toFixed(1)}万`;
|
||||||
|
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
|
||||||
|
return num;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
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 '即将截止';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取选项数据
|
||||||
|
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 statusColorMap = {
|
||||||
|
active: forumColors.success[500],
|
||||||
|
trading_closed: forumColors.warning[500],
|
||||||
|
settled: forumColors.text.secondary,
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabelMap = {
|
||||||
|
active: '交易中',
|
||||||
|
trading_closed: '已截止',
|
||||||
|
settled: '已结算',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionBox
|
||||||
|
bg={forumColors.background.card}
|
||||||
|
borderRadius="xl"
|
||||||
|
overflow="hidden"
|
||||||
|
border="2px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={handleCardClick}
|
||||||
|
whileHover={{ y: -8, scale: 1.02 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
_hover={{
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
boxShadow: forumColors.shadows.gold,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 头部:状态标识 */}
|
||||||
|
<Box
|
||||||
|
bg={forumColors.gradients.goldSubtle}
|
||||||
|
px="4"
|
||||||
|
py="2"
|
||||||
|
borderBottom="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<HStack spacing="2">
|
||||||
|
<Icon as={Zap} boxSize="16px" color={forumColors.primary[500]} />
|
||||||
|
<Text fontSize="xs" fontWeight="bold" color={forumColors.primary[500]}>
|
||||||
|
预测市场
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
bg={statusColorMap[topic.status]}
|
||||||
|
color="white"
|
||||||
|
px="3"
|
||||||
|
py="1"
|
||||||
|
borderRadius="full"
|
||||||
|
fontSize="xs"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{statusLabelMap[topic.status]}
|
||||||
|
</Badge>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<VStack align="stretch" p="5" spacing="4">
|
||||||
|
{/* 话题标题 */}
|
||||||
|
<Text
|
||||||
|
fontSize="lg"
|
||||||
|
fontWeight="700"
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
noOfLines={2}
|
||||||
|
lineHeight="1.4"
|
||||||
|
>
|
||||||
|
{topic.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
{topic.description && (
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
color={forumColors.text.secondary}
|
||||||
|
noOfLines={2}
|
||||||
|
lineHeight="1.6"
|
||||||
|
>
|
||||||
|
{topic.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 双向价格卡片 */}
|
||||||
|
<HStack spacing="3" w="full">
|
||||||
|
{/* Yes 方 */}
|
||||||
|
<Box
|
||||||
|
flex="1"
|
||||||
|
bg="linear-gradient(135deg, rgba(72, 187, 120, 0.1) 0%, rgba(72, 187, 120, 0.05) 100%)"
|
||||||
|
border="2px solid"
|
||||||
|
borderColor="green.400"
|
||||||
|
borderRadius="lg"
|
||||||
|
p="3"
|
||||||
|
position="relative"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
{/* 领主徽章 */}
|
||||||
|
{yesData.lord_id && (
|
||||||
|
<Icon
|
||||||
|
as={Crown}
|
||||||
|
position="absolute"
|
||||||
|
top="2"
|
||||||
|
right="2"
|
||||||
|
boxSize="16px"
|
||||||
|
color="yellow.400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VStack spacing="1" align="start">
|
||||||
|
<HStack spacing="1">
|
||||||
|
<Icon as={TrendingUp} boxSize="14px" color="green.500" />
|
||||||
|
<Text fontSize="xs" fontWeight="600" color="green.600">
|
||||||
|
看涨 / Yes
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="green.600">
|
||||||
|
{Math.round(yesData.current_price)}
|
||||||
|
<Text as="span" fontSize="xs" ml="1">
|
||||||
|
积分
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fontSize="xs" color={forumColors.text.secondary}>
|
||||||
|
{yesData.total_shares}份 · {yesPercent.toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* No 方 */}
|
||||||
|
<Box
|
||||||
|
flex="1"
|
||||||
|
bg="linear-gradient(135deg, rgba(245, 101, 101, 0.1) 0%, rgba(245, 101, 101, 0.05) 100%)"
|
||||||
|
border="2px solid"
|
||||||
|
borderColor="red.400"
|
||||||
|
borderRadius="lg"
|
||||||
|
p="3"
|
||||||
|
position="relative"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
{/* 领主徽章 */}
|
||||||
|
{noData.lord_id && (
|
||||||
|
<Icon
|
||||||
|
as={Crown}
|
||||||
|
position="absolute"
|
||||||
|
top="2"
|
||||||
|
right="2"
|
||||||
|
boxSize="16px"
|
||||||
|
color="yellow.400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VStack spacing="1" align="start">
|
||||||
|
<HStack spacing="1">
|
||||||
|
<Icon as={TrendingDown} boxSize="14px" color="red.500" />
|
||||||
|
<Text fontSize="xs" fontWeight="600" color="red.600">
|
||||||
|
看跌 / No
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="red.600">
|
||||||
|
{Math.round(noData.current_price)}
|
||||||
|
<Text as="span" fontSize="xs" ml="1">
|
||||||
|
积分
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fontSize="xs" color={forumColors.text.secondary}>
|
||||||
|
{noData.total_shares}份 · {noPercent.toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 市场情绪进度条 */}
|
||||||
|
<Box>
|
||||||
|
<Flex justify="space-between" mb="1">
|
||||||
|
<Text fontSize="xs" color={forumColors.text.tertiary}>
|
||||||
|
市场情绪
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.tertiary}>
|
||||||
|
{yesPercent.toFixed(0)}% vs {noPercent.toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Progress
|
||||||
|
value={yesPercent}
|
||||||
|
size="sm"
|
||||||
|
borderRadius="full"
|
||||||
|
bg="red.100"
|
||||||
|
sx={{
|
||||||
|
'& > div': {
|
||||||
|
bg: 'linear-gradient(90deg, #48BB78 0%, #38A169 100%)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 奖池和数据 */}
|
||||||
|
<HStack spacing="4" fontSize="sm" color={forumColors.text.secondary}>
|
||||||
|
<HStack spacing="1">
|
||||||
|
<Icon as={DollarSign} boxSize="16px" color={forumColors.primary[500]} />
|
||||||
|
<Text fontWeight="600" color={forumColors.primary[500]}>
|
||||||
|
{formatNumber(topic.total_pool)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs">奖池</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack spacing="1">
|
||||||
|
<Icon as={Users} boxSize="16px" />
|
||||||
|
<Text>{topic.stats?.unique_traders?.size || 0}人</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack spacing="1">
|
||||||
|
<Icon as={Clock} boxSize="16px" />
|
||||||
|
<Text>{formatTime(topic.deadline)}</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 底部:作者信息 */}
|
||||||
|
<Flex justify="space-between" align="center" pt="2" borderTop="1px solid" borderColor={forumColors.border.default}>
|
||||||
|
<HStack spacing="2">
|
||||||
|
<Avatar
|
||||||
|
size="xs"
|
||||||
|
name={topic.author_name}
|
||||||
|
src={topic.author_avatar}
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
/>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.tertiary}>
|
||||||
|
{topic.author_name}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 分类标签 */}
|
||||||
|
{topic.category && (
|
||||||
|
<Badge
|
||||||
|
bg={forumColors.background.hover}
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
px="2"
|
||||||
|
py="1"
|
||||||
|
borderRadius="md"
|
||||||
|
fontSize="xs"
|
||||||
|
>
|
||||||
|
{topic.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</VStack>
|
||||||
|
</MotionBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PredictionTopicCard;
|
||||||
494
src/views/ValueForum/components/TradeModal.js
Normal file
494
src/views/ValueForum/components/TradeModal.js
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
/**
|
||||||
|
* 交易模态框组件
|
||||||
|
* 用于买入/卖出预测市场席位
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalBody,
|
||||||
|
ModalFooter,
|
||||||
|
ModalCloseButton,
|
||||||
|
Button,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Box,
|
||||||
|
Icon,
|
||||||
|
Slider,
|
||||||
|
SliderTrack,
|
||||||
|
SliderFilledTrack,
|
||||||
|
SliderThumb,
|
||||||
|
RadioGroup,
|
||||||
|
Radio,
|
||||||
|
Stack,
|
||||||
|
Flex,
|
||||||
|
useToast,
|
||||||
|
Badge,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { TrendingUp, TrendingDown, DollarSign, AlertCircle, Zap } from 'lucide-react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { forumColors } from '@theme/forumTheme';
|
||||||
|
import {
|
||||||
|
buyPosition,
|
||||||
|
sellPosition,
|
||||||
|
calculateBuyCost,
|
||||||
|
calculateSellRevenue,
|
||||||
|
calculateTax,
|
||||||
|
getTopic,
|
||||||
|
} from '@services/predictionMarketService';
|
||||||
|
import { getUserAccount, CREDIT_CONFIG } from '@services/creditSystemService';
|
||||||
|
import { useAuth } from '@contexts/AuthContext';
|
||||||
|
|
||||||
|
const MotionBox = motion(Box);
|
||||||
|
|
||||||
|
const TradeModal = ({ isOpen, onClose, topic, mode = 'buy', onTradeSuccess }) => {
|
||||||
|
const toast = useToast();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const [selectedOption, setSelectedOption] = useState('yes');
|
||||||
|
const [shares, setShares] = useState(1);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 获取用户账户
|
||||||
|
const userAccount = user ? getUserAccount(user.id) : null;
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSelectedOption('yes');
|
||||||
|
setShares(1);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!topic || !userAccount) return null;
|
||||||
|
|
||||||
|
// 获取市场数据
|
||||||
|
const selectedSide = topic.positions[selectedOption];
|
||||||
|
const otherOption = selectedOption === 'yes' ? 'no' : 'yes';
|
||||||
|
const otherSide = topic.positions[otherOption];
|
||||||
|
|
||||||
|
// 计算交易数据
|
||||||
|
let cost = 0;
|
||||||
|
let tax = 0;
|
||||||
|
let totalCost = 0;
|
||||||
|
let avgPrice = 0;
|
||||||
|
|
||||||
|
if (mode === 'buy') {
|
||||||
|
cost = calculateBuyCost(selectedSide.total_shares, otherSide.total_shares, shares);
|
||||||
|
tax = calculateTax(cost);
|
||||||
|
totalCost = cost + tax;
|
||||||
|
avgPrice = cost / shares;
|
||||||
|
} else {
|
||||||
|
cost = calculateSellRevenue(selectedSide.total_shares, otherSide.total_shares, shares);
|
||||||
|
tax = calculateTax(cost);
|
||||||
|
totalCost = cost - tax;
|
||||||
|
avgPrice = cost / shares;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户在该方向的持仓
|
||||||
|
const userPosition = userAccount.active_positions?.find(
|
||||||
|
(p) => p.topic_id === topic.id && p.option_id === selectedOption
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxShares = mode === 'buy' ? 10 : userPosition?.shares || 0;
|
||||||
|
|
||||||
|
// 检查是否可以交易
|
||||||
|
const canTrade = () => {
|
||||||
|
if (mode === 'buy') {
|
||||||
|
// 检查余额
|
||||||
|
if (userAccount.balance < totalCost) {
|
||||||
|
return { ok: false, reason: '积分不足' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查单次上限
|
||||||
|
if (totalCost > CREDIT_CONFIG.MAX_SINGLE_BET) {
|
||||||
|
return { ok: false, reason: `单次购买上限${CREDIT_CONFIG.MAX_SINGLE_BET}积分` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
} else {
|
||||||
|
// 检查持仓
|
||||||
|
if (!userPosition || userPosition.shares < shares) {
|
||||||
|
return { ok: false, reason: '持仓不足' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tradeCheck = canTrade();
|
||||||
|
|
||||||
|
// 处理交易
|
||||||
|
const handleTrade = async () => {
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (mode === 'buy') {
|
||||||
|
result = buyPosition({
|
||||||
|
user_id: user.id,
|
||||||
|
user_name: user.name || user.username,
|
||||||
|
user_avatar: user.avatar,
|
||||||
|
topic_id: topic.id,
|
||||||
|
option_id: selectedOption,
|
||||||
|
shares,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result = sellPosition({
|
||||||
|
user_id: user.id,
|
||||||
|
topic_id: topic.id,
|
||||||
|
option_id: selectedOption,
|
||||||
|
shares,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: mode === 'buy' ? '购买成功!' : '卖出成功!',
|
||||||
|
description: mode === 'buy' ? `花费${totalCost}积分` : `获得${totalCost}积分`,
|
||||||
|
status: 'success',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通知父组件刷新
|
||||||
|
if (onTradeSuccess) {
|
||||||
|
onTradeSuccess(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('交易失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '交易失败',
|
||||||
|
description: error.message,
|
||||||
|
status: 'error',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} size="lg" isCentered>
|
||||||
|
<ModalOverlay backdropFilter="blur(4px)" />
|
||||||
|
<ModalContent
|
||||||
|
bg={forumColors.background.card}
|
||||||
|
borderRadius="xl"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
>
|
||||||
|
<ModalHeader
|
||||||
|
bg={forumColors.gradients.goldSubtle}
|
||||||
|
borderTopRadius="xl"
|
||||||
|
borderBottom="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
>
|
||||||
|
<HStack spacing="2">
|
||||||
|
<Icon
|
||||||
|
as={mode === 'buy' ? Zap : DollarSign}
|
||||||
|
boxSize="20px"
|
||||||
|
color={forumColors.primary[500]}
|
||||||
|
/>
|
||||||
|
<Text color={forumColors.text.primary}>
|
||||||
|
{mode === 'buy' ? '购买席位' : '卖出席位'}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalCloseButton color={forumColors.text.primary} />
|
||||||
|
|
||||||
|
<ModalBody py="6">
|
||||||
|
<VStack spacing="5" align="stretch">
|
||||||
|
{/* 话题标题 */}
|
||||||
|
<Box
|
||||||
|
bg={forumColors.background.hover}
|
||||||
|
borderRadius="lg"
|
||||||
|
p="3"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
>
|
||||||
|
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
|
||||||
|
{topic.title}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 选择方向 */}
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary} mb="3">
|
||||||
|
选择方向
|
||||||
|
</Text>
|
||||||
|
<RadioGroup value={selectedOption} onChange={setSelectedOption}>
|
||||||
|
<Stack spacing="3">
|
||||||
|
{/* Yes 选项 */}
|
||||||
|
<MotionBox
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
bg={
|
||||||
|
selectedOption === 'yes'
|
||||||
|
? 'linear-gradient(135deg, rgba(72, 187, 120, 0.2) 0%, rgba(72, 187, 120, 0.1) 100%)'
|
||||||
|
: forumColors.background.hover
|
||||||
|
}
|
||||||
|
border="2px solid"
|
||||||
|
borderColor={selectedOption === 'yes' ? 'green.400' : forumColors.border.default}
|
||||||
|
borderRadius="lg"
|
||||||
|
p="4"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => setSelectedOption('yes')}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<HStack spacing="3">
|
||||||
|
<Radio value="yes" colorScheme="green" />
|
||||||
|
<VStack align="start" spacing="0">
|
||||||
|
<HStack spacing="2">
|
||||||
|
<Icon as={TrendingUp} boxSize="16px" color="green.500" />
|
||||||
|
<Text fontWeight="600" color="green.600">
|
||||||
|
看涨 / Yes
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.secondary}>
|
||||||
|
{topic.positions.yes.total_shares}份持仓
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<VStack align="end" spacing="0">
|
||||||
|
<Text fontSize="xl" fontWeight="bold" color="green.600">
|
||||||
|
{Math.round(topic.positions.yes.current_price)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.secondary}>
|
||||||
|
积分/份
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</MotionBox>
|
||||||
|
|
||||||
|
{/* No 选项 */}
|
||||||
|
<MotionBox
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
bg={
|
||||||
|
selectedOption === 'no'
|
||||||
|
? 'linear-gradient(135deg, rgba(245, 101, 101, 0.2) 0%, rgba(245, 101, 101, 0.1) 100%)'
|
||||||
|
: forumColors.background.hover
|
||||||
|
}
|
||||||
|
border="2px solid"
|
||||||
|
borderColor={selectedOption === 'no' ? 'red.400' : forumColors.border.default}
|
||||||
|
borderRadius="lg"
|
||||||
|
p="4"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => setSelectedOption('no')}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<HStack spacing="3">
|
||||||
|
<Radio value="no" colorScheme="red" />
|
||||||
|
<VStack align="start" spacing="0">
|
||||||
|
<HStack spacing="2">
|
||||||
|
<Icon as={TrendingDown} boxSize="16px" color="red.500" />
|
||||||
|
<Text fontWeight="600" color="red.600">
|
||||||
|
看跌 / No
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.secondary}>
|
||||||
|
{topic.positions.no.total_shares}份持仓
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<VStack align="end" spacing="0">
|
||||||
|
<Text fontSize="xl" fontWeight="bold" color="red.600">
|
||||||
|
{Math.round(topic.positions.no.current_price)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.secondary}>
|
||||||
|
积分/份
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</MotionBox>
|
||||||
|
</Stack>
|
||||||
|
</RadioGroup>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 购买份额 */}
|
||||||
|
<Box>
|
||||||
|
<Flex justify="space-between" mb="3">
|
||||||
|
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
|
||||||
|
{mode === 'buy' ? '购买份额' : '卖出份额'}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color={forumColors.text.secondary}>
|
||||||
|
{shares} 份
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
value={shares}
|
||||||
|
onChange={setShares}
|
||||||
|
min={1}
|
||||||
|
max={maxShares}
|
||||||
|
step={1}
|
||||||
|
focusThumbOnChange={false}
|
||||||
|
>
|
||||||
|
<SliderTrack bg={forumColors.background.hover}>
|
||||||
|
<SliderFilledTrack bg={forumColors.gradients.goldPrimary} />
|
||||||
|
</SliderTrack>
|
||||||
|
<SliderThumb boxSize="6" bg={forumColors.primary[500]}>
|
||||||
|
<Box as={Icon} as={DollarSign} boxSize="12px" color="white" />
|
||||||
|
</SliderThumb>
|
||||||
|
</Slider>
|
||||||
|
|
||||||
|
<HStack justify="space-between" mt="2" fontSize="xs" color={forumColors.text.tertiary}>
|
||||||
|
<Text>1份</Text>
|
||||||
|
<Text>{maxShares}份 (最大)</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{mode === 'sell' && userPosition && (
|
||||||
|
<Text fontSize="xs" color={forumColors.text.secondary} mt="2">
|
||||||
|
你的持仓:{userPosition.shares}份 · 平均成本:{Math.round(userPosition.avg_cost)}积分/份
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 费用明细 */}
|
||||||
|
<Box
|
||||||
|
bg={forumColors.gradients.goldSubtle}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.gold}
|
||||||
|
borderRadius="lg"
|
||||||
|
p="4"
|
||||||
|
>
|
||||||
|
<VStack spacing="2" align="stretch">
|
||||||
|
<Flex justify="space-between" fontSize="sm">
|
||||||
|
<Text color={forumColors.text.secondary}>
|
||||||
|
{mode === 'buy' ? '购买成本' : '卖出收益'}
|
||||||
|
</Text>
|
||||||
|
<Text fontWeight="600" color={forumColors.text.primary}>
|
||||||
|
{Math.round(cost)} 积分
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex justify="space-between" fontSize="sm">
|
||||||
|
<Text color={forumColors.text.secondary}>平均价格</Text>
|
||||||
|
<Text fontWeight="600" color={forumColors.text.primary}>
|
||||||
|
{Math.round(avgPrice)} 积分/份
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex justify="space-between" fontSize="sm">
|
||||||
|
<Text color={forumColors.text.secondary}>交易税 (2%)</Text>
|
||||||
|
<Text fontWeight="600" color={forumColors.text.primary}>
|
||||||
|
{Math.round(tax)} 积分
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Box borderTop="1px solid" borderColor={forumColors.border.default} pt="2" mt="1">
|
||||||
|
<Flex justify="space-between">
|
||||||
|
<Text fontWeight="bold" color={forumColors.text.primary}>
|
||||||
|
{mode === 'buy' ? '总计' : '净收益'}
|
||||||
|
</Text>
|
||||||
|
<HStack spacing="1">
|
||||||
|
<Icon as={DollarSign} boxSize="20px" color={forumColors.primary[500]} />
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color={forumColors.primary[500]}>
|
||||||
|
{Math.round(totalCost)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color={forumColors.text.secondary}>
|
||||||
|
积分
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 余额提示 */}
|
||||||
|
<Box borderTop="1px solid" borderColor={forumColors.border.default} pt="2">
|
||||||
|
<Flex justify="space-between" fontSize="sm">
|
||||||
|
<Text color={forumColors.text.secondary}>你的余额:</Text>
|
||||||
|
<Text fontWeight="600" color={forumColors.text.primary}>
|
||||||
|
{userAccount.balance} 积分
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Flex justify="space-between" fontSize="sm" mt="1">
|
||||||
|
<Text color={forumColors.text.secondary}>
|
||||||
|
{mode === 'buy' ? '交易后:' : '交易后:'}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fontWeight="600"
|
||||||
|
color={
|
||||||
|
mode === 'buy'
|
||||||
|
? userAccount.balance >= totalCost
|
||||||
|
? forumColors.success[500]
|
||||||
|
: forumColors.error[500]
|
||||||
|
: forumColors.success[500]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mode === 'buy'
|
||||||
|
? userAccount.balance - totalCost
|
||||||
|
: userAccount.balance + totalCost}{' '}
|
||||||
|
积分
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 警告提示 */}
|
||||||
|
{!tradeCheck.ok && (
|
||||||
|
<Box
|
||||||
|
bg="red.50"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="red.200"
|
||||||
|
borderRadius="lg"
|
||||||
|
p="3"
|
||||||
|
>
|
||||||
|
<HStack spacing="2">
|
||||||
|
<Icon as={AlertCircle} boxSize="16px" color="red.500" />
|
||||||
|
<Text fontSize="sm" color="red.600" fontWeight="600">
|
||||||
|
{tradeCheck.reason}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter borderTop="1px solid" borderColor={forumColors.border.default}>
|
||||||
|
<HStack spacing="3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
color={forumColors.text.secondary}
|
||||||
|
_hover={{ bg: forumColors.background.hover }}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
bg={mode === 'buy' ? forumColors.gradients.goldPrimary : 'red.500'}
|
||||||
|
color="white"
|
||||||
|
fontWeight="bold"
|
||||||
|
onClick={handleTrade}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
loadingText={mode === 'buy' ? '购买中...' : '卖出中...'}
|
||||||
|
isDisabled={!tradeCheck.ok}
|
||||||
|
_hover={{
|
||||||
|
opacity: 0.9,
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
}}
|
||||||
|
_active={{ transform: 'translateY(0)' }}
|
||||||
|
>
|
||||||
|
{mode === 'buy' ? `购买 ${shares} 份` : `卖出 ${shares} 份`}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TradeModal;
|
||||||
@@ -22,26 +22,38 @@ import {
|
|||||||
useDisclosure,
|
useDisclosure,
|
||||||
Flex,
|
Flex,
|
||||||
Badge,
|
Badge,
|
||||||
|
Tabs,
|
||||||
|
TabList,
|
||||||
|
TabPanels,
|
||||||
|
Tab,
|
||||||
|
TabPanel,
|
||||||
|
Icon,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { Search, PenSquare, TrendingUp, Clock, Heart } from 'lucide-react';
|
import { Search, PenSquare, TrendingUp, Clock, Heart, Zap } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { forumColors } from '@theme/forumTheme';
|
import { forumColors } from '@theme/forumTheme';
|
||||||
import { getPosts, searchPosts } from '@services/elasticsearchService';
|
import { getPosts, searchPosts } from '@services/elasticsearchService';
|
||||||
|
import { getTopics } from '@services/predictionMarketService';
|
||||||
import PostCard from './components/PostCard';
|
import PostCard from './components/PostCard';
|
||||||
|
import PredictionTopicCard from './components/PredictionTopicCard';
|
||||||
import CreatePostModal from './components/CreatePostModal';
|
import CreatePostModal from './components/CreatePostModal';
|
||||||
|
import CreatePredictionModal from './components/CreatePredictionModal';
|
||||||
|
|
||||||
const MotionBox = motion(Box);
|
const MotionBox = motion(Box);
|
||||||
|
|
||||||
const ValueForum = () => {
|
const ValueForum = () => {
|
||||||
const [posts, setPosts] = useState([]);
|
const [posts, setPosts] = useState([]);
|
||||||
|
const [predictionTopics, setPredictionTopics] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchKeyword, setSearchKeyword] = useState('');
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
const [sortBy, setSortBy] = useState('created_at');
|
const [sortBy, setSortBy] = useState('created_at');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen: isPostModalOpen, onOpen: onPostModalOpen, onClose: onPostModalClose } = useDisclosure();
|
||||||
|
const { isOpen: isPredictionModalOpen, onOpen: onPredictionModalOpen, onClose: onPredictionModalClose } = useDisclosure();
|
||||||
|
|
||||||
// 获取帖子列表
|
// 获取帖子列表
|
||||||
const fetchPosts = async (currentPage = 1, reset = false) => {
|
const fetchPosts = async (currentPage = 1, reset = false) => {
|
||||||
@@ -78,10 +90,27 @@ const ValueForum = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取预测话题列表
|
||||||
|
const fetchPredictionTopics = () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const topics = getTopics({ status: 'active', sortBy });
|
||||||
|
setPredictionTopics(topics);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取预测话题失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 初始化加载
|
// 初始化加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPosts(1, true);
|
if (activeTab === 0) {
|
||||||
}, [sortBy]);
|
fetchPosts(1, true);
|
||||||
|
} else {
|
||||||
|
fetchPredictionTopics();
|
||||||
|
}
|
||||||
|
}, [sortBy, activeTab]);
|
||||||
|
|
||||||
// 搜索处理
|
// 搜索处理
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
@@ -102,6 +131,11 @@ const ValueForum = () => {
|
|||||||
fetchPosts(1, true);
|
fetchPosts(1, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 预测话题创建成功回调
|
||||||
|
const handlePredictionCreated = (newTopic) => {
|
||||||
|
setPredictionTopics((prev) => [newTopic, ...prev]);
|
||||||
|
};
|
||||||
|
|
||||||
// 排序选项
|
// 排序选项
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ value: 'created_at', label: '最新发布', icon: Clock },
|
{ value: 'created_at', label: '最新发布', icon: Clock },
|
||||||
@@ -143,21 +177,41 @@ const ValueForum = () => {
|
|||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
{/* 发帖按钮 */}
|
{/* 发帖按钮 */}
|
||||||
<Button
|
<HStack spacing="3">
|
||||||
leftIcon={<PenSquare size={18} />}
|
<Button
|
||||||
bg={forumColors.gradients.goldPrimary}
|
leftIcon={<PenSquare size={18} />}
|
||||||
color={forumColors.background.main}
|
bg={forumColors.background.card}
|
||||||
size="lg"
|
color={forumColors.text.primary}
|
||||||
fontWeight="bold"
|
size="lg"
|
||||||
onClick={onOpen}
|
fontWeight="bold"
|
||||||
_hover={{
|
border="1px solid"
|
||||||
transform: 'translateY(-2px)',
|
borderColor={forumColors.border.default}
|
||||||
boxShadow: forumColors.shadows.goldHover,
|
onClick={onPostModalOpen}
|
||||||
}}
|
_hover={{
|
||||||
_active={{ transform: 'translateY(0)' }}
|
transform: 'translateY(-2px)',
|
||||||
>
|
borderColor: forumColors.border.gold,
|
||||||
发布帖子
|
}}
|
||||||
</Button>
|
_active={{ transform: 'translateY(0)' }}
|
||||||
|
>
|
||||||
|
发布帖子
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
leftIcon={<Zap size={18} />}
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
size="lg"
|
||||||
|
fontWeight="bold"
|
||||||
|
onClick={onPredictionModalOpen}
|
||||||
|
_hover={{
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: forumColors.shadows.goldHover,
|
||||||
|
}}
|
||||||
|
_active={{ transform: 'translateY(0)' }}
|
||||||
|
>
|
||||||
|
发起预测
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* 搜索和筛选栏 */}
|
{/* 搜索和筛选栏 */}
|
||||||
@@ -224,86 +278,190 @@ const ValueForum = () => {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</MotionBox>
|
</MotionBox>
|
||||||
|
|
||||||
{/* 帖子网格 */}
|
{/* 标签页 */}
|
||||||
{loading && page === 1 ? (
|
<Tabs
|
||||||
<Center py="20">
|
index={activeTab}
|
||||||
<VStack spacing="4">
|
onChange={setActiveTab}
|
||||||
<Spinner
|
variant="soft-rounded"
|
||||||
size="xl"
|
colorScheme="yellow"
|
||||||
thickness="4px"
|
>
|
||||||
speed="0.8s"
|
<TabList mb="8" bg={forumColors.background.card} p="2" borderRadius="xl">
|
||||||
color={forumColors.primary[500]}
|
<Tab
|
||||||
/>
|
_selected={{
|
||||||
<Text color={forumColors.text.secondary}>加载中...</Text>
|
bg: forumColors.gradients.goldPrimary,
|
||||||
</VStack>
|
color: forumColors.background.main,
|
||||||
</Center>
|
}}
|
||||||
) : posts.length === 0 ? (
|
>
|
||||||
<Center py="20">
|
<HStack spacing="2">
|
||||||
<VStack spacing="4">
|
<Icon as={PenSquare} boxSize="16px" />
|
||||||
<Text color={forumColors.text.secondary} fontSize="lg">
|
<Text>社区帖子</Text>
|
||||||
{searchKeyword ? '未找到相关帖子' : '暂无帖子,快来发布第一篇吧!'}
|
</HStack>
|
||||||
</Text>
|
</Tab>
|
||||||
{!searchKeyword && (
|
<Tab
|
||||||
<Button
|
_selected={{
|
||||||
leftIcon={<PenSquare size={18} />}
|
bg: forumColors.gradients.goldPrimary,
|
||||||
bg={forumColors.gradients.goldPrimary}
|
color: forumColors.background.main,
|
||||||
color={forumColors.background.main}
|
}}
|
||||||
onClick={onOpen}
|
>
|
||||||
_hover={{ opacity: 0.9 }}
|
<HStack spacing="2">
|
||||||
|
<Icon as={Zap} boxSize="16px" />
|
||||||
|
<Text>预测市场</Text>
|
||||||
|
<Badge
|
||||||
|
bg="red.500"
|
||||||
|
color="white"
|
||||||
|
borderRadius="full"
|
||||||
|
px="2"
|
||||||
|
fontSize="xs"
|
||||||
>
|
>
|
||||||
发布帖子
|
NEW
|
||||||
</Button>
|
</Badge>
|
||||||
)}
|
</HStack>
|
||||||
</VStack>
|
</Tab>
|
||||||
</Center>
|
</TabList>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="6">
|
|
||||||
<AnimatePresence>
|
|
||||||
{posts.map((post, index) => (
|
|
||||||
<MotionBox
|
|
||||||
key={post.id}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -20 }}
|
|
||||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
|
||||||
>
|
|
||||||
<PostCard post={post} />
|
|
||||||
</MotionBox>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
{/* 加载更多按钮 */}
|
<TabPanels>
|
||||||
{hasMore && (
|
{/* 普通帖子标签页 */}
|
||||||
<Center mt="10">
|
<TabPanel p="0">
|
||||||
<Button
|
{loading && page === 1 ? (
|
||||||
onClick={loadMore}
|
<Center py="20">
|
||||||
isLoading={loading}
|
<VStack spacing="4">
|
||||||
loadingText="加载中..."
|
<Spinner
|
||||||
bg={forumColors.background.card}
|
size="xl"
|
||||||
color={forumColors.text.primary}
|
thickness="4px"
|
||||||
border="1px solid"
|
speed="0.8s"
|
||||||
borderColor={forumColors.border.default}
|
color={forumColors.primary[500]}
|
||||||
_hover={{
|
/>
|
||||||
borderColor: forumColors.border.gold,
|
<Text color={forumColors.text.secondary}>加载中...</Text>
|
||||||
bg: forumColors.background.hover,
|
</VStack>
|
||||||
}}
|
</Center>
|
||||||
>
|
) : posts.length === 0 ? (
|
||||||
加载更多
|
<Center py="20">
|
||||||
</Button>
|
<VStack spacing="4">
|
||||||
</Center>
|
<Text color={forumColors.text.secondary} fontSize="lg">
|
||||||
)}
|
{searchKeyword ? '未找到相关帖子' : '暂无帖子,快来发布第一篇吧!'}
|
||||||
</>
|
</Text>
|
||||||
)}
|
{!searchKeyword && (
|
||||||
|
<Button
|
||||||
|
leftIcon={<PenSquare size={18} />}
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
onClick={onPostModalOpen}
|
||||||
|
_hover={{ opacity: 0.9 }}
|
||||||
|
>
|
||||||
|
发布帖子
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="6">
|
||||||
|
<AnimatePresence>
|
||||||
|
{posts.map((post, index) => (
|
||||||
|
<MotionBox
|
||||||
|
key={post.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
|
>
|
||||||
|
<PostCard post={post} />
|
||||||
|
</MotionBox>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* 加载更多按钮 */}
|
||||||
|
{hasMore && (
|
||||||
|
<Center mt="10">
|
||||||
|
<Button
|
||||||
|
onClick={loadMore}
|
||||||
|
isLoading={loading}
|
||||||
|
loadingText="加载中..."
|
||||||
|
bg={forumColors.background.card}
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
_hover={{
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
bg: forumColors.background.hover,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
加载更多
|
||||||
|
</Button>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* 预测市场标签页 */}
|
||||||
|
<TabPanel p="0">
|
||||||
|
{loading ? (
|
||||||
|
<Center py="20">
|
||||||
|
<VStack spacing="4">
|
||||||
|
<Spinner
|
||||||
|
size="xl"
|
||||||
|
thickness="4px"
|
||||||
|
speed="0.8s"
|
||||||
|
color={forumColors.primary[500]}
|
||||||
|
/>
|
||||||
|
<Text color={forumColors.text.secondary}>加载中...</Text>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
) : predictionTopics.length === 0 ? (
|
||||||
|
<Center py="20">
|
||||||
|
<VStack spacing="4">
|
||||||
|
<Icon as={Zap} boxSize="48px" color={forumColors.text.tertiary} />
|
||||||
|
<Text color={forumColors.text.secondary} fontSize="lg">
|
||||||
|
暂无预测话题,快来发起第一个吧!
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
leftIcon={<Zap size={18} />}
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
onClick={onPredictionModalOpen}
|
||||||
|
_hover={{ opacity: 0.9 }}
|
||||||
|
>
|
||||||
|
发起预测
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing="6">
|
||||||
|
<AnimatePresence>
|
||||||
|
{predictionTopics.map((topic, index) => (
|
||||||
|
<MotionBox
|
||||||
|
key={topic.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
|
>
|
||||||
|
<PredictionTopicCard topic={topic} />
|
||||||
|
</MotionBox>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{/* 发帖模态框 */}
|
{/* 发帖模态框 */}
|
||||||
<CreatePostModal
|
<CreatePostModal
|
||||||
isOpen={isOpen}
|
isOpen={isPostModalOpen}
|
||||||
onClose={onClose}
|
onClose={onPostModalClose}
|
||||||
onPostCreated={handlePostCreated}
|
onPostCreated={handlePostCreated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 发起预测模态框 */}
|
||||||
|
<CreatePredictionModal
|
||||||
|
isOpen={isPredictionModalOpen}
|
||||||
|
onClose={onPredictionModalClose}
|
||||||
|
onTopicCreated={handlePredictionCreated}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user