ExamCard.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import React,{ useState, useCallback, useEffect } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { useSearchParams } from "react-router";
  4. import dayjs from 'dayjs';
  5. import { message } from 'antd';
  6. import { useSocketClient } from './hooks/useSocketClient.ts';
  7. import { ClassroomDataAPI } from '../../api/classroom_data.ts';
  8. import { ClassroomStatus } from '../../../share/types_stock.ts';
  9. import type { ExamSocketRoomMessage } from './types.ts';
  10. import type { AnswerRecord, Answer } from './types.ts';
  11. import type { ClassroomData } from '../../../share/types_stock.ts';
  12. import { useAuth } from "../../hooks.tsx";
  13. // 答题卡页面
  14. export default function ExamCard() {
  15. const { user } = useAuth();
  16. const [searchParams] = useSearchParams();
  17. const classroom = searchParams.get('classroom');
  18. const {
  19. socketRoom: { joinRoom, leaveRoom },
  20. answerManagement,
  21. currentQuestion,
  22. lastMessage,
  23. userAnswers
  24. } = useSocketClient(classroom as string);
  25. const [currentDate, setCurrentDate] = useState('');
  26. const [currentPrice, setCurrentPrice] = useState('0');
  27. const [holdingStock, setHoldingStock] = useState('0');
  28. const [holdingCash, setHoldingCash] = useState('0');
  29. const [isStarted, setIsStarted] = useState(false);
  30. const [answerRecords, setAnswerRecords] = useState<AnswerRecord[]>([]);
  31. const { data: classroomData, isLoading } = useQuery({
  32. queryKey: ['classroom', classroom],
  33. queryFn: async () => {
  34. if (!classroom) return null;
  35. const response = await ClassroomDataAPI.getClassroomDatas({ classroom_no: classroom });
  36. if (!response.data?.length) {
  37. message.error('获取教室数据失败');
  38. return null;
  39. }
  40. return response.data[0] || null;
  41. },
  42. enabled: !!classroom
  43. });
  44. useEffect(() => {
  45. if (!classroom || !user?.username) {
  46. globalThis.location.href = '/exam';
  47. return;
  48. }
  49. if (classroomData && classroomData.status !== ClassroomStatus.OPEN) {
  50. message.error('该教室已关闭');
  51. globalThis.location.href = '/exam';
  52. return;
  53. }
  54. }, [classroom, user, classroomData]);
  55. // 加入/离开房间
  56. useEffect(() => {
  57. if (!classroom) return;
  58. joinRoom(classroom);
  59. return () => {
  60. leaveRoom(classroom);
  61. };
  62. }, [classroom, joinRoom, leaveRoom]);
  63. // 处理房间消息
  64. useEffect(() => {
  65. if (!lastMessage?.message) return;
  66. const { type } = lastMessage.message;
  67. // 只处理重开消息的UI重置
  68. if (type === 'restart') {
  69. setCurrentDate('');
  70. setCurrentPrice('0');
  71. setHoldingStock('0');
  72. setHoldingCash('0');
  73. setIsStarted(false);
  74. setAnswerRecords([]);
  75. }
  76. }, [lastMessage]);
  77. useEffect(() => {
  78. if (currentQuestion) {
  79. console.log('currentQuestion', currentQuestion);
  80. setCurrentDate(currentQuestion.date);
  81. setCurrentPrice(String(currentQuestion.price));
  82. setIsStarted(true);
  83. } else {
  84. // 如果没有当前问题,重置状态
  85. setCurrentDate('');
  86. setCurrentPrice('0');
  87. setIsStarted(false);
  88. }
  89. }, [currentQuestion]);
  90. // 处理选择A(持股)
  91. const handleChooseA = useCallback(async () => {
  92. setHoldingStock('1');
  93. setHoldingCash('0');
  94. if (classroom && user?.username) {
  95. const answer = {
  96. date: currentDate,
  97. holdingStock: '1',
  98. holdingCash: '0',
  99. userId: String(user.id),
  100. price: currentPrice
  101. };
  102. try {
  103. await answerManagement.storeAnswer(
  104. classroom as string,
  105. currentQuestion?.id || '',
  106. user.username,
  107. answer,
  108. (success) => {
  109. if (success) {
  110. message.success('答案提交成功');
  111. } else {
  112. message.error('提交答案失败');
  113. }
  114. }
  115. );
  116. } catch (error) {
  117. message.error('提交答案失败');
  118. }
  119. }
  120. }, [classroom, user, currentDate, currentPrice, answerManagement, currentQuestion]);
  121. const handleChooseB = useCallback(async () => {
  122. setHoldingStock('0');
  123. setHoldingCash('1');
  124. if (classroom && user?.username) {
  125. const answer = {
  126. date: currentDate,
  127. holdingStock: '0',
  128. holdingCash: '1',
  129. userId: String(user.id),
  130. price: currentPrice
  131. };
  132. try {
  133. await answerManagement.storeAnswer(
  134. classroom as string,
  135. currentQuestion?.id || '',
  136. user.username,
  137. answer,
  138. (success) => {
  139. if (success) {
  140. message.success('答案提交成功');
  141. } else {
  142. message.error('提交答案失败');
  143. }
  144. }
  145. );
  146. } catch (error) {
  147. message.error('提交答案失败');
  148. }
  149. }
  150. }, [classroom, user, currentDate, currentPrice, answerManagement, currentQuestion]);
  151. // 初始化用户的答题记录
  152. useEffect(() => {
  153. if (userAnswers && userAnswers.length > 0) {
  154. const lastAnswer = userAnswers[userAnswers.length - 1];
  155. setHoldingStock(lastAnswer.holdingStock);
  156. setHoldingCash(lastAnswer.holdingCash);
  157. // 直接使用 userAnswers 中已计算好的数据
  158. const records = userAnswers.map((answer: Answer, index: number): AnswerRecord => ({
  159. date: answer.date,
  160. price: String(answer.price || '0'),
  161. holdingStock: answer.holdingStock,
  162. holdingCash: answer.holdingCash,
  163. profitAmount: answer.profitAmount || 0,
  164. profitPercent: answer.profitPercent || 0,
  165. index: index + 1
  166. }));
  167. setAnswerRecords(records);
  168. }
  169. }, [userAnswers]);
  170. if (isLoading || !classroomData) {
  171. return <div className="flex items-center justify-center min-h-screen">加载中...</div>;
  172. }
  173. return (
  174. <div className="flex flex-col items-center min-h-screen bg-gray-100 py-8">
  175. {/* 选择区域 */}
  176. <div className="w-full max-w-2xl">
  177. <div className="text-center mb-8">
  178. <h2 className="text-2xl font-bold mb-2">持股选A, 持币选B</h2>
  179. <div className="flex justify-center space-x-4 text-gray-600">
  180. {isStarted ? (
  181. <>
  182. <span>日期: {currentDate}</span>
  183. <span>价格: {currentPrice}</span>
  184. </>
  185. ) : (
  186. <div className="text-blue-600">
  187. <div className="mb-2">等待训练开始...</div>
  188. <div className="text-sm text-gray-500">
  189. 训练日期: {dayjs(classroomData.training_date).format('YYYY-MM-DD')}
  190. </div>
  191. </div>
  192. )}
  193. </div>
  194. </div>
  195. {/* 选择按钮 */}
  196. <div className="flex justify-center items-center space-x-4 mb-8 bg-white p-6 rounded-lg shadow-md">
  197. <button
  198. onClick={handleChooseA}
  199. disabled={!isStarted}
  200. className={`flex-1 py-8 text-3xl font-bold rounded-lg transition-colors ${
  201. !isStarted
  202. ? 'bg-gray-200 text-gray-400 cursor-not-allowed'
  203. : holdingStock === '1'
  204. ? 'bg-red-500 text-white'
  205. : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
  206. }`}
  207. >
  208. A
  209. </button>
  210. <div className="text-xl font-medium text-gray-700">
  211. {isStarted ? '开始' : '等待'}
  212. </div>
  213. <button
  214. onClick={handleChooseB}
  215. disabled={!isStarted}
  216. className={`flex-1 py-8 text-3xl font-bold rounded-lg transition-colors ${
  217. !isStarted
  218. ? 'bg-gray-200 text-gray-400 cursor-not-allowed'
  219. : holdingCash === '1'
  220. ? 'bg-green-500 text-white'
  221. : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
  222. }`}
  223. >
  224. B
  225. </button>
  226. </div>
  227. {/* 信息显示 */}
  228. <div className="bg-white p-6 rounded-lg shadow-md">
  229. <div className="grid grid-cols-2 gap-4 mb-4">
  230. <div className="text-gray-600">昵称: {user?.username || '未知用户'}</div>
  231. <div className="text-gray-600">代码: {classroomData.code}</div>
  232. </div>
  233. {/* 表格头部 */}
  234. <div className="grid grid-cols-8 gap-4 py-2 border-b border-gray-200 text-sm font-medium text-gray-600">
  235. <div>序</div>
  236. <div>训练日期</div>
  237. <div>持股</div>
  238. <div>持币</div>
  239. <div>价格</div>
  240. <div>收益(元)</div>
  241. <div>盈亏率</div>
  242. <div>号</div>
  243. </div>
  244. {/* 表格内容 */}
  245. <div className="max-h-60 overflow-y-auto">
  246. {[...answerRecords].reverse().map((record: AnswerRecord) => (
  247. <div key={record.date} className="grid grid-cols-8 gap-4 py-2 text-sm text-gray-800 hover:bg-gray-50">
  248. <div>{record.index}</div>
  249. <div>{dayjs(record.date).format('YYYY-MM-DD')}</div>
  250. <div className="text-red-500">{record.holdingStock}</div>
  251. <div className="text-green-500">{record.holdingCash}</div>
  252. <div>{record.price}</div>
  253. <div className={record.profitAmount >= 0 ? 'text-red-500' : 'text-green-500'}>
  254. {record.profitAmount.toFixed(2)}
  255. </div>
  256. <div className={record.profitPercent >= 0 ? 'text-red-500' : 'text-green-500'}>
  257. {record.profitPercent.toFixed(2)}%
  258. </div>
  259. <div>{record.index}</div>
  260. </div>
  261. ))}
  262. </div>
  263. </div>
  264. </div>
  265. </div>
  266. );
  267. }