|
|
@@ -0,0 +1,511 @@
|
|
|
+import { useEffect, useState, useCallback } from 'react';
|
|
|
+import { APIClient } from '@d8d-appcontainer/api';
|
|
|
+import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
+import type {
|
|
|
+ QuizContent,
|
|
|
+ QuizState,
|
|
|
+ ExamSocketMessage,
|
|
|
+ ExamSocketRoomMessage,
|
|
|
+ Answer
|
|
|
+} from '../types.ts';
|
|
|
+
|
|
|
+interface LoaderData {
|
|
|
+ token: string;
|
|
|
+ serverUrl: string;
|
|
|
+}
|
|
|
+
|
|
|
+// 系统消息默认值
|
|
|
+const SYSTEM_USER_ID = 'system';
|
|
|
+const DEFAULT_HOLDING_STOCK = '0';
|
|
|
+const DEFAULT_HOLDING_CASH = '1';
|
|
|
+
|
|
|
+// 工具函数:统一错误处理
|
|
|
+const handleAsyncOperation = async <T>(
|
|
|
+ operation: () => Promise<T>,
|
|
|
+ errorMessage: string
|
|
|
+): Promise<T> => {
|
|
|
+ try {
|
|
|
+ return await operation();
|
|
|
+ } catch (error) {
|
|
|
+ console.error(`${errorMessage}:`, error);
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 计算收益的辅助函数
|
|
|
+interface ProfitResult {
|
|
|
+ profitAmount: number; // 金额收益
|
|
|
+ profitPercent: number; // 百分比收益
|
|
|
+}
|
|
|
+
|
|
|
+function calculateProfit(currentPrice: number, previousPrice: number, holdingStock: string): ProfitResult {
|
|
|
+ if (holdingStock === '1') {
|
|
|
+ const profitAmount = currentPrice - previousPrice;
|
|
|
+ const profitPercent = ((currentPrice - previousPrice) / previousPrice) * 100;
|
|
|
+ return {
|
|
|
+ profitAmount,
|
|
|
+ profitPercent
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ profitAmount: 0,
|
|
|
+ profitPercent: 0
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// 核心Socket客户端Hook
|
|
|
+export function useExamSocketClient(roomId: string | null) {
|
|
|
+ const { token, serverUrl } = {
|
|
|
+ token: '',
|
|
|
+ serverUrl: '/wss'
|
|
|
+ };
|
|
|
+
|
|
|
+ const { data: client } = useQuery({
|
|
|
+ queryKey: ['socket-client'],
|
|
|
+ queryFn: async () => {
|
|
|
+ if (!token || !serverUrl) return null;
|
|
|
+
|
|
|
+ const apiClient = new APIClient({
|
|
|
+ scope: 'user',
|
|
|
+ config: {
|
|
|
+ serverUrl,
|
|
|
+ type: 'socket',
|
|
|
+ token,
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ await apiClient.connect();
|
|
|
+ return apiClient;
|
|
|
+ },
|
|
|
+ enabled: !!token && !!serverUrl && !!roomId,
|
|
|
+ staleTime: Infinity,
|
|
|
+ retry: 3,
|
|
|
+ gcTime: 0
|
|
|
+ });
|
|
|
+
|
|
|
+ const joinRoom = useCallback(async (roomId: string) => {
|
|
|
+ if (client && roomId) {
|
|
|
+ await client.socket.joinRoom(roomId);
|
|
|
+ }
|
|
|
+ }, [client]);
|
|
|
+
|
|
|
+ const leaveRoom = useCallback(async (roomId: string) => {
|
|
|
+ if (client && roomId) {
|
|
|
+ await client.socket.leaveRoom(roomId);
|
|
|
+ }
|
|
|
+ }, [client]);
|
|
|
+
|
|
|
+ const sendRoomMessage = useCallback(async (roomId: string | null, message: ExamSocketMessage) => {
|
|
|
+ if (client && roomId) {
|
|
|
+ const convertedMessage = {
|
|
|
+ ...message,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ content: {
|
|
|
+ ...message.content,
|
|
|
+ userId: message.content.userId || SYSTEM_USER_ID
|
|
|
+ }
|
|
|
+ };
|
|
|
+ await client.socket.sendRoomMessage(roomId, convertedMessage as any);
|
|
|
+ }
|
|
|
+ }, [client]);
|
|
|
+
|
|
|
+ const onRoomMessage = useCallback((callback: (data: ExamSocketRoomMessage) => void) => {
|
|
|
+ if (client) {
|
|
|
+ client.socket.onRoomMessage((data: any) => {
|
|
|
+ const convertedMessage: ExamSocketRoomMessage = {
|
|
|
+ roomId: data.roomId,
|
|
|
+ message: {
|
|
|
+ ...data.message,
|
|
|
+ timestamp: Number(data.message.timestamp),
|
|
|
+ content: {
|
|
|
+ ...data.message.content,
|
|
|
+ price: parseFloat(String(data.message.content.price))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ callback(convertedMessage);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }, [client]);
|
|
|
+
|
|
|
+ const getAnswers = useCallback(async (roomId: string | null, questionId: string): Promise<Answer[]> => {
|
|
|
+ if (!client || !roomId) return [];
|
|
|
+
|
|
|
+ return handleAsyncOperation(async () => {
|
|
|
+ const answersData = await client.redis.hgetall(`quiz:${roomId}:answers:${questionId}`);
|
|
|
+ if (!answersData) return [];
|
|
|
+
|
|
|
+ return Object.entries(answersData).map(([userId, data]) => ({
|
|
|
+ ...(JSON.parse(data) as QuizContent),
|
|
|
+ userId,
|
|
|
+ })) as Answer[];
|
|
|
+ }, '获取答案失败');
|
|
|
+ }, [client]);
|
|
|
+
|
|
|
+ const storeAnswer = useCallback(async (roomId: string | null, questionId: string, userId: string, answer: QuizContent) => {
|
|
|
+ if (!client || !roomId) return;
|
|
|
+
|
|
|
+ const pricesData = await client.redis.hgetall(`quiz:${roomId}:prices`);
|
|
|
+ if (!pricesData) {
|
|
|
+ const initialAnswer: Answer = {
|
|
|
+ ...answer,
|
|
|
+ userId,
|
|
|
+ profitAmount: 0,
|
|
|
+ profitPercent: 0,
|
|
|
+ totalProfitAmount: 0,
|
|
|
+ totalProfitPercent: 0
|
|
|
+ };
|
|
|
+
|
|
|
+ await client.redis.hset(
|
|
|
+ `quiz:${roomId}:answers:${questionId}`,
|
|
|
+ userId,
|
|
|
+ JSON.stringify(initialAnswer)
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const dates = Object.keys(pricesData).sort();
|
|
|
+ const allAnswers = await Promise.all(
|
|
|
+ dates.map(date => getAnswers(roomId, date))
|
|
|
+ );
|
|
|
+
|
|
|
+ const userAnswers = allAnswers
|
|
|
+ .flat()
|
|
|
+ .filter((a: Answer) => a.userId === userId)
|
|
|
+ .map((a: Answer) => {
|
|
|
+ if (!a.date) return a;
|
|
|
+ const priceData = JSON.parse(pricesData[a.date] || '{"price":"0"}');
|
|
|
+ return {
|
|
|
+ ...a,
|
|
|
+ price: priceData.price
|
|
|
+ };
|
|
|
+ })
|
|
|
+ .sort((a: Answer, b: Answer) => new Date(a.date || '').getTime() - new Date(b.date || '').getTime());
|
|
|
+
|
|
|
+ let totalProfitAmount = 0;
|
|
|
+ let totalProfitPercent = 0;
|
|
|
+
|
|
|
+ if (userAnswers.length > 0) {
|
|
|
+ const prevAnswer = userAnswers[userAnswers.length - 1];
|
|
|
+ const { profitAmount, profitPercent } = calculateProfit(
|
|
|
+ parseFloat(String(answer.price)),
|
|
|
+ parseFloat(String(prevAnswer.price)),
|
|
|
+ prevAnswer.holdingStock as string
|
|
|
+ );
|
|
|
+
|
|
|
+ totalProfitAmount = (prevAnswer.totalProfitAmount || 0) + profitAmount;
|
|
|
+ totalProfitPercent = (prevAnswer.totalProfitPercent || 0) + profitPercent;
|
|
|
+ }
|
|
|
+
|
|
|
+ const answerWithProfit: Answer = {
|
|
|
+ ...answer,
|
|
|
+ userId,
|
|
|
+ profitAmount: userAnswers.length > 0 ? totalProfitAmount - (userAnswers[userAnswers.length - 1].totalProfitAmount || 0) : 0,
|
|
|
+ profitPercent: userAnswers.length > 0 ? totalProfitPercent - (userAnswers[userAnswers.length - 1].totalProfitPercent || 0) : 0,
|
|
|
+ totalProfitAmount,
|
|
|
+ totalProfitPercent
|
|
|
+ };
|
|
|
+
|
|
|
+ if (client?.redis) {
|
|
|
+ await client.redis.hset(
|
|
|
+ `quiz:${roomId}:answers:${questionId}`,
|
|
|
+ userId,
|
|
|
+ JSON.stringify(answerWithProfit)
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }, [client, getAnswers]);
|
|
|
+
|
|
|
+ const cleanupRoom = useCallback(async (roomId: string | null, questionId?: string) => {
|
|
|
+ if (!client || !roomId) return;
|
|
|
+
|
|
|
+ await handleAsyncOperation(async () => {
|
|
|
+ if (questionId) {
|
|
|
+ await client.redis.del(`quiz:${roomId}:answers:${questionId}`);
|
|
|
+ } else {
|
|
|
+ await Promise.all([
|
|
|
+ client.redis.delByPattern(`quiz:${roomId}:answers:*`),
|
|
|
+ client.redis.del(`quiz:${roomId}:prices`)
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+ }, '清理房间数据失败');
|
|
|
+ }, [client]);
|
|
|
+
|
|
|
+ const sendNextQuestion = useCallback(async (roomId: string | null, state: QuizState) => {
|
|
|
+ if (!client || !roomId) return;
|
|
|
+
|
|
|
+ return handleAsyncOperation(async () => {
|
|
|
+ const messageContent: QuizContent = {
|
|
|
+ date: state.date,
|
|
|
+ price: state.price,
|
|
|
+ holdingStock: DEFAULT_HOLDING_STOCK,
|
|
|
+ holdingCash: DEFAULT_HOLDING_CASH,
|
|
|
+ userId: SYSTEM_USER_ID
|
|
|
+ };
|
|
|
+
|
|
|
+ const message: ExamSocketMessage = {
|
|
|
+ type: 'question',
|
|
|
+ content: messageContent
|
|
|
+ };
|
|
|
+
|
|
|
+ await storeAnswer(roomId, 'current_state', SYSTEM_USER_ID, messageContent);
|
|
|
+ await client.redis.hset(
|
|
|
+ `quiz:${roomId}:prices`,
|
|
|
+ state.date,
|
|
|
+ JSON.stringify({ price: state.price })
|
|
|
+ );
|
|
|
+ await sendRoomMessage(roomId, message);
|
|
|
+ }, '发送题目失败');
|
|
|
+ }, [client, sendRoomMessage, storeAnswer]);
|
|
|
+
|
|
|
+ const getCurrentQuestion = useCallback(async (roomId: string | null): Promise<QuizState | null> => {
|
|
|
+ if (!client || !roomId) return null;
|
|
|
+
|
|
|
+ return handleAsyncOperation(async () => {
|
|
|
+ const answers = await getAnswers(roomId, 'current_state');
|
|
|
+ const currentState = answers[0];
|
|
|
+ if (currentState) {
|
|
|
+ return {
|
|
|
+ date: currentState.date || '',
|
|
|
+ price: currentState.price || '0'
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }, '获取当前题目状态失败');
|
|
|
+ }, [client, getAnswers]);
|
|
|
+
|
|
|
+ const getPriceHistory = useCallback(async (roomId: string | null, date: string): Promise<string> => {
|
|
|
+ if (!client || !roomId) return '0';
|
|
|
+
|
|
|
+ return handleAsyncOperation(async () => {
|
|
|
+ const priceData = await client.redis.hget(`quiz:${roomId}:prices`, date);
|
|
|
+ if (!priceData) return '0';
|
|
|
+
|
|
|
+ const { price } = JSON.parse(priceData);
|
|
|
+ return String(price);
|
|
|
+ }, '获取历史价格失败');
|
|
|
+ }, [client]);
|
|
|
+
|
|
|
+ return {
|
|
|
+ client,
|
|
|
+ joinRoom,
|
|
|
+ leaveRoom,
|
|
|
+ sendRoomMessage,
|
|
|
+ onRoomMessage,
|
|
|
+ storeAnswer,
|
|
|
+ getAnswers,
|
|
|
+ cleanupRoom,
|
|
|
+ sendNextQuestion,
|
|
|
+ getCurrentQuestion,
|
|
|
+ getPriceHistory
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// Socket Room Hook
|
|
|
+export function useExamSocketRoom(roomId: string | null) {
|
|
|
+ const socketClient = useExamSocketClient(roomId);
|
|
|
+
|
|
|
+ const { data: roomConnection } = useQuery({
|
|
|
+ queryKey: ['socket-room', roomId],
|
|
|
+ queryFn: async () => {
|
|
|
+ if (!roomId || !socketClient.client) return null;
|
|
|
+ await socketClient.joinRoom(roomId);
|
|
|
+ console.log(`Connected to room: ${roomId}`);
|
|
|
+ return { roomId, connected: true };
|
|
|
+ },
|
|
|
+ enabled: !!roomId && !!socketClient.client,
|
|
|
+ staleTime: Infinity,
|
|
|
+ gcTime: Infinity,
|
|
|
+ retry: false
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ connected: roomConnection?.connected || false,
|
|
|
+ socketClient
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// 当前题目状态Hook
|
|
|
+export function useExamCurrentQuestion(roomId: string | null) {
|
|
|
+ const socketClient = useExamSocketClient(roomId);
|
|
|
+
|
|
|
+ const { data: currentQuestion, refetch } = useQuery({
|
|
|
+ queryKey: ['current-question', roomId],
|
|
|
+ queryFn: async () => {
|
|
|
+ if (!roomId || !socketClient) return null;
|
|
|
+ return socketClient.getCurrentQuestion(roomId);
|
|
|
+ },
|
|
|
+ enabled: !!roomId && !!socketClient,
|
|
|
+ staleTime: 0
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ currentQuestion,
|
|
|
+ refetchQuestion: refetch
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// 房间消息Hook
|
|
|
+export function useExamRoomMessages(roomId: string | null) {
|
|
|
+ const socketClient = useExamSocketClient(roomId);
|
|
|
+ const queryClient = useQueryClient();
|
|
|
+ const [lastMessage, setLastMessage] = useState<ExamSocketRoomMessage | null>(null);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!roomId || !socketClient) return;
|
|
|
+
|
|
|
+ const handleMessage = (data: ExamSocketRoomMessage) => {
|
|
|
+ setLastMessage(data);
|
|
|
+ const { type, content } = data.message;
|
|
|
+
|
|
|
+ switch (type) {
|
|
|
+ case 'question':
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['current-question', roomId] });
|
|
|
+ break;
|
|
|
+ case 'answer':
|
|
|
+ const { date } = content;
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['answers', roomId, date] });
|
|
|
+ break;
|
|
|
+ case 'settlement':
|
|
|
+ case 'submit':
|
|
|
+ queryClient.invalidateQueries({
|
|
|
+ queryKey: ['user-answers'],
|
|
|
+ predicate: (query) => query.queryKey[1] === roomId
|
|
|
+ });
|
|
|
+ queryClient.invalidateQueries({
|
|
|
+ queryKey: ['answers', roomId]
|
|
|
+ });
|
|
|
+ break;
|
|
|
+ case 'restart':
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['current-question', roomId] });
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['answers', roomId] });
|
|
|
+ queryClient.invalidateQueries({
|
|
|
+ queryKey: ['user-answers'],
|
|
|
+ predicate: (query) => query.queryKey[1] === roomId
|
|
|
+ });
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['training-results', roomId] });
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ socketClient.onRoomMessage(handleMessage);
|
|
|
+ return () => {
|
|
|
+ socketClient.client?.socket.offRoomMessage(handleMessage);
|
|
|
+ };
|
|
|
+ }, [roomId, socketClient, queryClient]);
|
|
|
+
|
|
|
+ return lastMessage;
|
|
|
+}
|
|
|
+
|
|
|
+// 用户答案历史Hook
|
|
|
+export function useExamUserAnswerHistory(roomId: string | null, userId: string | null) {
|
|
|
+ const socketClient = useExamSocketClient(roomId);
|
|
|
+
|
|
|
+ const { data: userAnswers, refetch: refetchUserAnswers } = useQuery({
|
|
|
+ queryKey: ['user-answers', roomId, userId],
|
|
|
+ queryFn: async () => {
|
|
|
+ if (!roomId || !userId || !socketClient) return [];
|
|
|
+
|
|
|
+ const pricesData = await socketClient.client?.redis.hgetall(`quiz:${roomId}:prices`);
|
|
|
+ if (!pricesData) return [];
|
|
|
+
|
|
|
+ const allAnswers = await Promise.all(
|
|
|
+ Object.keys(pricesData).map(date => socketClient.getAnswers(roomId, date))
|
|
|
+ );
|
|
|
+
|
|
|
+ const userAnswersWithPrice = allAnswers
|
|
|
+ .flat()
|
|
|
+ .filter((answer: Answer) => answer.userId === userId)
|
|
|
+ .map((answer: Answer) => {
|
|
|
+ if (!answer.date) return answer;
|
|
|
+ const priceData = JSON.parse(pricesData[answer.date] || '{"price":"0"}');
|
|
|
+ return {
|
|
|
+ ...answer,
|
|
|
+ price: priceData.price
|
|
|
+ };
|
|
|
+ })
|
|
|
+ .sort((a: Answer, b: Answer) => new Date(a.date || '').getTime() - new Date(b.date || '').getTime());
|
|
|
+
|
|
|
+ let totalProfitAmount = 0;
|
|
|
+ let totalProfitPercent = 0;
|
|
|
+ const answersWithProfit = userAnswersWithPrice.map((answer: Answer, index: number) => {
|
|
|
+ if (index === 0) {
|
|
|
+ return {
|
|
|
+ ...answer,
|
|
|
+ profitAmount: 0,
|
|
|
+ profitPercent: 0,
|
|
|
+ totalProfitAmount: 0,
|
|
|
+ totalProfitPercent: 0
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const prevAnswer = userAnswersWithPrice[index - 1];
|
|
|
+ const { profitAmount, profitPercent } = calculateProfit(
|
|
|
+ parseFloat(answer.price as string),
|
|
|
+ parseFloat(prevAnswer.price as string),
|
|
|
+ prevAnswer.holdingStock as string
|
|
|
+ );
|
|
|
+
|
|
|
+ totalProfitAmount += profitAmount;
|
|
|
+ totalProfitPercent += profitPercent;
|
|
|
+
|
|
|
+ return {
|
|
|
+ ...answer,
|
|
|
+ profitAmount,
|
|
|
+ profitPercent,
|
|
|
+ totalProfitAmount,
|
|
|
+ totalProfitPercent
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ return answersWithProfit;
|
|
|
+ },
|
|
|
+ enabled: !!roomId && !!userId && !!socketClient,
|
|
|
+ staleTime: 0
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ userAnswers: userAnswers || [],
|
|
|
+ refetchUserAnswers
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// 答案提交Hook
|
|
|
+export function useExamAnswerSubmission(roomId: string | null) {
|
|
|
+ const { client, sendRoomMessage, storeAnswer } = useExamSocketClient(roomId);
|
|
|
+ const queryClient = useQueryClient();
|
|
|
+
|
|
|
+ const submitAnswer = useCallback(async (date: string, nickname: string, answer: any) => {
|
|
|
+ if (!client) return;
|
|
|
+
|
|
|
+ return handleAsyncOperation(async () => {
|
|
|
+ await storeAnswer(roomId, date, nickname, answer);
|
|
|
+ await sendRoomMessage(roomId, {
|
|
|
+ type: 'answer',
|
|
|
+ content: answer
|
|
|
+ });
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['answers', roomId, date] });
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['user-answers', roomId, nickname] });
|
|
|
+ }, '提交答案失败');
|
|
|
+ }, [roomId, client, storeAnswer, sendRoomMessage, queryClient]);
|
|
|
+
|
|
|
+ return { submitAnswer };
|
|
|
+}
|
|
|
+
|
|
|
+// 题目管理Hook
|
|
|
+export function useExamQuestionManagement(roomId: string | null) {
|
|
|
+ const socketClient = useExamSocketClient(roomId);
|
|
|
+ const queryClient = useQueryClient();
|
|
|
+
|
|
|
+ const sendNextQuestion = useCallback(async (state: QuizState) => {
|
|
|
+ if (!socketClient) return;
|
|
|
+
|
|
|
+ return handleAsyncOperation(async () => {
|
|
|
+ await socketClient.sendNextQuestion(roomId, state);
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['current-question', roomId] });
|
|
|
+ }, '发送题目失败');
|
|
|
+ }, [roomId, socketClient, queryClient]);
|
|
|
+
|
|
|
+ return {
|
|
|
+ sendNextQuestion
|
|
|
+ };
|
|
|
+}
|