useSocketClient.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. import { useEffect, useState, useCallback } from 'react';
  2. import { useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { io, Socket } from 'socket.io-client';
  4. import type {
  5. QuizContent,
  6. QuizState,
  7. ExamSocketMessage,
  8. ExamSocketRoomMessage,
  9. Answer,
  10. CumulativeResult
  11. } from '../types.ts';
  12. interface FullExamSocketMessage extends Omit<ExamSocketMessage, 'timestamp'> {
  13. id: string;
  14. from: string;
  15. timestamp: string;
  16. }
  17. import { useAuth } from "../../../hooks.tsx";
  18. // 工具函数:统一错误处理
  19. const handleAsyncOperation = async <T>(
  20. operation: () => Promise<T>,
  21. errorMessage: string
  22. ): Promise<T> => {
  23. try {
  24. return await operation();
  25. } catch (error) {
  26. console.error(`${errorMessage}:`, error);
  27. throw error;
  28. }
  29. };
  30. // 计算收益的辅助函数
  31. interface ProfitResult {
  32. profitAmount: number; // 金额收益
  33. profitPercent: number; // 百分比收益
  34. }
  35. function calculateProfit(currentPrice: number, previousPrice: number, holdingStock: string): ProfitResult {
  36. if (holdingStock === '1') {
  37. const profitAmount = currentPrice - previousPrice;
  38. const profitPercent = ((currentPrice - previousPrice) / previousPrice) * 100;
  39. return { profitAmount, profitPercent };
  40. }
  41. return { profitAmount: 0, profitPercent: 0 };
  42. }
  43. // 提前声明函数
  44. function getAnswers(client: Socket | null, roomId: string, questionId: string): Promise<Answer[]> {
  45. if (!client) return Promise.resolve([]);
  46. return new Promise((resolve) => {
  47. client.emit('exam:getAnswers', { roomId, questionId }, (answers: Answer[]) => {
  48. resolve(answers || []);
  49. });
  50. });
  51. }
  52. export function useSocketClient(roomId: string | null) {
  53. const { token } = useAuth();
  54. const queryClient = useQueryClient();
  55. const [socket, setSocket] = useState<Socket | null>(null);
  56. const [currentQuestion, setCurrentQuestion] = useState<QuizState | null>(null);
  57. const [lastMessage, setLastMessage] = useState<ExamSocketRoomMessage | null>(null);
  58. const [userAnswers, setUserAnswers] = useState<Answer[]>([]);
  59. // 初始化socket连接
  60. const { data: client } = useQuery({
  61. queryKey: ['socket-client', token],
  62. queryFn: async () => {
  63. if (!token) return null;
  64. const newSocket = io('/', {
  65. path: '/socket.io',
  66. transports: ['websocket'],
  67. withCredentials: true,
  68. query: {
  69. socket_token: token
  70. },
  71. reconnection: true,
  72. reconnectionAttempts: 5,
  73. reconnectionDelay: 1000,
  74. });
  75. newSocket.on('connect', () => {
  76. console.log('Socket connected');
  77. });
  78. newSocket.on('disconnect', () => {
  79. console.log('Socket disconnected');
  80. });
  81. newSocket.on('error', (error) => {
  82. console.error('Socket error:', error);
  83. });
  84. setSocket(newSocket);
  85. return newSocket;
  86. },
  87. enabled: !!token && !!roomId,
  88. staleTime: Infinity,
  89. gcTime: 0,
  90. retry: 3,
  91. });
  92. // 加入房间
  93. const joinRoom = useCallback(async (roomId: string) => {
  94. if (client) {
  95. client.emit('exam:join', { roomId });
  96. }
  97. }, [client]);
  98. // 离开房间
  99. const leaveRoom = useCallback(async (roomId: string) => {
  100. if (client) {
  101. client.emit('exam:leave', { roomId });
  102. }
  103. }, [client]);
  104. // 发送房间消息
  105. const sendRoomMessage = useCallback(async (roomId: string, message: ExamSocketMessage) => {
  106. if (client) {
  107. client.emit('exam:message', { roomId, message });
  108. }
  109. }, [client]);
  110. // 监听房间消息
  111. const onRoomMessage = useCallback((callback: (data: ExamSocketRoomMessage) => void) => {
  112. if (client) {
  113. client.on('exam:message', (data) => {
  114. setLastMessage(data);
  115. callback(data);
  116. });
  117. }
  118. }, [client]);
  119. // 监听当前问题变化
  120. useEffect(() => {
  121. if (!client || !roomId) return;
  122. const handleQuestionUpdate = (question:QuizState ) => {
  123. setCurrentQuestion(question);
  124. };
  125. client.on('exam:question', handleQuestionUpdate);
  126. return () => {
  127. client.off('exam:question', handleQuestionUpdate);
  128. };
  129. }, [client, roomId]);
  130. // // 监听用户答案变化
  131. // useEffect(() => {
  132. // if (!client || !roomId) return;
  133. // const handleAnswersUpdate = async () => {
  134. // const answers = await getAnswers(client, roomId, 'current_state');
  135. // setUserAnswers(answers);
  136. // };
  137. // client.on('exam:answers', handleAnswersUpdate);
  138. // return () => {
  139. // client.off('exam:answers', handleAnswersUpdate);
  140. // };
  141. // }, [client, roomId]);
  142. // 存储答案
  143. const storeAnswer = useCallback(async (roomId: string, questionId: string, userId: string, answer: QuizContent, callback?: (success: boolean) => void) => {
  144. if (!client) return;
  145. return handleAsyncOperation(async () => {
  146. // 获取历史价格数据
  147. const pricesData = await new Promise<any>((resolve) => {
  148. client.emit('exam:getPrices', { roomId }, resolve);
  149. });
  150. if (!pricesData) {
  151. // 存储初始答案
  152. const initialAnswer: Answer = {
  153. ...answer,
  154. userId,
  155. holdingStock: '0',
  156. holdingCash: '0',
  157. profitAmount: 0,
  158. profitPercent: 0,
  159. totalProfitAmount: 0,
  160. totalProfitPercent: 0
  161. };
  162. client.emit('exam:storeAnswer', {
  163. roomId,
  164. questionId,
  165. userId,
  166. answer: initialAnswer
  167. }, (success: boolean) => {
  168. callback?.(success);
  169. });
  170. return;
  171. }
  172. // 获取该用户的所有历史答案
  173. const dates = Object.keys(pricesData).sort();
  174. const allUserAnswers = await getUserAnswers(roomId, userId);
  175. const userAnswers = allUserAnswers
  176. .filter((a: Answer) => dates.includes(a.date || ''))
  177. .map((a: Answer) => ({
  178. ...a,
  179. price: pricesData[a.date || '']?.price || '0'
  180. }))
  181. .sort((a: Answer, b: Answer) => new Date(a.date || '').getTime() - new Date(b.date || '').getTime());
  182. let totalProfitAmount = 0;
  183. let totalProfitPercent = 0;
  184. if (userAnswers.length > 0) {
  185. const prevAnswer = userAnswers[userAnswers.length - 1];
  186. const { profitAmount, profitPercent } = calculateProfit(
  187. parseFloat(String(answer.price)),
  188. parseFloat(String(prevAnswer.price)),
  189. prevAnswer.holdingStock as string
  190. );
  191. totalProfitAmount = (prevAnswer.totalProfitAmount || 0) + profitAmount;
  192. totalProfitPercent = (prevAnswer.totalProfitPercent || 0) + profitPercent;
  193. }
  194. // 存储带有收益信息的答案
  195. const answerWithProfit: Answer = {
  196. ...answer,
  197. userId,
  198. profitAmount: userAnswers.length > 0 ? totalProfitAmount - (userAnswers[userAnswers.length - 1].totalProfitAmount || 0) : 0,
  199. profitPercent: userAnswers.length > 0 ? totalProfitPercent - (userAnswers[userAnswers.length - 1].totalProfitPercent || 0) : 0,
  200. totalProfitAmount,
  201. totalProfitPercent
  202. };
  203. client.emit('exam:storeAnswer', {
  204. roomId,
  205. questionId,
  206. userId,
  207. answer: answerWithProfit
  208. }, (success: boolean) => {
  209. callback?.(success);
  210. // 提交成功后获取最新回答记录
  211. if (success && roomId && answerWithProfit.userId) {
  212. setUserAnswers([...userAnswers, answerWithProfit])
  213. }
  214. });
  215. }, '存储答案失败');
  216. }, [client]);
  217. // 清理房间数据
  218. const cleanupRoom = useCallback(async (roomId: string, questionId?: string) => {
  219. if (!client) return;
  220. await handleAsyncOperation(async () => {
  221. if (questionId) {
  222. client.emit('exam:cleanup', { roomId, questionId });
  223. } else {
  224. client.emit('exam:cleanup', { roomId });
  225. }
  226. }, '清理房间数据失败');
  227. }, [client]);
  228. // 发送下一题
  229. const sendNextQuestion = useCallback(async (roomId: string, state: QuizState) => {
  230. if (!client) return;
  231. return handleAsyncOperation(async () => {
  232. const message: FullExamSocketMessage = {
  233. id: `question-${Date.now()}`,
  234. type: 'question',
  235. from: 'system',
  236. timestamp: Date.now().toString(),
  237. content: {
  238. date: state.date,
  239. price: state.price,
  240. holdingStock: '0',
  241. holdingCash: '0',
  242. userId: 'system'
  243. }
  244. };
  245. // 存储当前问题状态
  246. await storeAnswer(roomId, 'current_state', 'system', {
  247. date: state.date,
  248. price: state.price,
  249. holdingStock: '0',
  250. holdingCash: '0',
  251. userId: 'system'
  252. });
  253. // 存储价格历史记录
  254. client.emit('exam:storePrice', {
  255. roomId,
  256. date: state.date,
  257. price: state.price
  258. });
  259. await sendRoomMessage(roomId, message);
  260. }, '发送题目失败');
  261. }, [client, sendRoomMessage, storeAnswer]);
  262. // 获取历史价格
  263. const getPriceHistory = useCallback(async (roomId: string, date: string): Promise<string> => {
  264. if (!client) return '0';
  265. return handleAsyncOperation(async () => {
  266. return new Promise((resolve) => {
  267. client.emit('exam:getPrice', { roomId, date }, (price: string) => {
  268. resolve(price || '0');
  269. });
  270. });
  271. }, '获取历史价格失败');
  272. }, [client]);
  273. // 获取答案 (封装为useCallback)
  274. const getAnswersCallback = useCallback((roomId: string, questionId: string): Promise<Answer[]> => {
  275. if (!client) return Promise.resolve([]);
  276. return handleAsyncOperation(async () => {
  277. return getAnswers(client, roomId, questionId);
  278. }, '获取答案失败');
  279. }, [client]);
  280. // 清理socket连接
  281. useEffect(() => {
  282. return () => {
  283. if (socket) {
  284. socket.disconnect();
  285. }
  286. };
  287. }, [socket]);
  288. // 导出所有功能作为单个对象
  289. const socketRoom = {
  290. client,
  291. joinRoom,
  292. leaveRoom,
  293. sendRoomMessage,
  294. onRoomMessage
  295. };
  296. // 获取用户答案
  297. const getUserAnswers = useCallback(async (roomId: string, userId: string): Promise<Answer[]> => {
  298. if (!client || !roomId || !userId) return Promise.resolve([]);
  299. return handleAsyncOperation(async () => {
  300. return new Promise((resolve) => {
  301. client.emit('exam:getUserAnswers', { roomId, userId }, (answers: Answer[]) => {
  302. resolve(answers || []);
  303. });
  304. });
  305. }, '获取用户答案失败');
  306. }, [client]);
  307. const getCurrentQuestion = useCallback(async (roomId: string): Promise<QuizState> => {
  308. if (!client) return Promise.reject(new Error('Socket not connected'));
  309. return handleAsyncOperation(async () => {
  310. return new Promise((resolve, reject) => {
  311. client.emit('exam:currentQuestion', { roomId }, (question: QuizState) => {
  312. if (!question) {
  313. reject(new Error('No current question available'));
  314. return;
  315. }
  316. resolve(question);
  317. });
  318. });
  319. }, '获取当前问题失败');
  320. }, [client])
  321. const answerManagement = {
  322. storeAnswer,
  323. getAnswers: getAnswersCallback,
  324. cleanupRoom,
  325. sendNextQuestion,
  326. getPriceHistory,
  327. getUserAnswers,
  328. getCurrentQuestion
  329. };
  330. // 计算累计结果
  331. // const calculateCumulativeResults = useCallback((answers: Answer[]): CumulativeResult[] => {
  332. // const userResults = new Map<string, CumulativeResult>();
  333. // answers.forEach((answer) => {
  334. // const userId = answer.userId;
  335. // if (!userResults.has(userId)) {
  336. // userResults.set(userId, {
  337. // userId,
  338. // totalProfitAmount: answer.totalProfitAmount || 0,
  339. // totalProfitPercent: answer.totalProfitPercent || 0
  340. // });
  341. // }
  342. // });
  343. // return Array.from(userResults.values());
  344. // }, []);
  345. return {
  346. socketRoom,
  347. answerManagement,
  348. // calculateCumulativeResults,
  349. currentQuestion,
  350. setCurrentQuestion,
  351. lastMessage,
  352. userAnswers,
  353. // 兼容旧版导入
  354. ...socketRoom,
  355. ...answerManagement
  356. };
  357. }