浏览代码

迁移答题模块

yourname 6 月之前
父节点
当前提交
1c02dbbd45

+ 351 - 0
client/mobile/components/Exam/ExamAdmin.tsx

@@ -0,0 +1,351 @@
+import React, { useState, useEffect } from 'react';
+import { useSearchParams } from 'react-router';
+import { Table, Button, message, Input, QRCode, Modal, Tabs } from 'antd';
+import type { GetProp, TableProps } from 'antd';
+import dayjs from 'dayjs';
+import {
+  useExamCurrentQuestion,
+  useExamRoomMessages,
+  useExamAnswerSubmission,
+  useExamSocketRoom
+} from './hooks/useSocketClient.ts';
+import type { Answer, CumulativeResult } from './types.ts';
+
+type ColumnType = GetProp<TableProps,'columns'>[number]
+
+// 当前答题情况组件 - 添加移动端响应式样式
+function CurrentAnswers({ answers, columns }: { answers: Answer[], columns: any[] }) {
+  return (
+    <div className="overflow-x-auto">
+      <Table 
+        columns={columns}
+        dataSource={answers}
+        rowKey={(record) => `${record.userId}-${record.date}`}
+        pagination={false}
+        scroll={{ x: true }}
+      />
+    </div>
+  );
+}
+
+// 每日统计组件 - 添加移动端响应式样式
+function DailyStatistics({ dailyAnswers, columns }: { dailyAnswers: {[key: string]: Answer[]}, columns: any[] }) {
+  return (
+    <div className="overflow-x-auto">
+      <Table 
+        columns={columns}
+        dataSource={Object.keys(dailyAnswers).map(date => ({ date }))}
+        rowKey="date"
+        pagination={false}
+        scroll={{ x: true }}
+      />
+    </div>
+  );
+}
+
+// 累计结果组件 - 添加移动端响应式样式
+function CumulativeResults({ results, columns }: { results: CumulativeResult[], columns: any[] }) {
+  return (
+    <div className="overflow-x-auto">
+      <Table
+        columns={columns}
+        dataSource={results}
+        rowKey="userId"
+        pagination={false}
+        scroll={{ x: true }}
+      />
+    </div>
+  );
+}
+
+// 二维码组件 - 保持原样
+function QRCodeSection({ classroom }: { classroom: string }) {
+  return (
+    <div className="text-center">
+      <div className="text-gray-600 mb-2">扫码参与训练</div>
+      <div className="inline-block p-4 bg-white rounded-lg shadow-md">
+        <QRCode value={`${globalThis.location.origin}/exam?classroom=${classroom}`} />
+      </div>
+    </div>
+  );
+}
+
+export default function ExamAdmin() {
+  const [searchParams] = useSearchParams();
+  const classroom = searchParams.get('classroom');
+  const { connected } = useExamSocketRoom(classroom);
+  const {currentQuestion} = useExamCurrentQuestion(classroom);
+  const lastMessage = useExamRoomMessages(classroom);
+  const { submitAnswer } = useExamAnswerSubmission(classroom);
+  
+  const [answers, setAnswers] = useState<Answer[]>([]);
+  const [dailyAnswers, setDailyAnswers] = useState<{[key: string]: Answer[]}>({});
+  const [currentDate, setCurrentDate] = useState('');
+  const [currentPrice, setCurrentPrice] = useState('0');
+  const [mark, setMark] = useState('');
+  const [activeTab, setActiveTab] = useState('current');
+
+  // 更新答案状态
+  useEffect(() => {
+    if (lastMessage?.message.type === 'answer') {
+      const answer = lastMessage.message.content;
+      setAnswers(prev => [...prev, answer]);
+    }
+  }, [lastMessage]);
+
+  // 更新每日答题情况
+  useEffect(() => {
+    if (currentDate && answers.length > 0) {
+      setDailyAnswers(prev => ({
+        ...prev,
+        [currentDate]: answers
+      }));
+    }
+  }, [currentDate, answers]);
+
+  useEffect(() => {
+    if (currentQuestion) {
+      setCurrentDate(currentQuestion.date);
+      setCurrentPrice(String(currentQuestion.price));
+    }
+  }, [currentQuestion]);
+
+  // 结算函数
+  const handleSettlement = async () => {
+    if (!classroom || answers.length === 0) return;
+
+    try {
+      await submitAnswer(currentDate, 'system', {
+        date: currentDate,
+        price: currentPrice,
+        holdingStock: '0',
+        holdingCash: '1',
+        userId: 'system'
+      });
+      message.success('结算成功');
+    } catch (error) {
+      console.error('结算失败:', error);
+      message.error('结算失败');
+    }
+  };
+
+  // 提交函数
+  const handleSubmit = async () => {
+    if (!classroom || answers.length === 0) return;
+
+    try {
+      message.success('答案提交成功');
+      setAnswers([]);
+    } catch (error: any) {
+      console.error('提交答案失败:', error);
+      message.error(error?.message || '提交答案失败');
+    }
+  };
+
+  // 重新开始
+  const handleRestart = async () => {
+    try {
+      setAnswers([]);
+      setDailyAnswers({});
+      setCurrentDate('');
+      setCurrentPrice('0');
+      message.success('已重新开始');
+    } catch (error) {
+      console.error('重新开始失败:', error);
+      message.error('重新开始失败');
+    }
+  };
+
+  const columns = [
+    {
+      title: '昵称',
+      dataIndex: 'userId',
+      key: 'userId',
+    },
+    {
+      title: '日期',
+      dataIndex: 'date',
+      key: 'date',
+      render: (text: string) => text ? dayjs(text).format('YYYY-MM-DD') : '-',
+    },
+    {
+      title: '持股',
+      dataIndex: 'holdingStock',
+      key: 'holdingStock',
+    },
+    {
+      title: '持币',
+      dataIndex: 'holdingCash',
+      key: 'holdingCash',
+    },
+    {
+      title: '价格',
+      dataIndex: 'price',
+      key: 'price',
+      render: (text: string | undefined) => text ? parseFloat(text).toFixed(2) : '-',
+    },
+    {
+      title: '收益(元)',
+      dataIndex: 'profitAmount',
+      key: 'profitAmount',
+      render: (text: number | undefined) => text !== undefined ? text.toFixed(2) : '-',
+    },
+    {
+      title: '盈亏率',
+      dataIndex: 'profitPercent',
+      key: 'profitPercent',
+      render: (text: number | undefined) => text !== undefined ? `${text.toFixed(2)}%` : '-',
+    }
+  ];
+
+  const resultColumns: ColumnType<CumulativeResult>[] = [
+    {
+      title: '昵称',
+      dataIndex: 'userId',
+      key: 'userId',
+    },
+    {
+      title: '累计盈亏(元)',
+      dataIndex: 'totalProfitAmount',
+      key: 'totalProfitAmount',
+      render: (text: number | undefined) => text !== undefined ? text.toFixed(2) : '-',
+    },
+    {
+      title: '累计盈亏率',
+      dataIndex: 'totalProfitPercent',
+      key: 'totalProfitPercent',
+      render: (text: number | undefined) => text !== undefined ? `${text.toFixed(2)}%` : '-',
+    },
+  ];
+
+  const dailyAnswersColumns = [
+    {
+      title: '日期',
+      dataIndex: 'date',
+      key: 'date',
+      render: (text: string) => dayjs(text).format('YYYY-MM-DD'),
+    },
+    {
+      title: '答题人数',
+      key: 'count',
+      render: (_: any, record: { date: string }) => dailyAnswers[record.date]?.length || 0,
+    },
+    {
+      title: '持股人数',
+      key: 'holdingStockCount',
+      render: (_: any, record: { date: string }) => 
+        dailyAnswers[record.date]?.filter((a: any) => a.holdingStock === '1').length || 0,
+    },
+    {
+      title: '持币人数',
+      key: 'holdingCashCount',
+      render: (_: any, record: { date: string }) => 
+        dailyAnswers[record.date]?.filter((a: any) => a.holdingCash === '1').length || 0,
+    }
+  ];
+
+  // 计算累计结果
+  const calculateCumulativeResults = (dailyAnswers: {[key: string]: Answer[]}): CumulativeResult[] => {
+    const userResults = new Map<string, CumulativeResult>();
+
+    const sortedDates = Object.keys(dailyAnswers).sort((a: string, b: string) => 
+      new Date(a).getTime() - new Date(b).getTime()
+    );
+    
+    sortedDates.forEach(date => {
+      const answers = dailyAnswers[date] || [];
+      answers.forEach((answer: Answer) => {
+        const userId = answer.userId;
+        const profitAmount = answer.profitAmount || 0;
+        const profitPercent = answer.profitPercent || 0;
+
+        if (!userResults.has(userId)) {
+          userResults.set(userId, {
+            userId,
+            totalProfitAmount: 0,
+            totalProfitPercent: 0
+          });
+        }
+
+        const currentResult = userResults.get(userId)!;
+        currentResult.totalProfitAmount += profitAmount;
+        currentResult.totalProfitPercent += profitPercent;
+        userResults.set(userId, currentResult);
+      });
+    });
+
+    return Array.from(userResults.values());
+  };
+
+  const items = [
+    {
+      key: 'current',
+      label: '当前答题情况',
+      children: <CurrentAnswers answers={answers} columns={columns} />,
+    },
+    {
+      key: 'daily',
+      label: '每日答题统计',
+      children: <DailyStatistics dailyAnswers={dailyAnswers} columns={dailyAnswersColumns} />,
+    },
+    {
+      key: 'cumulative',
+      label: '累计结果',
+      children: <CumulativeResults results={calculateCumulativeResults(dailyAnswers)} columns={resultColumns} />,
+    },
+  ];
+
+  return (
+    <div className="p-4">
+      <div className="mb-4">
+        <h2 className="text-xl font-bold">答题卡管理</h2>
+        <div className="mt-2 text-sm text-gray-600">
+          <div>教室号: {classroom}</div>
+          <div>当前日期: {currentDate}</div>
+          <div>当前价格: {currentPrice}</div>
+          <div>Socket状态: {connected ? '已连接' : '未连接'}</div>
+        </div>
+      </div>
+
+      {/* 主要内容区域 */}
+      <div className="mb-4">
+        <Tabs 
+          activeKey={activeTab} 
+          onChange={setActiveTab}
+          items={items}
+          destroyInactiveTabPane
+        />
+      </div>
+
+      {/* 底部按钮组 - 移动端优化布局 */}
+      <div className="grid grid-cols-2 gap-2 mb-4">
+        <Button 
+          onClick={handleSettlement} 
+          disabled={answers.length === 0}
+          block
+        >
+          结算
+        </Button>
+        <Button 
+          type="primary" 
+          onClick={handleSubmit} 
+          disabled={answers.length === 0}
+          block
+        >
+          收卷
+        </Button>
+        <Input
+          value={mark}
+          onChange={(e) => setMark(e.target.value)}
+          placeholder="标记"
+          className="col-span-2"
+        />
+        <Button onClick={() => message.info('标记已保存')} block>查看</Button>
+        <Button onClick={handleRestart} block>重开</Button>
+      </div>
+
+      {/* 二维码区域 */}
+      <QRCodeSection classroom={classroom || ''} />
+    </div>
+  );
+}

+ 258 - 0
client/mobile/components/Exam/ExamCard.tsx

@@ -0,0 +1,258 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useSearchParams } from "react-router";
+import dayjs from 'dayjs';
+import { message } from 'antd';
+import {
+  useExamCurrentQuestion,
+  useExamRoomMessages,
+  useExamAnswerSubmission,
+  useExamUserAnswerHistory,
+  useExamSocketRoom
+} from './hooks/useSocketClient.ts';
+import type { AnswerRecord, ClassroomData, Answer } from './types.ts';
+
+// 答题卡页面
+export default function ExamCard() {
+  const [searchParams] = useSearchParams();
+  const classroom = searchParams.get('classroom');
+  const nickname = searchParams.get('nickname');
+  useExamSocketRoom(classroom);
+  const lastMessage = useExamRoomMessages(classroom);
+  const { submitAnswer } = useExamAnswerSubmission(classroom);
+  const { currentQuestion } = useExamCurrentQuestion(classroom);
+  const { userAnswers } = useExamUserAnswerHistory(classroom, nickname);
+  const [currentDate, setCurrentDate] = useState('');
+  const [currentPrice, setCurrentPrice] = useState('0');
+  const [holdingStock, setHoldingStock] = useState('0');
+  const [holdingCash, setHoldingCash] = useState('0');
+  const [isStarted, setIsStarted] = useState(false);
+  const [answerRecords, setAnswerRecords] = useState<AnswerRecord[]>([]);
+
+  const { data: classroomData, isLoading } = useQuery({
+    queryKey: ['classroom', classroom],
+    queryFn: async () => {
+      if (!classroom) return null;
+      const response = await fetch(`/api/v1/classroom?classroom_no=${classroom}`);
+      const data = await response.json();
+      if (!data.success) {
+        message.error(data.message || '获取教室数据失败');
+        return null;
+      }
+      return data.data?.[0] as ClassroomData || null;
+    },
+    enabled: !!classroom
+  });
+
+  useEffect(() => {
+    if (!classroom || !nickname) {
+      globalThis.location.href = '/exam';
+      return;
+    }
+    if (classroomData && classroomData.status !== "1") {
+      message.error('该教室已关闭');
+      globalThis.location.href = '/exam';
+      return;
+    }
+  }, [classroom, nickname, classroomData]);
+
+  // 处理房间消息
+  useEffect(() => {
+    if (!lastMessage?.message) return;
+
+    const { type } = lastMessage.message;
+    
+    // 只处理重开消息的UI重置
+    if (type === 'restart') {
+      setCurrentDate('');
+      setCurrentPrice('0');
+      setHoldingStock('0');
+      setHoldingCash('0');
+      setIsStarted(false);
+      setAnswerRecords([]);
+    }
+  }, [lastMessage]);
+
+  useEffect(() => {
+    if (currentQuestion) {
+      console.log('currentQuestion', currentQuestion);
+      setCurrentDate(currentQuestion.date);
+      setCurrentPrice(String(currentQuestion.price));
+      setIsStarted(true);
+    } else {
+      // 如果没有当前问题,重置状态
+      setCurrentDate('');
+      setCurrentPrice('0');
+      setIsStarted(false);
+    }
+  }, [currentQuestion]);
+
+  // 处理选择A(持股)
+  const handleChooseA = useCallback(async () => {
+    setHoldingStock('1');
+    setHoldingCash('0');
+    
+    if (classroom && nickname) {
+      const answer = {
+        date: currentDate,
+        holdingStock: '1',
+        holdingCash: '0',
+        userId: nickname,
+        price: currentPrice
+      };
+      
+      try {
+        await submitAnswer(currentDate, nickname, answer);
+      } catch (error) {
+        message.error('提交答案失败');
+      }
+    }
+  }, [classroom, nickname, currentDate, currentPrice, submitAnswer]);
+
+  // 处理选择B(持币)
+  const handleChooseB = useCallback(async () => {
+    setHoldingStock('0');
+    setHoldingCash('1');
+    
+    if (classroom && nickname) {
+      const answer = {
+        date: currentDate,
+        holdingStock: '0',
+        holdingCash: '1',
+        userId: nickname,
+        price: currentPrice
+      };
+      
+      try {
+        await submitAnswer(currentDate, nickname, answer);
+      } catch (error) {
+        message.error('提交答案失败');
+      }
+    }
+  }, [classroom, nickname, currentDate, currentPrice, submitAnswer]);
+
+  // 初始化用户的答题记录
+  useEffect(() => {
+    if (userAnswers && userAnswers.length > 0) {
+      const lastAnswer = userAnswers[userAnswers.length - 1];
+      setHoldingStock(lastAnswer.holdingStock);
+      setHoldingCash(lastAnswer.holdingCash);
+      
+      // 直接使用 userAnswers 中已计算好的数据
+      const records = userAnswers.map((answer: Answer, index: number): AnswerRecord => ({
+        date: answer.date,
+        price: String(answer.price || '0'),
+        holdingStock: answer.holdingStock,
+        holdingCash: answer.holdingCash,
+        profitAmount: answer.profitAmount || 0,
+        profitPercent: answer.profitPercent || 0,
+        index: index + 1
+      }));
+      
+      setAnswerRecords(records);
+    }
+  }, [userAnswers]);
+
+  if (isLoading || !classroomData) {
+    return <div className="flex items-center justify-center min-h-screen">加载中...</div>;
+  }
+
+  return (
+    <div className="flex flex-col items-center min-h-screen bg-gray-100 py-8 px-4">
+      {/* 选择区域 */}
+      <div className="w-full max-w-2xl">
+        <div className="text-center mb-8">
+          <h2 className="text-2xl font-bold mb-2">持股选A, 持币选B</h2>
+          <div className="flex justify-center space-x-4 text-gray-600">
+            {isStarted ? (
+              <>
+                <span>日期: {currentDate}</span>
+                <span>价格: {currentPrice}</span>
+              </>
+            ) : (
+              <div className="text-blue-600">
+                <div className="mb-2">等待训练开始...</div>
+                <div className="text-sm text-gray-500">
+                  训练日期: {dayjs(classroomData.training_date).format('YYYY-MM-DD')}
+                </div>
+              </div>
+            )}
+          </div>
+        </div>
+
+        {/* 选择按钮 - 增大点击区域 */}
+        <div className="flex justify-center items-center space-x-4 mb-8 bg-white p-6 rounded-lg shadow-md">
+          <button
+            onClick={handleChooseA}
+            disabled={!isStarted}
+            className={`min-w-[120px] py-8 px-4 text-3xl font-bold rounded-lg transition-colors ${
+              !isStarted 
+                ? 'bg-gray-200 text-gray-400 cursor-not-allowed'
+                : holdingStock === '1'
+                  ? 'bg-red-500 text-white'
+                  : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
+            }`}
+          >
+            A
+          </button>
+          <div className="text-xl font-medium text-gray-700">
+            {isStarted ? '开始' : '等待'}
+          </div>
+          <button
+            onClick={handleChooseB}
+            disabled={!isStarted}
+            className={`min-w-[120px] py-8 px-4 text-3xl font-bold rounded-lg transition-colors ${
+              !isStarted 
+                ? 'bg-gray-200 text-gray-400 cursor-not-allowed'
+                : holdingCash === '1'
+                  ? 'bg-green-500 text-white'
+                  : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
+            }`}
+          >
+            B
+          </button>
+        </div>
+
+        {/* 信息显示 */}
+        <div className="bg-white p-6 rounded-lg shadow-md">
+          <div className="grid grid-cols-2 gap-4 mb-4">
+            <div className="text-gray-600">昵称: {nickname}</div>
+            <div className="text-gray-600">代码: {classroomData.code}</div>
+          </div>
+
+          {/* 表格头部 */}
+          <div className="grid grid-cols-8 gap-4 py-2 border-b border-gray-200 text-sm font-medium text-gray-600">
+            <div>序</div>
+            <div>训练日期</div>
+            <div>持股</div>
+            <div>持币</div>
+            <div>价格</div>
+            <div>收益(元)</div>
+            <div>盈亏率</div>
+            <div>号</div>
+          </div>
+
+          {/* 表格内容 - 优化滚动体验 */}
+          <div className="max-h-60 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
+            {[...answerRecords].reverse().map((record: AnswerRecord) => (
+              <div key={record.date} className="grid grid-cols-8 gap-4 py-2 text-sm text-gray-800 hover:bg-gray-50">
+                <div>{record.index}</div>
+                <div>{dayjs(record.date).format('YYYY-MM-DD')}</div>
+                <div className="text-red-500">{record.holdingStock}</div>
+                <div className="text-green-500">{record.holdingCash}</div>
+                <div>{record.price}</div>
+                <div className={record.profitAmount >= 0 ? 'text-red-500' : 'text-green-500'}>
+                  {record.profitAmount.toFixed(2)}
+                </div>
+                <div className={record.profitPercent >= 0 ? 'text-red-500' : 'text-green-500'}>
+                  {record.profitPercent.toFixed(2)}%
+                </div>
+                <div>{record.index}</div>
+              </div>
+            ))}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 108 - 0
client/mobile/components/Exam/ExamIndex.tsx

@@ -0,0 +1,108 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useNavigate, useSearchParams } from "react-router";
+import dayjs from 'dayjs';
+import { message } from 'antd';
+import { Skeleton } from 'antd';
+
+// 昵称输入页面
+function ExamIndex() {
+  const [nickname, setNickname] = useState('');
+  const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
+  const classroom = searchParams.get('classroom');
+
+  const { data: classroomData, isLoading } = useQuery({
+    queryKey: ['classroom', classroom],
+    queryFn: async () => {
+      if (!classroom) return null;
+      const response = await fetch(`/api/v1/classroom?classroom_no=${classroom}`);
+      const data = await response.json();
+      if (!data.success) {
+        message.error(data.message || '获取教室数据失败');
+        return null;
+      }
+      return data.data?.[0] || null;
+    },
+    enabled: !!classroom
+  });
+
+  useEffect(() => {
+    if (classroomData && classroomData.status !== "1") {
+      message.error('该教室已关闭');
+    }
+  }, [classroomData]);
+
+  const handleJoinTraining = useCallback(() => {
+    if (!nickname.trim()) {
+      message.error('请输入昵称');
+      return;
+    }
+    if (!classroom) {
+      message.error('教室号不能为空');
+      return;
+    }
+    if (!classroomData) {
+      message.error('教室不存在');
+      return;
+    }
+    if (classroomData.status !== "1") {
+      message.error('该教室已关闭');
+      return;
+    }
+    // 将昵称和教室号作为参数传递到答题页
+    navigate(`/exam/card?nickname=${encodeURIComponent(nickname)}&classroom=${classroom}`);
+  }, [nickname, navigate, classroom, classroomData]);
+
+  if (isLoading) {
+    return (
+      <div className="flex flex-col items-center justify-center min-h-screen p-4">
+        <Skeleton active paragraph={{ rows: 4 }} />
+      </div>
+    );
+  }
+
+  return (
+    <div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 p-4">
+      <div className="w-full max-w-md space-y-8">
+        <div className="text-center">
+          <h2 className="text-2xl font-bold text-gray-900">股票训练答题卡系统</h2>
+          <p className="mt-2 text-gray-600">
+            {classroom ? `教室号: ${classroom}` : '请输入昵称开始答题'}
+          </p>
+          {classroomData && (
+            <div className="mt-2 text-sm text-gray-500">
+              <p>训练日期: {dayjs(classroomData.training_date).format('YYYY-MM-DD')}</p>
+              <p>代码: {classroomData.code}</p>
+            </div>
+          )}
+        </div>
+        
+        <div className="mt-8">
+          <input
+            type="text"
+            value={nickname}
+            onChange={(e) => setNickname(e.target.value)}
+            placeholder="请输入昵称"
+            className="w-full px-4 py-3 text-base border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+            onKeyPress={(e) => e.key === 'Enter' && handleJoinTraining()}
+          />
+        </div>
+
+        <button
+          onClick={handleJoinTraining}
+          disabled={!classroomData || classroomData.status !== "1"}
+          className={`w-full px-8 py-3 text-base font-medium text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors ${
+            !classroomData || classroomData.status !== "1"
+              ? 'bg-gray-400 cursor-not-allowed'
+              : 'bg-blue-500 hover:bg-blue-600 focus:ring-blue-500'
+          }`}
+        >
+          开始答题
+        </button>
+      </div>
+    </div>
+  );
+}
+
+export default ExamIndex;

+ 511 - 0
client/mobile/components/Exam/hooks/useSocketClient.ts

@@ -0,0 +1,511 @@
+import { useEffect, useState, useCallback } from 'react';
+import { APIClient } from '@d8d-appcontainer/api';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import type {
+  QuizContent,
+  QuizState,
+  ExamSocketMessage,
+  ExamSocketRoomMessage,
+  Answer
+} from '../types.ts';
+
+interface LoaderData {
+  token: string;
+  serverUrl: string;
+}
+
+// 系统消息默认值
+const SYSTEM_USER_ID = 'system';
+const DEFAULT_HOLDING_STOCK = '0';
+const DEFAULT_HOLDING_CASH = '1';
+
+// 工具函数:统一错误处理
+const handleAsyncOperation = async <T>(
+  operation: () => Promise<T>,
+  errorMessage: string
+): Promise<T> => {
+  try {
+    return await operation();
+  } catch (error) {
+    console.error(`${errorMessage}:`, error);
+    throw error;
+  }
+};
+
+// 计算收益的辅助函数
+interface ProfitResult {
+  profitAmount: number;  // 金额收益
+  profitPercent: number; // 百分比收益
+}
+
+function calculateProfit(currentPrice: number, previousPrice: number, holdingStock: string): ProfitResult {
+  if (holdingStock === '1') {
+    const profitAmount = currentPrice - previousPrice;
+    const profitPercent = ((currentPrice - previousPrice) / previousPrice) * 100;
+    return {
+      profitAmount,
+      profitPercent
+    };
+  }
+  return {
+    profitAmount: 0,
+    profitPercent: 0
+  };
+}
+
+// 核心Socket客户端Hook
+export function useExamSocketClient(roomId: string | null) {
+  const { token, serverUrl } = {
+    token: '',
+    serverUrl: '/wss'
+  };
+
+  const { data: client } = useQuery({
+    queryKey: ['socket-client'],
+    queryFn: async () => {
+      if (!token || !serverUrl) return null;
+
+      const apiClient = new APIClient({
+        scope: 'user',
+        config: {
+          serverUrl,
+          type: 'socket',
+          token,
+        }
+      });
+
+      await apiClient.connect();
+      return apiClient;
+    },
+    enabled: !!token && !!serverUrl && !!roomId,
+    staleTime: Infinity,
+    retry: 3,
+    gcTime: 0
+  });
+
+  const joinRoom = useCallback(async (roomId: string) => {
+    if (client && roomId) {
+      await client.socket.joinRoom(roomId);
+    }
+  }, [client]);
+
+  const leaveRoom = useCallback(async (roomId: string) => {
+    if (client && roomId) {
+      await client.socket.leaveRoom(roomId);
+    }
+  }, [client]);
+
+  const sendRoomMessage = useCallback(async (roomId: string | null, message: ExamSocketMessage) => {
+    if (client && roomId) {
+      const convertedMessage = {
+        ...message,
+        timestamp: Date.now(),
+        content: {
+          ...message.content,
+          userId: message.content.userId || SYSTEM_USER_ID
+        }
+      };
+      await client.socket.sendRoomMessage(roomId, convertedMessage as any);
+    }
+  }, [client]);
+
+  const onRoomMessage = useCallback((callback: (data: ExamSocketRoomMessage) => void) => {
+    if (client) {
+      client.socket.onRoomMessage((data: any) => {
+        const convertedMessage: ExamSocketRoomMessage = {
+          roomId: data.roomId,
+          message: {
+            ...data.message,
+            timestamp: Number(data.message.timestamp),
+            content: {
+              ...data.message.content,
+              price: parseFloat(String(data.message.content.price))
+            }
+          }
+        };
+        callback(convertedMessage);
+      });
+    }
+  }, [client]);
+
+  const getAnswers = useCallback(async (roomId: string | null, questionId: string): Promise<Answer[]> => {
+    if (!client || !roomId) return [];
+
+    return handleAsyncOperation(async () => {
+      const answersData = await client.redis.hgetall(`quiz:${roomId}:answers:${questionId}`);
+      if (!answersData) return [];
+      
+      return Object.entries(answersData).map(([userId, data]) => ({
+        ...(JSON.parse(data) as QuizContent),
+        userId,
+      })) as Answer[];
+    }, '获取答案失败');
+  }, [client]);
+
+  const storeAnswer = useCallback(async (roomId: string | null, questionId: string, userId: string, answer: QuizContent) => {
+    if (!client || !roomId) return;
+
+    const pricesData = await client.redis.hgetall(`quiz:${roomId}:prices`);
+    if (!pricesData) {
+      const initialAnswer: Answer = {
+        ...answer,
+        userId,
+        profitAmount: 0,
+        profitPercent: 0,
+        totalProfitAmount: 0,
+        totalProfitPercent: 0
+      };
+      
+      await client.redis.hset(
+        `quiz:${roomId}:answers:${questionId}`,
+        userId,
+        JSON.stringify(initialAnswer)
+      );
+      return;
+    }
+
+    const dates = Object.keys(pricesData).sort();
+    const allAnswers = await Promise.all(
+      dates.map(date => getAnswers(roomId, date))
+    );
+
+    const userAnswers = allAnswers
+      .flat()
+      .filter((a: Answer) => a.userId === userId)
+      .map((a: Answer) => {
+        if (!a.date) return a;
+        const priceData = JSON.parse(pricesData[a.date] || '{"price":"0"}');
+        return {
+          ...a,
+          price: priceData.price
+        };
+      })
+      .sort((a: Answer, b: Answer) => new Date(a.date || '').getTime() - new Date(b.date || '').getTime());
+
+    let totalProfitAmount = 0;
+    let totalProfitPercent = 0;
+    
+    if (userAnswers.length > 0) {
+      const prevAnswer = userAnswers[userAnswers.length - 1];
+      const { profitAmount, profitPercent } = calculateProfit(
+        parseFloat(String(answer.price)),
+        parseFloat(String(prevAnswer.price)),
+        prevAnswer.holdingStock as string
+      );
+      
+      totalProfitAmount = (prevAnswer.totalProfitAmount || 0) + profitAmount;
+      totalProfitPercent = (prevAnswer.totalProfitPercent || 0) + profitPercent;
+    }
+
+    const answerWithProfit: Answer = {
+      ...answer,
+      userId,
+      profitAmount: userAnswers.length > 0 ? totalProfitAmount - (userAnswers[userAnswers.length - 1].totalProfitAmount || 0) : 0,
+      profitPercent: userAnswers.length > 0 ? totalProfitPercent - (userAnswers[userAnswers.length - 1].totalProfitPercent || 0) : 0,
+      totalProfitAmount,
+      totalProfitPercent
+    };
+
+    if (client?.redis) {
+      await client.redis.hset(
+        `quiz:${roomId}:answers:${questionId}`,
+        userId,
+        JSON.stringify(answerWithProfit)
+      );
+    }
+  }, [client, getAnswers]);
+
+  const cleanupRoom = useCallback(async (roomId: string | null, questionId?: string) => {
+    if (!client || !roomId) return;
+
+    await handleAsyncOperation(async () => {
+      if (questionId) {
+        await client.redis.del(`quiz:${roomId}:answers:${questionId}`);
+      } else {
+        await Promise.all([
+          client.redis.delByPattern(`quiz:${roomId}:answers:*`),
+          client.redis.del(`quiz:${roomId}:prices`)
+        ]);
+      }
+    }, '清理房间数据失败');
+  }, [client]);
+
+  const sendNextQuestion = useCallback(async (roomId: string | null, state: QuizState) => {
+    if (!client || !roomId) return;
+
+    return handleAsyncOperation(async () => {
+      const messageContent: QuizContent = {
+        date: state.date,
+        price: state.price,
+        holdingStock: DEFAULT_HOLDING_STOCK,
+        holdingCash: DEFAULT_HOLDING_CASH,
+        userId: SYSTEM_USER_ID
+      };
+
+      const message: ExamSocketMessage = {
+        type: 'question',
+        content: messageContent
+      };
+
+      await storeAnswer(roomId, 'current_state', SYSTEM_USER_ID, messageContent);
+      await client.redis.hset(
+        `quiz:${roomId}:prices`,
+        state.date,
+        JSON.stringify({ price: state.price })
+      );
+      await sendRoomMessage(roomId, message);
+    }, '发送题目失败');
+  }, [client, sendRoomMessage, storeAnswer]);
+
+  const getCurrentQuestion = useCallback(async (roomId: string | null): Promise<QuizState | null> => {
+    if (!client || !roomId) return null;
+
+    return handleAsyncOperation(async () => {
+      const answers = await getAnswers(roomId, 'current_state');
+      const currentState = answers[0];
+      if (currentState) {
+        return {
+          date: currentState.date || '',
+          price: currentState.price || '0'
+        };
+      }
+      return null;
+    }, '获取当前题目状态失败');
+  }, [client, getAnswers]);
+
+  const getPriceHistory = useCallback(async (roomId: string | null, date: string): Promise<string> => {
+    if (!client || !roomId) return '0';
+
+    return handleAsyncOperation(async () => {
+      const priceData = await client.redis.hget(`quiz:${roomId}:prices`, date);
+      if (!priceData) return '0';
+      
+      const { price } = JSON.parse(priceData);
+      return String(price);
+    }, '获取历史价格失败');
+  }, [client]);
+
+  return {
+    client,
+    joinRoom,
+    leaveRoom,
+    sendRoomMessage,
+    onRoomMessage,
+    storeAnswer,
+    getAnswers,
+    cleanupRoom,
+    sendNextQuestion,
+    getCurrentQuestion,
+    getPriceHistory
+  };
+}
+
+// Socket Room Hook
+export function useExamSocketRoom(roomId: string | null) {
+  const socketClient = useExamSocketClient(roomId);
+
+  const { data: roomConnection } = useQuery({
+    queryKey: ['socket-room', roomId],
+    queryFn: async () => {
+      if (!roomId || !socketClient.client) return null;
+      await socketClient.joinRoom(roomId);
+      console.log(`Connected to room: ${roomId}`);
+      return { roomId, connected: true };
+    },
+    enabled: !!roomId && !!socketClient.client,
+    staleTime: Infinity,
+    gcTime: Infinity,
+    retry: false
+  });
+
+  return {
+    connected: roomConnection?.connected || false,
+    socketClient
+  };
+}
+
+// 当前题目状态Hook
+export function useExamCurrentQuestion(roomId: string | null) {
+  const socketClient = useExamSocketClient(roomId);
+  
+  const { data: currentQuestion, refetch } = useQuery({
+    queryKey: ['current-question', roomId],
+    queryFn: async () => {
+      if (!roomId || !socketClient) return null;
+      return socketClient.getCurrentQuestion(roomId);
+    },
+    enabled: !!roomId && !!socketClient,
+    staleTime: 0
+  });
+
+  return {
+    currentQuestion,
+    refetchQuestion: refetch
+  };
+}
+
+// 房间消息Hook
+export function useExamRoomMessages(roomId: string | null) {
+  const socketClient = useExamSocketClient(roomId);
+  const queryClient = useQueryClient();
+  const [lastMessage, setLastMessage] = useState<ExamSocketRoomMessage | null>(null);
+
+  useEffect(() => {
+    if (!roomId || !socketClient) return;
+
+    const handleMessage = (data: ExamSocketRoomMessage) => {
+      setLastMessage(data);
+      const { type, content } = data.message;
+
+      switch (type) {
+        case 'question':
+          queryClient.invalidateQueries({ queryKey: ['current-question', roomId] });
+          break;
+        case 'answer':
+          const { date } = content;
+          queryClient.invalidateQueries({ queryKey: ['answers', roomId, date] });
+          break;
+        case 'settlement':
+        case 'submit':
+          queryClient.invalidateQueries({ 
+            queryKey: ['user-answers'],
+            predicate: (query) => query.queryKey[1] === roomId 
+          });
+          queryClient.invalidateQueries({ 
+            queryKey: ['answers', roomId] 
+          });
+          break;
+        case 'restart':
+          queryClient.invalidateQueries({ queryKey: ['current-question', roomId] });
+          queryClient.invalidateQueries({ queryKey: ['answers', roomId] });
+          queryClient.invalidateQueries({ 
+            queryKey: ['user-answers'],
+            predicate: (query) => query.queryKey[1] === roomId 
+          });
+          queryClient.invalidateQueries({ queryKey: ['training-results', roomId] });
+          break;
+      }
+    };
+
+    socketClient.onRoomMessage(handleMessage);
+    return () => {
+      socketClient.client?.socket.offRoomMessage(handleMessage);
+    };
+  }, [roomId, socketClient, queryClient]);
+
+  return lastMessage;
+}
+
+// 用户答案历史Hook
+export function useExamUserAnswerHistory(roomId: string | null, userId: string | null) {
+  const socketClient = useExamSocketClient(roomId);
+
+  const { data: userAnswers, refetch: refetchUserAnswers } = useQuery({
+    queryKey: ['user-answers', roomId, userId],
+    queryFn: async () => {
+      if (!roomId || !userId || !socketClient) return [];
+      
+      const pricesData = await socketClient.client?.redis.hgetall(`quiz:${roomId}:prices`);
+      if (!pricesData) return [];
+      
+      const allAnswers = await Promise.all(
+        Object.keys(pricesData).map(date => socketClient.getAnswers(roomId, date))
+      );
+      
+      const userAnswersWithPrice = allAnswers
+        .flat()
+        .filter((answer: Answer) => answer.userId === userId)
+        .map((answer: Answer) => {
+          if (!answer.date) return answer;
+          const priceData = JSON.parse(pricesData[answer.date] || '{"price":"0"}');
+          return {
+            ...answer,
+            price: priceData.price
+          };
+        })
+        .sort((a: Answer, b: Answer) => new Date(a.date || '').getTime() - new Date(b.date || '').getTime());
+
+      let totalProfitAmount = 0;
+      let totalProfitPercent = 0;
+      const answersWithProfit = userAnswersWithPrice.map((answer: Answer, index: number) => {
+        if (index === 0) {
+          return { 
+            ...answer, 
+            profitAmount: 0,
+            profitPercent: 0,
+            totalProfitAmount: 0,
+            totalProfitPercent: 0
+          };
+        }
+
+        const prevAnswer = userAnswersWithPrice[index - 1];
+        const { profitAmount, profitPercent } = calculateProfit(
+          parseFloat(answer.price as string),
+          parseFloat(prevAnswer.price as string),
+          prevAnswer.holdingStock as string
+        );
+        
+        totalProfitAmount += profitAmount;
+        totalProfitPercent += profitPercent;
+
+        return {
+          ...answer,
+          profitAmount,
+          profitPercent,
+          totalProfitAmount,
+          totalProfitPercent
+        };
+      });
+
+      return answersWithProfit;
+    },
+    enabled: !!roomId && !!userId && !!socketClient,
+    staleTime: 0
+  });
+
+  return {
+    userAnswers: userAnswers || [],
+    refetchUserAnswers
+  };
+}
+
+// 答案提交Hook
+export function useExamAnswerSubmission(roomId: string | null) {
+  const { client, sendRoomMessage, storeAnswer } = useExamSocketClient(roomId);
+  const queryClient = useQueryClient();
+
+  const submitAnswer = useCallback(async (date: string, nickname: string, answer: any) => {
+    if (!client) return;
+
+    return handleAsyncOperation(async () => {
+      await storeAnswer(roomId, date, nickname, answer);
+      await sendRoomMessage(roomId, {
+        type: 'answer',
+        content: answer
+      });
+      queryClient.invalidateQueries({ queryKey: ['answers', roomId, date] });
+      queryClient.invalidateQueries({ queryKey: ['user-answers', roomId, nickname] });
+    }, '提交答案失败');
+  }, [roomId, client, storeAnswer, sendRoomMessage, queryClient]);
+
+  return { submitAnswer };
+}
+
+// 题目管理Hook
+export function useExamQuestionManagement(roomId: string | null) {
+  const socketClient = useExamSocketClient(roomId);
+  const queryClient = useQueryClient();
+
+  const sendNextQuestion = useCallback(async (state: QuizState) => {
+    if (!socketClient) return;
+
+    return handleAsyncOperation(async () => {
+      await socketClient.sendNextQuestion(roomId, state);
+      queryClient.invalidateQueries({ queryKey: ['current-question', roomId] });
+    }, '发送题目失败');
+  }, [roomId, socketClient, queryClient]);
+
+  return {
+    sendNextQuestion
+  };
+}

+ 73 - 0
client/mobile/components/Exam/types.ts

@@ -0,0 +1,73 @@
+// Socket消息类型定义
+interface BaseSocketMessage {
+  type: string;
+  content: any;
+  timestamp?: number;
+  sender?: string;
+}
+
+type SocketMessageType = string;
+
+// 基础答题记录
+export interface AnswerRecord {
+  date: string;
+  price: string;
+  holdingStock: string;
+  holdingCash: string;
+  profitAmount: number;
+  profitPercent: number;
+  index: number;
+}
+
+// 答题内容
+export interface QuizContent {
+  date: string;
+  price: number | string;
+  holdingStock: string;
+  holdingCash: string;
+  userId: string;
+}
+
+// 题目状态
+export interface QuizState {
+  date: string;
+  price: number | string;
+}
+
+export type ExamSocketMessageType = SocketMessageType | 'question' | 'answer' | 'settlement' | 'submit' | 'restart';
+
+// Socket消息
+export interface ExamSocketMessage extends Omit<BaseSocketMessage, 'type' | 'content'> {
+  type: ExamSocketMessageType;
+  content: QuizContent;
+}
+
+// Socket房间消息
+export interface ExamSocketRoomMessage {
+  roomId: string;
+  message: ExamSocketMessage;
+}
+
+// 答案
+export interface Answer extends QuizContent {
+  userId: string;
+  profitAmount?: number;
+  profitPercent?: number;
+  totalProfitAmount?: number;
+  totalProfitPercent?: number;
+}
+
+// 教室数据
+export interface ClassroomData {
+  classroom_no: string;
+  status: string;
+  training_date: string;
+  code: string;
+}
+
+// 累计结果
+export interface CumulativeResult {
+  userId: string;
+  totalProfitAmount: number;
+  totalProfitPercent: number;
+}

+ 54 - 0
deno.lock

@@ -309,20 +309,27 @@
     "https://esm.d8d.fun/@types/mime-types@~2.1.4/index.d.ts": "https://esm.d8d.fun/@types/mime-types@2.1.4/index.d.ts",
     "https://esm.d8d.fun/@types/ms@~2.1.0/index.d.ts": "https://esm.d8d.fun/@types/ms@2.1.0/index.d.ts",
     "https://esm.d8d.fun/@types/proxy-from-env@~1.0.4/index.d.ts": "https://esm.d8d.fun/@types/proxy-from-env@1.0.4/index.d.ts",
+    "https://esm.d8d.fun/@types/react-dom@~18.3.7/client.d.ts": "https://esm.d8d.fun/@types/react-dom@18.3.7/client.d.ts",
+    "https://esm.d8d.fun/@types/react@~18.3.21/index.d.ts": "https://esm.d8d.fun/@types/react@18.3.21/index.d.ts",
+    "https://esm.d8d.fun/@types/react@~18.3.21/jsx-runtime.d.ts": "https://esm.d8d.fun/@types/react@18.3.21/jsx-runtime.d.ts",
     "https://esm.d8d.fun/@types/react@~19.0.14/index.d.ts": "https://esm.d8d.fun/@types/react@19.0.14/index.d.ts",
+    "https://esm.d8d.fun/@types/scheduler@~0.23.0/index.d.ts": "https://esm.d8d.fun/@types/scheduler@0.23.0/index.d.ts",
     "https://esm.d8d.fun/@types/sdp-transform@~2.4.9/index.d.ts": "https://esm.d8d.fun/@types/sdp-transform@2.4.9/index.d.ts",
     "https://esm.d8d.fun/@types/semver@~7.7.0/index.d.ts": "https://esm.d8d.fun/@types/semver@7.7.0/index.d.ts",
+    "https://esm.d8d.fun/@types/set-cookie-parser@~2.4.10/index.d.ts": "https://esm.d8d.fun/@types/set-cookie-parser@2.4.10/index.d.ts",
     "https://esm.d8d.fun/@types/ws@~8.18.0/index.d.mts": "https://esm.d8d.fun/@types/ws@8.18.1/index.d.mts",
     "https://esm.d8d.fun/asynckit@^0.4.0?target=denonext": "https://esm.d8d.fun/asynckit@0.4.0?target=denonext",
     "https://esm.d8d.fun/axios@^1.7.2?target=denonext": "https://esm.d8d.fun/axios@1.9.0?target=denonext",
     "https://esm.d8d.fun/bufferutil@^4.0.1?target=denonext": "https://esm.d8d.fun/bufferutil@4.0.9?target=denonext",
     "https://esm.d8d.fun/clsx@^2.1.1?target=denonext&dev": "https://esm.d8d.fun/clsx@2.1.1?target=denonext&dev",
     "https://esm.d8d.fun/combined-stream@^1.0.8?target=denonext": "https://esm.d8d.fun/combined-stream@1.0.8?target=denonext",
+    "https://esm.d8d.fun/cookie@^1.0.1?target=denonext&dev": "https://esm.d8d.fun/cookie@1.0.2?target=denonext&dev",
     "https://esm.d8d.fun/debug?target=denonext": "https://esm.d8d.fun/debug@4.4.0?target=denonext",
     "https://esm.d8d.fun/delayed-stream@~1.0.0?target=denonext": "https://esm.d8d.fun/delayed-stream@1.0.0?target=denonext",
     "https://esm.d8d.fun/engine.io-client@~6.6.1?target=denonext": "https://esm.d8d.fun/engine.io-client@6.6.3?target=denonext",
     "https://esm.d8d.fun/engine.io-parser@~5.2.1?target=denonext": "https://esm.d8d.fun/engine.io-parser@5.2.3?target=denonext",
     "https://esm.d8d.fun/eventemitter3@^5.0.1?target=denonext": "https://esm.d8d.fun/eventemitter3@5.0.1?target=denonext",
+    "https://esm.d8d.fun/follow-redirects@^1.15.0?target=denonext": "https://esm.d8d.fun/follow-redirects@1.15.9?target=denonext",
     "https://esm.d8d.fun/follow-redirects@^1.15.6?target=denonext": "https://esm.d8d.fun/follow-redirects@1.15.9?target=denonext",
     "https://esm.d8d.fun/form-data@^4.0.0?target=denonext": "https://esm.d8d.fun/form-data@4.0.2?target=denonext",
     "https://esm.d8d.fun/isexe@^3.1.1?target=denonext": "https://esm.d8d.fun/isexe@3.1.1?target=denonext",
@@ -343,7 +350,9 @@
     "https://esm.d8d.fun/node-gyp-build@^4.3.0?target=denonext": "https://esm.d8d.fun/node-gyp-build@4.8.4?target=denonext",
     "https://esm.d8d.fun/proxy-from-env@^1.1.0?target=denonext": "https://esm.d8d.fun/proxy-from-env@1.1.0?target=denonext",
     "https://esm.d8d.fun/safe-buffer@^5.0.1?target=denonext": "https://esm.d8d.fun/safe-buffer@5.2.1?target=denonext",
+    "https://esm.d8d.fun/scheduler@^0.23.2?target=denonext&dev": "https://esm.d8d.fun/scheduler@0.23.2?target=denonext&dev",
     "https://esm.d8d.fun/semver@^7.5.4?target=denonext": "https://esm.d8d.fun/semver@7.7.1?target=denonext",
+    "https://esm.d8d.fun/set-cookie-parser@^2.6.0?target=denonext&dev": "https://esm.d8d.fun/set-cookie-parser@2.7.1?target=denonext&dev",
     "https://esm.d8d.fun/socket.io-client@^4.7.2?target=denonext": "https://esm.d8d.fun/socket.io-client@4.8.1?target=denonext",
     "https://esm.d8d.fun/socket.io-parser@~4.2.4?target=denonext": "https://esm.d8d.fun/socket.io-parser@4.2.4?target=denonext",
     "https://esm.d8d.fun/supports-color?target=denonext": "https://esm.d8d.fun/supports-color@10.0.0?target=denonext",
@@ -713,8 +722,13 @@
     "https://esm.d8d.fun/@deno/shim-deno-test@0.5.0?target=denonext": "503b73de1a14bd33782220e11fa2b33e9c87d574ac793e7addf1466c5436e66a",
     "https://esm.d8d.fun/@deno/shim-deno@0.18.2/denonext/shim-deno.mjs": "819d8ac34fdaf60658cf03d137f14adaff3f13a279ffd79cd8797d84a6ac46ab",
     "https://esm.d8d.fun/@deno/shim-deno@0.18.2?target=denonext": "ffa3ca347bb6b6530720158f307a2e31b16728fbb52e6432254a07d52fcbc404",
+    "https://esm.d8d.fun/@heroicons/react@2.1.1/24/outline?dev&deps=react@18.3.1,react-dom@18.3.1": "0d31f816d37ee5b51270d0f0f7580a4184b154222cc7ec31af2a62932a13a448",
+    "https://esm.d8d.fun/@heroicons/react@2.1.1/X-ZHJlYWN0LWRvbUAxOC4zLjEscmVhY3RAMTguMy4x/denonext/24/outline.development.mjs": "89ddc9faafcc064529cf8e8d02132d431add341c4130729f850e9ab4c3a21015",
     "https://esm.d8d.fun/@socket.io/component-emitter@3.1.2/denonext/component-emitter.mjs": "3c6c5f2d64d4933b577a7117df1d8855c51ff01ab3dea8f42af1adcb1a5989e7",
     "https://esm.d8d.fun/@socket.io/component-emitter@3.1.2?target=denonext": "f6ff0f94ae3c9850a2c3a925cc2b236ec03a80fc2298d0ca48c2a90b10487db3",
+    "https://esm.d8d.fun/@tanstack/query-core@5.67.1/denonext/query-core.development.mjs": "7c11795ca9eb6dd576fd435aa0a19a81f50e383456a8ab48b72e1dad8c9f6673",
+    "https://esm.d8d.fun/@tanstack/react-query@5.67.1/X-ZHJlYWN0LWRvbUAxOC4zLjEscmVhY3RAMTguMy4x/denonext/react-query.development.mjs": "37b0c104fa2098dc8787ffe98075cfd48cecb708556c831e85d8aa5bb853ed9c",
+    "https://esm.d8d.fun/@tanstack/react-query@5.67.1?dev&deps=react@18.3.1,react-dom@18.3.1": "775e518009f913e5c691db21b19ab0690c3dff3b151e852318eeec85c59723c7",
     "https://esm.d8d.fun/aliyun-rtc-sdk@6.14.6": "1566efaba1afd30c2e0b1233dae52a3540b2ed887da9a5a2fed66f2d70cfd499",
     "https://esm.d8d.fun/aliyun-rtc-sdk@6.14.6/denonext/aliyun-rtc-sdk.bundle.mjs": "63e8f7401f1ac8f0f8a6ef702f8f8f1412fbae31c2b90061bb225fc4b6267e94",
     "https://esm.d8d.fun/aliyun-rtc-sdk@6.14.6/denonext/aliyun-rtc-sdk.mjs": "044487f61fc171791b039aa7e431783e41fd9e8091c7f74faa571e3f3746d24f",
@@ -722,6 +736,24 @@
     "https://esm.d8d.fun/aliyun-rts-sdk@2.12.3/denonext/aliyun-rts-sdk.mjs": "46e0725c93de20c85c89e23d3d7dd3fb957976a348046c013d64820226bd1ca2",
     "https://esm.d8d.fun/asynckit@0.4.0/denonext/asynckit.mjs": "4ef3be6eb52c104699b90ca5524db55ec15bc76b361432f05c16b6106279ba72",
     "https://esm.d8d.fun/asynckit@0.4.0?target=denonext": "c6bd8832d6d16b648e22d124a16d33c3a7f7076e92be9444f2e4f6b27545708d",
+    "https://esm.d8d.fun/axios@1.6.2": "6ed5cb6f7f773d035e3a7d6097a25361d77c2d6f63b9df6d9ba9e2af5a4f4a3e",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/axios.mjs": "cecb6586bc9779aeb64fb22e87824508e49e178fd2ddf2adaa138a5dc6b6945e",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/lib/adapters/http.mjs": "69f9d3b5924fe24d68867eee0de817aecc19ff21e6543c74552fc6cf59a39024",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/lib/adapters/xhr.mjs": "04792efb6d22afea17a7e66c7e97b2adc8aea49484c5ea2947072db70c8e8bb8",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/lib/cancel/CanceledError.mjs": "762e5f2015f3201d1dfd24e7c1a1a545ccf3336fc7d9e63bb27dcdaa61d20bf8",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/lib/core/AxiosError.mjs": "d36240dc9f49522abe5d21dfcfa6caa8e65cdf7c3b9290dcd10daeca9df2dc33",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/lib/core/AxiosHeaders.mjs": "c0687178ffa608c8e04e0711391c96f6a11d607412f063e3fa1f307ae73637e5",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/lib/defaults/transitional.mjs": "4428dbf935255c18443a3804f41f87b70b1a4060239a0caf0fdbf6eb8bb00370",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/lib/helpers/AxiosURLSearchParams.mjs": "bea317342f2cb1a5c3eb006f4cd31d7f1195f2fc62cd1fce1a08148dbfa72f07",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/lib/helpers/bind.mjs": "e1ce8050e9a84e0ca27e7f8cd0bb1e57d45f7ef9822b2926dce2bd9799b44f39",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/lib/helpers/toFormData.mjs": "086b098a98b5032d9fba6d30cf04e751aadae9b1149228a92d0ca2096f688100",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/lib/platform/index.mjs": "f55302665777b17a004f2fc6cd032d28cb7dacce776d26a5ceb7da7321cca3e1",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/unsafe/core/buildFullPath.mjs": "657440a4a7773f270cee91477799291e8408e5de021780e9cc42147bc9aa205e",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/unsafe/core/settle.mjs": "11d7c630bff6c52985d937b932c4df91135d8e5a2046212b8ce66d4a6c7121df",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/unsafe/helpers/buildURL.mjs": "015a6cc012e59edf6904f46f7a39a5b631ead8c19f99dc57993b5f3b97d11dc5",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/unsafe/helpers/combineURLs.mjs": "3a4a1b972a35063b5a9ee7889410190c67b8b8f8eec810cb536d0df5a5b610df",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/unsafe/helpers/isAbsoluteURL.mjs": "98b0cdecb8b376ed6add4182d3f76fe1d25ab5e3f7be11703f7598243199901f",
+    "https://esm.d8d.fun/axios@1.6.2/denonext/unsafe/utils.mjs": "e71bb35332a66d86c09e2fc7097453e9390f23b411262e199fc05eb363111dbd",
     "https://esm.d8d.fun/axios@1.9.0/denonext/axios.mjs": "e02794f43a18ccee752c072f6cd26e7d5fa6cfcd17683dabfcf7b405e32f83da",
     "https://esm.d8d.fun/axios@1.9.0/denonext/lib/adapters/http.mjs": "9ee51c50114cad81fd4ff6f74d154ba1ed2980c4fa82c901b1503fe4c3a49078",
     "https://esm.d8d.fun/axios@1.9.0/denonext/lib/adapters/xhr.mjs": "3174f84b1014a39cf9b4d962ebeb6f60c14d8c480f7c256540d5c982db3c93d3",
@@ -750,9 +782,13 @@
     "https://esm.d8d.fun/clsx@2.1.1?target=denonext&dev": "5c6d3cd35ea0f2a672316ae711c7820bb34d5703a4a46b67ff6a7ccffe27cbb7",
     "https://esm.d8d.fun/combined-stream@1.0.8/denonext/combined-stream.mjs": "364b91aa4c33e5f0b4075949d93a3407b21a8695031e7c2be29999d588f9ca2c",
     "https://esm.d8d.fun/combined-stream@1.0.8?target=denonext": "a0c89b8b29494e966774c7a708e33cc2df16a0bbe2279c841d088e169e7ab3c4",
+    "https://esm.d8d.fun/cookie@1.0.2/denonext/cookie.development.mjs": "84c3be463af62f5bc634a8087eb6fd2d9107d083cb48de2b9a09aa0029955760",
+    "https://esm.d8d.fun/cookie@1.0.2?target=denonext&dev": "fdd54aa58712ae417699e71321e9da41eb9a674c719082be7d5dd363b1277ddc",
     "https://esm.d8d.fun/dayjs@1.11.13": "89c34b8b3f7b970708114b4d264c9430c30eb0c2eab1419410c77ffefa18fe2c",
     "https://esm.d8d.fun/dayjs@1.11.13/denonext/dayjs.mjs": "a6d8258bec464149ab2c9ae26e4bd3736897828586b03f8fea45403080bf8a80",
+    "https://esm.d8d.fun/dayjs@1.11.13/denonext/locale/zh-cn.mjs": "6abdbc636540021cc0a7a01ecd3db2abb114aa9d68479e26e739f5e1fa686389",
     "https://esm.d8d.fun/dayjs@1.11.13/denonext/plugin/utc.mjs": "01c663b7318d6daa10a2377306a878808a535d6dc4056fa5b60a8d31c5d2254f",
+    "https://esm.d8d.fun/dayjs@1.11.13/locale/zh-cn": "eef17bda14d105b6c5919f98fb4be665486340b26733e5b5f94c20157c565fc6",
     "https://esm.d8d.fun/dayjs@1.11.13/plugin/utc": "2e41a0673e6e7c7c962983f1680911ef6feb27ded6007bc7705787ac1b2637b7",
     "https://esm.d8d.fun/debug@4.4.0": "dc29873ca5518385fcbddb2b2fa0f3b31dc6463ba52bdd790818683b9dbdc6ad",
     "https://esm.d8d.fun/debug@4.4.0/denonext/debug.mjs": "3077d1ff15cfc5b7baee65b0c00b3200aef8ab51ddddfa960972957c347c1cee",
@@ -1313,14 +1349,31 @@
     "https://esm.d8d.fun/node-gyp-build@4.8.4?target=denonext": "261a6cedf1fdbf159798141ba1e2311ac1510682c5c8b55dacc8cf5fdee4aa06",
     "https://esm.d8d.fun/proxy-from-env@1.1.0/denonext/proxy-from-env.mjs": "f60f9c79fc3baa07c13c800798d645ae70d1b2059b8d593dcd4f8c5710b50333",
     "https://esm.d8d.fun/proxy-from-env@1.1.0?target=denonext": "bf02a050a1a6aa56ddba25dbea2c355da294630e5c5520fddea4b2f30a9292bc",
+    "https://esm.d8d.fun/react-dom@18.3.1/client?dev": "8876664677756c788ab5cfc7a219349e0f5ea435bacf8605a5f4534fb8a4f7ba",
+    "https://esm.d8d.fun/react-dom@18.3.1/denonext/client.development.mjs": "b57df9bafa01d622410856d0875b7f77a0e4d602c3109e7680d337e7ab6be193",
+    "https://esm.d8d.fun/react-dom@18.3.1/denonext/react-dom.development.mjs": "270f4d399831bf8b33eea27d8061144b3c852225bb1897ccf27364ef3c56afc7",
+    "https://esm.d8d.fun/react-hook-form@7.55.0/X-ZHJlYWN0LWRvbUAxOC4zLjEscmVhY3RAMTguMy4x/denonext/react-hook-form.development.mjs": "71db55b9629b1eb2360291d63a6cffcf6338b27fafc9cbb9f8c0dd9ef585602c",
+    "https://esm.d8d.fun/react-hook-form@7.55.0?dev&deps=react@18.3.1,react-dom@18.3.1": "1692a9c0de49d3d25572819e908c12c7d640369f20382ab13b90bc2953e1754b",
+    "https://esm.d8d.fun/react-router@7.3.0/X-ZHJlYWN0LWRvbUAxOC4zLjEscmVhY3RAMTguMy4x/denonext/dist/development/chunk-K6CSEXPM.development.mjs": "6434593c46a420bc37e2557b9f0e087b74d3f2402296fd1b3104119ac157308a",
+    "https://esm.d8d.fun/react-router@7.3.0/X-ZHJlYWN0LWRvbUAxOC4zLjEscmVhY3RAMTguMy4x/denonext/react-router.development.mjs": "ad4f4a33cc25bc6bcb45a07a0a6a4799bd7d282f366b92f2cb5dd2cd2f98478b",
+    "https://esm.d8d.fun/react-router@7.3.0?dev&deps=react@18.3.1,react-dom@18.3.1": "d234049217756771ad5100741c21b7d0b06da94882e116c765c5aff3c06dafbd",
+    "https://esm.d8d.fun/react-toastify@11.0.5/X-ZHJlYWN0LWRvbUAxOC4zLjEscmVhY3RAMTguMy4x/denonext/react-toastify.development.mjs": "2605cae280cee4472806aa61344a91041670b91e5bc7e2672fbc6d72878f33c2",
     "https://esm.d8d.fun/react-toastify@11.0.5/X-ZHJlYWN0LWRvbUAxOS4wLjAscmVhY3RAMTkuMC4w/denonext/react-toastify.development.mjs": "0d9d46897d3d418a10bde1c15bbccf72fc2b59ea07090ebd1ce0932475148e10",
+    "https://esm.d8d.fun/react-toastify@11.0.5?dev&deps=react@18.3.1,react-dom@18.3.1": "4988fb1d3f04192150249d0dab28c8ce6d487d7f4e78a7b0572543bc171edd65",
     "https://esm.d8d.fun/react-toastify@11.0.5?dev&deps=react@19.0.0,react-dom@19.0.0": "fe51a53b3ea797bf0ebaa494c5f2439de57385d8bdef2530f8f6e37f77835673",
+    "https://esm.d8d.fun/react@18.3.1/denonext/jsx-runtime.development.mjs": "001d1f7ba46799fe26a145ada3a918c4616d992f6a8ca475e2aed96c60068fc9",
+    "https://esm.d8d.fun/react@18.3.1/denonext/react.development.mjs": "760b5286493222d05f9c6cccfaddda529605b6a68a201f87c7854be4e1067bd4",
+    "https://esm.d8d.fun/react@18.3.1?dev": "cb46fe4fc7f2090126ab169a0ed97525df8d73c636c3b66bb1883ecd6c672061",
     "https://esm.d8d.fun/react@19.0.0/denonext/react.development.mjs": "255b9e79d93da89b2393ad8e8acae71a224a499d60c62a6a39b629bf1dfa0ed3",
     "https://esm.d8d.fun/react@19.0.0?dev": "0495d83d2cac20ca28f4a787835182683a2d9fdce5da88b71638ca17de0f46e7",
     "https://esm.d8d.fun/safe-buffer@5.2.1/denonext/safe-buffer.mjs": "51b088d69d0bbf6d7ce4179853887e105715df40e432a3bff0e9575cc2285276",
     "https://esm.d8d.fun/safe-buffer@5.2.1?target=denonext": "34028b9647c849fa96dfd3d9f217a3adca8b43b13409820ac3f43fb15eba3e20",
+    "https://esm.d8d.fun/scheduler@0.23.2/denonext/scheduler.development.mjs": "895c5147056137150e0433166029d9405bf0c731b63892fcdf6edbbe69f894cc",
+    "https://esm.d8d.fun/scheduler@0.23.2?target=denonext&dev": "06b3cd777cf7a562dc514a3e0d5e74e175b03ca9b5ca65f032e883a2d580bbc9",
     "https://esm.d8d.fun/semver@7.7.1/denonext/semver.mjs": "f1d8c45a097d5f2da9662be5ff2087f1e4af9ebeb9b0c2eeeb0c90d74fa7a14c",
     "https://esm.d8d.fun/semver@7.7.1?target=denonext": "7d6e1f9de61981f17d0e5153d48b77475e3433225ce9265ad77206afe216c5c8",
+    "https://esm.d8d.fun/set-cookie-parser@2.7.1/denonext/set-cookie-parser.development.mjs": "1bb61d575be7d50e4653b467cfa729912c85844033f22394e63a1ff9d7f239a0",
+    "https://esm.d8d.fun/set-cookie-parser@2.7.1?target=denonext&dev": "1cae0c42be4e81ba376066181438b219c774b50709cc520eb962ebe3ca9cba4b",
     "https://esm.d8d.fun/socket.io-client@4.8.1/denonext/socket.io-client.mjs": "b902dafad93171849d6d6e9e98bfa5357513089e43b0fbf9268d394f0839f372",
     "https://esm.d8d.fun/socket.io-client@4.8.1?target=denonext": "f5543108c5018ca5904af75985dc9ff7b7210334782408cf87bdf091ce1fbf2e",
     "https://esm.d8d.fun/socket.io-parser@4.2.4/denonext/socket.io-parser.mjs": "a989568a92fa45870a4ae74fb731c5e554ef6c901b97f154d8c84267f7d5aaba",
@@ -1328,6 +1381,7 @@
     "https://esm.d8d.fun/supports-color@10.0.0/denonext/supports-color.mjs": "239cd39d0828e1a018dee102748da869b1b75c38fe6a9c0c8f0bd4ffbd3e1ea1",
     "https://esm.d8d.fun/supports-color@10.0.0?target=denonext": "4895255248e4ba0cbcce9437003dccf3658b1ac1d1e8eba5225fb8194c454ee1",
     "https://esm.d8d.fun/tslib@2.3.0/denonext/tslib.mjs": "9a444699c8083522786730944e1398b1a90540f9bb91e474d770eedd4f440736",
+    "https://esm.d8d.fun/turbo-stream@2.4.0/denonext/turbo-stream.development.mjs": "fd8a6367c8b4e07cbf32d313f512fd054dbebed8c6858357d74b3a8582bda7a4",
     "https://esm.d8d.fun/utf-8-validate@6.0.5/denonext/utf-8-validate.mjs": "90c0c88a13bc4749b497361480d618bf4809153f5d5ba694fac79ae9dbf634a9",
     "https://esm.d8d.fun/utf-8-validate@6.0.5?target=denonext": "071bc33ba1a58297e23a34d69dd589fd06df04b0f373b382ff5da544a623f271",
     "https://esm.d8d.fun/which@4.0.0/denonext/which.mjs": "9f47207c6dc9684fe3d852f2290c474577babaeabf60616652630c0b90421a53",