ExamAdmin.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  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 { GetProp, TableProps } from 'antd';
  5. import dayjs from 'dayjs';
  6. import {
  7. useExamCurrentQuestion,
  8. useExamRoomMessages,
  9. useExamAnswerSubmission,
  10. useExamSocketRoom
  11. } from './hooks/useSocketClient.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 className="overflow-x-auto">
  18. <Table
  19. columns={columns}
  20. dataSource={answers}
  21. rowKey={(record) => `${record.userId}-${record.date}`}
  22. pagination={false}
  23. scroll={{ x: true }}
  24. />
  25. </div>
  26. );
  27. }
  28. // 每日统计组件 - 添加移动端响应式样式
  29. function DailyStatistics({ dailyAnswers, columns }: { dailyAnswers: {[key: string]: Answer[]}, columns: any[] }) {
  30. return (
  31. <div className="overflow-x-auto">
  32. <Table
  33. columns={columns}
  34. dataSource={Object.keys(dailyAnswers).map(date => ({ date }))}
  35. rowKey="date"
  36. pagination={false}
  37. scroll={{ x: true }}
  38. />
  39. </div>
  40. );
  41. }
  42. // 累计结果组件 - 添加移动端响应式样式
  43. function CumulativeResults({ results, columns }: { results: CumulativeResult[], columns: any[] }) {
  44. return (
  45. <div className="overflow-x-auto">
  46. <Table
  47. columns={columns}
  48. dataSource={results}
  49. rowKey="userId"
  50. pagination={false}
  51. scroll={{ x: true }}
  52. />
  53. </div>
  54. );
  55. }
  56. // 二维码组件 - 保持原样
  57. function QRCodeSection({ classroom }: { classroom: string }) {
  58. return (
  59. <div className="text-center">
  60. <div className="text-gray-600 mb-2">扫码参与训练</div>
  61. <div className="inline-block p-4 bg-white rounded-lg shadow-md">
  62. <QRCode value={`${globalThis.location.origin}/exam?classroom=${classroom}`} />
  63. </div>
  64. </div>
  65. );
  66. }
  67. export default function ExamAdmin() {
  68. const [searchParams] = useSearchParams();
  69. const classroom = searchParams.get('classroom');
  70. const { connected } = useExamSocketRoom(classroom);
  71. const {currentQuestion} = useExamCurrentQuestion(classroom);
  72. const lastMessage = useExamRoomMessages(classroom);
  73. const { submitAnswer } = useExamAnswerSubmission(classroom);
  74. const [answers, setAnswers] = useState<Answer[]>([]);
  75. const [dailyAnswers, setDailyAnswers] = useState<{[key: string]: Answer[]}>({});
  76. const [currentDate, setCurrentDate] = useState('');
  77. const [currentPrice, setCurrentPrice] = useState('0');
  78. const [mark, setMark] = useState('');
  79. const [activeTab, setActiveTab] = useState('current');
  80. // 更新答案状态
  81. useEffect(() => {
  82. if (lastMessage?.message.type === 'answer') {
  83. const answer = lastMessage.message.content;
  84. setAnswers(prev => [...prev, answer]);
  85. }
  86. }, [lastMessage]);
  87. // 更新每日答题情况
  88. useEffect(() => {
  89. if (currentDate && answers.length > 0) {
  90. setDailyAnswers(prev => ({
  91. ...prev,
  92. [currentDate]: answers
  93. }));
  94. }
  95. }, [currentDate, answers]);
  96. useEffect(() => {
  97. if (currentQuestion) {
  98. setCurrentDate(currentQuestion.date);
  99. setCurrentPrice(String(currentQuestion.price));
  100. }
  101. }, [currentQuestion]);
  102. // 结算函数
  103. const handleSettlement = async () => {
  104. if (!classroom || answers.length === 0) return;
  105. try {
  106. await submitAnswer(currentDate, 'system', {
  107. date: currentDate,
  108. price: currentPrice,
  109. holdingStock: '0',
  110. holdingCash: '1',
  111. userId: 'system'
  112. });
  113. message.success('结算成功');
  114. } catch (error) {
  115. console.error('结算失败:', error);
  116. message.error('结算失败');
  117. }
  118. };
  119. // 提交函数
  120. const handleSubmit = async () => {
  121. if (!classroom || answers.length === 0) return;
  122. try {
  123. message.success('答案提交成功');
  124. setAnswers([]);
  125. } catch (error: any) {
  126. console.error('提交答案失败:', error);
  127. message.error(error?.message || '提交答案失败');
  128. }
  129. };
  130. // 重新开始
  131. const handleRestart = async () => {
  132. try {
  133. setAnswers([]);
  134. setDailyAnswers({});
  135. setCurrentDate('');
  136. setCurrentPrice('0');
  137. message.success('已重新开始');
  138. } catch (error) {
  139. console.error('重新开始失败:', error);
  140. message.error('重新开始失败');
  141. }
  142. };
  143. const columns = [
  144. {
  145. title: '昵称',
  146. dataIndex: 'userId',
  147. key: 'userId',
  148. },
  149. {
  150. title: '日期',
  151. dataIndex: 'date',
  152. key: 'date',
  153. render: (text: string) => text ? dayjs(text).format('YYYY-MM-DD') : '-',
  154. },
  155. {
  156. title: '持股',
  157. dataIndex: 'holdingStock',
  158. key: 'holdingStock',
  159. },
  160. {
  161. title: '持币',
  162. dataIndex: 'holdingCash',
  163. key: 'holdingCash',
  164. },
  165. {
  166. title: '价格',
  167. dataIndex: 'price',
  168. key: 'price',
  169. render: (text: string | undefined) => text ? parseFloat(text).toFixed(2) : '-',
  170. },
  171. {
  172. title: '收益(元)',
  173. dataIndex: 'profitAmount',
  174. key: 'profitAmount',
  175. render: (text: number | undefined) => text !== undefined ? text.toFixed(2) : '-',
  176. },
  177. {
  178. title: '盈亏率',
  179. dataIndex: 'profitPercent',
  180. key: 'profitPercent',
  181. render: (text: number | undefined) => text !== undefined ? `${text.toFixed(2)}%` : '-',
  182. }
  183. ];
  184. const resultColumns: ColumnType<CumulativeResult>[] = [
  185. {
  186. title: '昵称',
  187. dataIndex: 'userId',
  188. key: 'userId',
  189. },
  190. {
  191. title: '累计盈亏(元)',
  192. dataIndex: 'totalProfitAmount',
  193. key: 'totalProfitAmount',
  194. render: (text: number | undefined) => text !== undefined ? text.toFixed(2) : '-',
  195. },
  196. {
  197. title: '累计盈亏率',
  198. dataIndex: 'totalProfitPercent',
  199. key: 'totalProfitPercent',
  200. render: (text: number | undefined) => text !== undefined ? `${text.toFixed(2)}%` : '-',
  201. },
  202. ];
  203. const dailyAnswersColumns = [
  204. {
  205. title: '日期',
  206. dataIndex: 'date',
  207. key: 'date',
  208. render: (text: string) => dayjs(text).format('YYYY-MM-DD'),
  209. },
  210. {
  211. title: '答题人数',
  212. key: 'count',
  213. render: (_: any, record: { date: string }) => dailyAnswers[record.date]?.length || 0,
  214. },
  215. {
  216. title: '持股人数',
  217. key: 'holdingStockCount',
  218. render: (_: any, record: { date: string }) =>
  219. dailyAnswers[record.date]?.filter((a: any) => a.holdingStock === '1').length || 0,
  220. },
  221. {
  222. title: '持币人数',
  223. key: 'holdingCashCount',
  224. render: (_: any, record: { date: string }) =>
  225. dailyAnswers[record.date]?.filter((a: any) => a.holdingCash === '1').length || 0,
  226. }
  227. ];
  228. // 计算累计结果
  229. const calculateCumulativeResults = (dailyAnswers: {[key: string]: Answer[]}): CumulativeResult[] => {
  230. const userResults = new Map<string, CumulativeResult>();
  231. const sortedDates = Object.keys(dailyAnswers).sort((a: string, b: string) =>
  232. new Date(a).getTime() - new Date(b).getTime()
  233. );
  234. sortedDates.forEach(date => {
  235. const answers = dailyAnswers[date] || [];
  236. answers.forEach((answer: Answer) => {
  237. const userId = answer.userId;
  238. const profitAmount = answer.profitAmount || 0;
  239. const profitPercent = answer.profitPercent || 0;
  240. if (!userResults.has(userId)) {
  241. userResults.set(userId, {
  242. userId,
  243. totalProfitAmount: 0,
  244. totalProfitPercent: 0
  245. });
  246. }
  247. const currentResult = userResults.get(userId)!;
  248. currentResult.totalProfitAmount += profitAmount;
  249. currentResult.totalProfitPercent += profitPercent;
  250. userResults.set(userId, currentResult);
  251. });
  252. });
  253. return Array.from(userResults.values());
  254. };
  255. const items = [
  256. {
  257. key: 'current',
  258. label: '当前答题情况',
  259. children: <CurrentAnswers answers={answers} columns={columns} />,
  260. },
  261. {
  262. key: 'daily',
  263. label: '每日答题统计',
  264. children: <DailyStatistics dailyAnswers={dailyAnswers} columns={dailyAnswersColumns} />,
  265. },
  266. {
  267. key: 'cumulative',
  268. label: '累计结果',
  269. children: <CumulativeResults results={calculateCumulativeResults(dailyAnswers)} columns={resultColumns} />,
  270. },
  271. ];
  272. return (
  273. <div className="p-4">
  274. <div className="mb-4">
  275. <h2 className="text-xl font-bold">答题卡管理</h2>
  276. <div className="mt-2 text-sm text-gray-600">
  277. <div>教室号: {classroom}</div>
  278. <div>当前日期: {currentDate}</div>
  279. <div>当前价格: {currentPrice}</div>
  280. <div>Socket状态: {connected ? '已连接' : '未连接'}</div>
  281. </div>
  282. </div>
  283. {/* 主要内容区域 */}
  284. <div className="mb-4">
  285. <Tabs
  286. activeKey={activeTab}
  287. onChange={setActiveTab}
  288. items={items}
  289. destroyInactiveTabPane
  290. />
  291. </div>
  292. {/* 底部按钮组 - 移动端优化布局 */}
  293. <div className="grid grid-cols-2 gap-2 mb-4">
  294. <Button
  295. onClick={handleSettlement}
  296. disabled={answers.length === 0}
  297. block
  298. >
  299. 结算
  300. </Button>
  301. <Button
  302. type="primary"
  303. onClick={handleSubmit}
  304. disabled={answers.length === 0}
  305. block
  306. >
  307. 收卷
  308. </Button>
  309. <Input
  310. value={mark}
  311. onChange={(e) => setMark(e.target.value)}
  312. placeholder="标记"
  313. className="col-span-2"
  314. />
  315. <Button onClick={() => message.info('标记已保存')} block>查看</Button>
  316. <Button onClick={handleRestart} block>重开</Button>
  317. </div>
  318. {/* 二维码区域 */}
  319. <QRCodeSection classroom={classroom || ''} />
  320. </div>
  321. );
  322. }