ExamAdmin.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  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}/mobile/exam/card?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: { joinRoom, leaveRoom, client },
  69. answerManagement,
  70. isConnected,
  71. } = useSocketClient(classroom as string);
  72. const [answers, setAnswers] = useState<Answer[]>([]);
  73. const [dailyAnswers, setDailyAnswers] = useState<{[key: string]: Answer[]}>({});
  74. const [currentDate, setCurrentDate] = useState('');
  75. const [currentPrice, setCurrentPrice] = useState('0');
  76. const [mark, setMark] = useState('');
  77. const [activeTab, setActiveTab] = useState('current');
  78. const [loading, setLoading] = useState(false);
  79. const [error, setError] = useState<string | null>(null);
  80. const initExamData = async () => {
  81. if (!classroom) return;
  82. setLoading(true);
  83. setError(null);
  84. try {
  85. // 获取当前问题
  86. const question = await answerManagement.getCurrentQuestion(classroom);
  87. if (question) {
  88. setCurrentDate(question.date);
  89. setCurrentPrice(String(question.price));
  90. // 获取答题记录
  91. const answers = await answerManagement.getAnswers(
  92. classroom,
  93. ''
  94. );
  95. const processedAnswers = answers.map(answer => ({
  96. ...answer,
  97. profitAmount: answer.profitAmount || 0,
  98. profitPercent: answer.profitPercent || 0,
  99. holdingStock: answer.holdingStock || '0',
  100. holdingCash: answer.holdingCash || '0'
  101. }));
  102. const processedDailyAnswers:{[key: string]: Answer[]} = {};
  103. processedAnswers.forEach(val => {
  104. if(!processedDailyAnswers[val.date])
  105. processedDailyAnswers[val.date] = [];
  106. processedDailyAnswers[val.date].push(val)
  107. })
  108. setAnswers(processedAnswers);
  109. setDailyAnswers(processedDailyAnswers);
  110. }
  111. } catch (err) {
  112. console.error('初始化答题数据失败:', err);
  113. setError('初始化答题数据失败');
  114. } finally {
  115. setLoading(false);
  116. }
  117. };
  118. // 结算函数
  119. const handleSettlement = async () => {
  120. if (!classroom || answers.length === 0) return;
  121. setLoading(true);
  122. try {
  123. await answerManagement.sendSettleExam(classroom);
  124. message.success('结算成功');
  125. } catch (error) {
  126. console.error('结算失败:', error);
  127. message.error('结算失败');
  128. } finally {
  129. setLoading(false);
  130. }
  131. };
  132. const handleSubmit = async () => {
  133. if (!classroom || answers.length === 0) return;
  134. try {
  135. await answerManagement.cleanupRoom(classroom);
  136. message.success('答案提交成功');
  137. setAnswers([]);
  138. setDailyAnswers({});
  139. setCurrentDate('');
  140. setCurrentPrice('0');
  141. } catch (error: any) {
  142. console.error('提交答案失败:', error);
  143. message.error(error?.message || '提交答案失败');
  144. }
  145. };
  146. const handleRestart = async () => {
  147. if (!classroom) return;
  148. try {
  149. await answerManagement.cleanupRoom(classroom);
  150. setAnswers([]);
  151. setDailyAnswers({});
  152. setCurrentDate('');
  153. setCurrentPrice('0');
  154. message.success('已重新开始');
  155. } catch (error) {
  156. console.error('重新开始失败:', error);
  157. message.error('重新开始失败');
  158. }
  159. };
  160. const columns = [
  161. {
  162. title: '昵称',
  163. dataIndex: 'userId',
  164. key: 'userId',
  165. },
  166. {
  167. title: '日期',
  168. dataIndex: 'date',
  169. key: 'date',
  170. render: (text: string) => text ? dayjs(text).format('YYYY-MM-DD') : '-',
  171. },
  172. {
  173. title: '持股',
  174. dataIndex: 'holdingStock',
  175. key: 'holdingStock',
  176. },
  177. {
  178. title: '持币',
  179. dataIndex: 'holdingCash',
  180. key: 'holdingCash',
  181. },
  182. {
  183. title: '价格',
  184. dataIndex: 'price',
  185. key: 'price',
  186. render: (text: string | undefined) => text ? parseFloat(text).toFixed(2) : '-',
  187. },
  188. {
  189. title: '收益(元)',
  190. dataIndex: 'profitAmount',
  191. key: 'profitAmount',
  192. render: (text: number | undefined) => text !== undefined ? text.toFixed(2) : '-',
  193. },
  194. {
  195. title: '盈亏率',
  196. dataIndex: 'profitPercent',
  197. key: 'profitPercent',
  198. render: (text: number | undefined) => text !== undefined ? `${text.toFixed(2)}%` : '-',
  199. }
  200. ];
  201. const resultColumns: ColumnType[] = [
  202. {
  203. title: '昵称',
  204. dataIndex: 'userId',
  205. key: 'userId',
  206. },
  207. {
  208. title: '累计盈亏(元)',
  209. dataIndex: 'totalProfitAmount',
  210. key: 'totalProfitAmount',
  211. render: (text: number | undefined) => text !== undefined ? text.toFixed(2) : '-',
  212. },
  213. {
  214. title: '累计盈亏率',
  215. dataIndex: 'totalProfitPercent',
  216. key: 'totalProfitPercent',
  217. render: (text: number | undefined) => text !== undefined ? `${text.toFixed(2)}%` : '-',
  218. },
  219. ];
  220. const dailyAnswersColumns = [
  221. {
  222. title: '日期',
  223. dataIndex: 'date',
  224. key: 'date',
  225. render: (text: string) => dayjs(text).format('YYYY-MM-DD'),
  226. },
  227. {
  228. title: '答题人数',
  229. key: 'count',
  230. render: (_: any, record: { date: string }) => dailyAnswers[record.date]?.length || 0,
  231. },
  232. {
  233. title: '持股人数',
  234. key: 'holdingStockCount',
  235. render: (_: any, record: { date: string }) =>
  236. dailyAnswers[record.date]?.filter((a: any) => a.holdingStock === '1').length || 0,
  237. },
  238. {
  239. title: '持币人数',
  240. key: 'holdingCashCount',
  241. render: (_: any, record: { date: string }) =>
  242. dailyAnswers[record.date]?.filter((a: any) => a.holdingCash === '1').length || 0,
  243. }
  244. ];
  245. // 计算累计结果的函数
  246. const calculateCumulativeResults = (dailyAnswers: {[key: string]: Answer[]}): CumulativeResult[] => {
  247. const userResults = new Map<string, CumulativeResult>();
  248. // 按日期排序
  249. const sortedDates = Object.keys(dailyAnswers).sort((a: string, b: string) =>
  250. new Date(a).getTime() - new Date(b).getTime()
  251. );
  252. sortedDates.forEach(date => {
  253. const answers = dailyAnswers[date] || [];
  254. answers.forEach((answer: Answer) => {
  255. const userId = answer.userId;
  256. // 直接使用服务端计算好的收益数据
  257. const profitAmount = answer.profitAmount || 0;
  258. const profitPercent = answer.profitPercent || 0;
  259. if (!userResults.has(userId)) {
  260. userResults.set(userId, {
  261. userId,
  262. totalProfitAmount: 0,
  263. totalProfitPercent: 0
  264. });
  265. }
  266. const currentResult = userResults.get(userId)!;
  267. currentResult.totalProfitAmount += profitAmount;
  268. currentResult.totalProfitPercent += profitPercent;
  269. userResults.set(userId, currentResult);
  270. });
  271. });
  272. return Array.from(userResults.values());
  273. };
  274. const items = [
  275. {
  276. key: 'current',
  277. label: '当前答题情况',
  278. children: <CurrentAnswers answers={answers} columns={columns} />,
  279. },
  280. {
  281. key: 'daily',
  282. label: '每日答题统计',
  283. children: <DailyStatistics dailyAnswers={dailyAnswers} columns={dailyAnswersColumns} />,
  284. },
  285. {
  286. key: 'cumulative',
  287. label: '累计结果',
  288. children: <CumulativeResults
  289. results={calculateCumulativeResults(dailyAnswers)}
  290. columns={resultColumns}
  291. />,
  292. },
  293. ];
  294. // 加入/离开房间
  295. useEffect(() => {
  296. if (!classroom) return;
  297. joinRoom(classroom);
  298. initExamData();
  299. return () => {
  300. leaveRoom(classroom);
  301. };
  302. }, [classroom, joinRoom, leaveRoom]);
  303. // 监听答题消息并更新答案
  304. useEffect(() => {
  305. if (!classroom || !currentDate || !client) return;
  306. const handleAnswerMessage = async () => {
  307. try {
  308. const answers = await answerManagement.getAnswers(
  309. classroom as string,
  310. currentDate
  311. );
  312. const processedAnswers = answers.map(answer => ({
  313. ...answer,
  314. profitAmount: answer.profitAmount || 0,
  315. profitPercent: answer.profitPercent || 0,
  316. holdingStock: answer.holdingStock || '0',
  317. holdingCash: answer.holdingCash || '0'
  318. }));
  319. setAnswers(processedAnswers);
  320. setDailyAnswers(prev => ({
  321. ...prev,
  322. [currentDate]: processedAnswers
  323. }));
  324. } catch (error) {
  325. console.error('获取答案失败:', error);
  326. }
  327. };
  328. client.on('exam:answerUpdated', handleAnswerMessage);
  329. return () => {
  330. if (!client) return;
  331. client.off('exam:answerUpdated', handleAnswerMessage);
  332. };
  333. }, [classroom, currentDate, answerManagement, client]);
  334. // 监听当前问题变化
  335. useEffect(() => {
  336. if (!client ) return;
  337. const handleQuestionUpdate = (question:QuizState ) => {
  338. setCurrentDate(question.date);
  339. setCurrentPrice(String(question.price));
  340. };
  341. client.on('exam:question', handleQuestionUpdate);
  342. return () => {
  343. client.off('exam:question', handleQuestionUpdate);
  344. };
  345. }, [client]);
  346. return (
  347. <div className="p-6">
  348. {!isConnected && (
  349. <div className="bg-yellow-600 text-white text-center py-1 text-sm">
  350. 正在尝试连接答题卡服务...
  351. </div>
  352. )}
  353. <div className="mb-6 flex justify-between items-center">
  354. <div>
  355. <h2 className="text-2xl font-bold">答题卡管理</h2>
  356. <div className="mt-2 text-gray-600">
  357. <span className="mr-4">教室号: {classroom}</span>
  358. <span className="mr-4">当前日期: {currentDate}</span>
  359. <span>当前价格: {currentPrice}</span>
  360. </div>
  361. </div>
  362. </div>
  363. {/* 主要内容区域 */}
  364. <div className="mb-6">
  365. <Tabs
  366. activeKey={activeTab}
  367. onChange={setActiveTab}
  368. items={items}
  369. />
  370. </div>
  371. {/* 底部按钮组 */}
  372. <div className="flex items-center space-x-4 mb-8">
  373. <Button onClick={handleSettlement} disabled={answers.length === 0}>
  374. 结算
  375. </Button>
  376. <Button type="primary" onClick={handleSubmit} disabled={answers.length === 0}>
  377. 收卷
  378. </Button>
  379. <Input
  380. value={mark}
  381. onChange={(e) => setMark(e.target.value)}
  382. placeholder="标记"
  383. style={{ width: 200 }}
  384. />
  385. <Button onClick={() => message.info('标记已保存')}>查看</Button>
  386. <Button onClick={handleRestart}>重开</Button>
  387. </div>
  388. {/* 二维码区域 */}
  389. <QRCodeSection classroom={classroom || ''} />
  390. </div>
  391. );
  392. }