ExamAdmin.tsx 11 KB

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