ExamAdmin.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. import React, { useState, useEffect } from 'react';
  2. import { useSearchParams } from 'react-router';
  3. import { Table, Button, message, Input, QRCode, Modal, Tabs } from 'antd';
  4. // import type { ColumnType } from 'antd/es/table';
  5. import type { GetProp , TableProps} from 'antd';
  6. import dayjs from 'dayjs';
  7. import { useSocketClient } from './hooks/useSocketClient.ts';
  8. import type {
  9. QuizState,
  10. ExamSocketRoomMessage
  11. } from './types.ts';
  12. import type { Answer, CumulativeResult } from './types.ts';
  13. type ColumnType = GetProp<TableProps,'columns'>[number]
  14. // 当前答题情况组件
  15. function CurrentAnswers({ answers, columns }: { answers: Answer[], columns: any[] }) {
  16. return (
  17. <div>
  18. <Table
  19. columns={columns}
  20. dataSource={answers}
  21. rowKey={(record) => `${record.userId}-${record.date}`}
  22. pagination={false}
  23. />
  24. </div>
  25. );
  26. }
  27. // 每日统计组件
  28. function DailyStatistics({ dailyAnswers, columns }: { dailyAnswers: {[key: string]: Answer[]}, columns: any[] }) {
  29. return (
  30. <div>
  31. <Table
  32. columns={columns}
  33. dataSource={Object.keys(dailyAnswers).map(date => ({ date }))}
  34. rowKey="date"
  35. pagination={false}
  36. />
  37. </div>
  38. );
  39. }
  40. // 累计结果组件
  41. function CumulativeResults({ results, columns }: { results: CumulativeResult[], columns: any[] }) {
  42. return (
  43. <div>
  44. <Table
  45. columns={columns}
  46. dataSource={results}
  47. rowKey="userId"
  48. pagination={false}
  49. />
  50. </div>
  51. );
  52. }
  53. // 二维码组件
  54. function QRCodeSection({ classroom }: { classroom: string }) {
  55. return (
  56. <div className="text-center">
  57. <div className="text-gray-600 mb-2">扫码参与训练</div>
  58. <div className="inline-block p-4 bg-white rounded-lg shadow-md">
  59. <QRCode value={`${globalThis.location.origin}/exam?classroom=${classroom}`} />
  60. </div>
  61. </div>
  62. );
  63. }
  64. export default function ExamAdmin() {
  65. const [searchParams] = useSearchParams();
  66. const classroom = searchParams.get('classroom');
  67. const {
  68. socketRoom,
  69. answerManagement,
  70. currentQuestion,
  71. // calculateCumulativeResults
  72. } = useSocketClient(classroom as string);
  73. const [answers, setAnswers] = useState<Answer[]>([]);
  74. const [dailyAnswers, setDailyAnswers] = useState<{[key: string]: Answer[]}>({});
  75. const [currentDate, setCurrentDate] = useState('');
  76. const [currentPrice, setCurrentPrice] = useState('0');
  77. const [mark, setMark] = useState('');
  78. const [activeTab, setActiveTab] = useState('current');
  79. // 获取当前题目
  80. // const [currentQuestion, setCurrentQuestion] = useState<QuizState | null>(null);
  81. const [lastMessage, setLastMessage] = useState<ExamSocketRoomMessage | null>(null);
  82. // 初始化socket连接和监听
  83. // useEffect(() => {
  84. // if (!classroom) return;
  85. // socketRoom.joinRoom(classroom);
  86. // const handleMessage = (data: ExamSocketRoomMessage) => {
  87. // setLastMessage(data);
  88. // };
  89. // socketRoom.onRoomMessage(handleMessage);
  90. // return () => {
  91. // socketRoom.leaveRoom(classroom);
  92. // socketRoom.onRoomMessage(handleMessage);
  93. // };
  94. // }, [classroom]);
  95. // 加入/离开房间
  96. useEffect(() => {
  97. if (!classroom) return;
  98. socketRoom.joinRoom(classroom);
  99. return () => {
  100. socketRoom.leaveRoom(classroom);
  101. };
  102. }, [classroom, socketRoom.joinRoom, socketRoom.leaveRoom]);
  103. // 监听答题消息并更新答案
  104. useEffect(() => {
  105. if (!classroom || !currentDate) return;
  106. const handleAnswerMessage = async () => {
  107. try {
  108. const answers = await answerManagement.getAnswers(
  109. classroom as string,
  110. currentDate
  111. );
  112. const processedAnswers = answers.map(answer => ({
  113. ...answer,
  114. profitAmount: answer.profitAmount || 0,
  115. profitPercent: answer.profitPercent || 0,
  116. holdingStock: answer.holdingStock || '0',
  117. holdingCash: answer.holdingCash || '0'
  118. }));
  119. setAnswers(processedAnswers);
  120. setDailyAnswers(prev => ({
  121. ...prev,
  122. [currentDate]: processedAnswers
  123. }));
  124. } catch (error) {
  125. console.error('获取答案失败:', error);
  126. }
  127. };
  128. // 初始获取答案
  129. handleAnswerMessage();
  130. // 订阅答题消息
  131. if (socketRoom.client) {
  132. socketRoom.client.on('exam:answer', handleAnswerMessage);
  133. }
  134. return () => {
  135. if (socketRoom.client) {
  136. socketRoom.client.off('exam:answer', handleAnswerMessage);
  137. }
  138. };
  139. }, [classroom, currentDate, answerManagement, socketRoom]);
  140. // // 更新答案状态
  141. // useEffect(() => {
  142. // if (cachedAnswers && cachedAnswers.length > 0) {
  143. // setAnswers(cachedAnswers);
  144. // }
  145. // }, [cachedAnswers]);
  146. // // 更新每日答题情况
  147. // useEffect(() => {
  148. // if (currentDate && cachedAnswers) {
  149. // setDailyAnswers((prev: {[key: string]: Answer[]}) => ({
  150. // ...prev,
  151. // [currentDate]: cachedAnswers
  152. // }));
  153. // }
  154. // }, [currentDate, cachedAnswers]);
  155. useEffect(() => {
  156. if (currentQuestion) {
  157. console.log('currentQuestion', currentQuestion);
  158. setCurrentDate(currentQuestion.date);
  159. setCurrentPrice(String(currentQuestion.price));
  160. }
  161. }, [currentQuestion]);
  162. // 添加结算函数
  163. const handleSettlement = async () => {
  164. if (!classroom || answers.length === 0) return;
  165. try {
  166. await answerManagement.sendNextQuestion(classroom, {
  167. date: currentDate,
  168. price: currentPrice
  169. });
  170. message.success('结算成功');
  171. } catch (error) {
  172. console.error('结算失败:', error);
  173. message.error('结算失败');
  174. }
  175. };
  176. const handleSubmit = async () => {
  177. if (!classroom || answers.length === 0) return;
  178. try {
  179. await answerManagement.cleanupRoom(classroom, currentDate);
  180. message.success('答案提交成功');
  181. setAnswers([]);
  182. } catch (error: any) {
  183. console.error('提交答案失败:', error);
  184. message.error(error?.message || '提交答案失败');
  185. }
  186. };
  187. const handleRestart = async () => {
  188. if (!classroom) return;
  189. try {
  190. await answerManagement.cleanupRoom(classroom);
  191. setAnswers([]);
  192. setDailyAnswers({});
  193. setCurrentDate('');
  194. setCurrentPrice('0');
  195. message.success('已重新开始');
  196. } catch (error) {
  197. console.error('重新开始失败:', error);
  198. message.error('重新开始失败');
  199. }
  200. };
  201. const columns = [
  202. {
  203. title: '昵称',
  204. dataIndex: 'userId',
  205. key: 'userId',
  206. },
  207. {
  208. title: '日期',
  209. dataIndex: 'date',
  210. key: 'date',
  211. render: (text: string) => text ? dayjs(text).format('YYYY-MM-DD') : '-',
  212. },
  213. {
  214. title: '持股',
  215. dataIndex: 'holdingStock',
  216. key: 'holdingStock',
  217. },
  218. {
  219. title: '持币',
  220. dataIndex: 'holdingCash',
  221. key: 'holdingCash',
  222. },
  223. {
  224. title: '价格',
  225. dataIndex: 'price',
  226. key: 'price',
  227. render: (text: string | undefined) => text ? parseFloat(text).toFixed(2) : '-',
  228. },
  229. {
  230. title: '收益(元)',
  231. dataIndex: 'profitAmount',
  232. key: 'profitAmount',
  233. render: (text: number | undefined) => text !== undefined ? text.toFixed(2) : '-',
  234. },
  235. {
  236. title: '盈亏率',
  237. dataIndex: 'profitPercent',
  238. key: 'profitPercent',
  239. render: (text: number | undefined) => text !== undefined ? `${text.toFixed(2)}%` : '-',
  240. }
  241. ];
  242. const resultColumns: ColumnType[] = [
  243. {
  244. title: '昵称',
  245. dataIndex: 'userId',
  246. key: 'userId',
  247. },
  248. {
  249. title: '累计盈亏(元)',
  250. dataIndex: 'totalProfitAmount',
  251. key: 'totalProfitAmount',
  252. render: (text: number | undefined) => text !== undefined ? text.toFixed(2) : '-',
  253. },
  254. {
  255. title: '累计盈亏率',
  256. dataIndex: 'totalProfitPercent',
  257. key: 'totalProfitPercent',
  258. render: (text: number | undefined) => text !== undefined ? `${text.toFixed(2)}%` : '-',
  259. },
  260. ];
  261. const dailyAnswersColumns = [
  262. {
  263. title: '日期',
  264. dataIndex: 'date',
  265. key: 'date',
  266. render: (text: string) => dayjs(text).format('YYYY-MM-DD'),
  267. },
  268. {
  269. title: '答题人数',
  270. key: 'count',
  271. render: (_: any, record: { date: string }) => dailyAnswers[record.date]?.length || 0,
  272. },
  273. {
  274. title: '持股人数',
  275. key: 'holdingStockCount',
  276. render: (_: any, record: { date: string }) =>
  277. dailyAnswers[record.date]?.filter((a: any) => a.holdingStock === '1').length || 0,
  278. },
  279. {
  280. title: '持币人数',
  281. key: 'holdingCashCount',
  282. render: (_: any, record: { date: string }) =>
  283. dailyAnswers[record.date]?.filter((a: any) => a.holdingCash === '1').length || 0,
  284. }
  285. ];
  286. // 计算累计结果的函数
  287. const calculateCumulativeResults = (dailyAnswers: {[key: string]: Answer[]}): CumulativeResult[] => {
  288. const userResults = new Map<string, CumulativeResult>();
  289. // 按日期排序
  290. const sortedDates = Object.keys(dailyAnswers).sort((a: string, b: string) =>
  291. new Date(a).getTime() - new Date(b).getTime()
  292. );
  293. sortedDates.forEach(date => {
  294. const answers = dailyAnswers[date] || [];
  295. answers.forEach((answer: Answer) => {
  296. const userId = answer.userId;
  297. // 直接使用服务端计算好的收益数据
  298. const profitAmount = answer.profitAmount || 0;
  299. const profitPercent = answer.profitPercent || 0;
  300. if (!userResults.has(userId)) {
  301. userResults.set(userId, {
  302. userId,
  303. totalProfitAmount: 0,
  304. totalProfitPercent: 0
  305. });
  306. }
  307. const currentResult = userResults.get(userId)!;
  308. currentResult.totalProfitAmount += profitAmount;
  309. currentResult.totalProfitPercent += profitPercent;
  310. userResults.set(userId, currentResult);
  311. });
  312. });
  313. return Array.from(userResults.values());
  314. };
  315. const items = [
  316. {
  317. key: 'current',
  318. label: '当前答题情况',
  319. children: <CurrentAnswers answers={answers} columns={columns} />,
  320. },
  321. {
  322. key: 'daily',
  323. label: '每日答题统计',
  324. children: <DailyStatistics dailyAnswers={dailyAnswers} columns={dailyAnswersColumns} />,
  325. },
  326. {
  327. key: 'cumulative',
  328. label: '累计结果',
  329. children: <CumulativeResults
  330. results={calculateCumulativeResults(dailyAnswers)}
  331. columns={resultColumns}
  332. />,
  333. },
  334. ];
  335. return (
  336. <div className="p-6">
  337. <div className="mb-6 flex justify-between items-center">
  338. <div>
  339. <h2 className="text-2xl font-bold">答题卡管理</h2>
  340. <div className="mt-2 text-gray-600">
  341. <span className="mr-4">教室号: {classroom}</span>
  342. <span className="mr-4">当前日期: {currentDate}</span>
  343. <span>当前价格: {currentPrice}</span>
  344. </div>
  345. </div>
  346. </div>
  347. {/* 主要内容区域 */}
  348. <div className="mb-6">
  349. <Tabs
  350. activeKey={activeTab}
  351. onChange={setActiveTab}
  352. items={items}
  353. />
  354. </div>
  355. {/* 底部按钮组 */}
  356. <div className="flex items-center space-x-4 mb-8">
  357. <Button onClick={handleSettlement} disabled={answers.length === 0}>
  358. 结算
  359. </Button>
  360. <Button type="primary" onClick={handleSubmit} disabled={answers.length === 0}>
  361. 收卷
  362. </Button>
  363. <Input
  364. value={mark}
  365. onChange={(e) => setMark(e.target.value)}
  366. placeholder="标记"
  367. style={{ width: 200 }}
  368. />
  369. <Button onClick={() => message.info('标记已保存')}>查看</Button>
  370. <Button onClick={handleRestart}>重开</Button>
  371. </div>
  372. {/* 二维码区域 */}
  373. <QRCodeSection classroom={classroom || ''} />
  374. </div>
  375. );
  376. }