useSocketClient.ts 11 KB

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