2
0
Просмотр исходного кода

✨ feat(admin): 新增教室数据、提交记录、股票数据、训练代码和日期备注管理功能

- 添加教室数据管理页面,支持创建、编辑、删除教室数据记录
- 实现提交记录管理功能,包含答题记录和收益统计
- 集成股票数据管理模块,支持股票历史数据查看和编辑
- 开发训练代码管理页面,用于股票训练案例管理
- 实现日期备注管理功能,支持股票交易日的备注添加和管理
- 更新路由配置,新增相关管理页面路由
- 优化移动端答题卡和股票训练页面体验

✨ feat(mobile): 新增移动端股票训练和答题卡功能

- 开发移动端股票训练页面,支持股票图表分析和交易模拟
- 实现答题卡系统,支持创建教室、学生答题和管理员监控
- 集成阿里云实时音视频和互动消息功能
- 添加移动端路由和页面布局适配
- 优化移动端用户体验和交互设计
yourname 7 месяцев назад
Родитель
Сommit
c1b48aa14d
54 измененных файлов с 9656 добавлено и 161 удалено
  1. 40 0
      src/client/admin/menu.tsx
  2. 414 0
      src/client/admin/pages/ClassroomDataPage.tsx
  3. 307 0
      src/client/admin/pages/DateNotesPage.tsx
  4. 350 0
      src/client/admin/pages/StockDataPage.tsx
  5. 357 0
      src/client/admin/pages/StockXunlianCodesPage.tsx
  6. 449 0
      src/client/admin/pages/SubmissionRecordsPage.tsx
  7. 30 0
      src/client/admin/routes.tsx
  8. 27 1
      src/client/api.ts
  9. 3 159
      src/client/home/pages/HomePage.tsx
  10. 3 1
      src/client/index.tsx
  11. 17 0
      src/client/mobile/components/Classroom/AuthLayout.tsx
  12. 200 0
      src/client/mobile/components/Classroom/ClassroomLayout.tsx
  13. 38 0
      src/client/mobile/components/Classroom/ClassroomProvider.tsx
  14. 1515 0
      src/client/mobile/components/Classroom/alivc-im.iife.d.ts
  15. 940 0
      src/client/mobile/components/Classroom/useClassroom.ts
  16. 43 0
      src/client/mobile/components/ErrorPage.tsx
  17. 438 0
      src/client/mobile/components/Exam/ExamAdmin.tsx
  18. 360 0
      src/client/mobile/components/Exam/ExamCard.tsx
  19. 103 0
      src/client/mobile/components/Exam/ExamIndex.tsx
  20. 387 0
      src/client/mobile/components/Exam/hooks/useSocketClient.ts
  21. 66 0
      src/client/mobile/components/Exam/types.ts
  22. 26 0
      src/client/mobile/components/NotFoundPage.tsx
  23. 37 0
      src/client/mobile/components/ProtectedRoute.tsx
  24. 14 0
      src/client/mobile/components/stock/components/stock-chart/mod.ts
  25. 75 0
      src/client/mobile/components/stock/components/stock-chart/src/components/DrawingToolbar.tsx
  26. 26 0
      src/client/mobile/components/stock/components/stock-chart/src/components/MemoToggle.tsx
  27. 65 0
      src/client/mobile/components/stock/components/stock-chart/src/components/ProfitDisplay.tsx
  28. 246 0
      src/client/mobile/components/stock/components/stock-chart/src/components/StockChart.tsx
  29. 33 0
      src/client/mobile/components/stock/components/stock-chart/src/components/TradePanel.tsx
  30. 85 0
      src/client/mobile/components/stock/components/stock-chart/src/hooks/useProfitCalculator.ts
  31. 62 0
      src/client/mobile/components/stock/components/stock-chart/src/hooks/useStockDataFilter.ts
  32. 94 0
      src/client/mobile/components/stock/components/stock-chart/src/hooks/useStockQueries.ts
  33. 63 0
      src/client/mobile/components/stock/components/stock-chart/src/hooks/useTradeRecords.ts
  34. 77 0
      src/client/mobile/components/stock/components/stock-chart/src/lib/DateMemoHandler.ts
  35. 114 0
      src/client/mobile/components/stock/components/stock-chart/src/lib/StockChart.ts
  36. 153 0
      src/client/mobile/components/stock/components/stock-chart/src/lib/config/ChartBaseConfig.ts
  37. 9 0
      src/client/mobile/components/stock/components/stock-chart/src/lib/constants/colors.ts
  38. 67 0
      src/client/mobile/components/stock/components/stock-chart/src/lib/data/DataProcessor.ts
  39. 518 0
      src/client/mobile/components/stock/components/stock-chart/src/lib/drawing/ChartDrawingTools.ts
  40. 222 0
      src/client/mobile/components/stock/components/stock-chart/src/lib/drawing/DrawingTools.ts
  41. 3 0
      src/client/mobile/components/stock/components/stock-chart/src/lib/index.ts
  42. 131 0
      src/client/mobile/components/stock/components/stock-chart/src/lib/markers/MarkerProcessor.ts
  43. 36 0
      src/client/mobile/components/stock/components/stock-chart/src/services/api.ts
  44. 210 0
      src/client/mobile/components/stock/components/stock-chart/src/types/index.ts
  45. 96 0
      src/client/mobile/components/stock/hooks/useStockSocketClient.ts
  46. 250 0
      src/client/mobile/components/stock/stock_main.tsx
  47. 65 0
      src/client/mobile/components/stock/types/exam.ts
  48. 140 0
      src/client/mobile/hooks/AuthProvider.tsx
  49. 41 0
      src/client/mobile/index.tsx
  50. 212 0
      src/client/mobile/pages/ClassroomPage.tsx
  51. 144 0
      src/client/mobile/pages/Login.tsx
  52. 44 0
      src/client/mobile/pages/StockHomePage.tsx
  53. 121 0
      src/client/mobile/pages/XunlianPage.tsx
  54. 90 0
      src/client/mobile/routes.tsx

+ 40 - 0
src/client/admin/menu.tsx

@@ -7,6 +7,11 @@ import {
   DashboardOutlined,
   TeamOutlined,
   InfoCircleOutlined,
+  DatabaseOutlined,
+  FileTextOutlined,
+  LineChartOutlined,
+  CodeOutlined,
+  CalendarOutlined,
 } from '@ant-design/icons';
 
 export interface MenuItem {
@@ -85,6 +90,41 @@ export const useMenu = () => {
       path: '/admin/users',
       permission: 'user:manage'
     },
+    {
+      key: 'classroom-data',
+      label: '教室数据管理',
+      icon: <DatabaseOutlined />,
+      path: '/admin/classroom-data',
+      permission: 'classroom:manage'
+    },
+    {
+      key: 'submission-records',
+      label: '提交记录管理',
+      icon: <FileTextOutlined />,
+      path: '/admin/submission-records',
+      permission: 'submission:manage'
+    },
+    {
+      key: 'stock-data',
+      label: '股票数据管理',
+      icon: <LineChartOutlined />,
+      path: '/admin/stock-data',
+      permission: 'stock:manage'
+    },
+    {
+      key: 'stock-xunlian-codes',
+      label: '训练代码管理',
+      icon: <CodeOutlined />,
+      path: '/admin/stock-xunlian-codes',
+      permission: 'stock:manage'
+    },
+    {
+      key: 'date-notes',
+      label: '日期备注管理',
+      icon: <CalendarOutlined />,
+      path: '/admin/date-notes',
+      permission: 'stock:manage'
+    }
   ];
 
   // 用户菜单项

+ 414 - 0
src/client/admin/pages/ClassroomDataPage.tsx

@@ -0,0 +1,414 @@
+import React, { useState, useEffect } from 'react';
+import { Table, Button, Modal, Form, Input, DatePicker, Space, Typography, message, Select } from 'antd';
+import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, CopyOutlined } from '@ant-design/icons';
+import { classroomDataClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import { App } from 'antd';
+import dayjs from 'dayjs';
+
+const { Option } = Select;
+
+const { Title } = Typography;
+
+// 定义类型
+type ClassroomDataListResponse = InferResponseType<typeof classroomDataClient.$get, 200>;
+type ClassroomDataItem = ClassroomDataListResponse['data'][0];
+type CreateClassroomDataRequest = InferRequestType<typeof classroomDataClient.$post>['json'];
+type UpdateClassroomDataRequest = InferRequestType<typeof classroomDataClient[':id']['$put']>['json'];
+
+export const ClassroomDataPage: React.FC = () => {
+  const [data, setData] = useState<ClassroomDataItem[]>([]);
+  const [loading, setLoading] = useState<boolean>(true);
+  const [pagination, setPagination] = useState({
+    current: 1,
+    pageSize: 10,
+    total: 0,
+  });
+  const [searchText, setSearchText] = useState('');
+  const [isModalVisible, setIsModalVisible] = useState(false);
+  const [isEditing, setIsEditing] = useState(false);
+  const [currentItem, setCurrentItem] = useState<ClassroomDataItem | null>(null);
+  const [form] = Form.useForm();
+  const { message: antMessage } = App.useApp();
+
+  // 获取数据列表
+  const fetchData = async () => {
+    try {
+      setLoading(true);
+      const res = await classroomDataClient.$get({
+        query: {
+          page: pagination.current,
+          pageSize: pagination.pageSize,
+          keyword: searchText,
+        },
+      });
+      
+      if (!res.ok) {
+        throw new Error('获取数据失败');
+      }
+      
+      const result = await res.json() as ClassroomDataListResponse;
+      setData(result.data);
+      setPagination(prev => ({
+        ...prev,
+        total: result.pagination.total,
+      }));
+    } catch (error) {
+      console.error('获取教室数据失败:', error);
+      antMessage.error('获取数据失败,请重试');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 初始加载和分页、搜索变化时重新获取数据
+  useEffect(() => {
+    fetchData();
+  }, [pagination.current, pagination.pageSize]);
+
+  // 搜索功能
+  const handleSearch = () => {
+    setPagination(prev => ({ ...prev, current: 1 }));
+    fetchData();
+  };
+
+  // 显示创建模态框
+  const showCreateModal = () => {
+    setIsEditing(false);
+    setCurrentItem(null);
+    form.resetFields();
+    setIsModalVisible(true);
+  };
+
+  // 显示编辑模态框
+  const showEditModal = (record: ClassroomDataItem) => {
+    setIsEditing(true);
+    setCurrentItem(record);
+    form.setFieldsValue({
+      classroomNo: record.classroomNo || undefined,
+      trainingDate: record.trainingDate ? dayjs(record.trainingDate) : null,
+      holdingStock: record.holdingStock || undefined,
+      holdingCash: record.holdingCash || undefined,
+      price: record.price || undefined,
+      code: record.code || undefined,
+      status: record.status !== null ? record.status : undefined,
+      spare: record.spare || undefined,
+    });
+    setIsModalVisible(true);
+  };
+
+  // 处理表单提交
+  const handleSubmit = async () => {
+    try {
+      const values = await form.validateFields();
+      
+      if (isEditing && currentItem) {
+        // 更新数据
+        const res = await classroomDataClient[':id'].$put({
+          param: { id: currentItem.id },
+          json: values as UpdateClassroomDataRequest,
+        });
+        
+        if (!res.ok) {
+          throw new Error('更新失败');
+        }
+        antMessage.success('更新成功');
+      } else {
+        // 创建新数据
+        const res = await classroomDataClient.$post({
+          json: values as CreateClassroomDataRequest,
+        });
+        
+        if (!res.ok) {
+          throw new Error('创建失败');
+        }
+        antMessage.success('创建成功');
+      }
+      
+      setIsModalVisible(false);
+      fetchData();
+    } catch (error) {
+      console.error('提交表单失败:', error);
+      antMessage.error(isEditing ? '更新失败,请重试' : '创建失败,请重试');
+    }
+  };
+
+  // 删除数据
+  const handleDelete = async (id: number) => {
+    try {
+      const res = await classroomDataClient[':id'].$delete({
+        param: { id },
+      });
+      
+      if (!res.ok) {
+        throw new Error('删除失败');
+      }
+      
+      antMessage.success('删除成功');
+      fetchData();
+    } catch (error) {
+      console.error('删除数据失败:', error);
+      antMessage.error('删除失败,请重试');
+    }
+  };
+
+  // 复制链接到剪贴板
+  const copyLink = (type: 'exam' | 'stock' | 'admin', classroomNo: string, stockCode: string) => {
+    const baseUrl = window.location.origin;
+    let url = '';
+    let successMsg = '';
+
+    switch(type) {
+      case 'exam':
+        url = `${baseUrl}/mobile/exam/card?classroom=${classroomNo}`;
+        successMsg = '答题卡链接已复制';
+        break;
+      case 'stock':
+        url = `${baseUrl}/mobile/stock?classroom=${classroomNo}&code=${stockCode}`;
+        successMsg = '股票训练链接已复制';
+        break;
+      case 'admin':
+        url = `${baseUrl}/mobile/exam/admin?classroom=${classroomNo}`;
+        successMsg = '管理员链接已复制';
+        break;
+    }
+
+    navigator.clipboard.writeText(url).then(() => {
+      antMessage.success(successMsg);
+    }).catch(() => {
+      antMessage.error('复制失败,请手动复制');
+    });
+  };
+
+  // 表格列定义
+  const columns = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+      key: 'id',
+      width: 80,
+    },
+    {
+      title: '教室号',
+      dataIndex: 'classroomNo',
+      key: 'classroomNo',
+    },
+    {
+      title: '训练日期',
+      dataIndex: 'trainingDate',
+      key: 'trainingDate',
+      render: (date: string) => date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-',
+    },
+    {
+      title: '持股',
+      dataIndex: 'holdingStock',
+      key: 'holdingStock',
+    },
+    {
+      title: '持币',
+      dataIndex: 'holdingCash',
+      key: 'holdingCash',
+    },
+    {
+      title: '价格',
+      dataIndex: 'price',
+      key: 'price',
+    },
+    {
+      title: '代码',
+      dataIndex: 'code',
+      key: 'code',
+    },
+    {
+      title: '状态',
+      dataIndex: 'status',
+      key: 'status',
+      render: (status: number) => {
+        const statusMap = {
+          0: '关闭',
+          1: '开放'
+        };
+        return statusMap[status as keyof typeof statusMap] || '-';
+      },
+    },
+    {
+      title: '链接',
+      key: 'links',
+      render: (_: any, record: ClassroomDataItem) => (
+        <Space direction="vertical" size={4}>
+          <Button
+            type="link"
+            size="small"
+            icon={<CopyOutlined />}
+            onClick={() => copyLink('stock', record.classroomNo || '', record.code || '')}
+          >
+            复制股票训练链接
+          </Button>
+          <Button
+            type="link"
+            size="small"
+            icon={<CopyOutlined />}
+            onClick={() => copyLink('exam', record.classroomNo || '', record.code || '')}
+          >
+            复制答题卡链接
+          </Button>
+          <Button
+            type="link"
+            size="small"
+            icon={<CopyOutlined />}
+            onClick={() => copyLink('admin', record.classroomNo || '', record.code || '')}
+          >
+            复制管理员链接
+          </Button>
+        </Space>
+      ),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      render: (_: any, record: ClassroomDataItem) => (
+        <Space size="small">
+          <Button
+            type="text"
+            icon={<EditOutlined />}
+            onClick={() => showEditModal(record)}
+          >
+            编辑
+          </Button>
+          <Button
+            type="text"
+            danger
+            icon={<DeleteOutlined />}
+            onClick={() => handleDelete(record.id)}
+          >
+            删除
+          </Button>
+        </Space>
+      ),
+    },
+  ];
+
+  return (
+    <div className="page-container">
+      <div className="page-header" style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+        <Title level={2}>教室数据管理</Title>
+        <Button type="primary" icon={<PlusOutlined />} onClick={showCreateModal}>
+          添加数据
+        </Button>
+      </div>
+      
+      <div className="search-container" style={{ marginBottom: 16 }}>
+        <Input
+          placeholder="搜索教室号、代码或提交用户"
+          value={searchText}
+          onChange={(e) => setSearchText(e.target.value)}
+          onPressEnter={handleSearch}
+          style={{ width: 300 }}
+          suffix={<SearchOutlined onClick={handleSearch} />}
+        />
+      </div>
+      
+      <Table
+        columns={columns}
+        dataSource={data.map(item => ({ ...item, key: item.id }))}
+        loading={loading}
+        pagination={{
+          current: pagination.current,
+          pageSize: pagination.pageSize,
+          total: pagination.total,
+          showSizeChanger: true,
+          showTotal: (total) => `共 ${total} 条记录`,
+        }}
+        onChange={(p) => setPagination({ ...pagination, current: p.current || 1, pageSize: p.pageSize || 10 })}
+        bordered
+        scroll={{ x: 'max-content' }}
+        className="ant-table-striped"
+        rowClassName={(record, index) => index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
+        rowKey="id"
+      />
+      
+      <Modal
+        title={isEditing ? "编辑教室数据" : "添加教室数据"}
+        open={isModalVisible}
+        onOk={handleSubmit}
+        onCancel={() => setIsModalVisible(false)}
+        destroyOnClose
+        maskClosable={false}
+      >
+        <Form
+          form={form}
+          layout="vertical"
+          name="classroom_data_form"
+        >
+          <Form.Item
+            name="classroomNo"
+            label="教室号"
+            rules={[{ max: 255, message: '教室号不能超过255个字符' }]}
+          >
+            <Input placeholder="请输入教室号" />
+          </Form.Item>
+          
+          <Form.Item
+            name="trainingDate"
+            label="训练日期"
+          >
+            <DatePicker showTime placeholder="请选择训练日期" style={{ width: '100%' }} />
+          </Form.Item>
+          
+          <Form.Item
+            name="holdingStock"
+            label="持股"
+            rules={[{ max: 255, message: '持股信息不能超过255个字符' }]}
+          >
+            <Input placeholder="请输入持股信息" />
+          </Form.Item>
+          
+          <Form.Item
+            name="holdingCash"
+            label="持币"
+            rules={[{ max: 255, message: '持币信息不能超过255个字符' }]}
+          >
+            <Input placeholder="请输入持币信息" />
+          </Form.Item>
+          
+          <Form.Item
+            name="price"
+            label="价格"
+            rules={[{ max: 255, message: '价格不能超过255个字符' }]}
+          >
+            <Input placeholder="请输入价格" />
+          </Form.Item>
+          
+          <Form.Item
+            name="code"
+            label="代码"
+            rules={[{ max: 255, message: '代码不能超过255个字符' }]}
+          >
+            <Input placeholder="请输入代码" />
+          </Form.Item>
+          
+          <Form.Item
+            name="status"
+            label="状态"
+            rules={[{ required: true, message: '请选择状态' }]}
+          >
+            <Select placeholder="请选择状态">
+              <Option value={0}>关闭</Option>
+              <Option value={1}>开放</Option>
+            </Select>
+          </Form.Item>
+          
+          <Form.Item
+            name="spare"
+            label="备用"
+            rules={[{ max: 255, message: '备用信息不能超过255个字符' }]}
+          >
+            <Input placeholder="请输入备用信息" />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+};
+
+export default ClassroomDataPage;

+ 307 - 0
src/client/admin/pages/DateNotesPage.tsx

@@ -0,0 +1,307 @@
+import dayjs from 'dayjs';
+import React, { useState, useEffect } from 'react';
+import { Table, Button, Modal, Form, Input, DatePicker, Space, Typography, message, Tag } from 'antd';
+import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons';
+import { dateNotesClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import { App } from 'antd';
+
+const { Title } = Typography;
+
+// 定义类型
+type DateNotesListResponse = InferResponseType<typeof dateNotesClient.$get, 200>;
+type DateNotesItem = DateNotesListResponse['data'][0];
+type CreateDateNotesRequest = InferRequestType<typeof dateNotesClient.$post>['json'];
+type UpdateDateNotesRequest = InferRequestType<typeof dateNotesClient[':id']['$put']>['json'];
+
+export const DateNotesPage: React.FC = () => {
+  const [data, setData] = useState<DateNotesItem[]>([]);
+  const [loading, setLoading] = useState<boolean>(true);
+  const [pagination, setPagination] = useState({
+    current: 1,
+    pageSize: 10,
+    total: 0,
+  });
+  const [searchText, setSearchText] = useState('');
+  const [isModalVisible, setIsModalVisible] = useState(false);
+  const [isEditing, setIsEditing] = useState(false);
+  const [currentItem, setCurrentItem] = useState<DateNotesItem | null>(null);
+  const [form] = Form.useForm();
+  const { message: antMessage } = App.useApp();
+
+  // 获取数据列表
+  const fetchData = async () => {
+    try {
+      setLoading(true);
+      const res = await dateNotesClient.$get({
+        query: {
+          page: pagination.current,
+          pageSize: pagination.pageSize,
+          keyword: searchText,
+        },
+      });
+      
+      if (!res.ok) {
+        throw new Error('获取数据失败');
+      }
+      
+      const result = await res.json() as DateNotesListResponse;
+      setData(result.data);
+      setPagination(prev => ({
+        ...prev,
+        total: result.pagination.total,
+      }));
+    } catch (error) {
+      console.error('获取日期备注数据失败:', error);
+      antMessage.error('获取数据失败,请重试');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 初始加载和分页、搜索变化时重新获取数据
+  useEffect(() => {
+    fetchData();
+  }, [pagination.current, pagination.pageSize]);
+
+  // 搜索功能
+  const handleSearch = () => {
+    setPagination(prev => ({ ...prev, current: 1 }));
+    fetchData();
+  };
+
+  // 显示创建模态框
+  const showCreateModal = () => {
+    setIsEditing(false);
+    setCurrentItem(null);
+    form.resetFields();
+    setIsModalVisible(true);
+  };
+
+  // 显示编辑模态框
+  const showEditModal = (record: DateNotesItem) => {
+    setIsEditing(true);
+    setCurrentItem(record);
+    form.setFieldsValue({
+      code: record.code,
+      noteDate: record.noteDate ? dayjs(record.noteDate) : null,
+      note: record.note,
+    });
+    setIsModalVisible(true);
+  };
+
+  // 处理表单提交
+  const handleSubmit = async () => {
+    try {
+      const values = await form.validateFields();
+      
+      if (isEditing && currentItem) {
+        // 更新数据
+        const res = await dateNotesClient[':id'].$put({
+          param: { id: currentItem.id },
+          json: values as UpdateDateNotesRequest,
+        });
+        
+        if (!res.ok) {
+          throw new Error('更新失败');
+        }
+        antMessage.success('更新成功');
+      } else {
+        // 创建新数据
+        const res = await dateNotesClient.$post({
+          json: values as CreateDateNotesRequest,
+        });
+        
+        if (!res.ok) {
+          throw new Error('创建失败');
+        }
+        antMessage.success('创建成功');
+      }
+      
+      setIsModalVisible(false);
+      fetchData();
+    } catch (error) {
+      console.error('提交表单失败:', error);
+      antMessage.error(isEditing ? '更新失败,请重试' : '创建失败,请重试');
+    }
+  };
+
+  // 删除数据
+  const handleDelete = async (id: number) => {
+    try {
+      const res = await dateNotesClient[':id'].$delete({
+        param: { id },
+      });
+      
+      if (!res.ok) {
+        throw new Error('删除失败');
+      }
+      
+      antMessage.success('删除成功');
+      fetchData();
+    } catch (error) {
+      console.error('删除数据失败:', error);
+      antMessage.error('删除失败,请重试');
+    }
+  };
+
+  // 表格列定义
+  const columns = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+      key: 'id',
+      width: 80,
+    },
+    {
+      title: '股票代码',
+      dataIndex: 'code',
+      key: 'code',
+      filters: [
+        ...Array.from(new Set(data.map(item => item.code))).map(code => ({
+          text: code,
+          value: code,
+        }))
+      ],
+      onFilter: (value: string, record: DateNotesItem) => record.code === value,
+    },
+    {
+      title: '备注日期',
+      dataIndex: 'noteDate',
+      key: 'noteDate',
+      render: (date: string) => date ? new Date(date).toLocaleString() : '-',
+    },
+    {
+      title: '备注内容',
+      dataIndex: 'note',
+      key: 'note',
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createdAt',
+      key: 'createdAt',
+      render: (date: string) => new Date(date).toLocaleString(),
+    },
+    {
+      title: '状态',
+      key: 'status',
+      render: (_, record: DateNotesItem) => (
+        <Tag color={new Date(record.updatedAt) > new Date(record.createdAt) ? 'blue' : 'green'}>
+          {new Date(record.updatedAt) > new Date(record.createdAt) ? '已更新' : '原始数据'}
+        </Tag>
+      ),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      render: (_: any, record: DateNotesItem) => (
+        <Space size="small">
+          <Button 
+            type="text" 
+            icon={<EditOutlined />} 
+            onClick={() => showEditModal(record)}
+          >
+            编辑
+          </Button>
+          <Button 
+            type="text" 
+            danger 
+            icon={<DeleteOutlined />} 
+            onClick={() => handleDelete(record.id)}
+          >
+            删除
+          </Button>
+        </Space>
+      ),
+    },
+  ];
+
+  return (
+    <div className="page-container">
+      <div className="page-header" style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+        <Title level={2}>日期备注管理</Title>
+        <Button type="primary" icon={<PlusOutlined />} onClick={showCreateModal}>
+          添加备注
+        </Button>
+      </div>
+      
+      <div className="search-container" style={{ marginBottom: 16 }}>
+        <Input
+          placeholder="搜索股票代码或备注内容"
+          value={searchText}
+          onChange={(e) => setSearchText(e.target.value)}
+          onPressEnter={handleSearch}
+          style={{ width: 300 }}
+          suffix={<SearchOutlined onClick={handleSearch} />}
+        />
+      </div>
+      
+      <Table
+        columns={columns}
+        dataSource={data.map(item => ({ ...item, key: item.id }))}
+        loading={loading}
+        pagination={{
+          current: pagination.current,
+          pageSize: pagination.pageSize,
+          total: pagination.total,
+          showSizeChanger: true,
+          showTotal: (total) => `共 ${total} 条记录`,
+        }}
+        onChange={(p) => setPagination({ ...pagination, current: p.current || 1, pageSize: p.pageSize || 10 })}
+        rowKey="id"
+        bordered
+        scroll={{ x: 'max-content' }}
+        headerCellStyle={{ backgroundColor: '#f9fafb' }}
+        rowClassName={(record, index) => index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
+      />
+      
+      <Modal
+        title={isEditing ? "编辑日期备注" : "添加日期备注"}
+        open={isModalVisible}
+        onOk={handleSubmit}
+        onCancel={() => setIsModalVisible(false)}
+        destroyOnClose
+        maskClosable={false}
+        width={600}
+      >
+        <Form
+          form={form}
+          layout="vertical"
+          name="date_notes_form"
+        >
+          <Form.Item
+            name="code"
+            label="股票代码"
+            rules={[
+              { required: true, message: '请输入股票代码' },
+              { max: 255, message: '股票代码不能超过255个字符' }
+            ]}
+          >
+            <Input placeholder="请输入股票代码" />
+          </Form.Item>
+          
+          <Form.Item
+            name="noteDate"
+            label="备注日期"
+            rules={[{ required: true, message: '请选择备注日期' }]}
+          >
+            <DatePicker showTime placeholder="请选择备注日期" style={{ width: '100%' }} />
+          </Form.Item>
+          
+          <Form.Item
+            name="note"
+            label="备注内容"
+            rules={[
+              { required: true, message: '请输入备注内容' },
+              { max: 255, message: '备注内容不能超过255个字符' }
+            ]}
+          >
+            <Input.TextArea rows={4} placeholder="请输入备注内容" />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+};
+
+export default DateNotesPage;

+ 350 - 0
src/client/admin/pages/StockDataPage.tsx

@@ -0,0 +1,350 @@
+import React, { useState, useEffect } from 'react';
+import { Table, Button, Modal, Form, Input, Space, Typography, message, Card, Tag } from 'antd';
+import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, EyeOutlined } from '@ant-design/icons';
+import { stockDataClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import { App } from 'antd';
+// import ReactJson from 'react-json-view';
+
+const { Title } = Typography;
+
+// 定义类型
+type StockDataListResponse = InferResponseType<typeof stockDataClient.$get, 200>;
+type StockDataItem = StockDataListResponse['data'][0];
+type CreateStockDataRequest = InferRequestType<typeof stockDataClient.$post>['json'];
+type UpdateStockDataRequest = InferRequestType<typeof stockDataClient[':id']['$put']>['json'];
+
+export const StockDataPage: React.FC = () => {
+  const [data, setData] = useState<StockDataItem[]>([]);
+  const [loading, setLoading] = useState<boolean>(true);
+  const [pagination, setPagination] = useState({
+    current: 1,
+    pageSize: 10,
+    total: 0,
+  });
+  const [searchText, setSearchText] = useState('');
+  const [isModalVisible, setIsModalVisible] = useState(false);
+  const [isViewModalVisible, setIsViewModalVisible] = useState(false);
+  const [isEditing, setIsEditing] = useState(false);
+  const [currentItem, setCurrentItem] = useState<StockDataItem | null>(null);
+  const [form] = Form.useForm();
+  const { message: antMessage } = App.useApp();
+
+  // 获取数据列表
+  const fetchData = async () => {
+    try {
+      setLoading(true);
+      const res = await stockDataClient.$get({
+        query: {
+          page: pagination.current,
+          pageSize: pagination.pageSize,
+          keyword: searchText,
+        },
+      });
+      
+      if (!res.ok) {
+        throw new Error('获取数据失败');
+      }
+      
+      const result = await res.json() as StockDataListResponse;
+      setData(result.data);
+      setPagination(prev => ({
+        ...prev,
+        total: result.pagination.total,
+      }));
+    } catch (error) {
+      console.error('获取股票数据失败:', error);
+      antMessage.error('获取数据失败,请重试');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 初始加载和分页、搜索变化时重新获取数据
+  useEffect(() => {
+    fetchData();
+  }, [pagination.current, pagination.pageSize]);
+
+  // 搜索功能
+  const handleSearch = () => {
+    setPagination(prev => ({ ...prev, current: 1 }));
+    fetchData();
+  };
+
+  // 显示创建模态框
+  const showCreateModal = () => {
+    setIsEditing(false);
+    setCurrentItem(null);
+    form.resetFields();
+    setIsModalVisible(true);
+  };
+
+  // 显示编辑模态框
+  const showEditModal = (record: StockDataItem) => {
+    setIsEditing(true);
+    setCurrentItem(record);
+    form.setFieldsValue({
+      code: record.code,
+      data: JSON.stringify(record.data, null, 2 ),
+    });
+    setIsModalVisible(true);
+  };
+
+  // 显示查看模态框
+  const showViewModal = (record: StockDataItem) => {
+    setCurrentItem(record);
+    setIsViewModalVisible(true);
+  };
+
+  // 处理表单提交
+  const handleSubmit = async () => {
+    try {
+      const values = await form.validateFields();
+      
+      if (isEditing && currentItem) {
+        // 更新数据
+        const res = await stockDataClient[':id'].$put({
+          param: { id: currentItem.id },
+          json: values as UpdateStockDataRequest,
+        });
+        
+        if (!res.ok) {
+          throw new Error('更新失败');
+        }
+        antMessage.success('更新成功');
+      } else {
+        // 创建新数据
+        const res = await stockDataClient.$post({
+          json: values as CreateStockDataRequest,
+        });
+        
+        if (!res.ok) {
+          throw new Error('创建失败');
+        }
+        antMessage.success('创建成功');
+      }
+      
+      setIsModalVisible(false);
+      fetchData();
+    } catch (error) {
+      console.error('提交表单失败:', error);
+      antMessage.error(isEditing ? '更新失败,请重试' : '创建失败,请重试');
+    }
+  };
+
+  // 删除数据
+  const handleDelete = async (id: number) => {
+    try {
+      const res = await stockDataClient[':id'].$delete({
+        param: { id },
+      });
+      
+      if (!res.ok) {
+        throw new Error('删除失败');
+      }
+      
+      antMessage.success('删除成功');
+      fetchData();
+    } catch (error) {
+      console.error('删除数据失败:', error);
+      antMessage.error('删除失败,请重试');
+    }
+  };
+
+  // 表格列定义
+  const columns = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+      key: 'id',
+      width: 80,
+    },
+    {
+      title: '股票代码',
+      dataIndex: 'code',
+      key: 'code',
+      filters: [
+        ...Array.from(new Set(data.map(item => item.code))).map(code => ({
+          text: code,
+          value: code,
+        }))
+      ],
+      onFilter: (value: string, record: StockDataItem) => record.code === value,
+    },
+    {
+      title: '数据摘要',
+      key: 'dataSummary',
+      render: (_: any, record: StockDataItem) => (
+        <div>
+          {record.data.date && <div>日期: {record.data.date}</div>}
+          {record.data.close && <div>收盘价: {record.data.close}</div>}
+          {record.data.volume && <div>成交量: {record.data.volume.toLocaleString()}</div>}
+        </div>
+      ),
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createdAt',
+      key: 'createdAt',
+      render: (date: string) => new Date(date).toLocaleString(),
+    },
+    {
+      title: '状态',
+      key: 'status',
+      render: (_: any, record: StockDataItem) => (
+        <Tag color={new Date(record.updatedAt) > new Date(record.createdAt) ? 'blue' : 'green'}>
+          {new Date(record.updatedAt) > new Date(record.createdAt) ? '已更新' : '原始数据'}
+        </Tag>
+      ),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      render: (_: any, record: StockDataItem) => (
+        <Space size="small">
+          <Button 
+            type="text" 
+            icon={<EyeOutlined />} 
+            onClick={() => showViewModal(record)}
+          >
+            查看
+          </Button>
+          <Button 
+            type="text" 
+            icon={<EditOutlined />} 
+            onClick={() => showEditModal(record)}
+          >
+            编辑
+          </Button>
+          <Button 
+            type="text" 
+            danger 
+            icon={<DeleteOutlined />} 
+            onClick={() => handleDelete(record.id)}
+          >
+            删除
+          </Button>
+        </Space>
+      ),
+    },
+  ];
+
+  return (
+    <div className="page-container">
+      <div className="page-header" style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+        <Title level={2}>股票数据管理</Title>
+        <Button type="primary" icon={<PlusOutlined />} onClick={showCreateModal}>
+          添加股票数据
+        </Button>
+      </div>
+      
+      <div className="search-container" style={{ marginBottom: 16 }}>
+        <Input
+          placeholder="搜索股票代码"
+          value={searchText}
+          onChange={(e) => setSearchText(e.target.value)}
+          onPressEnter={handleSearch}
+          style={{ width: 300 }}
+          suffix={<SearchOutlined onClick={handleSearch} />}
+        />
+      </div>
+      
+      <Table
+        columns={columns}
+        dataSource={data.map(item => ({ ...item, key: item.id }))}
+        loading={loading}
+        pagination={{
+          current: pagination.current,
+          pageSize: pagination.pageSize,
+          total: pagination.total,
+          showSizeChanger: true,
+          showTotal: (total) => `共 ${total} 条记录`,
+        }}
+        onChange={(p) => setPagination({ ...pagination, current: p.current || 1, pageSize: p.pageSize || 10 })}
+        rowKey="id"
+        bordered
+        scroll={{ x: 'max-content' }}
+        headerCellStyle={{ backgroundColor: '#f9fafb' }}
+        rowClassName={(record, index) => index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
+      />
+      
+      {/* 添加/编辑模态框 */}
+      <Modal
+        title={isEditing ? "编辑股票数据" : "添加股票数据"}
+        open={isModalVisible}
+        onOk={handleSubmit}
+        onCancel={() => setIsModalVisible(false)}
+        destroyOnClose
+        maskClosable={false}
+        width={700}
+      >
+        <Form
+          form={form}
+          layout="vertical"
+          name="stock_data_form"
+        >
+          <Form.Item
+            name="code"
+            label="股票代码"
+            rules={[
+              { required: true, message: '请输入股票代码' },
+              { max: 255, message: '股票代码不能超过255个字符' }
+            ]}
+          >
+            <Input placeholder="请输入股票代码" />
+          </Form.Item>
+          
+          <Form.Item
+            name="data"
+            label="股票数据 (JSON格式)"
+            rules={[
+              { required: true, message: '请输入股票数据' }
+            ]}
+            getValueFromEvent={(e) => {
+              try {
+                return JSON.stringify(JSON.parse(e.target.value || '{}'), null, 2);
+              } catch (err) {
+                return e.target.value;
+              }
+            }}
+          >
+            <Input.TextArea
+              placeholder='请输入JSON格式的股票数据,例如: {"date": "2025-05-21", "open": 15.68, "close": 16.25, "high": 16.50, "low": 15.50, "volume": 1250000}'
+              rows={8}
+            />
+          </Form.Item>
+        </Form>
+      </Modal>
+      
+      {/* 查看模态框 */}
+      <Modal
+        title={`股票数据详情 - ${currentItem?.code}`}
+        open={isViewModalVisible}
+        onCancel={() => setIsViewModalVisible(false)}
+        destroyOnClose
+        maskClosable={false}
+        width={800}
+        footer={null}
+      >
+        {currentItem && (
+          <div>
+            <Card title="基本信息" style={{ marginBottom: 16 }}>
+              <p><strong>ID:</strong> {currentItem.id}</p>
+              <p><strong>股票代码:</strong> {currentItem.code}</p>
+              <p><strong>创建时间:</strong> {new Date(currentItem.createdAt).toLocaleString()}</p>
+              <p><strong>更新时间:</strong> {new Date(currentItem.updatedAt).toLocaleString()}</p>
+            </Card>
+            
+            <Card title="股票数据">
+              <pre style={{ whiteSpace: 'pre-wrap', wordWrap: 'break-word', maxHeight: '400px', overflow: 'auto', padding: '10px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
+                {JSON.stringify(currentItem.data, null, 2)}
+              </pre>
+            </Card>
+          </div>
+        )}
+      </Modal>
+    </div>
+  );
+};
+
+export default StockDataPage;

+ 357 - 0
src/client/admin/pages/StockXunlianCodesPage.tsx

@@ -0,0 +1,357 @@
+import React, { useState, useEffect } from 'react';
+import { Table, Button, Modal, Form, Input, DatePicker, Space, Typography, message, Tag } from 'antd';
+import dayjs from 'dayjs';
+import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons';
+import { stockXunlianCodesClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import { App } from 'antd';
+
+const { Title } = Typography;
+
+// 定义类型
+type StockXunlianCodesListResponse = InferResponseType<typeof stockXunlianCodesClient.$get, 200>;
+type StockXunlianCodesItem = StockXunlianCodesListResponse['data'][0];
+type CreateStockXunlianCodesRequest = InferRequestType<typeof stockXunlianCodesClient.$post>['json'];
+type UpdateStockXunlianCodesRequest = InferRequestType<typeof stockXunlianCodesClient[':id']['$put']>['json'];
+
+export const StockXunlianCodesPage: React.FC = () => {
+  const [data, setData] = useState<StockXunlianCodesItem[]>([]);
+  const [loading, setLoading] = useState<boolean>(true);
+  const [pagination, setPagination] = useState({
+    current: 1,
+    pageSize: 10,
+    total: 0,
+  });
+  const [searchText, setSearchText] = useState('');
+  const [isModalVisible, setIsModalVisible] = useState(false);
+  const [isEditing, setIsEditing] = useState(false);
+  const [currentItem, setCurrentItem] = useState<StockXunlianCodesItem | null>(null);
+  const [form] = Form.useForm();
+  const { message: antMessage } = App.useApp();
+
+  // 获取数据列表
+  const fetchData = async () => {
+    try {
+      setLoading(true);
+      const res = await stockXunlianCodesClient.$get({
+        query: {
+          page: pagination.current,
+          pageSize: pagination.pageSize,
+          keyword: searchText,
+        },
+      });
+      
+      if (!res.ok) {
+        throw new Error('获取数据失败');
+      }
+      
+      const result = await res.json() as StockXunlianCodesListResponse;
+      setData(result.data);
+      setPagination(prev => ({
+        ...prev,
+        total: result.pagination.total,
+      }));
+    } catch (error) {
+      console.error('获取训练代码数据失败:', error);
+      antMessage.error('获取数据失败,请重试');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 初始加载和分页、搜索变化时重新获取数据
+  useEffect(() => {
+    fetchData();
+  }, [pagination.current, pagination.pageSize]);
+
+  // 搜索功能
+  const handleSearch = () => {
+    setPagination(prev => ({ ...prev, current: 1 }));
+    fetchData();
+  };
+
+  // 显示创建模态框
+  const showCreateModal = () => {
+    setIsEditing(false);
+    setCurrentItem(null);
+    form.resetFields();
+    setIsModalVisible(true);
+  };
+
+  // 显示编辑模态框
+  const showEditModal = (record: StockXunlianCodesItem) => {
+    setIsEditing(true);
+    setCurrentItem(record);
+    form.setFieldsValue({
+      code: record.code,
+      stockName: record.stockName,
+      name: record.name,
+      type: record.type || undefined,
+      description: record.description || undefined,
+      tradeDate: record.tradeDate ? dayjs(record.tradeDate) : null,
+    });
+    setIsModalVisible(true);
+  };
+
+  // 处理表单提交
+  const handleSubmit = async () => {
+    try {
+      const values = await form.validateFields();
+      
+      if (isEditing && currentItem) {
+        // 更新数据
+        const res = await stockXunlianCodesClient[':id'].$put({
+          param: { id: currentItem.id },
+          json: values as UpdateStockXunlianCodesRequest,
+        });
+        
+        if (!res.ok) {
+          throw new Error('更新失败');
+        }
+        antMessage.success('更新成功');
+      } else {
+        // 创建新数据
+        const res = await stockXunlianCodesClient.$post({
+          json: values as CreateStockXunlianCodesRequest,
+        });
+        
+        if (!res.ok) {
+          throw new Error('创建失败');
+        }
+        antMessage.success('创建成功');
+      }
+      
+      setIsModalVisible(false);
+      fetchData();
+    } catch (error) {
+      console.error('提交表单失败:', error);
+      antMessage.error(isEditing ? '更新失败,请重试' : '创建失败,请重试');
+    }
+  };
+
+  // 删除数据
+  const handleDelete = async (id: number) => {
+    try {
+      const res = await stockXunlianCodesClient[':id'].$delete({
+        param: { id },
+      });
+      
+      if (!res.ok) {
+        throw new Error('删除失败');
+      }
+      
+      antMessage.success('删除成功');
+      fetchData();
+    } catch (error) {
+      console.error('删除数据失败:', error);
+      antMessage.error('删除失败,请重试');
+    }
+  };
+
+  // 表格列定义
+  const columns = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+      key: 'id',
+      width: 80,
+    },
+    {
+      title: '股票代码',
+      dataIndex: 'code',
+      key: 'code',
+      filters: [
+        ...Array.from(new Set(data.map(item => item.code))).map(code => ({
+          text: code,
+          value: code,
+        }))
+      ],
+      onFilter: (value: string, record: StockXunlianCodesItem) => record.code === value,
+    },
+    {
+      title: '股票名称',
+      dataIndex: 'stockName',
+      key: 'stockName',
+    },
+    {
+      title: '案例名称',
+      dataIndex: 'name',
+      key: 'name',
+    },
+    {
+      title: '案例类型',
+      dataIndex: 'type',
+      key: 'type',
+      render: (type: string | null) => type ? (
+        <Tag color="blue">{type}</Tag>
+      ) : (
+        <Tag color="default">未分类</Tag>
+      ),
+      filters: [
+        { text: '技术分析', value: '技术分析' },
+        { text: '基本面分析', value: '基本面分析' },
+        { text: '未分类', value: '未分类' },
+      ],
+      onFilter: (value: string, record: StockXunlianCodesItem) => {
+        if (value === '未分类') {
+          return !record.type;
+        }
+        return record.type === value;
+      },
+    },
+    {
+      title: '交易日期',
+      dataIndex: 'tradeDate',
+      key: 'tradeDate',
+      render: (date: string) => date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-',
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createdAt',
+      key: 'createdAt',
+      render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      render: (_: any, record: StockXunlianCodesItem) => (
+        <Space size="small">
+          <Button 
+            type="text" 
+            icon={<EditOutlined />} 
+            onClick={() => showEditModal(record)}
+          >
+            编辑
+          </Button>
+          <Button 
+            type="text" 
+            danger 
+            icon={<DeleteOutlined />} 
+            onClick={() => handleDelete(record.id)}
+          >
+            删除
+          </Button>
+        </Space>
+      ),
+    },
+  ];
+
+  return (
+    <div className="page-container">
+      <div className="page-header" style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+        <Title level={2}>训练代码管理</Title>
+        <Button type="primary" icon={<PlusOutlined />} onClick={showCreateModal}>
+          添加训练代码
+        </Button>
+      </div>
+      
+      <div className="search-container" style={{ marginBottom: 16 }}>
+        <Input
+          placeholder="搜索股票代码或案例名称"
+          value={searchText}
+          onChange={(e) => setSearchText(e.target.value)}
+          onPressEnter={handleSearch}
+          style={{ width: 300 }}
+          suffix={<SearchOutlined onClick={handleSearch} />}
+        />
+      </div>
+      
+      <Table
+        columns={columns}
+        dataSource={data.map(item => ({ ...item, key: item.id }))}
+        loading={loading}
+        pagination={{
+          current: pagination.current,
+          pageSize: pagination.pageSize,
+          total: pagination.total,
+          showSizeChanger: true,
+          showTotal: (total) => `共 ${total} 条记录`,
+        }}
+        onChange={(p) => setPagination({ ...pagination, current: p.current || 1, pageSize: p.pageSize || 10 })}
+        rowKey="id"
+        bordered
+        scroll={{ x: 'max-content' }}
+        headerCellStyle={{ backgroundColor: '#f9fafb' }}
+        rowClassName={(record, index) => index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
+      />
+      
+      <Modal
+        title={isEditing ? "编辑训练代码" : "添加训练代码"}
+        open={isModalVisible}
+        onOk={handleSubmit}
+        onCancel={() => setIsModalVisible(false)}
+        destroyOnClose
+        maskClosable={false}
+        width={700}
+      >
+        <Form
+          form={form}
+          layout="vertical"
+          name="stock_xunlian_codes_form"
+        >
+          <Form.Item
+            name="code"
+            label="股票代码"
+            rules={[
+              { required: true, message: '请输入股票代码' },
+              { max: 255, message: '股票代码不能超过255个字符' }
+            ]}
+          >
+            <Input placeholder="请输入股票代码" />
+          </Form.Item>
+          
+          <Form.Item
+            name="stockName"
+            label="股票名称"
+            rules={[
+              { required: true, message: '请输入股票名称' },
+              { max: 255, message: '股票名称不能超过255个字符' }
+            ]}
+          >
+            <Input placeholder="请输入股票名称" />
+          </Form.Item>
+          
+          <Form.Item
+            name="name"
+            label="案例名称"
+            rules={[
+              { required: true, message: '请输入案例名称' },
+              { max: 255, message: '案例名称不能超过255个字符' }
+            ]}
+          >
+            <Input placeholder="请输入案例名称" />
+          </Form.Item>
+          
+          <Form.Item
+            name="type"
+            label="案例类型"
+            rules={[
+              { required: true, message: '请输入案例类型' },
+              { max: 255, message: '案例类型不能超过255个字符' }
+            ]}
+          >
+            <Input placeholder="请输入案例类型(如:技术分析、基本面分析)" />
+          </Form.Item>
+          
+          <Form.Item
+            name="description"
+            label="案例描述"
+            rules={[{ max: 255, message: '案例描述不能超过255个字符' }]}
+          >
+            <Input.TextArea rows={4} placeholder="请输入案例描述" />
+          </Form.Item>
+          
+          <Form.Item
+            name="tradeDate"
+            label="交易日期"
+            rules={[{ required: true, message: '请选择交易日期' }]}
+          >
+            <DatePicker showTime placeholder="请选择交易日期" style={{ width: '100%' }} />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+};
+
+export default StockXunlianCodesPage;

+ 449 - 0
src/client/admin/pages/SubmissionRecordsPage.tsx

@@ -0,0 +1,449 @@
+import React, { useState, useEffect } from 'react';
+import { Table, Button, Modal, Form, Input, DatePicker, InputNumber, Space, Typography, message } from 'antd';
+import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons';
+import { submissionRecordsClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import { App } from 'antd';
+
+const { Title } = Typography;
+
+// 定义类型
+type SubmissionRecordsListResponse = InferResponseType<typeof submissionRecordsClient.$get, 200>;
+type SubmissionRecordsItem = SubmissionRecordsListResponse['data'][0];
+type CreateSubmissionRecordsRequest = InferRequestType<typeof submissionRecordsClient.$post>['json'];
+type UpdateSubmissionRecordsRequest = InferRequestType<typeof submissionRecordsClient[':id']['$put']>['json'];
+
+export const SubmissionRecordsPage: React.FC = () => {
+  const [data, setData] = useState<SubmissionRecordsItem[]>([]);
+  const [loading, setLoading] = useState<boolean>(true);
+  const [pagination, setPagination] = useState({
+    current: 1,
+    pageSize: 10,
+    total: 0,
+  });
+  const [searchText, setSearchText] = useState('');
+  const [isModalVisible, setIsModalVisible] = useState(false);
+  const [isEditing, setIsEditing] = useState(false);
+  const [currentItem, setCurrentItem] = useState<SubmissionRecordsItem | null>(null);
+  const [form] = Form.useForm();
+  const { message: antMessage } = App.useApp();
+
+  // 获取数据列表
+  const fetchData = async () => {
+    try {
+      setLoading(true);
+      const res = await submissionRecordsClient.$get({
+        query: {
+          page: pagination.current,
+          pageSize: pagination.pageSize,
+          keyword: searchText,
+        },
+      });
+      
+      if (!res.ok) {
+        throw new Error('获取数据失败');
+      }
+      
+      const result = await res.json() as SubmissionRecordsListResponse;
+      setData(result.data);
+      setPagination(prev => ({
+        ...prev,
+        total: result.pagination.total,
+      }));
+    } catch (error) {
+      console.error('获取提交记录数据失败:', error);
+      antMessage.error('获取数据失败,请重试');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 初始加载和分页、搜索变化时重新获取数据
+  useEffect(() => {
+    fetchData();
+  }, [pagination.current, pagination.pageSize]);
+
+  // 搜索功能
+  const handleSearch = () => {
+    setPagination(prev => ({ ...prev, current: 1 }));
+    fetchData();
+  };
+
+  // 显示创建模态框
+  const showCreateModal = () => {
+    setIsEditing(false);
+    setCurrentItem(null);
+    form.resetFields();
+    setIsModalVisible(true);
+  };
+
+  // 显示编辑模态框
+  const showEditModal = (record: SubmissionRecordsItem) => {
+    setIsEditing(true);
+    setCurrentItem(record);
+    form.setFieldsValue({
+      classroomNo: record.classroomNo || undefined,
+      userId: record.userId || undefined,
+      nickname: record.nickname || undefined,
+      score: record.score || undefined,
+      code: record.code || undefined,
+      trainingDate: record.trainingDate ? new Date(record.trainingDate) : null,
+      mark: record.mark || undefined,
+      status: record.status || undefined,
+      holdingStock: record.holdingStock || undefined,
+      holdingCash: record.holdingCash || undefined,
+      price: record.price || undefined,
+      profitAmount: record.profitAmount || undefined,
+      profitPercent: record.profitPercent || undefined,
+      totalProfitAmount: record.totalProfitAmount || undefined,
+      totalProfitPercent: record.totalProfitPercent || undefined,
+    });
+    setIsModalVisible(true);
+  };
+
+  // 处理表单提交
+  const handleSubmit = async () => {
+    try {
+      const values = await form.validateFields();
+      
+      if (isEditing && currentItem) {
+        // 更新数据
+        const res = await submissionRecordsClient[':id'].$put({
+          param: { id: currentItem.id },
+          json: values as UpdateSubmissionRecordsRequest,
+        });
+        
+        if (!res.ok) {
+          throw new Error('更新失败');
+        }
+        antMessage.success('更新成功');
+      } else {
+        // 创建新数据
+        const res = await submissionRecordsClient.$post({
+          json: values as CreateSubmissionRecordsRequest,
+        });
+        
+        if (!res.ok) {
+          throw new Error('创建失败');
+        }
+        antMessage.success('创建成功');
+      }
+      
+      setIsModalVisible(false);
+      fetchData();
+    } catch (error) {
+      console.error('提交表单失败:', error);
+      antMessage.error(isEditing ? '更新失败,请重试' : '创建失败,请重试');
+    }
+  };
+
+  // 删除数据
+  const handleDelete = async (id: number) => {
+    try {
+      const res = await submissionRecordsClient[':id'].$delete({
+        param: { id },
+      });
+      
+      if (!res.ok) {
+        throw new Error('删除失败');
+      }
+      
+      antMessage.success('删除成功');
+      fetchData();
+    } catch (error) {
+      console.error('删除数据失败:', error);
+      antMessage.error('删除失败,请重试');
+    }
+  };
+
+  // 表格列定义
+  const columns = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+      key: 'id',
+      width: 80,
+    },
+    {
+      title: '教室号',
+      dataIndex: 'classroomNo',
+      key: 'classroomNo',
+    },
+    {
+      title: '用户ID',
+      dataIndex: 'userId',
+      key: 'userId',
+    },
+    {
+      title: '昵称',
+      dataIndex: 'nickname',
+      key: 'nickname',
+    },
+    {
+      title: '成绩',
+      dataIndex: 'score',
+      key: 'score',
+    },
+    {
+      title: '代码',
+      dataIndex: 'code',
+      key: 'code',
+    },
+    {
+      title: '训练日期',
+      dataIndex: 'trainingDate',
+      key: 'trainingDate',
+      render: (date: string) => date ? new Date(date).toLocaleString() : '-',
+    },
+    {
+      title: '状态',
+      dataIndex: 'status',
+      key: 'status',
+    },
+    {
+      title: '收益率',
+      dataIndex: 'profitPercent',
+      key: 'profitPercent',
+      render: (percent: number) => percent ? `${percent}%` : '-',
+    },
+    {
+      title: '累计收益率',
+      dataIndex: 'totalProfitPercent',
+      key: 'totalProfitPercent',
+      render: (percent: number) => percent ? `${percent}%` : '-',
+    },
+    {
+      title: '操作',
+      key: 'action',
+      render: (_: any, record: SubmissionRecordsItem) => (
+        <Space size="small">
+          <Button 
+            type="text" 
+            icon={<EditOutlined />} 
+            onClick={() => showEditModal(record)}
+          >
+            编辑
+          </Button>
+          <Button 
+            type="text" 
+            danger 
+            icon={<DeleteOutlined />} 
+            onClick={() => handleDelete(record.id)}
+          >
+            删除
+          </Button>
+        </Space>
+      ),
+    },
+  ];
+
+  return (
+    <div className="page-container">
+      <div className="page-header" style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+        <Title level={2}>提交记录管理</Title>
+        <Button type="primary" icon={<PlusOutlined />} onClick={showCreateModal}>
+          添加记录
+        </Button>
+      </div>
+      
+      <div className="search-container" style={{ marginBottom: 16 }}>
+        <Input
+          placeholder="搜索教室号、用户ID或代码"
+          value={searchText}
+          onChange={(e) => setSearchText(e.target.value)}
+          onPressEnter={handleSearch}
+          style={{ width: 300 }}
+          suffix={<SearchOutlined onClick={handleSearch} />}
+        />
+      </div>
+      
+      <Table
+        columns={columns}
+        dataSource={data.map(item => ({ ...item, key: item.id }))}
+        loading={loading}
+        pagination={{
+          current: pagination.current,
+          pageSize: pagination.pageSize,
+          total: pagination.total,
+          showSizeChanger: true,
+          showTotal: (total) => `共 ${total} 条记录`,
+        }}
+        onChange={(p) => setPagination({ ...pagination, current: p.current || 1, pageSize: p.pageSize || 10 })}
+        bordered
+        scroll={{ x: 'max-content' }}
+        headerCellStyle={{ backgroundColor: '#f9fafb' }}
+        rowClassName={(record, index) => index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
+        rowKey="id"
+      />
+      
+      <Modal
+        title={isEditing ? "编辑提交记录" : "添加提交记录"}
+        open={isModalVisible}
+        onOk={handleSubmit}
+        onCancel={() => setIsModalVisible(false)}
+        destroyOnClose
+        maskClosable={false}
+        width={700}
+      >
+        <Form
+          form={form}
+          layout="vertical"
+          name="submission_records_form"
+        >
+          <Form.Item
+            name="classroomNo"
+            label="教室号"
+            rules={[{ max: 255, message: '教室号不能超过255个字符' }]}
+          >
+            <Input placeholder="请输入教室号" />
+          </Form.Item>
+          
+          <Form.Item
+            name="userId"
+            label="用户ID"
+            rules={[{ max: 255, message: '用户ID不能超过255个字符' }]}
+          >
+            <Input placeholder="请输入用户ID" />
+          </Form.Item>
+          
+          <Form.Item
+            name="nickname"
+            label="昵称"
+            rules={[{ max: 255, message: '昵称不能超过255个字符' }]}
+          >
+            <Input placeholder="请输入昵称" />
+          </Form.Item>
+          
+          <Form.Item
+            name="score"
+            label="成绩"
+          >
+            <InputNumber 
+              placeholder="请输入成绩" 
+              style={{ width: '100%' }} 
+              formatter={value => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
+              parser={value => value!.replace(/\$\s?|(,*)/g, '')}
+              precision={2}
+            />
+          </Form.Item>
+          
+          <Form.Item
+            name="code"
+            label="代码"
+            rules={[{ max: 255, message: '代码不能超过255个字符' }]}
+          >
+            <Input placeholder="请输入代码" />
+          </Form.Item>
+          
+          <Form.Item
+            name="trainingDate"
+            label="训练日期"
+          >
+            <DatePicker showTime placeholder="请选择训练日期" style={{ width: '100%' }} />
+          </Form.Item>
+          
+          <Form.Item
+            name="mark"
+            label="标记"
+            rules={[{ max: 255, message: '标记不能超过255个字符' }]}
+          >
+            <Input placeholder="请输入标记" />
+          </Form.Item>
+          
+          <Form.Item
+            name="status"
+            label="状态"
+          >
+            <InputNumber placeholder="请输入状态" style={{ width: '100%' }} />
+          </Form.Item>
+          
+          <div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
+            <Form.Item
+              name="holdingStock"
+              label="持股"
+              rules={[{ max: 255, message: '持股信息不能超过255个字符' }]}
+              style={{ flex: 1 }}
+            >
+              <Input placeholder="请输入持股信息" />
+            </Form.Item>
+            
+            <Form.Item
+              name="holdingCash"
+              label="持币"
+              rules={[{ max: 255, message: '持币信息不能超过255个字符' }]}
+              style={{ flex: 1 }}
+            >
+              <Input placeholder="请输入持币信息" />
+            </Form.Item>
+          </div>
+          
+          <div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
+            <Form.Item
+              name="price"
+              label="价格"
+              style={{ flex: 1 }}
+            >
+              <InputNumber 
+                placeholder="请输入价格" 
+                style={{ width: '100%' }} 
+                precision={2}
+              />
+            </Form.Item>
+            
+            <Form.Item
+              name="profitAmount"
+              label="收益金额"
+              style={{ flex: 1 }}
+            >
+              <InputNumber 
+                placeholder="请输入收益金额" 
+                style={{ width: '100%' }} 
+                precision={2}
+              />
+            </Form.Item>
+          </div>
+          
+          <div style={{ display: 'flex', gap: 16 }}>
+            <Form.Item
+              name="profitPercent"
+              label="收益率(%)"
+              style={{ flex: 1 }}
+            >
+              <InputNumber 
+                placeholder="请输入收益率" 
+                style={{ width: '100%' }} 
+                precision={2}
+              />
+            </Form.Item>
+            
+            <Form.Item
+              name="totalProfitPercent"
+              label="累计收益率(%)"
+              style={{ flex: 1 }}
+            >
+              <InputNumber 
+                placeholder="请输入累计收益率" 
+                style={{ width: '100%' }} 
+                precision={2}
+              />
+            </Form.Item>
+            
+            <Form.Item
+              name="totalProfitAmount"
+              label="累计收益金额"
+              style={{ flex: 1 }}
+            >
+              <InputNumber 
+                placeholder="请输入累计收益金额" 
+                style={{ width: '100%' }} 
+                precision={2}
+              />
+            </Form.Item>
+          </div>
+        </Form>
+      </Modal>
+    </div>
+  );
+};
+
+export default SubmissionRecordsPage;

+ 30 - 0
src/client/admin/routes.tsx

@@ -6,6 +6,11 @@ import { ErrorPage } from './components/ErrorPage';
 import { NotFoundPage } from './components/NotFoundPage';
 import { DashboardPage } from './pages/Dashboard';
 import { UsersPage } from './pages/Users';
+import { ClassroomDataPage } from './pages/ClassroomDataPage';
+import { SubmissionRecordsPage } from './pages/SubmissionRecordsPage';
+import { StockDataPage } from './pages/StockDataPage';
+import { StockXunlianCodesPage } from './pages/StockXunlianCodesPage';
+import { DateNotesPage } from './pages/DateNotesPage';
 import { LoginPage } from './pages/Login';
 
 export const router = createBrowserRouter([
@@ -39,6 +44,31 @@ export const router = createBrowserRouter([
         element: <UsersPage />,
         errorElement: <ErrorPage />
       },
+      {
+        path: 'classroom-data',
+        element: <ClassroomDataPage />,
+        errorElement: <ErrorPage />
+      },
+      {
+        path: 'submission-records',
+        element: <SubmissionRecordsPage />,
+        errorElement: <ErrorPage />
+      },
+      {
+        path: 'stock-data',
+        element: <StockDataPage />,
+        errorElement: <ErrorPage />
+      },
+      {
+        path: 'stock-xunlian-codes',
+        element: <StockXunlianCodesPage />,
+        errorElement: <ErrorPage />
+      },
+      {
+        path: 'date-notes',
+        element: <DateNotesPage />,
+        errorElement: <ErrorPage />
+      },
       {
         path: '*',
         element: <NotFoundPage />,

+ 27 - 1
src/client/api.ts

@@ -1,7 +1,9 @@
 import axios, { isAxiosError } from 'axios';
 import { hc } from 'hono/client'
 import type {
-  AuthRoutes, UserRoutes, RoleRoutes
+  AuthRoutes, UserRoutes, RoleRoutes,
+  ClassroomDataRoutes, SubmissionRecordsRoutes,
+  StockDataRoutes, StockXunlianCodesRoutes, DateNotesRoutes, AliyunRoutes
 } from '@/server/api';
 
 // 创建 axios 适配器
@@ -70,3 +72,27 @@ export const userClient = hc<UserRoutes>('/', {
 export const roleClient = hc<RoleRoutes>('/', {
   fetch: axiosFetch,
 }).api.v1.roles;
+
+export const classroomDataClient = hc<ClassroomDataRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1['classroom-data'];
+
+export const submissionRecordsClient = hc<SubmissionRecordsRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1['submission-records'];
+
+export const stockDataClient = hc<StockDataRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1['stock-data'];
+
+export const stockXunlianCodesClient = hc<StockXunlianCodesRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1['stock-xunlian-codes'];
+
+export const dateNotesClient = hc<DateNotesRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1['date-notes'];
+
+export const aliyunClient = hc<AliyunRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1.aliyun;

+ 3 - 159
src/client/home/pages/HomePage.tsx

@@ -1,95 +1,11 @@
-import React, { useState, useEffect, useRef } from 'react';
+import React from 'react';
 import { useAuth } from '@/client/home/hooks/AuthProvider';
 import { useNavigate } from 'react-router-dom';
-import io, { Socket } from 'socket.io-client';
 
 const HomePage: React.FC = () => {
   const { user } = useAuth();
   const navigate = useNavigate();
-  
-  // Socket.IO相关状态和引用
-  const [socket, setSocket] = useState<Socket | null>(null);
-  const [isConnected, setIsConnected] = useState(false);
-  const [connectionStatus, setConnectionStatus] = useState('未连接');
-  const [messages, setMessages] = useState<string[]>([]);
-  const [messageInput, setMessageInput] = useState('');
-  const socketRef = useRef<Socket | null>(null);
-  
-  // 连接到Socket.IO服务器
-  const connectToSocket = () => {
-    // 替换为你的Socket.IO服务器地址
-    // const newSocket = io('/socket.io');
-    const newSocket = io('/', {
-      path: '/socket.io',
-      transports: ['websocket'],
-      withCredentials: true,
-      query: { 
-        socket_token:'3424242423'
-      },
-      auth:{
-        token: 'wfwfw2342424'
-      },
-      reconnection: true,
-      reconnectionAttempts: 5,
-      reconnectionDelay: 1000,
-    });
-    socketRef.current = newSocket;
-    setSocket(newSocket);
-    
-    newSocket.on('connect', () => {
-      setIsConnected(true);
-      setConnectionStatus('已连接 (ID: ' + newSocket.id + ')');
-      addMessage('成功连接到服务器');
-    });
-    
-    newSocket.on('disconnect', () => {
-      setIsConnected(false);
-      setConnectionStatus('已断开连接');
-      addMessage('与服务器断开连接');
-    });
-    
-    newSocket.on('message', (data: string) => {
-      addMessage('收到服务器消息: ' + data);
-    });
-    
-    newSocket.on('error', (error: any) => {
-      addMessage('错误: ' + error.message);
-    });
-  };
-  
-  // 断开Socket.IO连接
-  const disconnectFromSocket = () => {
-    if (socket) {
-      socket.disconnect();
-      setSocket(null);
-      socketRef.current = null;
-    }
-  };
-  
-  // 添加消息到消息列表
-  const addMessage = (message: string) => {
-    const timestamp = new Date().toLocaleTimeString();
-    setMessages(prev => [...prev, `[${timestamp}] ${message}`].slice(-10)); // 只保留最近10条消息
-  };
-  
-  // 发送消息到服务器
-  const sendMessage = () => {
-    if (socket && messageInput.trim()) {
-      socket.emit('message', messageInput.trim());
-      addMessage('发送: ' + messageInput.trim());
-      setMessageInput('');
-    }
-  };
-  
-  // 清理函数,组件卸载时断开连接
-  useEffect(() => {
-    return () => {
-      if (socket) {
-        socket.disconnect();
-      }
-    };
-  }, [socket]);
-  
+
   return (
     <div className="min-h-screen bg-gray-50 flex flex-col">
       {/* 顶部导航 */}
@@ -166,78 +82,6 @@ const HomePage: React.FC = () => {
               <p className="text-gray-600 text-sm">适配各种设备屏幕,提供良好的用户体验</p>
             </div>
           </div>
-          
-          {/* Socket.IO连接测试区域 */}
-          <div className="mt-12 p-6 bg-gray-50 rounded-lg border border-gray-200">
-            <h3 className="text-xl font-semibold text-gray-800 mb-4 flex items-center">
-              <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-indigo-600 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 21h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
-              </svg>
-              Socket.IO 连接测试
-            </h3>
-            
-            <div className="flex flex-col md:flex-row gap-4 mb-6">
-              <div className="flex-1">
-                <p className="text-sm text-gray-600 mb-2">连接状态:</p>
-                <div className={`p-3 rounded border ${isConnected ? 'border-green-200 bg-green-50 text-green-700' : 'border-red-200 bg-red-50 text-red-700'}`}>
-                  {connectionStatus}
-                </div>
-              </div>
-              
-              <div className="flex gap-3">
-                <button 
-                  onClick={connectToSocket}
-                  disabled={isConnected}
-                  className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-indigo-300"
-                >
-                  连接
-                </button>
-                <button 
-                  onClick={disconnectFromSocket}
-                  disabled={!isConnected}
-                  className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-300"
-                >
-                  断开
-                </button>
-              </div>
-            </div>
-            
-            {/* 消息发送区域 */}
-            <div className="mb-6">
-              <p className="text-sm text-gray-600 mb-2">发送消息:</p>
-              <div className="flex gap-2">
-                <input
-                  type="text"
-                  value={messageInput}
-                  onChange={(e) => setMessageInput(e.target.value)}
-                  onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
-                  placeholder="输入消息并发送..."
-                  className="flex-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500"
-                />
-                <button 
-                  onClick={sendMessage}
-                  disabled={!isConnected || !messageInput.trim()}
-                  className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-indigo-300"
-                >
-                  发送
-                </button>
-              </div>
-            </div>
-            
-            {/* 消息记录区域 */}
-            <div>
-              <p className="text-sm text-gray-600 mb-2">消息记录:</p>
-              <div className="h-64 bg-white border border-gray-200 rounded p-3 overflow-y-auto text-sm">
-                {messages.length === 0 ? (
-                  <p className="text-gray-400 italic">暂无消息</p>
-                ) : (
-                  messages.map((msg, index) => (
-                    <p key={index} className="mb-1">{msg}</p>
-                  ))
-                )}
-              </div>
-            </div>
-          </div>
         </div>
       </main>
       
@@ -256,4 +100,4 @@ const HomePage: React.FC = () => {
   );
 };
 
-export default HomePage;
+export default HomePage;

+ 3 - 1
src/client/index.tsx

@@ -1,6 +1,8 @@
 // 如果当前是在 /big 下
 if (window.location.pathname.startsWith('/admin')) {
   import('./admin/index')
+// } else if (window.location.pathname.startsWith('/mobile')) {
+//   import('./mobile/index')
 } else {
-  import('./home/index')
+  import('./mobile/index')
 }

+ 17 - 0
src/client/mobile/components/Classroom/AuthLayout.tsx

@@ -0,0 +1,17 @@
+import React, { ReactNode } from 'react';
+
+interface AuthLayoutProps {
+  children: ReactNode;
+}
+
+export const AuthLayout = ({ children }: AuthLayoutProps) => {
+  return (
+    <div className="flex flex-col h-screen bg-gray-100">
+      <div className="flex-1 flex items-center justify-center p-4">
+        <div className="w-full max-w-md bg-white rounded-lg shadow p-6">
+          {children}
+        </div>
+      </div>
+    </div>
+  );
+};

+ 200 - 0
src/client/mobile/components/Classroom/ClassroomLayout.tsx

@@ -0,0 +1,200 @@
+import React, { ReactNode } from 'react';
+import { Role } from './useClassroom';
+import { useClassroomContext } from './ClassroomProvider';
+import {
+  VideoCameraIcon,
+  CameraIcon,
+  MicrophoneIcon,
+  ShareIcon,
+  ClipboardDocumentIcon,
+  PaperAirplaneIcon
+} from '@heroicons/react/24/outline';
+
+interface ClassroomLayoutProps {
+  children: ReactNode;
+  role: Role;
+}
+
+export const ClassroomLayout = ({ children, role }: ClassroomLayoutProps) => {
+  const [showVideo, setShowVideo] = React.useState(role !== Role.Teacher);
+  const [showShareLink, setShowShareLink] = React.useState(false);
+  const {
+    remoteScreenContainer,
+    remoteCameraContainer,
+    isCameraOn,
+    isAudioOn,
+    isScreenSharing,
+    toggleCamera,
+    toggleAudio,
+    toggleScreenShare,
+    messageList,
+    msgText,
+    setMsgText,
+    sendMessage,
+    handUpList,
+    questions,
+    classStatus,
+    shareLink,
+    showCameraOverlay,
+    setShowCameraOverlay
+  } = useClassroomContext();
+
+  return (
+    <div className="flex flex-col md:flex-row h-screen bg-gray-100">
+      {/* 视频区域 */}
+      {showVideo && (
+        <div className="relative h-[300px] md:flex-1 md:h-auto bg-black">
+          {/* 主屏幕共享容器 */}
+          <div
+            id="remoteScreenContainer"
+            ref={remoteScreenContainer}
+            className="w-full h-full"
+          >
+            {/* 屏幕共享视频将在这里动态添加 */}
+          </div>
+          
+          {/* 摄像头小窗容器 - 固定在右上角 */}
+          <div
+            id="remoteCameraContainer"
+            ref={remoteCameraContainer}
+            className={`absolute top-4 right-4 z-10 w-1/4 aspect-video ${
+              showCameraOverlay ? 'block' : 'hidden'
+            }`}
+          >
+            {/* 摄像头视频将在这里动态添加 */}
+          </div>
+          
+          {/* 摄像头小窗开关按钮 */}
+          <button
+            type="button"
+            onClick={() => setShowCameraOverlay(!showCameraOverlay)}
+            className={`absolute top-4 right-4 z-20 p-2 rounded-full ${
+              showCameraOverlay ? 'bg-green-500' : 'bg-gray-500'
+            } text-white`}
+            title={showCameraOverlay ? '隐藏摄像头小窗' : '显示摄像头小窗'}
+          >
+            <CameraIcon className="w-4 h-4" />
+          </button>
+        </div>
+      )}
+
+      {/* 消息和控制面板列 */}
+      <div className={`${showVideo ? 'w-full md:w-96 flex-1' : 'flex-1'} flex flex-col`}>
+        {/* 消息区域 */}
+        <div className="flex flex-col h-full">
+          {/* 消息列表 - 填充剩余空间 */}
+          <div className="flex-1 overflow-y-auto bg-white shadow-lg p-4">
+            {messageList.map((msg, i) => (
+              <div key={i} className="text-sm mb-1">{msg}</div>
+            ))}
+            </div>
+          </div>
+
+          {/* 底部固定区域 */}
+          <div className="bg-white shadow-lg p-4">
+            {/* 控制面板 */}
+            <div className="p-2 flex flex-col gap-3 mb-1 border-b border-gray-200">
+              <div className="flex flex-wrap gap-2">
+                {role === Role.Teacher && (
+                  <button
+                    type="button"
+                    onClick={() => setShowVideo(!showVideo)}
+                    className={`p-2 rounded-full ${showVideo ? 'bg-gray-500' : 'bg-gray-300'} text-white`}
+                    title={showVideo ? '隐藏视频' : '显示视频'}
+                  >
+                    <VideoCameraIcon className="w-4 h-4" />
+                  </button>
+                )}
+                <button
+                  type="button"
+                  onClick={toggleCamera}
+                  className={`p-2 rounded-full ${isCameraOn ? 'bg-green-500' : 'bg-red-500'} text-white`}
+                  title={isCameraOn ? '关闭摄像头' : '开启摄像头'}
+                >
+                  <CameraIcon className="w-4 h-4" />
+                </button>
+                <button
+                  type="button"
+                  onClick={toggleAudio}
+                  className={`p-2 rounded-full ${isAudioOn ? 'bg-green-500' : 'bg-red-500'} text-white`}
+                  title={isAudioOn ? '关闭麦克风' : '开启麦克风'}
+                >
+                  <MicrophoneIcon className="w-4 h-4" />
+                </button>
+                {role === Role.Teacher && (
+                  <button
+                    type="button"
+                    onClick={toggleScreenShare}
+                    className={`p-2 rounded-full ${isScreenSharing ? 'bg-green-500' : 'bg-blue-500'} text-white`}
+                    title={isScreenSharing ? '停止共享' : '共享屏幕'}
+                  >
+                    <ShareIcon className="w-4 h-4" />
+                  </button>
+                )}
+                {role === Role.Teacher && shareLink && (
+                  <button
+                    type="button"
+                    onClick={() => setShowShareLink(!showShareLink)}
+                    className="p-2 rounded-full bg-blue-500 text-white"
+                    title="分享链接"
+                  >
+                    <ClipboardDocumentIcon className="w-4 h-4" />
+                  </button>
+                )}
+              </div>
+
+              {showShareLink && shareLink && (
+                <div className="bg-blue-50 p-2 rounded">
+                  <div className="flex items-center gap-1">
+                    <input
+                      type="text"
+                      value={shareLink}
+                      readOnly
+                      className="flex-1 text-xs border rounded px-2 py-1 truncate"
+                    />
+                    <button
+                      type="button"
+                      onClick={() => navigator.clipboard.writeText(shareLink)}
+                      className="p-2 bg-blue-500 text-white rounded"
+                      title="复制链接"
+                    >
+                      <ClipboardDocumentIcon className="w-4 h-4" />
+                    </button>
+                  </div>
+                </div>
+              )}
+              
+              {/* 角色特定内容 */}
+              <div className="flex-1 overflow-y-auto">
+                {children}
+              </div>
+            </div>
+
+            {/* 消息输入框 */}
+            <div className="relative mt-2">
+              <textarea
+                value={msgText}
+                onChange={(e) => setMsgText(e.target.value)}
+                onKeyDown={(e) => {
+                  if (e.key === 'Enter' && !e.shiftKey) {
+                    e.preventDefault();
+                    sendMessage();
+                  }
+                }}
+                className="w-full border rounded px-2 py-1 pr-10"
+                placeholder="输入消息..."
+                rows={3}
+              />
+              <button
+                type="button"
+                onClick={sendMessage}
+                className="absolute right-2 bottom-2 p-1 bg-blue-500 text-white rounded-full"
+              >
+                <PaperAirplaneIcon className="w-4 h-4" />
+              </button>
+            </div>
+          </div>
+      </div>
+    </div>
+  );
+};

+ 38 - 0
src/client/mobile/components/Classroom/ClassroomProvider.tsx

@@ -0,0 +1,38 @@
+import React, { useState, useEffect, useRef, createContext, useContext, useMemo } from 'react';
+import { useParams } from 'react-router';
+import { useClassroom , Role } from './useClassroom';
+import { User } from '../../hooks/AuthProvider';
+
+type ClassroomContextType = ReturnType<typeof useClassroom>;
+
+const ClassroomContext = createContext<ClassroomContextType | null>(null);
+
+export const ClassroomProvider: React.FC<{children: React.ReactNode, user: User}> = ({ children, user }) => {
+  const classroom = useClassroom({ user });
+
+  const { id: classId, role: pathRole } = useParams();
+
+  useEffect(() => {
+    if (classId) {
+      classroom.setClassId(classId);
+    }
+    
+    if (pathRole && (pathRole === Role.Teacher || pathRole === Role.Student)) {
+      classroom.setRole(pathRole === Role.Teacher ? Role.Teacher : Role.Student);
+    }
+  }, [classId, pathRole]);
+
+  return (
+    <ClassroomContext.Provider value={classroom}>
+      {children}
+    </ClassroomContext.Provider>
+  );
+};
+
+export const useClassroomContext = () => {
+  const context = useContext(ClassroomContext);
+  if (!context) {
+    throw new Error('useClassroomContext must be used within a ClassroomProvider');
+  }
+  return context;
+};

+ 1515 - 0
src/client/mobile/components/Classroom/alivc-im.iife.d.ts

@@ -0,0 +1,1515 @@
+declare namespace AliVCInteraction {
+  class AliVCIMAttachmentManager {
+    private static instance;
+    private wasmIns;
+    private attachmentManager;
+    private uploader;
+    constructor(wasmIns: any, wasmInterface: any);
+    static getInstance(wasmIns: any, wasmInterface: any): AliVCIMAttachmentManager;
+    getAttachmentReq(attachmentReq: ImAttachmentReq): Promise<any>;
+    /**
+     * 上传附件
+     * @param {string} reqId
+     * @param {ImAttachmentReq} attachmentReq
+     * @returns {ImAttachmentRes}
+     */
+    uploadAttachment(reqId: string, attachmentReq: ImAttachmentReq): Promise<ImAttachmentRes>;
+    /**
+     * 取消上传附件
+     * @param {string} reqId
+     * @returns
+     */
+    cancelAttachmentUpload(reqId: string): Promise<void>;
+    /**
+     * 删除已上传附件
+     * @param {ImAttachmentRes} attachment
+     * @returns
+     */
+    deleteAttachment(attachment: ImAttachmentRes): Promise<void>;
+    destroy(): void;
+}
+
+class AliVCIMGroupManager extends EventEmitter<ImGroupListener> {
+    private wasmIns;
+    private wasmGroupManager;
+    private groupListener;
+    constructor(wasmIns: any, wasmInterface: any);
+    addGroupListener(): void;
+    removeGroupListener(): void;
+    destroy(): void;
+    /**
+     * 创建群组,限管理员才能操作
+     * @param {ImCreateGroupReq} req
+     * @returns {Promise<ImCreateGroupRsp>}
+     */
+    createGroup(req: ImCreateGroupReq): Promise<ImCreateGroupRsp>;
+    /**
+     * 查询群组信息
+     * @param {string | ImQueryGroupReq} groupIdOrReq
+     * @returns {Promise<ImGroupInfo>}
+     */
+    queryGroup(groupIdOrReq: string | ImQueryGroupReq): Promise<ImGroupInfo>;
+    /**
+     * 关闭群组,限管理员才能操作
+     * @param {string | ImCloseGroupReq} groupIdOrReq
+     * @returns {Promise<void>}
+     */
+    closeGroup(groupIdOrReq: string | ImCloseGroupReq): Promise<void>;
+    /**
+     * 加入群组
+     * @param {string | ImJoinGroupReq} groupIdOrReq
+     * @returns {Promise<ImGroupInfo>}
+     */
+    joinGroup(groupIdOrReq: string | ImJoinGroupReq): Promise<ImGroupInfo>;
+    /**
+     * 离开群组
+     * @param {string | ImLeaveGroupReq} groupIdOrReq
+     * @returns {Promise<void>}
+     */
+    leaveGroup(groupIdOrReq: string | ImLeaveGroupReq): Promise<void>;
+    /**
+     * 修改群组信息
+     * @param {ImModifyGroupReq} req
+     * @returns {Promise<void>}
+     */
+    modifyGroup(req: ImModifyGroupReq): Promise<void>;
+    /**
+     * 查询最近组成员
+     * @param {string | ImListRecentGroupUserReq} groupIdOrReq
+     * @returns {Promise<ImListRecentGroupUserRsp>}
+     */
+    listRecentGroupUser(groupIdOrReq: string | ImListRecentGroupUserReq): Promise<ImListRecentGroupUserRsp>;
+    /**
+     * 查询群组成员,限管理员才能操作
+     * @param {string | ImListGroupUserReq} groupIdOrReq
+     * @returns {Promise<ImListGroupUserRsp>}
+     */
+    listGroupUser(groupIdOrReq: string | ImListGroupUserReq): Promise<ImListGroupUserRsp>;
+    /**
+     * 全体禁言,限管理员才能操作
+     * @param {string | ImMuteAllReq} groupIdOrReq
+     * @returns {Promise<void>}
+     */
+    muteAll(groupIdOrReq: string | ImMuteAllReq): Promise<void>;
+    /**
+     * 取消全体禁言,限管理员才能操作
+     * @param {string | ImCancelMuteAllReq} groupIdOrReq
+     * @returns {Promise<void>}
+     */
+    cancelMuteAll(groupIdOrReq: string | ImCancelMuteAllReq): Promise<void>;
+    /**
+     * 禁言指定用户,限管理员才能操作
+     * @param {ImMuteUserReq} req
+     * @returns {Promise<void>}
+     */
+    muteUser(req: ImMuteUserReq): Promise<void>;
+    /**
+     * 取消禁言指定用户,限管理员才能操作
+     * @param {ImCancelMuteUserReq} req
+     * @returns {Promise<void>}
+     */
+    cancelMuteUser(req: ImCancelMuteUserReq): Promise<void>;
+    /**
+     * 查询禁言用户列表,限管理员才能操作
+     * @param {string | ImListMuteUsersReq} groupIdOrReq
+     * @returns {Promise<ImListMuteUsersRsp>}
+     */
+    listMuteUsers(groupIdOrReq: string | ImListMuteUsersReq): Promise<ImListMuteUsersRsp>;
+}
+
+class AliVCIMMessageManager extends EventEmitter<ImMessageListener> {
+    private wasmIns;
+    private wasmMessageManager;
+    private messageListener;
+    private streamMessageManager;
+    constructor(wasmIns: any, wasmInterface: any);
+    addMessageListener(): void;
+    removeMessageListener(): void;
+    destroy(): void;
+    /**
+     * 发送单聊普通消息
+     * @param {ImSendMessageToUserReq} req
+     * @returns {string} messageId
+     */
+    sendC2cMessage(req: ImSendMessageToUserReq): Promise<string>;
+    /**
+     * 发送群聊普通消息
+     * @param {ImSendMessageToGroupReq} req
+     * @returns {string} messageId
+     */
+    sendGroupMessage(req: ImSendMessageToGroupReq): Promise<string>;
+    /**
+     * 查询消息列表
+     * @param {ImListMessageReq} req
+     * @returns {ImListMessageRsp}
+     */
+    listMessage(req: ImListMessageReq): Promise<ImListMessageRsp>;
+    /**
+     * 查询最近消息
+     * @param {string |ImListRecentMessageReq} groupIdOrReq
+     * @returns {ImListRecentMessageRsp}
+     */
+    listRecentMessage(groupIdOrReq: string | ImListRecentMessageReq): Promise<ImListRecentMessageRsp>;
+    /**
+     * 查询历史消息,该接口主要用户直播结束后的历史消息回放,用户无需进入群组可查询,比较耗时,在直播过程中不建议使用,另外该接口后续可能会收费。
+     * @param {ImListHistoryMessageReq} req
+     * @returns {ImListHistoryMessageRsp}
+     */
+    listHistoryMessage(req: ImListHistoryMessageReq): Promise<ImListHistoryMessageRsp>;
+    /**
+     * 删除/撤回群消息
+     */
+    deleteMessage(req: ImDeleteMessageReq): Promise<void>;
+    /**
+     * 创建流式消息
+     */
+    createStreamMessage(req: ImCreateStreamMessageReq): Promise<ImStreamMessageSender>;
+    /**
+     * 拒收流式消息
+     */
+    rejectStreamMessage(req: ImRejectStreamMessageReq): Promise<void>;
+    /**
+     * 自定义流式消息转发
+     */
+    forwardCustomMessage(req: ImForwardCustomMessageReq): Promise<ImForwardCustomMessageRsp>;
+}
+
+/**
+ * Minimal `EventEmitter` interface that is molded against the Node.js
+ * `EventEmitter` interface.
+ */
+class EventEmitter<
+EventTypes extends EventEmitter.ValidEventTypes = string | symbol,
+Context extends any = any
+> {
+    static prefixed: string | boolean;
+
+    /**
+     * Return an array listing the events for which the emitter has registered
+     * listeners.
+     */
+    eventNames(): Array<EventEmitter.EventNames<EventTypes>>;
+
+    /**
+     * Return the listeners registered for a given event.
+     */
+    listeners<T extends EventEmitter.EventNames<EventTypes>>(
+    event: T
+    ): Array<EventEmitter.EventListener<EventTypes, T>>;
+
+    /**
+     * Return the number of listeners listening to a given event.
+     */
+    listenerCount(event: EventEmitter.EventNames<EventTypes>): number;
+
+    /**
+     * Calls each of the listeners registered for a given event.
+     */
+    emit<T extends EventEmitter.EventNames<EventTypes>>(
+    event: T,
+    ...args: EventEmitter.EventArgs<EventTypes, T>
+    ): boolean;
+
+    /**
+     * Add a listener for a given event.
+     */
+    on<T extends EventEmitter.EventNames<EventTypes>>(
+    event: T,
+    fn: EventEmitter.EventListener<EventTypes, T>,
+    context?: Context
+    ): this;
+    addListener<T extends EventEmitter.EventNames<EventTypes>>(
+    event: T,
+    fn: EventEmitter.EventListener<EventTypes, T>,
+    context?: Context
+    ): this;
+
+    /**
+     * Add a one-time listener for a given event.
+     */
+    once<T extends EventEmitter.EventNames<EventTypes>>(
+    event: T,
+    fn: EventEmitter.EventListener<EventTypes, T>,
+    context?: Context
+    ): this;
+
+    /**
+     * Remove the listeners of a given event.
+     */
+    removeListener<T extends EventEmitter.EventNames<EventTypes>>(
+    event: T,
+    fn?: EventEmitter.EventListener<EventTypes, T>,
+    context?: Context,
+    once?: boolean
+    ): this;
+    off<T extends EventEmitter.EventNames<EventTypes>>(
+    event: T,
+    fn?: EventEmitter.EventListener<EventTypes, T>,
+    context?: Context,
+    once?: boolean
+    ): this;
+
+    /**
+     * Remove all listeners, or those of the specified event.
+     */
+    removeAllListeners(event?: EventEmitter.EventNames<EventTypes>): this;
+}
+
+namespace EventEmitter {
+    interface ListenerFn<Args extends any[] = any[]> {
+        (...args: Args): void;
+    }
+
+    interface EventEmitterStatic {
+        new <
+        EventTypes extends ValidEventTypes = string | symbol,
+        Context = any
+        >(): EventEmitter<EventTypes, Context>;
+    }
+
+    /**
+     * `object` should be in either of the following forms:
+     * ```
+     * interface EventTypes {
+     *   'event-with-parameters': any[]
+     *   'event-with-example-handler': (...args: any[]) => void
+     * }
+     * ```
+     */
+    type ValidEventTypes = string | symbol | object;
+
+    type EventNames<T extends ValidEventTypes> = T extends string | symbol
+    ? T
+    : keyof T;
+
+    type ArgumentMap<T extends object> = {
+        [K in keyof T]: T[K] extends (...args: any[]) => void
+        ? Parameters<T[K]>
+        : T[K] extends any[]
+        ? T[K]
+        : any[];
+    };
+
+    type EventListener<
+    T extends ValidEventTypes,
+    K extends EventNames<T>
+    > = T extends string | symbol
+    ? (...args: any[]) => void
+    : (
+    ...args: ArgumentMap<Exclude<T, string | symbol>>[Extract<K, keyof T>]
+    ) => void;
+
+    type EventArgs<
+    T extends ValidEventTypes,
+    K extends EventNames<T>
+    > = Parameters<EventListener<T, K>>;
+
+    const EventEmitter: EventEmitterStatic;
+}
+
+interface ImAttachmentProgress {
+    progress: number;
+    totalSize: number;
+    currentSize: number;
+}
+
+interface ImAttachmentReq {
+    id: string;
+    fileType: ImAttachmentType;
+    fileName: string;
+    file?: File | Blob;
+    filePath?: string;
+    extra?: string;
+    onProgress?: (res: ImAttachmentProgress) => void;
+}
+
+interface ImAttachmentRes {
+    id: string;
+    fileType: ImAttachmentType;
+    fileSize: number;
+    fileName: string;
+    accessKey: string;
+    fileDuration: number;
+    extra: string;
+}
+
+enum ImAttachmentType {
+    IMAGE = 1,
+    AUDIO = 2,
+    VIDEO = 3,
+    OTHER = 4
+}
+
+interface ImAuth {
+    /**
+     * 随机数,格式:"AK-随机串", 最长64字节, 仅限A-Z,a-z,0-9及"_",可为空
+     */
+    nonce: string;
+    /**
+     * 过期时间:从1970到过期时间的秒数
+     */
+    timestamp: number;
+    /**
+     * 角色,为admin时,表示该用户可以调用管控接口,可为空,如果要给当前用户admin权限,应该传admin
+     */
+    role?: string;
+    /**
+     * token
+     */
+    token: string;
+}
+
+interface ImCancelMuteAllReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+}
+
+interface ImCancelMuteUserReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param userList 被取消禁言的用户列表
+     */
+    userList: string[];
+}
+
+interface ImCloseGroupReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+}
+
+interface ImCreateGroupReq {
+    /**
+     * @param groupId 群组id,【可选】id为空的话,会由sdk内部生成
+     */
+    groupId?: string;
+    /**
+     * @param groupName 群组名称
+     */
+    groupName: string;
+    /**
+     * @param extension 业务扩展字段
+     */
+    groupMeta?: string;
+}
+
+interface ImCreateGroupRsp {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param alreadyExist 是否已经创建过
+     */
+    alreadyExist: boolean;
+}
+
+interface ImCreateStreamMessageReq {
+    /**
+     * 数据类型
+     */
+    dataType: ImStreamMessageDataType;
+    /**
+     * 数据接收类型
+     */
+    receiverType: ImStreamMessageReceiverType;
+    /**
+     * 数据接收者ID
+     */
+    receiverId: string;
+}
+
+interface ImDeleteMessageReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param messageId 消息id
+     */
+    messageId: string;
+}
+
+let ImEngine: typeof ImEngine_2;
+
+class ImEngine_2 extends EventEmitter<ImSdkListener> {
+    private wasmIns;
+    private wasmEngine;
+    private wasmInterface;
+    private transport?;
+    private appEventManager?;
+    private eventListener;
+    private messageManager?;
+    private groupManager?;
+    private attachmentManager?;
+    private pluginProvider?;
+    private uploader;
+    private supportsWebRtc;
+    private supportWASM;
+    private initFlag;
+    constructor();
+    static engine: ImEngine_2;
+    /**
+     * @brief 获取 SDK 引擎实例(单例)
+     * @returns ImEngine
+     */
+    static createEngine(): ImEngine_2;
+    /**
+     * 当前 SDK 是否支持,支持 WASM 或者 ASM
+     * @returns
+     */
+    static isSupport(): boolean;
+    static getSdkVersion(): string;
+    private initTransport;
+    private initAppEvent;
+    private loadWasm;
+    private preloadUploader;
+    private initNativePlugin;
+    /**
+     * @brief 初始化
+     * @param config SDK配置信息
+     */
+    init(config: ImSdkConfig): Promise<0 | ImErrors.ERROR_CLIENT_REPEATED_INIT>;
+    /**
+     * 添加 Engine 事件监听
+     */
+    private addEventListener;
+    private removeEventListener;
+    private destroy;
+    /**
+     * @brief 销毁
+     */
+    unInit(): boolean;
+    /**
+     * @brief 登录
+     * @param req 登录请求数据
+     */
+    login(loginReq: ImLoginReq): Promise<void>;
+    /**
+     * @brief 登出
+     */
+    logout(): Promise<void>;
+    /**
+     * 强制重连
+     */
+    reconnect(): void;
+    /**
+     * @brief 获取当前登录用户 ID
+     */
+    getCurrentUserId(): string;
+    /**
+     * @brief 是否登录
+     */
+    isLogin(): boolean;
+    /**
+     * @brief 是否已退出登录
+     */
+    isLogout(): boolean;
+    /**
+     * @brief 获取消息管理器 {AliVCIMMessageInterface}
+     * @return 返回消息管理器实例
+     */
+    getMessageManager(): AliVCIMMessageManager | undefined;
+    /**
+     * @brief 获取群组管理器 {AliVCIMGroupInterface}
+     * @return 返回群组管理器实例
+     */
+    getGroupManager(): AliVCIMGroupManager | undefined;
+    /**
+     * @brief 获取附件管理器 {AliVCIMAttachmentInterface}
+     * @return 返回附件管理器实例
+     */
+    getAttachmentManager(): AliVCIMAttachmentManager | undefined;
+}
+
+enum ImErrors {
+    /**
+     * 已经登录
+     */
+    ERROR_HAS_LOGIN = 304,
+    /**
+     * 参数错误;参数无法解析
+     */
+    ERROR_INVALID_PARAM = 400,
+    /**
+     * 错误码(subcode)	说明
+     * 403	操作无权限; 或登录时鉴权失败
+     */
+    ERROR_NO_PERMISSION = 403,
+    /**
+     * no session,可能因为客户网络变化等原因导致的连接变化,服务器在新连接上收到消息无法正常处理,需要reconnect 信令。
+     */
+    ERROR_NO_SESSION = 404,
+    /**
+     * 审核不通过
+     */
+    ERROR_AUDIT_FAIL = 406,
+    /**
+     * 繁忙,发送太快,稍候重试
+     * 服务端同学确认不需要区分这两个错误
+     */
+    ERROR_INTERNAL_BUSY = 412,
+    ERROR_INTERNAL_BUSY2 = 413,
+    /**
+     * 发送 c2c 消息对方用户不在线
+     */
+    ERROR_USER_OFFLINE = 424,
+    /**
+     * 未加入群组
+     */
+    ERROR_GROUP_NOT_JOINED = 425,
+    /**
+     * 操作过快,短时间内,发起过多请求。如同一个用户,1秒内发起2次登录。
+     */
+    ERROR_INTERNAL_BUSY3 = 429,
+    /**
+     * 群组不存在
+     */
+    ERROR_GROUP_NOT_EXIST = 440,
+    /**
+     * 群组已删除
+     */
+    ERROR_GROUP_DELETED = 441,
+    /**
+     * 无法在该群组中发送消息,被禁言
+     */
+    ERROR_SEND_GROUP_MSG_FAIL = 442,
+    /**
+     * 进了太多的群组, 列表人数超大等
+     */
+    ERROR_REACH_MAX = 443,
+    /**
+     * 无法加入该群,被禁止加入(暂无需求未实现)预留
+     */
+    ERROR_JOIN_GROUP_FAIL = 450,
+    /**
+     * ots 查询错误
+     */
+    ERROR_OTS_FAIL = 480,
+    /**
+     * 系统临时错误,稍候重试
+     */
+    ERROR_INTERNALE_RROR = 500,
+    /**
+     * 底层重复初始化
+     */
+    ERROR_CLIENT_REPEATED_INIT = -1,
+    /**
+     * 初始化配置信息有误
+     */
+    ERROR_CLIENT_INIT_INVALID_PARAM = -2,
+    /**
+     * 未初始化
+     */
+    ERROR_CLIENT_NOT_INIT = 1,
+    /**
+     * 参数异常
+     */
+    ERROR_CLIENT_INVALID_PARAM = 2,
+    /**
+     * 状态有误
+     */
+    ERROR_CLIENT_INVALID_STATE = 3,
+    /**
+     * 建连失败
+     */
+    ERROR_CLIENT_CONNECT_ERROR = 4,
+    /**
+     * 建连超时
+     */
+    ERROR_CLIENT_CONNECT_TIMEOUT = 5,
+    /**
+     * 发送失败
+     */
+    ERROR_CLIENT_SEND_FAILED = 6,
+    /**
+     * 发送取消
+     */
+    ERROR_CLIENT_SEND_CANCEL = 7,
+    /**
+     * 发送超时
+     */
+    ERROR_CLIENT_SEND_TIMEOUT = 8,
+    /**
+     * 订阅失败
+     */
+    ERROR_CLIENT_SUB_ERROR = 9,
+    /**
+     * 订阅通道断连
+     */
+    ERROR_CLIENT_SUB_DISCONNECT = 10,
+    /**
+     * 订阅超时
+     */
+    ERROR_CLIENT_SUB_TIMEOUT = 11,
+    /**
+     * 压缩失败
+     */
+    ERROR_CLIENT_COMPRESS_ERROR = 12,
+    /**
+     * 解压失败
+     */
+    ERROR_CLIENT_DECOMPRESS_ERROR = 13,
+    /**
+     * 加密失败
+     */
+    ERROR_CLIENT_ENCRYPT_ERROR = 14,
+    /**
+     * 解密失败
+     */
+    ERROR_CLIENT_DECRYPT_ERROR = 15,
+    /**
+     * 消息体封装失败
+     */
+    ERROR_CLIENT_CONVERTER_ERROR = 16,
+    /**
+     * 消息体解析失败
+     */
+    ERROR_CLIENT_PARSE_ERROR = 17,
+    /**
+     * 数据为空
+     */
+    ERROR_CLIENT_DATA_EMPTY = 18,
+    /**
+     * 数据错误
+     */
+    ERROR_CLIENT_DATA_ERROR = 19,
+    /**
+     * 地址出错(可能是AppSign有误,如头部带了空格、内容被截断等)
+     */
+    ERROR_CLIENT_URL_ERROR = 20,
+    /**
+     * 建连取消
+     */
+    CONNECT_CANCEL = 21,
+    /**
+     * 重试超过次数限制
+     */
+    RETRY_OVER_TIME = 22,
+    /**
+     * 状态错误
+     */
+    ERROR_INVALID_STATE = 601,
+    /**
+     * 未登录
+     */
+    ERROR_NOT_LOGIN = 602,
+    /**
+     * 收到上次session的消息
+     */
+    ERROR_RECEIVE_LAST_SESSION = 603,
+    /**
+     * Parse Data Error
+     */
+    ERROR_PARSE_DATA_ERROR = 604
+}
+
+interface ImForwardCustomMessageReq {
+    /**
+     * 数据接收者ID
+     */
+    receiverId: string;
+    /**
+     * 数据,若需要传对象,需要序列化
+     */
+    data: string;
+}
+
+interface ImForwardCustomMessageRsp {
+    /**
+     * 流式消息ID
+     */
+    messageId: string;
+    /**
+     * 数据,若返回的是对象,需要反序列化
+     */
+    data: string;
+}
+
+interface ImGroupInfo {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param groupName 群组名称
+     */
+    groupName: string;
+    /**
+     * @param groupMeta 群组透传信息
+     */
+    groupMeta: string;
+    /**
+     * @param createTime 创建时间
+     */
+    createTime: number;
+    /**
+     * @param creator 创建者id
+     */
+    creator: string;
+    /**
+     * @param admins 管理员列表
+     */
+    admins: string[];
+    /**
+     * @param statistics 群组统计
+     */
+    statistics: ImGroupStatistics;
+    /**
+     * @param muteStatus 群禁言信息
+     */
+    muteStatus: ImGroupMuteStatus;
+}
+
+interface ImGroupInfoStatus {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param groupMeta 群组扩展信息
+     */
+    groupMeta: string;
+    /**
+     * @param adminList 管理员列表
+     */
+    adminList: string[];
+}
+
+interface ImGroupListener {
+    /**
+     * @deprecated 1.4.1 后请使用 memberdatachange 事件
+     *
+     * 群组成员变化
+     * @param groupId  群组ID
+     * @param memberCount 当前群组人数
+     * @param joinUsers 加入的用户
+     * @param leaveUsers 离开的用户
+     */
+    memberchange: (groupId: string, memberCount: number, joinUsers: ImUser[], leaveUsers: ImUser[]) => void;
+    /**
+     * 1.4.1 版本新增新的群组成员变化,返回的是一个对象
+     * @param data 群组成员变化数据对象
+     * @param data.groupId  群组ID
+     * @param data.onlineCount 当前群组在线人数
+     * @param data.pv  加入群组累积pv数
+     * @param data.isBigGroup 是否是大群组
+     * @param data.joinUsers 加入的用户
+     * @param data.leaveUsers 离开的用户
+     */
+    memberdatachange: (data: ImMemberChangeData) => void;
+    /**
+     * 退出群组
+     * @param groupId  群组ID
+     * @param reason 退出原因 1: 群被解散, 2:被踢出来了
+     */
+    exit: (groupId: string, reason: number) => void;
+    /**
+     * 群组静音状态变化
+     * @param groupId  群组ID
+     * @param status 静音状态
+     */
+    mutechange: (groupId: string, status: ImGroupMuteStatus) => void;
+    /**
+     * 群组信息变化
+     * @param groupId  群组ID
+     * @param info 群组信息
+     */
+    infochange: (groupId: string, info: ImGroupInfoStatus) => void;
+}
+
+interface ImGroupMuteStatus {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param muteAll 是否全员禁言
+     */
+    muteAll: boolean;
+    /**
+     * @param muteUserList 禁言用户ID列表
+     */
+    muteUserList: string[];
+    /**
+     * @param whiteUserList 白名单用户ID列表
+     */
+    whiteUserList: string[];
+}
+
+interface ImGroupStatistics {
+    /**
+     * @param pv PV
+     */
+    pv: number;
+    /**
+     * @param onlineCount 在线人数
+     */
+    onlineCount: number;
+    /**
+     * @param msgAmount 消息数量
+     */
+    msgAmount: {
+        [key: string]: number;
+    };
+}
+
+interface ImJoinGroupReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+}
+
+interface ImLeaveGroupReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+}
+
+interface ImListGroupUserReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param sortType 排序方式,ASC-先加入优先,DESC-后加入优先
+     */
+    sortType?: ImSortType;
+    /**
+     * @param nextPageToken 默认表示第一页,遍历时服务端会返回,客户端获取下一页时,应带上
+     */
+    nextPageToken?: number;
+    /**
+     * @deprecated 请使用 nextPageToken
+     */
+    nextpagetoken?: number;
+    /**
+     * @param pageSize 最大不超过50
+     */
+    pageSize?: number;
+}
+
+interface ImListGroupUserRsp {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param nextPageToken 下一页的token
+     */
+    nextPageToken: number;
+    /**
+     * @deprecated 请使用 nextPageToken
+     */
+    nextpagetoken?: number;
+    /**
+     * @param hasMore 是否还有下一页
+     */
+    hasMore: boolean;
+    /**
+     * @param userList 返回的群组的在线成员列表
+     */
+    userList: ImUser[];
+}
+
+interface ImListHistoryMessageReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param type 消息类型
+     */
+    type: number;
+    /**
+     * @param nextPageToken 不传时表示第一页,遍历时服务端会返回,客户端获取下一页时,应带上
+     */
+    nextPageToken?: number;
+    /**
+     * @param sortType 排序类型,默认为时间递增
+     */
+    sortType?: ImSortType;
+    /**
+     * @param pageSize 取值范围 10~30
+     */
+    pageSize?: number;
+    /**
+     * @param begintime 按时间范围遍历,开始时间,不传时表示最早时间,单位:秒
+     */
+    beginTime?: number;
+    /**
+     * @param endtime 按时间范围遍历,结束时间,不传时表示最晚时间,单位:秒
+     */
+    endTime?: number;
+}
+
+interface ImListHistoryMessageRsp {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param nextPageToken 不传时表示第一页,遍历时服务端会返回,客户端获取下一页时,应带上
+     */
+    nextPageToken?: number;
+    /**
+     *@param hasMore 是否有更多数据
+     */
+    hasMore: boolean;
+    /**
+     *@param messageList 返回消息列表
+     **/
+    messageList: ImMessage[];
+}
+
+interface ImListMessageReq {
+    /**
+     * @param groupId 话题id,聊天插件实例id
+     */
+    groupId: string;
+    /**
+     * @param type 消息类型
+     */
+    type: number;
+    /**
+     * @param nextPageToken 不传时表示第一页,遍历时服务端会返回,客户端获取下一页时应带上
+     */
+    nextPageToken?: number;
+    /**
+     * @deprecated 请使用nextPageToken
+     */
+    nextpagetoken?: number;
+    /**
+     * @param sortType 排序类型,默认为时间递增
+     */
+    sortType?: ImSortType;
+    /**
+     * @param pageSize 分页拉取的大小,默认10条,最大30条
+     */
+    pageSize?: number;
+    /**
+     * @param begintime 按时间范围遍历,开始时间,不传时表示最早时间,单位:秒
+     */
+    beginTime?: number;
+    /**
+     * @param endtime 按时间范围遍历,结束时间,不传时表示最晚时间,单位:秒
+     */
+    endTime?: number;
+}
+
+interface ImListMessageRsp {
+    /**
+     ** @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     *@param nextpagetoken 客户端获取下一页时,应带上
+     */
+    nextPageToken: number;
+    /**
+     * @deprecated 请使用 nextPageToken
+     */
+    nextpagetoken?: number;
+    /**
+     *@param hasmore 是否有更多数据
+     */
+    hasMore: boolean;
+    /**
+     *@param messageList 返回消息列表
+     **/
+    messageList: ImMessage[];
+}
+
+interface ImListMuteUsersReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+}
+
+interface ImListMuteUsersRsp {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param muteAll 是否全员禁言
+     */
+    muteAll: boolean;
+    /**
+     * @param muteUserList 禁言用户ID列表
+     */
+    muteUserList: string[];
+    /**
+     * @param whiteUserList 白名单用户ID列表
+     */
+    whiteUserList: string[];
+}
+
+interface ImListRecentGroupUserReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+}
+
+interface ImListRecentGroupUserRsp {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param total 群组成员总数
+     */
+    total: number;
+    /**
+     * @param userList 返回的群组的在线成员列表
+     */
+    userList: ImUser[];
+}
+
+interface ImListRecentMessageReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param seqnum 消息序列号
+     */
+    seqnum?: number;
+    /**
+     * @param pageSize 分页拉取的大小,默认50条
+     */
+    pageSize?: number;
+}
+
+interface ImListRecentMessageRsp {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param messageList 返回消息列表
+     */
+    messageList: ImMessage[];
+}
+
+interface ImLoginReq {
+    user: ImUser;
+    /**
+     * 用户鉴权信息
+     */
+    userAuth: ImAuth;
+}
+
+enum ImLogLevel {
+    NONE = 0,
+    DBUG = 1,
+    INFO = 2,
+    WARN = 3,
+    ERROR = 4
+}
+
+interface ImMemberChangeData {
+    groupId: string;
+    onlineCount: number;
+    pv: number;
+    isBigGroup: boolean;
+    joinUsers: ImUser[];
+    leaveUsers: ImUser[];
+}
+
+interface ImMessage {
+    /**
+     * @param groupId 话题id,聊天插件实例id
+     */
+    groupId?: string;
+    /**
+     * @param messageId 消息id
+     */
+    messageId: string;
+    /**
+     *@param type 消息类型。系统消息小于10000
+     */
+    type: number;
+    /**
+     *@param sender 发送者
+     */
+    sender?: ImUser;
+    /**
+     **@param data 消息内容
+     */
+    data: string;
+    /**
+     *@param seqnum 消息顺序号
+     */
+    seqnum: number;
+    /**
+     *@param timestamp 消息发送时间
+     */
+    timestamp: number;
+    /**
+     *@param level 消息分级
+     **/
+    level: ImMessageLevel;
+    /**
+     * @param repeatCount 消息统计数量增长值,默认1,主要用于聚合同类型消息。
+     */
+    repeatCount: number;
+    /**
+     * @param totalMsgs 同类型的消息数量
+     */
+    totalMsgs: number;
+}
+
+enum ImMessageLevel {
+    NORMAL = 0,
+    HIGH = 1
+}
+
+interface ImMessageListener {
+    /**
+     * 接收到c2c消息
+     * @param msg 消息
+     */
+    recvc2cmessage: (msg: ImMessage) => void;
+    /**
+     * 接收到群消息
+     * @param msg 消息
+     * @param groupId 群id
+     */
+    recvgroupmessage: (msg: ImMessage, groupId: string) => void;
+    /**
+     * 删除群消息
+     * @param msgId 消息id
+     * @param groupId 群id
+     */
+    deletegroupmessage: (msgId: string, groupId: string) => void;
+    /**
+     * 流消息结束通知
+     * @param {string} messageId 消息ID
+     * @param {number} endCode 结束原因:0正常处理结束,1主动取消,2与智能体服务连接异常断开,3连接超时断开,4收到新的开始切片,5包请求异常
+     * @param {number} [subCode] 详情码
+     * @param {string} [subMsg]  详情信息
+     */
+    streammessageend: (messageId: string, endCode: number, subCode?: number, subMsg?: string) => void;
+    /**
+     * 接收到流消息
+     * @param message 流消息
+     */
+    recvstreammessage: (message: ImStreamMessage) => void;
+}
+
+interface ImModifyGroupReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param forceUpdateGroupMeta 为true表示强制刷新groupMeta信息,若groupMeta为空则表示清空;
+     *                             为false,则只有groupMeta不空才更新groupMeta信息
+     */
+    forceUpdateGroupMeta?: boolean;
+    /**
+     * @param groupMeta 群信息扩展字段
+     */
+    groupMeta?: string;
+    /**
+     * @param forceUpdateAdmins 为true表示强制刷新admins信息,若admins为空则表示清空;
+     *                          为false,则只有admins不空才更新admins信息
+     */
+    forceUpdateAdmins?: boolean;
+    /**
+     * @param admins 群管理员ID列表,最多设置3个管理员
+     */
+    admins?: string[];
+}
+
+interface ImMuteAllReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+}
+
+interface ImMuteUserReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+    /**
+     * @param userList 需要禁言的用户列表
+     */
+    userList: string[];
+}
+
+interface ImQueryGroupReq {
+    /**
+     * @param groupId 群组id
+     */
+    groupId: string;
+}
+
+interface ImRejectStreamMessageReq {
+    /**
+     * 流式消息ID
+     */
+    messageId: string;
+    /**
+     * 数据接收类型
+     */
+    receiverType: ImStreamMessageReceiverType;
+    /**
+     * 错误码
+     */
+    code: number;
+    /**
+     * 错误信息
+     */
+    msg: string;
+}
+
+interface ImSdkConfig {
+    /**
+     * 设备唯一标识
+     */
+    deviceId?: string;
+    /**
+     * 应用ID
+     */
+    appId: string;
+    /**
+     * 应用签名
+     */
+    appSign: string;
+    /**
+     * 日志级别
+     */
+    logLevel?: ImLogLevel;
+    /**
+     * 来源
+     */
+    source?: string;
+    /**
+     * 心跳超时时间,单位是秒,默认 99s,允许 [15-120]s
+     */
+    heartbeatTimeout?: number;
+    /**
+     * @param extra 用户自定义参数
+     */
+    extra?: {
+        [key: string]: string;
+    };
+    /**
+     * @param uploader 附件上传器参数
+     */
+    uploader?: {
+        /**
+         * 是否提前加载,默认 false
+         */
+        preload?: boolean;
+        /**
+         * 指定sdk文件地址
+         */
+        sdkUrl?: string;
+    };
+}
+
+interface ImSdkListener {
+    /**
+     * 连接中
+     */
+    connecting: () => void;
+    /**
+     * 连接成功
+     */
+    connectsuccess: () => void;
+    /**
+     * 连接失败
+     */
+    connectfailed: (error: Error) => void;
+    /**
+     * 连接断开
+     * @param code 断开原因 1:主动退出, 2:被踢出 3:超时等其他原因 4:在其他端上登录
+     */
+    disconnect: (code: number) => void;
+    /**
+     * 连接状态变化
+     * state 状态 0:未连接 1:连接中 2:已连接 3:已断联
+     */
+    linkstate: (data: {
+        previousState: number;
+        currentState: number;
+    }) => void;
+    /**
+     * token过期
+     * @param callback 更新 Token 的回调
+     */
+    tokenexpired: (callback: TokenCallback) => void;
+    /**
+     * 重连成功
+     */
+    reconnectsuccess: (groupInfos: ImGroupInfo[]) => void;
+}
+
+interface ImSendMessageToGroupReq {
+    /**
+     * @param groupId 话题id,聊天插件实例id
+     */
+    groupId: string;
+    /**
+     * @param type 消息类型,小于等于10000位系统消息,大于10000位自定义消息
+     */
+    type: number;
+    /**
+     * @param data 消息体
+     */
+    data: string;
+    /**
+     * @param skipMuteCheck 跳过禁言检测,true:忽略被禁言用户,还可发消息;false:当被禁言时,消息无法发送,默认为false,即为不跳过禁言检测。
+     */
+    skipMuteCheck?: boolean;
+    /**
+     * @param skipAudit 跳过安全审核,true:发送的消息不经过阿里云安全审核服务审核;false:发送的消息经过阿里云安全审核服务审核,审核失败则不发送;
+     */
+    skipAudit?: boolean;
+    /**
+     * @param level 消息分级
+     */
+    level?: ImMessageLevel;
+    /**
+     * @param noStorage 为true时,表示该消息不需要存储,也无法拉取查询
+     */
+    noStorage?: boolean;
+    /**
+     * @param repeatCount 消息统计数量增长值,默认1,主要用于聚合同类型消息,只发送一次请求,例如点赞场景
+     */
+    repeatCount?: number;
+}
+
+interface ImSendMessageToUserReq {
+    /**
+     * 消息类型。系统消息小于10000
+     */
+    type: number;
+    /**
+     * 消息体
+     */
+    data: string;
+    /**
+     * 接收者用户
+     */
+    receiverId: string;
+    /**
+     * 跳过安全审核,true:发送的消息不经过阿里云安全审核服务审核;false:发送的消息经过阿里云安全审核服务审核,审核失败则不发送;
+     */
+    skipAudit?: boolean;
+    /**
+     * 消息分级
+     */
+    level?: ImMessageLevel;
+}
+
+enum ImSortType {
+    ASC = 0,
+    DESC = 1
+}
+
+interface ImStreamData {
+    seqNum: number;
+    byteData: ArrayBuffer;
+}
+
+interface ImStreamMessage {
+    /**
+     * 流式消息ID
+     */
+    messageId: string;
+    /**
+     * 发送用户
+     */
+    sender: ImUser;
+    /**
+     * 流式消息帧数据
+     */
+    data: ImStreamData;
+}
+
+enum ImStreamMessageDataType {
+    TEXT = 1,// 文本
+    BINARY_FILE = 2
+}
+
+enum ImStreamMessageReceiverType {
+    SERVER = 0
+}
+
+class ImStreamMessageSender extends EventEmitter<ImStreamMessageSenderListener> {
+    private wasmIns;
+    private sender;
+    constructor(wasmIns: any);
+    setSender(sender: any): void;
+    getMessageId(): string;
+    /**
+     * 发送字节数据
+     * @param {Uint8Array} byteData 字节数据
+     * @param {boolean} isLast 是否结束流
+     * @param {ImAttachmentRes[]} [attachments] 附件列表
+     */
+    sendByteData(byteData: Uint8Array, isLast: boolean, attachments?: ImAttachmentRes[]): void;
+    /**
+     * 取消发送
+     * @param {number} [code] 取消码
+     * @param {string} [msg] 取消原因
+     */
+    cancel(code?: number, msg?: string): void;
+    destroy(): void;
+}
+
+interface ImStreamMessageSenderListener {
+    /**
+     * 流消息结束通知
+     * @param {string} messageId 消息ID
+     * @param {number} endCode 结束原因:0正常处理结束,1主动取消,2与智能体服务连接异常断开,3连接超时断开,4收到新的开始切片,5包请求异常
+     * @param {number} [subCode] 详情码
+     * @param {string} [subMsg]  详情信息
+     */
+    streammessageend: (messageId: string, endCode: number, subCode?: number, subMsg?: string) => void;
+}
+
+enum ImStreamMessageStatus {
+    CONTINUE = 0,// 中间帧
+    START = 1,// 开始帧
+    END = 2,// 结束帧
+    ALL = 3,// 一次性传输
+    CANCEL = 4
+}
+
+enum ImStreamMessageType {
+    NORMAL = 0
+}
+
+interface ImUser {
+    /**
+     * @param user_id 用户id
+     */
+    userId: string;
+    /**
+     * @param user_extension 用户扩展信息
+     */
+    userExtension?: string;
+}
+
+type TokenCallback = (error: {
+    code?: number;
+    msg: string;
+} | null, auth?: ImAuth) => void;
+}

+ 940 - 0
src/client/mobile/components/Classroom/useClassroom.ts

@@ -0,0 +1,940 @@
+import { useState, useEffect, useRef } from 'react';
+import { useParams } from 'react-router';
+// import { ClassroomAPI } from '../../api/index.ts';
+// @ts-types="../../../share/aliyun-rtc-sdk.d.ts"
+// @ts-types="./alivc-im.iife.d.ts"
+import AliRtcEngine, { AliRtcSubscribeState, AliRtcVideoTrack } from 'aliyun-rtc-sdk';
+import { toast } from 'react-toastify';
+import { User } from '../../hooks/AuthProvider';
+import { aliyunClient } from '@/client/api';
+export enum Role {
+  Teacher = 'admin',
+  Student = 'student'
+}
+
+// 从SDK中提取需要的类型和枚举
+type ImEngine = InstanceType<typeof AliVCInteraction.ImEngine>;
+type ImGroupManager = AliVCInteraction.AliVCIMGroupManager;
+type ImMessageManager = AliVCInteraction.AliVCIMMessageManager;
+type ImLogLevel = AliVCInteraction.ImLogLevel;
+type ImMessageLevel = AliVCInteraction.ImMessageLevel;
+const { ERROR } = AliVCInteraction.ImLogLevel;
+const { NORMAL, HIGH } = AliVCInteraction.ImMessageLevel;
+
+interface ImUser {
+  userId: string;
+  userExtension?: string;
+}
+
+interface ImGroupMessage {
+  groupId: string;
+  type: number;
+  data: string;
+  sender?: ImUser;
+  timestamp?: number;
+}
+
+// 互动消息类型
+enum InteractionAction {
+  HandUp = 'hand_up',
+  CancelHandUp = 'cancel_hand_up',
+  AnswerHandUp = 'answer_hand_up'
+}
+
+interface InteractionMessage {
+  action: InteractionAction;
+  studentId: string;
+  studentName?: string;
+  timestamp?: number;
+  question?: string;
+}
+
+interface HandUpRequest {
+  studentId: string;
+  studentName?: string;
+  timestamp: number;
+  question?: string;
+}
+
+interface Question {
+  studentId: string;
+  studentName?: string;
+  question: string;
+  timestamp: number;
+}
+
+export enum ClassStatus {
+  NOT_STARTED = 'not_started',
+  IN_PROGRESS = 'in_progress',
+  ENDED = 'ended'
+}
+
+
+export const useClassroom = ({ user }:{ user : User }) => {
+  // 状态管理
+  // const [userId, setUserId] = useState<string>(''); // 保持string类型
+  const userId = user.id.toString();
+  const [isCameraOn, setIsCameraOn] = useState<boolean>(false);
+  const [isAudioOn, setIsAudioOn] = useState<boolean>(false);
+  const [isScreenSharing, setIsScreenSharing] = useState<boolean>(false);
+  const [className, setClassName] = useState<string>('');
+  const [role, setRole] = useState<Role | undefined>();
+  const [classId, setClassId] = useState<string>('');
+  const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
+  const [isJoinedClass, setIsJoinedClass] = useState<boolean>(false);
+  const [msgText, setMsgText] = useState<string>('');
+  const [messageList, setMessageList] = useState<string[]>([]);
+  const [errorMessage, setErrorMessage] = useState<string>('');
+  const [classStatus, setClassStatus] = useState<ClassStatus>(ClassStatus.NOT_STARTED);
+  const [handUpList, setHandUpList] = useState<HandUpRequest[]>([]);
+  const [questions, setQuestions] = useState<Question[]>([]);
+  const [students, setStudents] = useState<Array<{id: string, name: string}>>([]);
+  const [shareLink, setShareLink] = useState<string>('');
+  const [showCameraOverlay, setShowCameraOverlay] = useState<boolean>(true);
+
+  // SDK实例
+  const imEngine = useRef<ImEngine | null>(null);
+  const imGroupManager = useRef<ImGroupManager | null>(null);
+  const imMessageManager = useRef<ImMessageManager | null>(null);
+  const aliRtcEngine = useRef<AliRtcEngine | null>(null);
+  const remoteVideoElMap = useRef<Record<string, HTMLVideoElement>>({});
+  const remoteScreenContainer = useRef<HTMLDivElement>(null); // 主屏幕共享容器(重命名)
+  const remoteCameraContainer = useRef<HTMLDivElement>(null); // 摄像头小窗容器
+
+  // 辅助函数
+  const showMessage = (text: string): void => {
+    setMessageList((prevMessageList) => [...prevMessageList, text])
+  };
+
+  const showToast = (type: 'info' | 'success' | 'error', message: string): void => {
+    toast[type](message);
+  };
+
+
+
+
+  // 事件监听函数
+  const listenImEvents = (): void => {
+    if (!imEngine.current) return;    
+    if (!role) return;    
+
+    imEngine.current.on('connectsuccess', () => {
+      showMessage('IM连接成功');
+    });
+
+    imEngine.current.on('disconnect', async (code: number) => {
+      showMessage(`IM断开连接: ${code}`);
+      // 自动重连
+      try {
+        const res = await aliyunClient.im_token.$post({
+          json: { role }
+        });
+        if(!res.ok) { 
+          const { message } = await res.json()
+          throw new Error(message)
+        }
+        const { token, nonce, timestamp } = await res.json()
+        await imEngine.current!.login({
+          user: {
+            userId,
+            userExtension: JSON.stringify(user)
+          },
+          userAuth: {
+            nonce,
+            timestamp,
+            token,
+            role
+          }
+        });
+        showMessage('IM自动重连成功');
+      } catch (err: unknown) {
+        const error = err as Error;
+        showMessage(`IM自动重连失败: ${error.message}`);
+      }
+    });
+  };
+
+  const listenGroupEvents = (): void => {
+    if (!imGroupManager.current) return;
+
+    imGroupManager.current.on('memberchange', (groupId: string, memberCount: number, joinUsers: ImUser[], leaveUsers: ImUser[]) => {
+      showMessage(`成员变更: 加入${joinUsers.length}人, 离开${leaveUsers.length}人`);
+    });
+  };
+
+  const listenMessageEvents = (): void => {
+    if (!imMessageManager.current) return;
+
+    imMessageManager.current.on('recvgroupmessage', (msg: AliVCInteraction.ImMessage, groupId: string) => {
+      if (msg.type === 88889) { // 课堂状态消息
+        try {
+          const data = JSON.parse(msg.data);
+          if (data.action === 'start_class') {
+            setClassStatus(ClassStatus.IN_PROGRESS);
+            showMessage('老师已开始上课');
+          } else if (data.action === 'end_class') {
+            setClassStatus(ClassStatus.ENDED);
+            showMessage('老师已结束上课');
+          }
+        } catch (err) {
+          console.error('解析课堂状态消息失败', err);
+        }
+      } else if (msg.type === 88890) { // 静音指令
+        try {
+          const data = JSON.parse(msg.data);
+          if (data.action === 'toggle_mute' && data.userId === userId) {
+            showMessage(data.mute ? '你已被老师静音' : '老师已取消你的静音');
+          }
+        } catch (err) {
+          console.error('解析静音指令失败', err);
+        }
+      } else if (msg.type === 88891) { // 举手消息
+        try {
+          const data = JSON.parse(msg.data) as InteractionMessage;
+          if (data.action === InteractionAction.HandUp) {
+            const handUpData: HandUpRequest = {
+              ...data,
+              timestamp: data.timestamp || Date.now()
+            };
+            setHandUpList([...handUpList, handUpData]);
+            showMessage(`${data.studentName || data.studentId} 举手了`);
+          } else if (data.action === InteractionAction.CancelHandUp) {
+            setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
+          }
+        } catch (err) {
+          console.error('解析举手消息失败', err);
+        }
+      } else if (msg.type === 88892) { // 问题消息
+        try {
+          const data = JSON.parse(msg.data) as {question: string};
+          if (typeof data.question === 'string') {
+            const question: Question = {
+              studentId: msg.sender?.userId || 'unknown',
+              studentName: (() => {
+                try {
+                  return msg.sender?.userExtension ? JSON.parse(msg.sender.userExtension)?.nickname : null;
+                } catch {
+                  return null;
+                }
+              })() || msg.sender?.userId || '未知用户',
+              question: data.question,
+              timestamp: msg.timestamp || Date.now()
+            };
+            setQuestions([...questions, question]);
+          }
+          showMessage(`收到问题: ${data.question}`);
+        } catch (err) {
+          console.error('解析问题消息失败', err);
+        }
+      } else if (msg.type === 88893) { // 应答消息
+        try {
+          const data = JSON.parse(msg.data) as InteractionMessage;
+          if (data.action === InteractionAction.AnswerHandUp && data.studentId === userId) {
+            showMessage('老师已应答你的举手');
+            setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
+          }
+        } catch (err) {
+          console.error('解析应答消息失败', err);
+        }
+      } else if (msg.type === 88888) { // 普通文本消息
+        const sender = msg.sender;
+        const userExtension = JSON.parse(sender?.userExtension || '{}') as User;
+        const senderName = userExtension.nickname || userExtension.username;
+        showMessage(`${ senderName || '未知用户' }: ${msg.data}`);
+      }
+    });
+  };
+
+  // RTC相关函数
+  const removeRemoteVideo = (userId: string, type: 'camera' | 'screen' = 'camera') => {
+    const vid = `${type}_${userId}`;
+    const el = remoteVideoElMap.current[vid];
+    if (el) {
+      aliRtcEngine.current!.setRemoteViewConfig(null, userId, type === 'camera' ? AliRtcVideoTrack.AliRtcVideoTrackCamera : AliRtcVideoTrack.AliRtcVideoTrackScreen);
+      el.pause();
+      
+      // 根据流类型从不同容器移除
+      if (type === 'camera') {
+        remoteCameraContainer.current?.removeChild(el);
+      } else {
+        remoteScreenContainer.current?.removeChild(el);
+      }
+      delete remoteVideoElMap.current[vid];
+    }
+  };
+
+  const listenRtcEvents = () => {
+    if (!aliRtcEngine.current) return;
+
+    showMessage('注册rtc事件监听')
+
+    aliRtcEngine.current.on('remoteUserOnLineNotify', (userId: string) => {
+      showMessage(`用户 ${userId} 加入课堂`);
+      console.log('用户上线通知:', userId);
+    });
+
+    aliRtcEngine.current.on('remoteUserOffLineNotify', (userId: string) => {
+      showMessage(`用户 ${userId} 离开课堂`);
+      console.log('用户下线通知:', userId);
+      removeRemoteVideo(userId, 'camera');
+      removeRemoteVideo(userId, 'screen');
+    });
+
+    aliRtcEngine.current.on('videoSubscribeStateChanged', (
+      userId: string,
+      oldState: AliRtcSubscribeState,
+      newState: AliRtcSubscribeState,
+      interval: number,
+      channelId: string
+    ) => {
+      console.log(`视频订阅状态变化: 用户 ${userId}, 旧状态 ${oldState}, 新状态 ${newState}`);
+
+      switch(newState) {
+        case 3: // 订阅成功
+          try {
+            console.log('开始创建远程视频元素');
+            
+            if (remoteVideoElMap.current[`camera_${userId}`]) {
+              console.log(`用户 ${userId} 的视频元素已存在`);
+              return;
+            }
+            
+            const video = document.createElement('video');
+            video.autoplay = true;
+            video.playsInline = true;
+            video.className = 'w-80 h-45 mr-2 mb-2 bg-black';
+            
+            if (!remoteCameraContainer.current) {
+              console.error('摄像头视频容器未找到');
+              return;
+            }
+            
+            remoteCameraContainer.current.appendChild(video);
+            remoteVideoElMap.current[`camera_${userId}`] = video;
+            
+            aliRtcEngine.current!.setRemoteViewConfig(
+              video,
+              userId,
+              AliRtcVideoTrack.AliRtcVideoTrackCamera
+            );
+            
+            console.log(`已订阅用户 ${userId} 的视频流`);
+            showMessage(`已显示用户 ${userId} 的视频`);
+          } catch (err) {
+            console.error(`订阅用户 ${userId} 视频流失败:`, err);
+            showMessage(`订阅用户 ${userId} 视频流失败`);
+          }
+          break;
+          
+        case 1: // 取消订阅
+          console.log(`取消订阅用户 ${userId} 的视频流`);
+          showMessage(`取消订阅用户 ${userId} 的视频流`);
+          removeRemoteVideo(userId, 'camera');
+          break;
+          
+        case 2: // 订阅中
+          console.log(`正在订阅用户 ${userId} 的视频流...`);
+          break;
+          
+        default:
+          console.warn(`未知订阅状态: ${newState}`);
+      }
+    });
+
+    aliRtcEngine.current.on('screenShareSubscribeStateChanged', (
+      userId: string,
+      oldState: AliRtcSubscribeState,
+      newState: AliRtcSubscribeState,
+      elapseSinceLastState: number,
+      channel: string
+    ) => {
+      console.log(`屏幕分享订阅状态变更:uid=${userId}, oldState=${oldState}, newState=${newState}`);
+
+      switch(newState) {
+        case 3: // 订阅成功
+          try {
+            console.log('开始创建屏幕分享视频元素');
+            
+            if (remoteVideoElMap.current[`screen_${userId}`]) {
+              console.log(`用户 ${userId} 的屏幕分享元素已存在`);
+              return;
+            }
+            
+            const video = document.createElement('video');
+            video.autoplay = true;
+            video.playsInline = true;
+            video.className = 'w-full h-full bg-black';
+            
+            if (!remoteScreenContainer.current) {
+              console.error('屏幕共享容器未找到');
+              return;
+            }
+            
+            remoteScreenContainer.current.appendChild(video);
+            remoteVideoElMap.current[`screen_${userId}`] = video;
+            
+            aliRtcEngine.current!.setRemoteViewConfig(
+              video,
+              userId,
+              AliRtcVideoTrack.AliRtcVideoTrackScreen
+            );
+            
+            console.log(`已订阅用户 ${userId} 的屏幕分享流`);
+            showMessage(`已显示用户 ${userId} 的屏幕分享`);
+          } catch (err) {
+            console.error(`订阅用户 ${userId} 屏幕分享流失败:`, err);
+            showMessage(`订阅用户 ${userId} 屏幕分享流失败`);
+          }
+          break;
+          
+        case 1: // 取消订阅
+          console.log(`取消订阅用户 ${userId} 的屏幕分享流`);
+          showMessage(`取消订阅用户 ${userId} 的屏幕分享流`);
+          removeRemoteVideo(userId, 'screen');
+          break;
+          
+        case 2: // 订阅中
+          console.log(`正在订阅用户 ${userId} 的屏幕分享流...`);
+          break;
+          
+        default:
+          console.warn(`未知屏幕分享订阅状态: ${newState}`);
+      }
+    });
+  };
+
+  // 课堂操作方法
+  const login = async (role: Role): Promise<void> => {
+    if(!role) {
+      showToast('error', '角色不存在');
+      return;
+    }
+
+    try {
+      const { ImEngine: ImEngineClass } = window.AliVCInteraction;
+      const res = await aliyunClient.im_token.$post({
+        json: { role }
+      });
+      if(!res.ok) { 
+        const { message } = await res.json()
+        throw new Error(message)
+      }
+      const {appId, appSign, timestamp, nonce, token} = await res.json();
+      imEngine.current = ImEngineClass.createEngine();
+      await imEngine.current.init({
+        deviceId: 'xxxx',
+        appId,
+        appSign,
+        logLevel: ERROR,
+      });
+      await imEngine.current.login({
+        user: {
+          userId,
+          userExtension: JSON.stringify({ nickname: user?.nickname || user?.username || '' })
+        },
+        userAuth: {
+          nonce,
+          timestamp,
+          token,
+          role
+        }
+      });
+      
+      aliRtcEngine.current = AliRtcEngine.getInstance();
+      AliRtcEngine.setLogLevel(0);
+      
+      listenImEvents();
+      listenRtcEvents();
+      
+      setIsLoggedIn(true);
+      setErrorMessage('');
+      showToast('success', '登录成功');
+    } catch (err: any) {
+      setErrorMessage(`登录失败: ${err.message}`);
+      showToast('error', '登录失败');
+    }
+  };
+
+  const joinClass = async (classId: string): Promise<void> => {
+    if (!imEngine.current || !aliRtcEngine.current) return;
+    
+    // // 优先使用URL参数中的classId和role
+    // const { id: pathClassId, role: pathRole } = useParams();
+    // const finalClassId = (classId || pathClassId) as string;
+    // if (pathRole && ['teacher', 'student'].includes(pathRole)) {
+    //   setRole(pathRole === 'teacher' ? Role.Teacher : Role.Student);
+    // }
+    
+    // if (!finalClassId) {
+    //   setErrorMessage('课堂ID不能为空');
+    //   showToast('error', '请输入有效的课堂ID');
+    //   return;
+    // }
+
+    try {
+      const gm = imEngine.current.getGroupManager();
+      const mm = imEngine.current.getMessageManager();
+      imGroupManager.current = gm || null;
+      imMessageManager.current = mm || null;
+      await gm!.joinGroup(classId);
+      listenGroupEvents();
+      listenMessageEvents();
+
+      await joinRtcChannel(classId);
+
+      buildShareLink(classId)
+
+      setIsJoinedClass(true);
+      setErrorMessage('');
+      showToast('success', '加入课堂成功');
+    } catch (err: any) {
+      setErrorMessage(`加入课堂失败: ${err.message}`);
+      showToast('error', '加入课堂失败');
+      
+      if (imGroupManager.current) {
+        try {
+          await imGroupManager.current.leaveGroup(classId);
+        } catch (leaveErr) {
+          console.error('离开IM群组失败:', leaveErr);
+        }
+      }
+    }
+  };
+
+  const leaveClass = async (): Promise<void> => {
+    try {
+      if (imGroupManager.current && classId) {
+        await imGroupManager.current.leaveGroup(classId);
+      }
+      if (aliRtcEngine.current) {
+        await leaveRtcChannel();
+      }
+      
+      setIsJoinedClass(false);
+      showToast('info', '已离开课堂');
+    } catch (err) {
+      console.error('离开课堂失败:', err);
+      showToast('error', '离开课堂时发生错误');
+    }
+  };
+
+  const sendMessage = async (): Promise<void> => {
+    if (!imMessageManager.current || !classId) return;
+
+    if (!msgText.trim()) {
+      showToast('error', '消息不能为空');
+      return;
+    }
+
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: msgText,
+        type: 88888,
+        level: NORMAL,
+      });
+      setMsgText('');
+      setErrorMessage('');
+    } catch (err: any) {
+      setErrorMessage(`消息发送失败: ${err.message}`);
+    }
+  };
+
+  const startClass = async (): Promise<void> => {
+    if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
+    
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: JSON.stringify({ action: 'start_class' }),
+        type: 88889,
+        level: HIGH,
+      });
+      setClassStatus(ClassStatus.IN_PROGRESS);
+      showToast('success', '课堂已开始');
+    } catch (err: any) {
+      setErrorMessage(`开始上课失败: ${err.message}`);
+    }
+  };
+
+  const endClass = async (): Promise<void> => {
+    if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
+    
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: JSON.stringify({ action: 'end_class' }),
+        type: 88889,
+        level: HIGH,
+      });
+      setClassStatus(ClassStatus.ENDED);
+      showToast('success', '课堂已结束');
+      
+      try {
+        await leaveRtcChannel();
+      } catch (err: any) {
+        console.error('离开RTC频道失败:', err);
+        showToast('error', '离开RTC频道失败');
+      }
+    } catch (err: any) {
+      setErrorMessage(`结束上课失败: ${err.message}`);
+    }
+  };
+
+  const toggleMuteMember = async (userId: string, mute: boolean): Promise<void> => {
+    if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
+    
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: JSON.stringify({
+          action: 'toggle_mute',
+          userId,
+          mute
+        }),
+        type: 88890,
+        level: HIGH,
+      });
+      showToast('info', mute ? `已静音用户 ${userId}` : `已取消静音用户 ${userId}`);
+    } catch (err: any) {
+      setErrorMessage(`操作失败: ${err.message}`);
+    }
+  };
+
+  const buildShareLink = (classId: string) => {
+    const getBaseUrl = () => {
+      const protocol = window.location.protocol;
+      const host = window.location.host;
+      return `${protocol}//${host}`;
+  }
+    // const baseUrl = window.location.href.split('?')[0].replace(/\/[^/]*$/, '');
+    const baseUrl = getBaseUrl();
+    setShareLink(`${baseUrl}/mobile/classroom/${classId}/student`);
+  }
+
+  const createClass = async (className: string, maxMembers = 200): Promise<string | null> => {
+    if (!imEngine.current || !isLoggedIn || role !== Role.Teacher) {
+      showToast('error', '只有老师可以创建课堂');
+      return null;
+    }
+    
+    try {
+      const groupManager = imEngine.current.getGroupManager();
+      if (!groupManager) {
+        throw new Error('群组管理器未初始化');
+      }
+
+      showToast('info', '正在创建课堂...');
+      
+      const response = await groupManager.createGroup({
+        groupName: className,
+        groupMeta: JSON.stringify({
+          classType: 'interactive',
+          creator: userId,
+          createdAt: Date.now(),
+          maxMembers
+        })
+      });
+
+      if (!response?.groupId) {
+        throw new Error('创建群组失败: 未返回群组ID');
+      }
+
+      try {
+        await groupManager.joinGroup(response.groupId);
+        
+        showToast('success', '课堂创建并加入成功');
+        showMessage(`课堂 ${className} 创建成功,ID: ${response.groupId}`);
+        
+        setClassId(response.groupId);
+        setIsJoinedClass(true);
+        
+        const messageManager = imEngine.current.getMessageManager();
+        if (messageManager) {
+          imMessageManager.current = messageManager;
+          listenMessageEvents();
+        }
+        
+        await joinRtcChannel(response.groupId);
+        
+        // const baseUrl = window.location.href.split('?')[0].replace(/\/[^/]*$/, '');
+        // setShareLink(`${baseUrl}/mobile/classroom/${response.groupId}/student`);
+        buildShareLink(response.groupId)
+        
+        return response.groupId;
+      } catch (joinErr: any) {
+        throw new Error(`创建成功但加入失败: ${joinErr.message}`);
+      }
+    } catch (err: any) {
+      const errorMsg = err.message.includes('alreadyExist')
+        ? '课堂已存在'
+        : `课堂创建失败: ${err.message}`;
+      
+      setErrorMessage(errorMsg);
+      showToast('error', errorMsg);
+      return null;
+    }
+  };
+
+  const joinRtcChannel = async (classId: string, publishOptions?: {
+    publishVideo?: boolean
+    publishAudio?: boolean
+    publishScreen?: boolean
+  }) => {
+    if (!aliRtcEngine.current) return;
+    const {
+      publishVideo = false,
+      publishAudio = false,
+      publishScreen = false,
+    } = publishOptions || {};
+    const res = await aliyunClient.rtc_token.$post({
+      json: { channelId: classId }
+    });
+    if(!res.ok) { 
+      const { message } = await res.json()
+      throw new Error(message)
+    }
+    const { appId, token, timestamp } = await res.json()
+    await aliRtcEngine.current.publishLocalVideoStream(publishVideo);
+    await aliRtcEngine.current.publishLocalAudioStream(publishAudio);
+    await aliRtcEngine.current.publishLocalScreenShareStream(publishScreen);
+    await aliRtcEngine.current.joinChannel(
+      {
+        channelId: classId,
+        userId,
+        appId,
+        token,
+        timestamp,
+      },
+      userId
+    );
+  };
+
+  const leaveRtcChannel = async () => {
+    if (!aliRtcEngine.current) return;
+    await aliRtcEngine.current.leaveChannel();
+  };
+
+  // 切换摄像头状态
+  const toggleCamera = async () => {
+    if(!aliRtcEngine.current?.isInCall){
+      showToast('error', '先加入课堂');
+      return;
+    }
+
+    try {
+      if (isCameraOn) {
+        await aliRtcEngine.current?.stopPreview();
+        await aliRtcEngine.current?.enableLocalVideo(false)
+        await aliRtcEngine.current?.publishLocalVideoStream(false)
+      } else {
+        await aliRtcEngine.current?.setLocalViewConfig('localPreviewer', AliRtcVideoTrack.AliRtcVideoTrackCamera);
+        await aliRtcEngine.current?.enableLocalVideo(true)
+        await aliRtcEngine.current?.startPreview();
+        await aliRtcEngine.current?.publishLocalVideoStream(true)
+   
+      }
+      setIsCameraOn(!isCameraOn);
+    } catch (err) {
+      console.error('切换摄像头状态失败:', err);
+      showToast('error', '切换摄像头失败');
+    }
+  };
+
+  // 切换音频状态
+  const toggleAudio = async () => {
+    if(!aliRtcEngine.current?.isInCall){
+      showToast('error', '先加入课堂');
+      return;
+    }
+
+    try {
+      if (isAudioOn) {
+        await aliRtcEngine.current?.stopAudioCapture()
+        await aliRtcEngine.current?.publishLocalAudioStream(false);
+      } else {
+        await aliRtcEngine.current?.publishLocalAudioStream(true);
+      }
+      setIsAudioOn(!isAudioOn);
+    } catch (err) {
+      console.error('切换麦克风状态失败:', err);
+      showToast('error', '切换麦克风失败');
+    }
+  };
+
+  // 切换屏幕分享状态
+  const toggleScreenShare = async () => {
+    if(!aliRtcEngine.current?.isInCall){
+      showToast('error', '先加入课堂');
+      return;
+    }
+
+    try {
+      if (isScreenSharing) {
+        await aliRtcEngine.current?.publishLocalScreenShareStream(false)
+        await aliRtcEngine.current?.stopScreenShare()
+      } else {
+        await aliRtcEngine.current?.publishLocalScreenShareStream(true)
+        await aliRtcEngine.current?.setLocalViewConfig(
+          'screenPreviewer',
+          AliRtcVideoTrack.AliRtcVideoTrackScreen
+        );
+      }
+      setIsScreenSharing(!isScreenSharing);
+    } catch (err) {
+      console.error('切换屏幕分享失败:', err);
+      showToast('error', '切换屏幕分享失败');
+    }
+  };
+
+  const handUp = async (question?: string): Promise<void> => {
+    if (!imMessageManager.current || !classId || role !== 'student') return;
+    
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: JSON.stringify({
+          action: 'hand_up',
+          studentId: userId,
+          timestamp: Date.now(),
+          question
+        }),
+        type: 88891,
+        level: NORMAL,
+      });
+    } catch (err: any) {
+      setErrorMessage(`举手失败: ${err.message}`);
+    }
+  };
+
+  const muteStudent = async (studentId: string): Promise<void> => {
+    if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
+    
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: JSON.stringify({
+          action: 'toggle_mute',
+          userId: studentId,
+          mute: true
+        }),
+        type: 88890,
+        level: HIGH,
+      });
+      showToast('info', `已静音学生 ${studentId}`);
+    } catch (err: any) {
+      setErrorMessage(`静音失败: ${err.message}`);
+    }
+  };
+
+  const kickStudent = async (studentId: string): Promise<void> => {
+    if (!imGroupManager.current || !classId || role !== Role.Teacher) return;
+    
+    try {
+      await imGroupManager.current.leaveGroup(classId);
+      showToast('info', `已移出学生 ${studentId}`);
+    } catch (err: any) {
+      setErrorMessage(`移出失败: ${err.message}`);
+    }
+  };
+
+  const answerHandUp = async (studentId: string): Promise<void> => {
+    if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
+    
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: JSON.stringify({
+          action: 'answer_hand_up',
+          studentId
+        }),
+        type: 88893,
+        level: HIGH,
+      });
+      showToast('info', `已应答学生 ${studentId} 的举手`);
+    } catch (err: any) {
+      setErrorMessage(`应答失败: ${err.message}`);
+    }
+  };
+
+  const sendQuestion = async (question: string): Promise<void> => {
+    if (!imMessageManager.current || !classId) return;
+    
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: question,
+        type: 88892,
+        level: NORMAL,
+      });
+    } catch (err: any) {
+      setErrorMessage(`问题发送失败: ${err.message}`);
+    }
+  };
+
+  // 清理资源
+  useEffect(() => {
+    return () => {
+      if (imGroupManager.current) {
+        imGroupManager.current.removeAllListeners();
+      }
+      if (imMessageManager.current) {
+        imMessageManager.current.removeAllListeners();
+      }
+      if (imEngine.current) {
+        imEngine.current.removeAllListeners();
+      }
+      if (aliRtcEngine.current) {
+        aliRtcEngine.current.destroy();
+      }
+    };
+  }, []);
+
+  return {
+    // 状态
+    userId,
+    isCameraOn,
+    isAudioOn,
+    isScreenSharing,
+    className,
+    setClassName,
+    role,
+    setRole,
+    classId,
+    setClassId,
+    isLoggedIn,
+    isJoinedClass,
+    msgText,
+    setMsgText,
+    messageList,
+    errorMessage,
+    classStatus,
+    handUpList,
+    questions,
+    students,
+    shareLink,
+    remoteScreenContainer, // 重命名为remoteScreenContainer
+    remoteCameraContainer, // 导出摄像头容器ref
+    showCameraOverlay,
+    setShowCameraOverlay,
+
+    // 方法
+    login,
+    joinClass,
+    leaveClass,
+    sendMessage,
+    startClass,
+    endClass,
+    toggleMuteMember,
+    createClass,
+    toggleCamera,
+    toggleAudio,
+    toggleScreenShare,
+    handUp,
+    answerHandUp,
+    sendQuestion,
+    muteStudent,
+    kickStudent
+  };
+};
+

+ 43 - 0
src/client/mobile/components/ErrorPage.tsx

@@ -0,0 +1,43 @@
+import React from 'react';
+import { useRouteError, useNavigate } from 'react-router';
+import { Alert, Button } from 'antd';
+
+export const ErrorPage = () => {
+  const navigate = useNavigate();
+  const error = useRouteError() as any;
+  const errorMessage = error?.statusText || error?.message || '未知错误';
+  
+  return (
+    <div className="flex flex-col items-center justify-center flex-grow p-4"
+    >
+      <div className="max-w-3xl w-full">
+        <h1 className="text-2xl font-bold mb-4">发生错误</h1>
+        <Alert 
+          type="error"
+          message={error?.message || '未知错误'}
+          description={
+            error?.stack ? (
+              <pre className="text-xs overflow-auto p-2 bg-gray-100 dark:bg-gray-800 rounded">
+                {error.stack}
+              </pre>
+            ) : null
+          }
+          className="mb-4"
+        />
+        <div className="flex gap-4">
+          <Button 
+            type="primary" 
+            onClick={() => navigate(0)}
+          >
+            重新加载
+          </Button>
+          <Button 
+            onClick={() => navigate('/admin')}
+          >
+            返回首页
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+};

+ 438 - 0
src/client/mobile/components/Exam/ExamAdmin.tsx

@@ -0,0 +1,438 @@
+import React, { useState, useEffect } from 'react';
+import { useSearchParams } from 'react-router';
+import { Table, Button, message, Input, QRCode, Modal, Tabs } from 'antd';
+// import type { ColumnType } from 'antd/es/table';
+import type { GetProp , TableProps} from 'antd';
+import dayjs from 'dayjs';
+import { useSocketClient } from './hooks/useSocketClient';
+import type {
+  QuizState,
+  ExamSocketRoomMessage
+} from './types.ts';
+
+import type { Answer, CumulativeResult } from './types.ts';
+
+type ColumnType = GetProp<TableProps,'columns'>[number]
+
+// 当前答题情况组件
+function CurrentAnswers({ answers, columns }: { answers: Answer[], columns: any[] }) {
+  return (
+    <div>
+      <Table 
+        columns={columns}
+        dataSource={answers}
+        rowKey={(record) => `${record.userId}-${record.date}`}
+        pagination={false}
+      />
+    </div>
+  );
+}
+
+// 每日统计组件
+function DailyStatistics({ dailyAnswers, columns }: { dailyAnswers: {[key: string]: Answer[]}, columns: any[] }) {
+  return (
+    <div>
+      <Table 
+        columns={columns}
+        dataSource={Object.keys(dailyAnswers).map(date => ({ date }))}
+        rowKey="date"
+        pagination={false}
+      />
+    </div>
+  );
+}
+
+// 累计结果组件
+function CumulativeResults({ results, columns }: { results: CumulativeResult[], columns: any[] }) {
+  return (
+    <div>
+      <Table
+        columns={columns}
+        dataSource={results}
+        rowKey="userId"
+        pagination={false}
+      />
+    </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}/mobile/exam/card?classroom=${classroom}`} />
+      </div>
+    </div>
+  );
+}
+
+export default function ExamAdmin() {
+  const [searchParams] = useSearchParams();
+  const classroom = searchParams.get('classroom');
+  const {
+    socketRoom: { joinRoom, leaveRoom, client },
+    answerManagement,
+    isConnected,
+  } = useSocketClient(classroom as string);
+
+  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');
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const initExamData = async () => {
+    if (!classroom) return;
+    setLoading(true);
+    setError(null);
+    try {
+      // 获取当前问题
+      const question = await answerManagement.getCurrentQuestion(classroom);
+      if (question) {
+        setCurrentDate(question.date);
+        setCurrentPrice(String(question.price));
+
+        // 获取答题记录
+        const answers = await answerManagement.getAnswers(
+          classroom,
+          ''
+        );
+        
+        const processedAnswers = answers.map(answer => ({
+          ...answer,
+          profitAmount: answer.profitAmount || 0,
+          profitPercent: answer.profitPercent || 0,
+          holdingStock: answer.holdingStock || '0',
+          holdingCash: answer.holdingCash || '0'
+        }));
+
+        const processedDailyAnswers:{[key: string]: Answer[]} = {};
+        processedAnswers.forEach(val => {
+          if(!processedDailyAnswers[val.date])
+            processedDailyAnswers[val.date] = [];
+          processedDailyAnswers[val.date].push(val)
+        })
+
+        setAnswers(processedAnswers);
+        setDailyAnswers(processedDailyAnswers);
+      }
+    } catch (err) {
+      console.error('初始化答题数据失败:', err);
+      setError('初始化答题数据失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 结算函数
+  const handleSettlement = async () => {
+    if (!classroom || answers.length === 0) return;
+    setLoading(true);
+    
+    try {
+      await answerManagement.sendSettleExam(classroom);
+      message.success('结算成功');
+    } catch (error) {
+      console.error('结算失败:', error);
+      message.error('结算失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleSubmit = async () => {
+    if (!classroom || answers.length === 0) return;
+
+    try {
+      await answerManagement.cleanupRoom(classroom);
+      message.success('答案提交成功');
+      setAnswers([]);
+      setDailyAnswers({});
+      setCurrentDate('');
+      setCurrentPrice('0');
+    } catch (error: any) {
+      console.error('提交答案失败:', error);
+      message.error(error?.message || '提交答案失败');
+    }
+  };
+
+  const handleRestart = async () => {
+    if (!classroom) return;
+    try {
+      await answerManagement.cleanupRoom(classroom);
+      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[] = [
+    {
+      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}
+      />,
+    },
+  ];
+
+  
+  // 加入/离开房间
+  useEffect(() => {
+    if (!classroom) return;
+    
+    joinRoom(classroom);
+    initExamData();
+
+    return () => {
+      leaveRoom(classroom);
+    };
+  }, [classroom, joinRoom, leaveRoom]);
+
+
+  // 监听答题消息并更新答案
+  useEffect(() => {
+    if (!classroom || !currentDate || !client) return;
+
+    const handleAnswerMessage = async () => {
+      try {
+        const answers = await answerManagement.getAnswers(
+          classroom as string,
+          currentDate
+        );
+        
+        const processedAnswers = answers.map(answer => ({
+          ...answer,
+          profitAmount: answer.profitAmount || 0,
+          profitPercent: answer.profitPercent || 0,
+          holdingStock: answer.holdingStock || '0',
+          holdingCash: answer.holdingCash || '0'
+        }));
+
+        setAnswers(processedAnswers);
+        setDailyAnswers(prev => ({
+          ...prev,
+          [currentDate]: processedAnswers
+        }));
+      } catch (error) {
+        console.error('获取答案失败:', error);
+      }
+    };
+
+    client.on('exam:answerUpdated', handleAnswerMessage);
+
+    return () => {
+      if (!client) return;
+      client.off('exam:answerUpdated', handleAnswerMessage);
+    };
+  }, [classroom, currentDate, answerManagement, client]);
+
+  // 监听当前问题变化
+  useEffect(() => {
+    if (!client ) return;
+
+    const handleQuestionUpdate = (question:QuizState ) => {
+      setCurrentDate(question.date);
+      setCurrentPrice(String(question.price));
+    };
+
+    client.on('exam:question', handleQuestionUpdate);
+    return () => {
+      client.off('exam:question', handleQuestionUpdate);
+    };
+  }, [client]);
+
+  return (
+    <div className="p-6">
+      {!isConnected && (
+        <div className="bg-yellow-600 text-white text-center py-1 text-sm">
+          正在尝试连接答题卡服务...
+        </div>
+      )}
+      <div className="mb-6 flex justify-between items-center">
+        <div>
+          <h2 className="text-2xl font-bold">答题卡管理</h2>
+          <div className="mt-2 text-gray-600">
+            <span className="mr-4">教室号: {classroom}</span>
+            <span className="mr-4">当前日期: {currentDate}</span>
+            <span>当前价格: {currentPrice}</span>
+          </div>
+        </div>
+      </div>
+
+      {/* 主要内容区域 */}
+      <div className="mb-6">
+        <Tabs 
+          activeKey={activeTab} 
+          onChange={setActiveTab}
+          items={items}
+        />
+      </div>
+
+      {/* 底部按钮组 */}
+      <div className="flex items-center space-x-4 mb-8">
+        <Button onClick={handleSettlement} disabled={answers.length === 0}>
+          结算
+        </Button>
+        <Button type="primary" onClick={handleSubmit} disabled={answers.length === 0}>
+          收卷
+        </Button>
+        <Input
+          value={mark}
+          onChange={(e) => setMark(e.target.value)}
+          placeholder="标记"
+          style={{ width: 200 }}
+        />
+        <Button onClick={() => message.info('标记已保存')}>查看</Button>
+        <Button onClick={handleRestart}>重开</Button>
+      </div>
+
+      {/* 二维码区域 */}
+      <QRCodeSection classroom={classroom || ''} />
+    </div>
+  );
+} 

+ 360 - 0
src/client/mobile/components/Exam/ExamCard.tsx

@@ -0,0 +1,360 @@
+import React,{ useState, useCallback, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useSearchParams, useNavigate } from "react-router";
+import dayjs from 'dayjs';
+import { useSocketClient } from './hooks/useSocketClient';
+import { classroomDataClient } from '@/client/api';
+import type { QuizState } from './types';
+import type { AnswerRecord, Answer } from './types';
+import { useAuth } from '@/client/mobile/hooks/AuthProvider';
+import { ClassroomStatus } from '@/server/modules/classroom/classroom-data.entity';
+import { toast } from 'react-toastify';
+
+// 答题卡页面
+export default function ExamCard() {
+  const { user } = useAuth();
+  const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
+  const classroom = searchParams.get('classroom');
+  const {
+    socketRoom: { joinRoom, leaveRoom, client },
+    answerManagement,
+    isConnected,
+  } = useSocketClient(classroom as string);
+  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 classroomDataClient.$get({
+        query: { filters: JSON.stringify({ classroomNo: classroom }) }
+      });
+      if (response.status !== 200) {
+        toast.error('获取教室数据失败');
+        return null;
+      }
+      const data = await response.json();
+      return data.data[0] || null;
+    },
+    enabled: !!classroom
+  });
+
+  // 初始化答题卡数据
+  const initExamCard = useCallback(async () => {
+    if (!classroom || !user?.username) {
+      toast.error('参数缺少');
+      // globalThis.location.href = '/exam';
+      navigate('/mobile')
+      return;
+    }
+    
+    if (classroomData && classroomData.status !== ClassroomStatus.OPEN) {
+      toast.error('该教室已关闭');
+      // globalThis.location.href = '/exam';
+      navigate('/mobile')
+      return;
+    }
+    
+    // 获取当前问题并更新状态
+    const question = await answerManagement.getCurrentQuestion(classroom);
+    setCurrentDate(question.date);
+    setCurrentPrice(String(question.price));
+    setIsStarted(true);
+    
+    // 获取用户回答记录
+    if (user?.id) {
+      try {
+        const answers = await answerManagement.getUserAnswers(classroom, String(user.id));
+        if (answers && answers.length > 0) {
+          const lastAnswer = answers[answers.length - 1];
+          setHoldingStock(lastAnswer.holdingStock);
+          setHoldingCash(lastAnswer.holdingCash);
+          
+          const records = answers.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);
+        }
+      } catch (error) {
+        console.error('获取用户回答记录失败:', error);
+      }
+    }
+  }, [classroom, user, classroomData, answerManagement]);
+
+  // 加入/离开房间
+  useEffect(() => {
+    if (!classroom) return;
+    
+    joinRoom(classroom);
+
+    initExamCard();
+    
+    return () => {
+      leaveRoom(classroom);
+    };
+  }, [classroom, joinRoom, leaveRoom]);
+
+  // // 处理房间消息
+  // useEffect(() => {
+  //   if (!lastMessage?.message) return;
+
+  //   const { type } = lastMessage.message;
+    
+  //   // 只处理重开消息的UI重置
+  //   if (type === 'restart') {
+  //     setCurrentDate('');
+  //     setCurrentPrice('0');
+  //     setHoldingStock('0');
+  //     setHoldingCash('0');
+  //     setIsStarted(false);
+  //     setAnswerRecords([]);
+  //   }
+  // }, [lastMessage]);
+
+  // 处理选择A(持股)
+  const handleChooseA = useCallback(async () => {
+    setHoldingStock('1');
+    setHoldingCash('0');
+    
+    if (classroom && user?.username && currentDate) {
+      const answer = {
+        date: currentDate,
+        holdingStock: '1',
+        holdingCash: '0',
+        userId: String(user.id),
+        price: currentPrice
+      };
+      
+      try {
+        await answerManagement.storeAnswer(
+          classroom as string,
+          currentDate,
+          String(user.id),
+          answer,
+          (answers) => {
+            const records = answers.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);
+          }
+        );
+      } catch (error) {
+        toast.error('提交答案失败');
+      }
+    }
+  }, [classroom, user, currentDate, currentPrice, answerManagement]);
+
+  const handleChooseB = useCallback(async () => {
+    setHoldingStock('0');
+    setHoldingCash('1');
+    
+    if (classroom && user?.username && currentDate) {
+      const answer = {
+        date: currentDate,
+        holdingStock: '0',
+        holdingCash: '1',
+        userId: String(user.id),
+        price: currentPrice
+      };
+      
+      try {
+        await answerManagement.storeAnswer(
+          classroom as string,
+          currentDate,
+          String(user.id),
+          answer,
+          (answers) => {
+            const records = answers.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);
+          }
+        );
+      } catch (error) {
+        toast.error('提交答案失败');
+      }
+    }
+  }, [classroom, user, currentDate, currentPrice, answerManagement]);
+
+  // 监听当前问题变化
+  useEffect(() => {
+    if (!client ) return;
+
+    const handleQuestionUpdate = (question:QuizState ) => {
+      setCurrentDate(question.date);
+      setCurrentPrice(String(question.price));
+      setIsStarted(true);
+    };
+
+    client.on('exam:question', handleQuestionUpdate);
+    return () => {
+      client.off('exam:question', handleQuestionUpdate);
+    };
+  }, [client]);
+
+  // 监听重开
+  useEffect(() => {
+    if (!client ) return;
+
+    const handleCleaned = () => {
+      setCurrentDate('');
+      setCurrentPrice('0');
+      setHoldingStock('0');
+      setHoldingCash('0');
+      setIsStarted(false);
+      setAnswerRecords([]);
+    };
+
+    client.on('exam:cleaned', handleCleaned);
+    return () => {
+      client.off('exam:cleaned', handleCleaned);
+    };
+  }, [client]);
+
+  // 监听结算消息
+  useEffect(() => {
+    if (!client) return;
+
+    const handleSettle = () => {
+      handleChooseB();
+    };
+
+    client.on('exam:settle', handleSettle);
+    return () => {
+      client.off('exam:settle', handleSettle);
+    };
+  }, [client, handleChooseB]);
+
+  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">
+      {!isConnected && (
+        <div className="bg-yellow-600 text-white text-center py-1 text-sm">
+          正在尝试连接答题卡服务...
+        </div>
+      )}
+      {/* 选择区域 */}
+      <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.trainingDate).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={`flex-1 py-8 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={`flex-1 py-8 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">昵称: {user?.username || '未知用户'}</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">
+            {[...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>
+  );
+}

+ 103 - 0
src/client/mobile/components/Exam/ExamIndex.tsx

@@ -0,0 +1,103 @@
+import React, { useState, useCallback } from 'react';
+import { useNavigate } from "react-router";
+import dayjs from 'dayjs';
+import { classroomDataClient } from '@/client/api';
+import { ClassroomStatus } from '@/server/modules/classroom/classroom-data.entity';
+import type { InferResponseType } from 'hono/client';
+import { toast } from 'react-toastify';
+
+type ClassroomDataResponse = InferResponseType<typeof classroomDataClient.$get, 200>;
+type ClassroomData = ClassroomDataResponse['data'][0];
+// 教室号输入页面
+function ExamIndex() {
+  const [classroom, setClassroom] = useState('');
+  const navigate = useNavigate();
+  
+  const [classroomData, setClassroomData] = useState<ClassroomData | null>(null);
+  const [isLoading, setIsLoading] = useState(false);
+
+  const handleJoinTraining = useCallback(async () => {
+    if (!classroom) {
+      toast.error('教室号不能为空');
+      return;
+    }
+
+    try {
+      setIsLoading(true);
+      const response = await classroomDataClient.$get({
+        query: { filters: JSON.stringify({ classroomNo: classroom }) }
+      });
+      
+      if (response.status !== 200) {
+        toast.error('获取教室数据失败');
+        setClassroomData(null);
+        return;
+      }
+      
+      const result = await response.json();
+      if (!result.data?.length) {
+        toast.error('教室不存在');
+        setClassroomData(null);
+        return;
+      }
+
+      const data = result.data[0];
+      setClassroomData(data);
+
+      if (data.status !== ClassroomStatus.OPEN) {
+        toast.error('该教室已关闭');
+        return;
+      }
+
+      // 将教室号作为参数传递到答题页
+      navigate(`/mobile/exam/card?classroom=${classroom}`);
+    } catch (error) {
+      toast.error('获取教室数据失败');
+      setClassroomData(null);
+    } finally {
+      setIsLoading(false);
+    }
+  }, [navigate, classroom]);
+
+  if (isLoading) {
+    return <div className="flex items-center justify-center min-h-screen">加载中...</div>;
+  }
+
+  return (
+    <div className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
+      <div className="w-full max-w-md space-y-8">
+        <div className="text-center">
+          <h2 className="text-3xl 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.trainingDate).format('YYYY-MM-DD')}</p>
+              <p>代码: {classroomData.code}</p>
+            </div>
+          )}
+        </div>
+        
+        <div className="mt-8 space-y-4">
+          <input
+            type="text"
+            value={classroom}
+            onChange={(e) => setClassroom(e.target.value)}
+            placeholder="请输入教室号"
+            className="w-full px-4 py-3 text-lg border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
+          />
+        </div>
+
+        <button
+          onClick={handleJoinTraining}
+          className="w-full px-8 py-3 text-lg font-medium text-white bg-blue-500 rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
+        >
+          开始答题
+        </button>
+      </div>
+    </div>
+  );
+}
+
+export default ExamIndex;

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

@@ -0,0 +1,387 @@
+import { useEffect, useState, useCallback } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { io, Socket } from 'socket.io-client';
+import type {
+  QuizContent,
+  QuizState,
+  ExamSocketMessage,
+  ExamSocketRoomMessage,
+  Answer,
+  CumulativeResult
+} from '../types.ts';
+import { useAuth } from '@/client/mobile/hooks/AuthProvider.js';
+
+interface FullExamSocketMessage extends Omit<ExamSocketMessage, 'timestamp'> {
+  id: string;
+  from: string;
+  timestamp: string;
+}
+
+
+// 工具函数:统一错误处理
+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 };
+}
+
+// 提前声明函数
+function getAnswers(client: Socket | null, roomId: string, questionId: string): Promise<Answer[]> {
+  if (!client) return Promise.resolve([]);
+
+  return new Promise((resolve) => {
+    client.emit('exam:getAnswers', { roomId, questionId }, (answers: Answer[]) => {
+      resolve(answers || []);
+    });
+  });
+}
+
+export function useSocketClient(roomId: string | null) {
+  const { token } = useAuth();
+  const [socket, setSocket] = useState<Socket | null>(null);
+  const [isConnected, setIsConnected] = useState(false);
+
+  // 初始化socket连接
+  const { data: client } = useQuery({
+    queryKey: ['socket-client', token],
+    queryFn: async () => {
+      if (!token) return null;
+
+      const newSocket = io('/', {
+        path: '/socket.io',
+        transports: ['websocket'],
+        withCredentials: true,
+        query: {
+          socket_token: token
+        },
+        reconnection: true,
+        reconnectionAttempts: 5,
+        reconnectionDelay: 1000,
+      });
+
+      newSocket.on('connect', () => {
+        console.log('Socket connected');
+      setIsConnected(true);
+      });
+
+      newSocket.on('disconnect', () => {
+        console.log('Socket disconnected');
+        setIsConnected(false);
+      });
+
+      newSocket.on('error', (error) => {
+        console.error('Socket error:', error);
+      });
+
+      setSocket(newSocket);
+      return newSocket;
+    },
+    enabled: !!token && !!roomId,
+    staleTime: Infinity,
+    gcTime: 0,
+    retry: 3,
+  });
+
+  // 加入房间
+  const joinRoom = useCallback(async (roomId: string) => {
+    if (client) {
+      client.emit('exam:join', { roomId });
+    }
+  }, [client]);
+
+  // 离开房间
+  const leaveRoom = useCallback(async (roomId: string) => {
+    if (client) {
+      client.emit('exam:leave', { roomId });
+    }
+  }, [client]);
+
+  // // 发送房间消息
+  // const sendRoomMessage = useCallback(async (roomId: string, message: ExamSocketMessage) => {
+  //   if (client) {
+  //     client.emit('exam:message', { roomId, message });
+  //   }
+  // }, [client]);
+
+  // // 监听房间消息
+  // const onRoomMessage = useCallback((callback: (data: ExamSocketRoomMessage) => void) => {
+  //   if (client) {
+  //     client.on('exam:message', (data) => {
+  //       setLastMessage(data);
+  //       callback(data);
+  //     });
+  //   }
+  // }, [client]);
+
+  // 存储答案
+  const storeAnswer = useCallback(async (roomId: string, questionId: string, userId: string, answer: QuizContent, callback?: (success: Answer[]) => void) => {
+    if (!client) return;
+
+    return handleAsyncOperation(async () => {
+      // // 获取历史价格数据
+      // const pricesData = await new Promise<any>((resolve) => {
+      //   client.emit('exam:getPrices', { roomId }, resolve);
+      // });
+
+      // if (!pricesData) {
+      //   // 存储初始答案
+      //   const initialAnswer: Answer = {
+      //     ...answer,
+      //     userId,
+      //     holdingStock: '0',
+      //     holdingCash: '0',
+      //     profitAmount: 0,
+      //     profitPercent: 0,
+      //     totalProfitAmount: 0,
+      //     totalProfitPercent: 0
+      //   };
+        
+      //   client.emit('exam:storeAnswer', {
+      //     roomId,
+      //     questionId,
+      //     userId,
+      //     answer: initialAnswer
+      //   }, (success: boolean) => {
+      //     callback?.([initialAnswer]);
+      //   });
+      //   return;
+      // }
+
+      // 获取该用户的所有历史答案
+      // const dates = Object.keys(pricesData).sort();
+      const allUserAnswers = await getUserAnswers(roomId, userId);
+      const userAnswers = allUserAnswers
+        .filter((a: Answer) => a.date !== answer.date)
+        // .filter((a: Answer) => dates.includes(a.date || ''))
+        .map((a: Answer) => ({
+          ...a,
+          // price: pricesData[a.date || '']?.price || '0'
+        }))
+        .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
+      };
+
+      client.emit('exam:storeAnswer', {
+        roomId,
+        questionId,
+        userId,
+        answer: answerWithProfit
+      }, (success: boolean) => {
+        callback?.([...userAnswers, answerWithProfit]);
+      });
+    }, '存储答案失败');
+  }, [client]);
+
+  // 清理房间数据
+  const cleanupRoom = useCallback(async (roomId: string, questionId?: string) => {
+    if (!client) return;
+
+    await handleAsyncOperation(async () => {
+      if (questionId) {
+        client.emit('exam:cleanup', { roomId, questionId });
+      } else {
+        client.emit('exam:cleanup', { roomId });
+      }
+    }, '清理房间数据失败');
+  }, [client]);
+
+  // // 发送下一题
+  // const sendNextQuestion = useCallback(async (roomId: string, state: QuizState) => {
+  //   if (!client) return;
+
+  //   return handleAsyncOperation(async () => {
+  //     const message: FullExamSocketMessage = {
+  //       id: `question-${Date.now()}`,
+  //       type: 'question',
+  //       from: 'system',
+  //       timestamp: Date.now().toString(),
+  //       content: {
+  //         date: state.date,
+  //         price: state.price,
+  //         holdingStock: '0',
+  //         holdingCash: '0',
+  //         userId: 'system'
+  //       }
+  //     };
+
+  //     // 存储当前问题状态
+  //     await storeAnswer(roomId, 'current_state', 'system', {
+  //       date: state.date,
+  //       price: state.price,
+  //       holdingStock: '0',
+  //       holdingCash: '0',
+  //       userId: 'system'
+  //     });
+
+  //     // 存储价格历史记录
+  //     client.emit('exam:storePrice', { 
+  //       roomId, 
+  //       date: state.date, 
+  //       price: state.price 
+  //     });
+
+  //     await sendRoomMessage(roomId, message);
+  //   }, '发送题目失败');
+  // }, [client, sendRoomMessage, storeAnswer]);
+
+
+  // 获取历史价格
+  const getPriceHistory = useCallback(async (roomId: string, date: string): Promise<string> => {
+    if (!client) return '0';
+
+    return handleAsyncOperation(async () => {
+      return new Promise((resolve) => {
+        client.emit('exam:getPrice', { roomId, date }, (price: string) => {
+          resolve(price || '0');
+        });
+      });
+    }, '获取历史价格失败');
+  }, [client]);
+
+  // 获取答案 (封装为useCallback)
+  const getAnswersCallback = useCallback((roomId: string, questionId: string): Promise<Answer[]> => {
+    if (!client) return Promise.resolve([]);
+    return handleAsyncOperation(async () => {
+      return getAnswers(client, roomId, questionId);
+    }, '获取答案失败');
+  }, [client]);
+
+  // 清理socket连接
+  useEffect(() => {
+    return () => {
+      if (socket) {
+        socket.disconnect();
+      }
+    };
+  }, [socket]);
+
+  // 导出所有功能作为单个对象
+  const socketRoom = {
+    client,
+    joinRoom,
+    leaveRoom,
+    // sendRoomMessage,
+    // onRoomMessage
+  };
+
+  // 获取用户答案
+  const getUserAnswers = useCallback(async (roomId: string, userId: string): Promise<Answer[]> => {
+    if (!client || !roomId || !userId) return Promise.resolve([]);
+
+    return handleAsyncOperation(async () => {
+      return new Promise((resolve) => {
+        client.emit('exam:getUserAnswers', { roomId, userId }, (answers: Answer[]) => {
+          resolve(answers || []);
+        });
+      });
+    }, '获取用户答案失败');
+  }, [client]);
+
+  const getCurrentQuestion = useCallback(async (roomId: string): Promise<QuizState> => {
+      if (!client) return Promise.reject(new Error('Socket not connected'));
+      
+      return handleAsyncOperation(async () => {
+        return new Promise((resolve, reject) => {
+          client.emit('exam:currentQuestion', { roomId }, (question: QuizState) => {
+            if (!question) {
+              reject(new Error('No current question available'));
+              return;
+            }
+            resolve(question);
+          });
+        });
+      }, '获取当前问题失败');
+  }, [client])
+
+  const sendSettleExam = async (roomId: string) => {
+      if (!client) return;
+      return handleAsyncOperation(async () => {
+        client.emit('exam:settle', { roomId });
+      }, '发送结算消息失败');
+    }
+
+  const answerManagement = {
+    storeAnswer,
+    getAnswers: getAnswersCallback,
+    cleanupRoom,
+    // sendNextQuestion,
+    getPriceHistory,
+    getUserAnswers,
+    getCurrentQuestion,
+    sendSettleExam,
+  };
+
+  // 计算累计结果
+  // const calculateCumulativeResults = useCallback((answers: Answer[]): CumulativeResult[] => {
+  //   const userResults = new Map<string, CumulativeResult>();
+    
+  //   answers.forEach((answer) => {
+  //     const userId = answer.userId;
+  //     if (!userResults.has(userId)) {
+  //       userResults.set(userId, {
+  //         userId,
+  //         totalProfitAmount: answer.totalProfitAmount || 0,
+  //         totalProfitPercent: answer.totalProfitPercent || 0
+  //       });
+  //     }
+  //   });
+
+  //   return Array.from(userResults.values());
+  // }, []);
+
+  return {
+    socketRoom,
+    answerManagement,
+    // calculateCumulativeResults,
+    // currentQuestion,
+    // setCurrentQuestion,
+    // lastMessage,
+    ...socketRoom,
+    ...answerManagement,
+    isConnected,
+  };
+}

+ 66 - 0
src/client/mobile/components/Exam/types.ts

@@ -0,0 +1,66 @@
+import type { SocketMessage as BaseSocketMessage, SocketMessageType } from '@d8d-appcontainer/types';
+
+// 基础答题记录
+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;
+  id?: string; // 新增可选id字段
+}
+
+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;
+} 

+ 26 - 0
src/client/mobile/components/NotFoundPage.tsx

@@ -0,0 +1,26 @@
+import React from 'react';
+import { useNavigate } from 'react-router';
+import { Button } from 'antd';
+
+export const NotFoundPage = () => {
+  const navigate = useNavigate();
+  
+  return (
+    <div className="flex flex-col items-center justify-center flex-grow p-4">
+      <div className="max-w-3xl w-full">
+        <h1 className="text-2xl font-bold mb-4">404 - 页面未找到</h1>
+        <p className="mb-6 text-gray-600 dark:text-gray-300">
+          您访问的页面不存在或已被移除
+        </p>
+        <div className="flex gap-4">
+          <Button 
+            type="primary" 
+            onClick={() => navigate('/admin')}
+          >
+            返回首页
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+};

+ 37 - 0
src/client/mobile/components/ProtectedRoute.tsx

@@ -0,0 +1,37 @@
+import React, { useEffect } from 'react';
+import { 
+  useNavigate,
+} from 'react-router';
+import { useAuth } from '../hooks/AuthProvider';
+
+
+
+
+
+export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
+  const { isAuthenticated, isLoading } = useAuth();
+  const navigate = useNavigate();
+  
+  useEffect(() => {
+    // 只有在加载完成且未认证时才重定向
+    if (!isLoading && !isAuthenticated) {
+      navigate('/mobile/login', { replace: true });
+    }
+  }, [isAuthenticated, isLoading, navigate]);
+  
+  // 显示加载状态,直到认证检查完成
+  if (isLoading) {
+    return (
+      <div className="flex justify-center items-center h-screen">
+        <div className="loader ease-linear rounded-full border-4 border-t-4 border-gray-200 h-12 w-12"></div>
+      </div>
+    );
+  }
+  
+  // 如果未认证且不再加载中,不显示任何内容(等待重定向)
+  if (!isAuthenticated) {
+    return null;
+  }
+  
+  return children;
+};

+ 14 - 0
src/client/mobile/components/stock/components/stock-chart/mod.ts

@@ -0,0 +1,14 @@
+import StockChart from "./src/components/StockChart";
+import MemoToggle from "./src/components/MemoToggle";
+import type { StockChartRef, StockChartProps } from "./src/components/StockChart";
+import type { TradeRecord } from "./src/types/index";
+import { TradePanel } from './src/components/TradePanel';
+import { useTradeRecords } from './src/hooks/useTradeRecords';
+import { useStockQueries } from './src/hooks/useStockQueries';
+import { useProfitCalculator } from './src/hooks/useProfitCalculator';
+import { ProfitDisplay } from './src/components/ProfitDisplay';
+import { useStockDataFilter } from './src/hooks/useStockDataFilter';
+import { DrawingToolbar } from './src/components/DrawingToolbar';
+
+export { StockChart, MemoToggle, TradePanel, useTradeRecords, useStockQueries, useProfitCalculator, ProfitDisplay, useStockDataFilter, DrawingToolbar };
+export type { StockChartRef, StockChartProps, TradeRecord };

+ 75 - 0
src/client/mobile/components/stock/components/stock-chart/src/components/DrawingToolbar.tsx

@@ -0,0 +1,75 @@
+import React from 'react';
+import { ActiveType } from '../types/index'
+
+interface DrawingToolbarProps {
+  onStartDrawing: (type: ActiveType) => void;
+  onStopDrawing: () => void;
+  onClearLines: () => void;
+  className?: string;
+}
+
+export const DrawingToolbar: React.FC<DrawingToolbarProps> = ({
+  onStartDrawing,
+  onStopDrawing,
+  onClearLines,
+  className = ''
+}: DrawingToolbarProps) => {
+  const [activeType, setActiveType] = React.useState<ActiveType | null>(null);
+
+  const handleToolClick = (type: ActiveType) => {
+    if (activeType === type) {
+      setActiveType(null);
+      onStopDrawing();
+    } else {
+      setActiveType(type);
+      onStartDrawing(type);
+    }
+  };
+
+  const handleClearClick = () => {
+    setActiveType(null);
+    onStopDrawing();
+    onClearLines();
+  };
+
+  return (
+    <div className={`flex items-center space-x-2 ${className}`}>
+      <button
+        onClick={() => handleToolClick(ActiveType.HORIZONTAL)}
+        className={`px-3 py-1 text-sm font-medium rounded-md transition-colors
+          ${activeType === ActiveType.HORIZONTAL
+            ? 'bg-blue-600 text-white'
+            : 'bg-gray-700 text-gray-200 hover:bg-gray-600'
+          }`}
+      >
+        水平线
+      </button>
+      <button
+        onClick={() => handleToolClick(ActiveType.TREND)}
+        className={`px-3 py-1 text-sm font-medium rounded-md transition-colors
+          ${activeType === ActiveType.TREND
+            ? 'bg-blue-600 text-white'
+            : 'bg-gray-700 text-gray-200 hover:bg-gray-600'
+          }`}
+      >
+        斜横线
+      </button>
+      <button
+        onClick={() => handleToolClick(ActiveType.TREND_EXTENDED)}
+        className={`px-3 py-1 text-sm font-medium rounded-md transition-colors
+          ${activeType === ActiveType.TREND_EXTENDED
+            ? 'bg-blue-600 text-white'
+            : 'bg-gray-700 text-gray-200 hover:bg-gray-600'
+          }`}
+      >
+        趋势线
+      </button>
+      <button
+        onClick={handleClearClick}
+        className="px-3 py-1 text-sm font-medium text-gray-200 bg-red-600 rounded-md hover:bg-red-700 transition-colors"
+      >
+        清除
+      </button>
+    </div>
+  );
+}; 

+ 26 - 0
src/client/mobile/components/stock/components/stock-chart/src/components/MemoToggle.tsx

@@ -0,0 +1,26 @@
+import React from 'react';
+
+interface MemoToggleProps {
+  onToggle: (visible: boolean) => void;
+  className?: string;
+}
+
+export default function MemoToggle({ onToggle, className }: MemoToggleProps) {
+  const [visible, setVisible] = React.useState(true);
+
+  const handleClick = () => {
+    const newVisible = !visible;
+    setVisible(newVisible);
+    onToggle(newVisible);
+  };
+
+  return (
+    <button
+      type="button"
+      onClick={handleClick}
+      className={className}
+    >
+      {visible ? '隐藏提示' : '显示提示'}
+    </button>
+  );
+} 

+ 65 - 0
src/client/mobile/components/stock/components/stock-chart/src/components/ProfitDisplay.tsx

@@ -0,0 +1,65 @@
+import React from 'react';
+import type { ProfitSummary } from '../types/index';
+
+interface ProfitDisplayProps {
+  profitSummary: ProfitSummary;
+}
+
+export const ProfitDisplay: React.FC<ProfitDisplayProps> = ({ 
+  profitSummary 
+}: ProfitDisplayProps) => {
+  const { totalProfit, dailyStats } = profitSummary;
+  
+  return (
+    <div className="flex justify-between items-center p-4 bg-gray-800 text-white shadow-lg">
+      {/* 累计收益 */}
+      <div className="flex items-center space-x-2">
+        <span className="text-gray-400">累计收益</span>
+        <span className={`text-xl font-bold ${totalProfit >= 0 ? 'text-red-500' : 'text-green-500'}`}>
+          {totalProfit >= 0 ? '+' : ''}{totalProfit.toFixed(2)}
+        </span>
+      </div>
+
+      {/* 行情数据 */}
+      <div className="flex items-center space-x-6">
+        {/* 日期 */}
+        <div className="flex flex-col items-center">
+          <span className="text-gray-400 text-sm">日期</span>
+          <span className="font-medium">{dailyStats.date}</span>
+        </div>
+
+        {/* 开盘价 */}
+        <div className="flex flex-col items-center">
+          <span className="text-gray-400 text-sm">开</span>
+          <span className="font-medium">{dailyStats.open.toFixed(2)}</span>
+        </div>
+
+        {/* 最高价 */}
+        <div className="flex flex-col items-center">
+          <span className="text-gray-400 text-sm">高</span>
+          <span className="font-medium text-red-500">{dailyStats.high.toFixed(2)}</span>
+        </div>
+
+        {/* 收盘价 */}
+        <div className="flex flex-col items-center">
+          <span className="text-gray-400 text-sm">收</span>
+          <span className="font-medium">{dailyStats.close.toFixed(2)}</span>
+        </div>
+
+        {/* 最低价 */}
+        <div className="flex flex-col items-center">
+          <span className="text-gray-400 text-sm">低</span>
+          <span className="font-medium text-green-500">{dailyStats.low.toFixed(2)}</span>
+        </div>
+
+        {/* 涨跌幅 */}
+        <div className="flex flex-col items-center">
+          <span className="text-gray-400 text-sm">涨跌幅</span>
+          <span className={`font-medium ${dailyStats.change >= 0 ? 'text-red-500' : 'text-green-500'}`}>
+            {dailyStats.change >= 0 ? '+' : ''}{dailyStats.change.toFixed(2)}%
+          </span>
+        </div>
+      </div>
+    </div>
+  );
+}; 

+ 246 - 0
src/client/mobile/components/stock/components/stock-chart/src/components/StockChart.tsx

@@ -0,0 +1,246 @@
+import React, { useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
+import * as echarts from 'echarts';
+import type { EChartsType, EChartsOption } from 'echarts';
+import { StockChart as StockChartLib } from '../lib/index';
+import type { StockData, DateMemo, TradeRecord, ActiveType } from '../types/index';
+import { ChartDrawingTools } from '../lib/drawing/ChartDrawingTools';
+
+// 将 StockChartRef 接口移到 Props 定义之前
+interface StockChartRef {
+  toggleMemoVisibility: (visible: boolean) => void;
+  startDrawing: (type: ActiveType) => void;
+  stopDrawing: () => void;
+  clearDrawings: () => void;
+}
+
+interface StockChartProps {
+  stockData: StockData[];
+  memoData?: DateMemo[];
+  width?: string | number;
+  height?: string | number;
+  className?: string;
+  onChartReady?: (chart: EChartsType) => void;
+  trades?: TradeRecord[];
+}
+
+// 添加自定义类型定义
+interface ScatterDataItemOption {
+  value: [number, number];
+  symbol?: string;
+  symbolSize?: number;
+  symbolRotate?: number;
+  label?: {
+    show?: boolean;
+    formatter?: string;
+    position?: 'top' | 'bottom' | 'left' | 'right';
+    backgroundColor?: string;
+    borderColor?: string;
+    color?: string;
+    padding?: number;
+    borderRadius?: number;
+    shadowBlur?: number;
+    shadowColor?: string;
+  };
+  itemStyle?: {
+    color?: string;
+  };
+}
+
+// 修改组件定义为 forwardRef,添加解构参数的类型
+const StockChart = forwardRef<StockChartRef, StockChartProps>((
+  props: StockChartProps, 
+  ref: React.ForwardedRef<StockChartRef>
+) => {
+  const chartRef = useRef<HTMLDivElement>(null);
+  const chartInstanceRef = useRef<EChartsType | null>(null);
+  const stockChartRef = useRef<StockChartLib | null>(null);
+  const drawingToolsRef = useRef<ChartDrawingTools | null>(null);
+
+  // 初始化图表和工具 - 只执行一次
+  useEffect(() => {
+    if (!chartRef.current) return;
+
+    const chartInstance = echarts.init(chartRef.current);
+    chartInstanceRef.current = chartInstance;
+    
+    // 创建 StockChart 实例
+    const stockChart = new StockChartLib(props.stockData, props.memoData, chartInstance);
+    stockChartRef.current = stockChart;
+
+    // 初始化画线工具 - 只初始化一次
+    drawingToolsRef.current = new ChartDrawingTools(chartInstance);
+
+    // 设置初始配置
+    const option = stockChart.createChartOption();
+    chartInstance.setOption(option as EChartsOption);
+
+    // 在设置完图表配置后初始化绘图工具
+    // stockChart.initDrawingTools();
+
+    // // 绑定鼠标事件
+    // const zr = chartInstance.getZr();
+    // zr.on('click', (params: any) => {
+    //   stockChart.handleMouseEvent('click', params);
+    // });
+
+    // zr.on('mousedown', (params: any) => {
+    //   stockChart.handleMouseEvent('mousedown', params);
+    // });
+
+    // zr.on('mousemove', (params: any) => {
+    //   stockChart.handleMouseEvent('mousemove', params);
+    // });
+
+    // 通知外部图表已准备就绪
+    props.onChartReady?.(chartInstance);
+
+    // 清理函数
+    return () => {
+      // zr.off('click');
+      // zr.off('mousedown');
+      // zr.off('mousemove');
+      chartInstance.dispose();
+    };
+  }, []); // 空依赖数组,只执行一次
+
+  // 处理数据更新
+  useEffect(() => {
+    if (!chartRef.current || !chartInstanceRef.current || !stockChartRef.current) return;
+    
+    const chartInstance = chartInstanceRef.current;
+    const stockChart = stockChartRef.current;
+    
+    // 更新 StockChart 实例的数据
+    stockChart.updateData(props.stockData, props.memoData);
+    
+    // 更新图表数据
+    const option = stockChart.createChartOption();
+    // console.log('option', option);
+    // if (!option) return;
+
+    // 保持原有的 markLine 数据
+    const currentOption = chartInstance.getOption() as EChartsOption;
+    
+    if (currentOption && currentOption.series && Array.isArray(currentOption.series)) {
+      const series = currentOption.series as echarts.SeriesOption[];
+      const existingMarkLine = series[0] && (series[0] as any).markLine;
+      if (existingMarkLine) {
+        (option.series[0] as any).markLine = existingMarkLine;
+      }
+    }
+
+    chartInstance.setOption(option);
+    // console.log('currentOption', chartInstance.getOption());
+    // 重新绘制所有线条
+    drawingToolsRef.current?.redrawLines();
+  }, [props.stockData, props.memoData]);
+
+  // 处理窗口大小变化
+  useEffect(() => {
+    const handleResize = () => {
+      chartInstanceRef.current?.resize();
+    };
+
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+
+  // 将 toggleMemoVisibility 方法暴露给父组件
+  useImperativeHandle(ref, () => ({
+    toggleMemoVisibility: (visible: boolean) => {
+      if (!stockChartRef.current || !chartInstanceRef.current) return;
+
+      const currentOption = chartInstanceRef.current.getOption();
+      const stockChart = stockChartRef.current;
+
+      stockChart.toggleMemoVisibility(visible);
+      stockChart.updateMemoVisibility({
+        ...currentOption,
+        series: currentOption.series
+      });
+      chartInstanceRef.current.setOption(currentOption);
+    },
+    startDrawing: (type: ActiveType) => {
+      drawingToolsRef.current?.startDrawing(type);
+    },
+    stopDrawing: () => {
+      drawingToolsRef.current?.stopDrawing();
+    },
+    clearDrawings: () => {
+      drawingToolsRef.current?.clearAllLines();
+    }
+  }));
+
+  // 添加交易标记渲染
+  useEffect(() => {
+    if (!chartInstanceRef.current || !stockChartRef.current || !props.trades?.length) return;
+    
+    const tradeMarkSeries: echarts.ScatterSeriesOption = {
+      name: "Mark",
+      type: "scatter",
+      xAxisIndex: 0,
+      yAxisIndex: 0,
+      data: props.trades.map((trade: TradeRecord, index: number) => {
+        const dataIndex = props.stockData.findIndex((data: StockData) => data.d === trade.date);
+        if (dataIndex === -1) return null;
+
+        const dayData = props.stockData[dataIndex];
+        const price = trade.type === 'BUY' 
+          ? parseFloat(dayData.h)
+          : parseFloat(dayData.l);
+
+        return {
+          value: [dataIndex, price],
+          symbol: "triangle",
+          symbolSize: 10,
+          symbolRotate: trade.type === 'BUY' ? 180 : 0,
+          label: {
+            show: true,
+            formatter: trade.type === 'BUY' ? 'B' : 'S',
+            position: trade.type === 'BUY' ? 'top' : 'bottom',
+            backgroundColor: '#FFA500',
+            borderColor: '#ffffff',
+            color: '#ffffff',
+            padding: 2,
+            borderRadius: 2,
+            shadowBlur: 2,
+            shadowColor: '#333',
+          },
+          itemStyle: {
+            color: trade.type === 'BUY' ? '#00da3c' : '#ec0000',
+          }
+        } as ScatterDataItemOption;
+      }).filter((item: unknown): item is ScatterDataItemOption => item !== null),
+      tooltip: {
+        show: false
+      }
+    };
+
+    const currentOption = chartInstanceRef.current.getOption();
+    
+    // 找到并移除旧的 Mark 系列(如果存在)
+    const markSeriesIndex = currentOption.series.findIndex((s: { name?: string }) => s.name === 'Mark');
+    if (markSeriesIndex > -1) {
+      currentOption.series.splice(markSeriesIndex, 1);
+    }
+    
+    (currentOption.series as echarts.SeriesOption[]).push(tradeMarkSeries);
+    
+    chartInstanceRef.current.setOption(currentOption);
+  }, [props.trades, props.stockData]);
+
+  return (
+    <div
+      ref={chartRef}
+      style={{ width: "100%", height: "100%" }}
+      className={props.className}
+    />
+  );
+});
+
+// 添加显示名称
+StockChart.displayName = 'StockChart';
+
+// 导出组件和相关类型,移除重复的 StockChartRef 导出
+export default StockChart;
+export type { StockChartRef, StockChartProps };

+ 33 - 0
src/client/mobile/components/stock/components/stock-chart/src/components/TradePanel.tsx

@@ -0,0 +1,33 @@
+import React from 'react';
+
+interface TradePanelProps {
+  hasBought: boolean;
+  onToggleTrade: (type: 'BUY' | 'SELL') => void;
+}
+
+export const TradePanel: React.FC<TradePanelProps> = ({
+  hasBought,
+  onToggleTrade,
+}: TradePanelProps) => {
+  return (
+    <div className="flex items-center justify-center p-4 bg-gray-800 rounded-lg shadow-lg">
+      <div className="flex space-x-4">
+        {hasBought ? (
+          <button 
+            onClick={() => onToggleTrade('SELL')}
+            className="px-6 py-2 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-colors"
+          >
+            卖出
+          </button>
+        ) : (
+          <button 
+            onClick={() => onToggleTrade('BUY')}
+            className="px-6 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-colors"
+          >
+            买入
+          </button>
+        )}
+      </div>
+    </div>
+  );
+}; 

+ 85 - 0
src/client/mobile/components/stock/components/stock-chart/src/hooks/useProfitCalculator.ts

@@ -0,0 +1,85 @@
+import { useState, useCallback, useMemo } from 'react';
+import type { TradeRecord, DailyProfit, ProfitSummary, StockData } from '../types/index';
+
+export function useProfitCalculator(stockData: StockData[], trades: TradeRecord[]) {
+  const [currentDate, setCurrentDate] = useState<string>('');
+
+  // 计算每日收益
+  const dailyProfits = useMemo(() => {
+    const profitMap = new Map<string, DailyProfit>();
+    let accumulatedProfit = 0;  // 累计收益
+    
+    trades.forEach(trade => {
+      const date = trade.date;
+      if (!profitMap.has(date)) {
+        profitMap.set(date, {
+          date,
+          profit: accumulatedProfit,  // 使用当前累计收益作为初始值
+          trades: [],
+        });
+      }
+      
+      const dailyProfit = profitMap.get(date)!;
+      dailyProfit.trades.push(trade);
+      
+      if (trade.type === 'SELL' && trade.buyPrice !== undefined) {
+        // 本次收益 = (当天收盘价 - 买入收盘价) / 买入收盘价
+        const currentProfit = (trade.price - trade.buyPrice) / trade.buyPrice;
+        // 累计收益 = 之前的累计收益 + 本次收益
+        accumulatedProfit += currentProfit;
+        // 记录当日收益为累计收益
+        dailyProfit.profit = accumulatedProfit;
+      }
+    });
+    
+    return Array.from(profitMap.values());
+  }, [trades]);
+
+  // 计算当日行情
+  const profitSummary = useMemo(() => {
+    // 获取累计收益
+    const totalProfit = trades.reduce((sum, trade) => {
+      if (trade.type === 'SELL' && trade.buyPrice !== undefined) {
+        return sum + (trade.price - trade.buyPrice) / trade.buyPrice;
+      }
+      return sum;
+    }, 0);
+    
+    // 获取当日行情数据
+    // 如果没有指定currentDate,则使用最新一天的数据
+    const currentStockData = currentDate ? 
+      stockData.find((data: StockData) => data.d === currentDate) :
+      stockData[stockData.length - 1];
+
+    const dailyStats = currentStockData ? {
+      date: currentStockData.d, // 添加日期
+      open: parseFloat(currentStockData.o),
+      high: parseFloat(currentStockData.h),
+      close: parseFloat(currentStockData.c),
+      low: parseFloat(currentStockData.l),
+      change: parseFloat(currentStockData.zd),
+    } : {
+      date: '', // 添加日期
+      open: 0,
+      high: 0,
+      close: 0,
+      low: 0,
+      change: 0,
+    };
+
+    return {
+      totalProfit,
+      dailyStats,
+    };
+  }, [dailyProfits, currentDate, stockData, trades]);
+  // 更新当前日期
+  const updateCurrentDate = useCallback((date: string) => {
+    setCurrentDate(date);
+  }, []);
+
+  return {
+    dailyProfits,
+    profitSummary,
+    updateCurrentDate,
+  };
+} 

+ 62 - 0
src/client/mobile/components/stock/components/stock-chart/src/hooks/useStockDataFilter.ts

@@ -0,0 +1,62 @@
+import { useState, useCallback } from 'react';
+import type { StockData } from '../types/index';
+
+export function useStockDataFilter(fullData: StockData[]) {
+  const [dayNum, setDayNum] = useState(120); // 默认120天
+  const [offsetNum, setOffsetNum] = useState(120); // 默认偏移120天
+  const [isInitialized, setIsInitialized] = useState(false);
+
+  const filterData = useCallback(() => {
+    if (!isInitialized) {
+      return []; // 未初始化时返回空数组
+    }
+
+    const arrLen = fullData.length;
+    // 从最后一天开始往前数 offsetNum 天
+    let endIndex = arrLen - offsetNum;
+    // 从 endIndex 再往前数 dayNum 天
+    let startIndex = endIndex - dayNum;
+    
+    // 确保索引在有效范围内
+    startIndex = Math.max(0, startIndex);
+    endIndex = Math.max(dayNum, endIndex); // 确保至少显示 dayNum 天的数据
+    
+    return fullData.slice(startIndex, endIndex);
+  }, [fullData, dayNum, offsetNum, isInitialized]);
+
+  const moveToNextDay = useCallback(() => {
+    return new Promise<string>((resolve) => {
+      setOffsetNum((prev: number) => {
+        const newOffset = Math.max(0, prev - 1);
+        // 计算新的结束索引
+        const endIndex = fullData.length - newOffset;
+        // 返回最新日期
+        const nextDate = fullData[endIndex - 1]?.d || '';
+        resolve(nextDate);
+        return newOffset;
+      });
+    });
+  }, [fullData]);
+
+  const resetOffset = useCallback(() => {
+    setOffsetNum(0);
+  }, []);
+
+  const setDayNumWithOffset = useCallback((num: number) => {
+    setDayNum(num);
+    setOffsetNum(num);
+  }, []);
+
+  const initializeView = useCallback(() => {
+    setIsInitialized(true);
+  }, []);
+
+  return {
+    filteredData: filterData(),
+    moveToNextDay,
+    resetOffset,
+    setDayNum: setDayNumWithOffset,
+    initializeView,
+    isInitialized
+  };
+} 

+ 94 - 0
src/client/mobile/components/stock/components/stock-chart/src/hooks/useStockQueries.ts

@@ -0,0 +1,94 @@
+import { useQuery } from '@tanstack/react-query';
+import { stockDataClient } from '@/client/api';
+import { dateNotesClient } from '@/client/api';
+import type { StockData, DateMemo } from '../types/index';
+import { toast } from 'react-toastify';
+import { useEffect } from 'react';
+import type { InferResponseType } from 'hono/client';
+
+// 定义响应类型
+type StockHistoryResponse = InferResponseType<typeof stockDataClient.history[':code']['$get'], 200>;
+type DateNotesListResponse = InferResponseType<typeof dateNotesClient.$get, 200>;
+
+export function useStockQueries(code?: string) {
+  // 查询股票历史数据
+  const {
+    data: stockDataResponse,
+    isLoading: isLoadingStock,
+    error: stockError,
+    refetch: refetchStock
+  } = useQuery<StockHistoryResponse>({
+    queryKey: ['stockHistory', code],
+    queryFn: async () => {
+      if (!code) throw new Error('股票代码不能为空');
+      const response = await stockDataClient.history[':code'].$get({
+        param: { code }
+      });
+      if (!response.ok) {
+        throw new Error('获取股票历史数据失败');
+      }
+      return response.json();
+    },
+    enabled: !!code,
+    retry: 0,
+  });
+
+  // 查询备忘录数据
+  const {
+    data: memoDataResponse,
+    isLoading: isLoadingMemo,
+    error: memoError,
+    refetch: refetchMemo
+  } = useQuery<DateNotesListResponse>({
+    queryKey: ['memoData', code],
+    queryFn: () => dateNotesClient.$get({
+      query: {
+        filters: JSON.stringify(code ? { code } : {}),
+        page: 1,
+        pageSize: 1000
+      }
+    }).then(res => res.json()),
+    enabled: false,
+  });
+
+  // 转换数据格式
+  const stockData = (stockDataResponse?.data || []) as StockData[];
+
+  const memoData = (memoDataResponse?.data?.map(item => ({
+    _id: item.id,
+    代码: item.code,
+    日期: item.noteDate,
+    提示: item.note,
+  })) || []) as DateMemo[];
+
+  const isLoading = isLoadingStock || isLoadingMemo;
+  const error = stockError || memoError;
+
+  useEffect(() => {
+    if (isLoading) {
+      toast.loading('正在加载数据...', { toastId: 'stockLoading' })
+    } else {
+      toast.done('stockLoading')
+      if (error instanceof Error) {
+        toast.error('加载数据失败,请稍后重试')
+      }
+    }
+  }, [isLoading, error]);
+
+  // 提供一个函数来手动触发查询
+  const fetchData = async () => {
+    if (!code) return;
+    await Promise.all([
+      refetchStock(),
+      refetchMemo()
+    ]);
+  };
+
+  return {
+    stockData,
+    memoData,
+    isLoading,
+    error,
+    fetchData,
+  };
+}

+ 63 - 0
src/client/mobile/components/stock/components/stock-chart/src/hooks/useTradeRecords.ts

@@ -0,0 +1,63 @@
+import { useState, useCallback } from 'react';
+import type { TradeRecord, TradeRecordGroup, StockData } from '../types/index';
+
+export function useTradeRecords(stockData: StockData[]) {
+  const [trades, setTrades] = useState<TradeRecord[]>([]);
+  const [tradeGroups, setTradeGroups] = useState<TradeRecordGroup[]>([]);
+  const [hasBought, setHasBought] = useState(false);
+  const [buyPrice, setBuyPrice] = useState(0);
+
+  const toggleTrade = useCallback((type: 'BUY' | 'SELL') => {
+    if (type === 'BUY' && hasBought) return;
+    if (type === 'SELL' && !hasBought) return;
+
+    const currentDate = stockData[stockData.length - 1]?.d;
+    if (!currentDate) return;
+
+    const closePrice = parseFloat(stockData[stockData.length - 1].c);
+
+    if (type === 'BUY') {
+      setHasBought(true);
+      setBuyPrice(closePrice);
+    } else {
+      setHasBought(false);
+      setBuyPrice(0);
+    }
+
+    const newTrade: TradeRecord = {
+      type,
+      price: closePrice,
+      timestamp: Date.now(),
+      date: currentDate,
+      ...(type === 'SELL' ? { buyPrice: buyPrice } : {})
+    };
+
+    setTrades((prev: TradeRecord[]) => [...prev, newTrade]);
+    setTradeGroups((prevGroups: TradeRecordGroup[]) => {
+      const existingGroup = prevGroups.find(
+        (group: TradeRecordGroup) => group.date === currentDate && group.type === type
+      );
+
+      if (existingGroup) {
+        return prevGroups.map((group: TradeRecordGroup) => 
+          group === existingGroup
+            ? { ...group, records: [...group.records, newTrade] }
+            : group
+        );
+      }
+
+      return [...prevGroups, {
+        date: currentDate,
+        type,
+        records: [newTrade]
+      }];
+    });
+  }, [stockData, hasBought, buyPrice]);
+
+  return {
+    trades,
+    tradeGroups,
+    toggleTrade,
+    hasBought,
+  };
+} 

+ 77 - 0
src/client/mobile/components/stock/components/stock-chart/src/lib/DateMemoHandler.ts

@@ -0,0 +1,77 @@
+import type { DateMemo, ChartOption, SplitData } from '../types/index.ts';
+
+export class DateMemoHandler {
+  private dateMemos: DateMemo[];
+  private memoVisible: boolean = true;
+  
+  constructor(dateMemos: DateMemo[]) {
+    this.dateMemos = dateMemos;
+  }
+
+  public addDateMemoMarkers(option: ChartOption, splitData: SplitData): ChartOption {
+    const memoMarkers = this.dateMemos
+      .map(memo => {
+        const index = splitData.categoryData.indexOf(memo.日期);
+        
+        if (index !== -1) {
+          return {
+            value: [index, splitData.values[index].value[1]],
+            symbol: 'rect',
+            symbolSize: [20, 40],
+            label: {
+              show: this.memoVisible,
+              position: 'top',
+              formatter: memo.提示.split('').join('\n'),
+              color: 'red',
+              backgroundColor: 'yellow',
+              padding: 4,
+              borderRadius: 2,
+              fontWeight: 'bold'
+            },
+            itemStyle: {
+              color: 'transparent'
+            }
+          };
+        }
+        return null;
+      })
+      .filter(item => item !== null);
+
+    const existingSeriesIndex = option.series.findIndex(
+      series => series.name === 'DateMemo'
+    );
+
+    if (existingSeriesIndex !== -1) {
+      option.series[existingSeriesIndex].data = memoMarkers;
+    } else {
+      option.series.push({
+        name: 'DateMemo',
+        type: 'scatter',
+        data: memoMarkers,
+        tooltip: {
+          show: false
+        }
+      } as any);
+    }
+
+    return option;
+  }
+
+  public toggleMemoVisibility(visible: boolean): void {
+    this.memoVisible = visible;
+  }
+
+  public updateMemoVisibility(option: ChartOption): void {
+    const dateMemoSeries = option.series.find(
+      series => series.name === 'DateMemo'
+    );
+
+    if (dateMemoSeries && dateMemoSeries.data) {
+      dateMemoSeries.data.forEach((item: any) => {
+        if (item && item.label) {
+          item.label.show = this.memoVisible;
+        }
+      });
+    }
+  }
+} 

+ 114 - 0
src/client/mobile/components/stock/components/stock-chart/src/lib/StockChart.ts

@@ -0,0 +1,114 @@
+import { ChartBaseConfig } from './config/ChartBaseConfig';
+import { DataProcessor } from './data/DataProcessor';
+import { MarkerProcessor } from './markers/MarkerProcessor';
+import type { StockData, DateMemo, ChartOption, SplitData } from '../types/index';
+import { DrawingTools } from './drawing/DrawingTools';
+import { DateMemoHandler } from './DateMemoHandler';
+
+export class StockChart {
+  private readonly dataProcessor: DataProcessor;
+  private readonly markerProcessor: MarkerProcessor;
+  private data: StockData[];
+  private dateMemos: DateMemo[];
+  private memoVisible: boolean = true;
+  private readonly drawingTools: DrawingTools;
+  private dateMemoHandler: DateMemoHandler;
+
+  constructor(data: StockData[], dateMemos: DateMemo[] = [], chart: any) {
+    this.data = data;
+    this.dateMemos = dateMemos;
+    this.dataProcessor = new DataProcessor();
+    this.markerProcessor = new MarkerProcessor();
+    this.drawingTools = new DrawingTools(chart);
+    this.dateMemoHandler = new DateMemoHandler(dateMemos);
+  }
+
+  public createChartOption(): ChartOption {
+    const processedData = this.dataProcessor.processData(this.data);
+    const splitData = this.dataProcessor.splitData(processedData);
+    
+    const option = ChartBaseConfig.createBaseOption(splitData.categoryData);
+    const chartOption = option as ChartOption;
+
+    // 添加K线图系列
+    chartOption.series.push({
+      name: 'Values',
+      type: 'candlestick',
+      data: splitData.values
+    });
+
+    // 添加成交量系列
+    chartOption.series.push({
+      name: 'Volumes',
+      type: 'bar',
+      xAxisIndex: 1,
+      yAxisIndex: 1,
+      data: splitData.volumes,
+      ...ChartBaseConfig.getVolumeBarStyle()
+    });
+
+    this.markerProcessor.add2OnTopOfVolumeMarkers(chartOption, splitData);
+    this.markerProcessor.add3OnTopOfVolumeMarkers(chartOption, splitData);
+    this.markerProcessor.addMiddleLinesToChartOption(chartOption, splitData);
+
+    // 添加日期备注标记
+    return this.dateMemoHandler.addDateMemoMarkers(chartOption, splitData);
+  }
+
+  // 切换备注显示状态
+  public toggleMemoVisibility(visible: boolean): void {
+    this.dateMemoHandler.toggleMemoVisibility(visible);
+  }
+
+  // 更新图表配置中的备注可见性
+  public updateMemoVisibility(option: ChartOption): void {
+    this.dateMemoHandler.updateMemoVisibility(option);
+  }
+
+  private tooltipFormatter(params: any[]): string {
+    const param = params[0];
+    if (param.seriesName === 'Values') {
+      const value = param.value;
+      return `${param.name}<br/>
+        开: ${value[1]}<br/>
+        收: ${value[2]}<br/>
+        高: ${value[4]}<br/>
+        低: ${value[3]}<br/>
+        涨幅: ${value[5]}<br/>`;
+    } else if (param.seriesName === 'Volumes') {
+      return `${param.name}<br/>成交量: ${param.value[1]}`;
+    }
+    return '';
+  }
+
+  public getSplitData(): SplitData {
+    const processedData = this.dataProcessor.processData(this.data);
+    return this.dataProcessor.splitData(processedData);
+  }
+
+  public getMemoVisible(): boolean {
+    return this.memoVisible;
+  }
+
+  // 添加绘图工具按钮
+  public initDrawingTools(): void {
+    this.drawingTools.initDrawingTools();
+  }
+
+  // 处理鼠标事件
+  public handleMouseEvent(event: string, params: any): void {
+    this.drawingTools.handleMouseEvent(event, params);
+  }
+
+  // 清除所有绘制的线条
+  public clearDrawings(): void {
+    this.drawingTools.clearMarkLine();
+  }
+
+  // 添加更新方法
+  public updateData(data: StockData[], dateMemos: DateMemo[] = []): void {
+    this.data = data;
+    this.dateMemos = dateMemos;
+    this.dateMemoHandler = new DateMemoHandler(dateMemos);
+  }
+} 

+ 153 - 0
src/client/mobile/components/stock/components/stock-chart/src/lib/config/ChartBaseConfig.ts

@@ -0,0 +1,153 @@
+import type { EChartsOption } from 'echarts';
+// import type { CallbackDataParams } from 'echarts/types/src/util/types';
+import { CHART_COLORS } from '../constants/colors';
+
+export class ChartBaseConfig {
+  static createBaseOption(categoryData: string[]): EChartsOption {
+    return {
+      tooltip: {
+        trigger: 'axis',
+        axisPointer: { type: 'cross' },
+        formatter: (params) => {
+          // 确保 params 是数组
+          const paramArray = Array.isArray(params) ? params : [params];
+          const param = paramArray[0];
+          
+          if (param.seriesName === 'Values') {
+            const value = param.value as number[];
+            return `${param.name}<br/>
+              <!-- 序号: ${value[0]}<br/> -->
+              开: ${value[1]}<br/>
+              收: ${value[2]}<br/>
+              低: ${value[3]}<br/>
+              高: ${value[4]}<br/>
+              涨幅: ${value[5]}<br/>`;
+          } else if (param.seriesName === 'Volumes') {
+            const value = param.value as number[];
+            return `${param.name}<br/>成交量: ${value[1]}`;
+          }
+          return '';
+        }
+      },
+      dataZoom: [
+        {
+          type: 'inside',
+          xAxisIndex: [0, 1],
+          startValue: categoryData.length - 30,
+          endValue: categoryData.length - 1,
+          minValueSpan: 10,
+          maxValueSpan: 120
+        },
+        {
+          show: true,
+          xAxisIndex: [0, 1],
+          type: 'slider',
+          top: '85%',
+          startValue: categoryData.length - 30,
+          endValue: categoryData.length - 1,
+          minValueSpan: 10,
+          maxValueSpan: 120
+        }
+      ],
+      grid: [
+        {
+          left: '10%',
+          right: '8%',
+          height: '50%'
+        },
+        {
+          left: '10%',
+          right: '8%',
+          top: '63%',
+          height: '16%'
+        }
+      ],
+      xAxis: this.createXAxisConfig(categoryData),
+      yAxis: this.createYAxisConfig(),
+      axisPointer: {
+        link: [{ xAxisIndex: 'all' }],
+        label: { backgroundColor: '#777' }
+      },
+      series: [] as any[]
+    };
+  }
+
+  static getUpColor(): string {
+    return CHART_COLORS.UP;
+  }
+
+  static getDownColor(): string {
+    return CHART_COLORS.DOWN;
+  }
+
+  static getOperateColor(): string {
+    return CHART_COLORS.OPERATE;
+  }
+
+  static getLimitUpColor(): string {
+    return CHART_COLORS.LIMIT_UP;
+  }
+
+  static getLimitDownColor(): string {
+    return CHART_COLORS.LIMIT_DOWN;
+  }
+
+  static getMiddleLineColor(): string {
+    return CHART_COLORS.MIDDLE_LINE;
+  }
+
+  static getVolumeBarStyle() {
+    return {
+      itemStyle: {
+        color: (params: any) => {
+          return params.value[2] > 0 ? CHART_COLORS.UP : CHART_COLORS.DOWN;
+        }
+      }
+    };
+  }
+
+  private static createXAxisConfig(categoryData: string[]) {
+    return [
+      {
+        type: 'category' as const,
+        data: categoryData,
+        boundaryGap: false,
+        axisLine: { onZero: false },
+        splitLine: { show: false },
+        min: 'dataMin',
+        max: 'dataMax',
+        axisPointer: { z: 100 }
+      },
+      {
+        type: 'category' as const,
+        gridIndex: 1,
+        data: categoryData,
+        boundaryGap: false,
+        axisLine: { onZero: false },
+        axisTick: { show: false },
+        splitLine: { show: false },
+        axisLabel: { show: false },
+        min: 'dataMin',
+        max: 'dataMax'
+      }
+    ];
+  }
+
+  private static createYAxisConfig() {
+    return [
+      {
+        scale: true,
+        splitArea: { show: true }
+      },
+      {
+        scale: true,
+        gridIndex: 1,
+        splitNumber: 2,
+        axisLabel: { show: false },
+        axisLine: { show: false },
+        axisTick: { show: false },
+        splitLine: { show: false }
+      }
+    ];
+  }
+} 

+ 9 - 0
src/client/mobile/components/stock/components/stock-chart/src/lib/constants/colors.ts

@@ -0,0 +1,9 @@
+export const CHART_COLORS = {
+  UP_FILL: 'white',          // 上涨填充色
+  UP: '#ec0000',            // 上涨边框色
+  DOWN: '#00da3c',          // 下跌色
+  LIMIT_UP: '#ff1493',      // 涨停板色
+  LIMIT_DOWN: '#4169e1',    // 跌停板色
+  MIDDLE_LINE: '#333',      // 中间线颜色
+  OPERATE: '#FFA500'        // 操作标记颜色
+} as const; 

+ 67 - 0
src/client/mobile/components/stock/components/stock-chart/src/lib/data/DataProcessor.ts

@@ -0,0 +1,67 @@
+import type { StockData, ProcessedData, SplitData } from '../../types/index';
+import { CHART_COLORS } from '../constants/colors';
+
+export class DataProcessor {
+  private readonly upFillColor = CHART_COLORS.UP_FILL;
+  private readonly upColor = CHART_COLORS.UP;
+  private readonly downColor = CHART_COLORS.DOWN;
+  private readonly limitUpColor = CHART_COLORS.LIMIT_UP;
+  private readonly limitDownColor = CHART_COLORS.LIMIT_DOWN;
+
+  processData(data: StockData[]): ProcessedData[] {
+    return data.map(item => this.processStockItem(item));
+  }
+
+  splitData(processedData: ProcessedData[]): SplitData {
+    const categoryData: string[] = [];
+    const values: any[] = [];
+    const volumes: [number, string, number][] = [];
+
+    processedData.forEach((item, i) => {
+      categoryData.push(item.categoryData);
+      values.push(item.values);
+
+      const open = Number(item.values.value[0]);
+      const close = Number(item.values.value[1]);
+      const zd = Number(item.values.value[4]);
+      const direction = (zd < 0 || open > close) ? -1 : 1;
+
+      volumes.push([i, item.volumes, direction]);
+    });
+
+    return { categoryData, values, volumes };
+  }
+
+  private processStockItem(item: StockData): ProcessedData {
+    const open = Number(item.o);
+    const close = Number(item.c);
+    const zd = Number(item.zd);
+    const isDown = zd < 0 || open > close;
+    const isLimitUp = zd > 9.7;
+    const isLimitDown = zd < -9.7;
+
+    return {
+      categoryData: item.d,
+      values: {
+        value: [item.o, item.c, item.l, item.h, item.pc || item.zd],
+        itemStyle: {
+          color: this.getItemColor(isDown, isLimitUp, isLimitDown),
+          borderColor: this.getBorderColor(isDown, isLimitUp, isLimitDown)
+        }
+      },
+      volumes: item.v
+    };
+  }
+
+  private getItemColor(isDown: boolean, isLimitUp: boolean, isLimitDown: boolean): string {
+    if (isLimitUp) return this.limitUpColor;
+    if (isLimitDown) return this.limitDownColor;
+    return isDown ? this.downColor : this.upColor;
+  }
+
+  private getBorderColor(isDown: boolean, isLimitUp: boolean, isLimitDown: boolean): string {
+    if (isLimitUp) return this.limitUpColor;
+    if (isLimitDown) return this.limitDownColor;
+    return isDown ? this.downColor : this.upColor;
+  }
+} 

+ 518 - 0
src/client/mobile/components/stock/components/stock-chart/src/lib/drawing/ChartDrawingTools.ts

@@ -0,0 +1,518 @@
+import type { EChartsType, EChartsOption } from 'echarts';
+import {Decimal} from 'decimal.js';
+
+interface DrawingLine {
+  id: string;
+  type: 'horizontal' | 'trend' | 'trendExtended';
+  points: {
+    xRatio: number;
+    yValue: number;
+    dataIndex?: number;
+    date?: string;
+  }[];
+  style?: {
+    color?: string;
+    width?: number;
+    type?: 'solid' | 'dashed';
+  };
+}
+
+// 添加 ECharts X轴配置的类型定义
+interface XAxisOption {
+  data: string[];
+}
+
+// 在文件顶部添加 dataZoom 的类型定义
+interface DataZoomOption {
+  start?: number;
+  end?: number;
+  startValue?: number;
+  endValue?: number;
+}
+
+// 首先修复类型定义
+interface PreviewPoint {
+  xRatio: number;
+  yValue: number;
+  dataIndex?: number;
+}
+
+
+export class ChartDrawingTools {
+  private readonly chart: EChartsType;
+  private readonly lines: Map<string, DrawingLine>;
+  private isDrawing: boolean;
+  private currentLineType: 'horizontal' | 'trend' | 'trendExtended' | null;
+  private tempLine: DrawingLine | null;
+  private canStartNewLine: boolean = true;
+  private isTrendFirstPoint: boolean = false;
+
+  constructor(chart: EChartsType) {
+    this.chart = chart;
+    this.lines = new Map();
+    this.isDrawing = false;
+    this.currentLineType = null;
+    this.tempLine = null;
+
+    this.bindEvents();
+  }
+
+  // 开始绘制
+  public startDrawing(type: 'horizontal' | 'trend' | 'trendExtended'): void {
+    this.isDrawing = true;
+    this.currentLineType = type;
+    this.canStartNewLine = true;
+    this.isTrendFirstPoint = type === 'trend' || type === 'trendExtended';
+  }
+
+  // 停止绘制
+  public stopDrawing(): void {
+    this.isDrawing = false;
+    this.currentLineType = null;
+    this.tempLine = null;
+    this.canStartNewLine = true;
+    this.isTrendFirstPoint = false;
+  }
+
+  // 清除所有线条
+  public clearAllLines(): void {
+    this.lines.clear();
+    this.updateChart();
+  }
+
+  // 删除指定线条
+  public deleteLine(id: string): void {
+    this.lines.delete(id);
+    this.updateChart();
+  }
+
+  // 更新图表
+  private updateChart(): void {
+    const option = this.chart.getOption() as EChartsOption;
+    const markLineData: any[] = [];
+    const xAxis = (option.xAxis as XAxisOption[])[0];
+    const dataZoom = (option.dataZoom as DataZoomOption[]) || [];
+    const viewRange = {
+      start: dataZoom[0]?.startValue ?? 0,
+      end: dataZoom[0]?.endValue ?? (xAxis.data.length - 1)
+    };
+    const yRange = this.getYAxisRange();
+
+    this.lines.forEach(line => {
+      if (line.type === 'horizontal') {
+        markLineData.push({
+          yAxis: line.points[0].yValue,
+          lineStyle: {
+            ...line.style,
+            type: line.style?.type || 'solid'
+          }
+        });
+      } else if (line.type === 'trend' && line.points.length === 2) {
+        // 查找日期对应的索引
+        const startIndex = xAxis.data.indexOf(line.points[0].date!);
+        const endIndex = xAxis.data.indexOf(line.points[1].date!);
+        
+        // 只有当两个点的日期都能找到对应索引时才显示线条
+        if (startIndex !== -1 && endIndex !== -1) {
+          markLineData.push([{
+            coord: [startIndex, line.points[0].yValue]
+          }, {
+            coord: [endIndex, line.points[1].yValue]
+          }]);
+        }
+      } else if (line.type === 'trendExtended' && line.points.length === 2) {
+        const startIndex = xAxis.data.indexOf(line.points[0].date!);
+        const endIndex = xAxis.data.indexOf(line.points[1].date!);
+        
+        if (startIndex !== -1 && endIndex !== -1) {
+          // 使用抽取的方法计算延伸线坐标
+          const coords = this.calculateExtendedTrendLineCoords(
+            { x: startIndex, y: line.points[0].yValue },
+            { x: endIndex, y: line.points[1].yValue },
+            viewRange,
+            yRange
+          );
+
+          markLineData.push([{
+            coord: [coords.left.x, coords.left.y],
+            symbol: 'none'
+          }, {
+            coord: [coords.right.x, coords.right.y],
+            symbol: 'none'
+          }]);
+        }
+      }
+    });
+
+    const series = (option.series as any[]) || [];
+    
+    if (series[0]) {
+      series[0].markLine = {
+        animation: false,
+        symbol: ['none', 'none'],
+        lineStyle: {
+          width: 1,
+          type: 'solid'
+        },
+        data: markLineData
+      };
+    }
+
+    this.chart.setOption({
+      series: series
+    }, { replaceMerge: ['series'] });
+  }
+
+  // 获取实际X轴坐标
+  private getActualX(xRatio: number, xAxis: XAxisOption): number {
+    const dataCount = xAxis.data.length;
+    return Math.floor(xRatio * dataCount);
+  }
+
+  // 获取相对X轴位置
+  private getXRatio(x: number, xAxis: XAxisOption): number {
+    const dataCount = xAxis.data.length;
+    return x / dataCount;
+  }
+
+  // 绑定事件处理器
+  private bindEvents(): void {
+    const zr = this.chart.getZr();
+
+    zr.on('mousedown', (params: { offsetX: number; offsetY: number }) => {
+      if (!this.isDrawing || !this.currentLineType || !this.canStartNewLine) return;
+
+      // 如果是趋势线的第二个点,不创建新的 tempLine
+      if (this.tempLine && !this.isTrendFirstPoint) return;
+
+      const point = this.chart.convertFromPixel({ seriesIndex: 0 }, [
+        params.offsetX,
+        params.offsetY
+      ]);
+
+      if (!point) return;
+
+      const option = this.chart.getOption() as EChartsOption;
+      const xAxis = (option.xAxis as XAxisOption[])[0];
+      const xRatio = this.getXRatio(point[0], xAxis);
+      
+      // 记录日期信息
+      const dataIndex = Math.floor(point[0]);
+      const date = xAxis.data[dataIndex];
+
+      this.tempLine = {
+        id: crypto.randomUUID(),
+        type: this.currentLineType,
+        points: [{
+          xRatio,
+          yValue: point[1],
+          dataIndex,
+          date
+        }]
+      };
+
+      if (this.currentLineType === 'horizontal') {
+        this.updatePreview();
+      }
+    });
+
+    zr.on('mousemove', (params: { offsetX: number; offsetY: number }) => {
+      if (!this.isDrawing || !this.tempLine) return;
+
+      const point = this.chart.convertFromPixel({ seriesIndex: 0 }, [
+        params.offsetX,
+        params.offsetY
+      ]);
+
+      if (!point) return;
+
+      const option = this.chart.getOption() as EChartsOption;
+      const xAxis = (option.xAxis as XAxisOption[])[0];
+      const xRatio = this.getXRatio(point[0], xAxis);
+      const dataIndex = Math.floor(point[0]);  // 计算当前点的索引
+
+      if (this.tempLine.type === 'horizontal') {
+        this.tempLine.points = [{
+          xRatio: 0,
+          yValue: point[1]
+        }];
+        this.updatePreview();
+      } else if (this.tempLine.type === 'trend' || this.tempLine.type === 'trendExtended') {
+        if (this.tempLine.points.length > 0) {
+          const previewPoints = [
+            this.tempLine.points[0],
+            {
+              xRatio,
+              yValue: point[1],
+              dataIndex  // 添加 dataIndex
+            }
+          ];
+          
+          this.updatePreviewWithPoints(previewPoints);
+        }
+      }
+    });
+
+    zr.on('mouseup', (params: { offsetX: number; offsetY: number }) => {
+      if (!this.isDrawing || !this.tempLine) return;
+
+      if (this.tempLine.type === 'trend' || this.tempLine.type === 'trendExtended') {
+        if (this.isTrendFirstPoint) {
+          this.isTrendFirstPoint = false;
+          return;
+        }
+
+        const point = this.chart.convertFromPixel({ seriesIndex: 0 }, [
+          params.offsetX,
+          params.offsetY
+        ]);
+
+        if (!point) return;
+
+        const option = this.chart.getOption() as EChartsOption;
+        const xAxis = (option.xAxis as XAxisOption[])[0];
+        const xRatio = this.getXRatio(point[0], xAxis);
+        
+        // 记录第二个点的信息
+        const dataIndex = Math.floor(point[0]);
+        const date = xAxis.data[dataIndex];
+
+        // 确保两个点不重合
+        if (this.tempLine.points[0].xRatio === xRatio) {
+          this.tempLine = null;
+          return;
+        }
+
+        this.tempLine.points.push({
+          xRatio,
+          yValue: point[1],
+          dataIndex,
+          date
+        });
+      }
+
+      this.lines.set(this.tempLine.id, this.tempLine);
+      this.updateChart();
+      const currentType = this.tempLine.type;
+      this.tempLine = null;
+      this.canStartNewLine = true;
+      if (currentType === 'trend' || currentType === 'trendExtended') {
+        this.isTrendFirstPoint = true;
+      }
+    });
+
+    this.chart.on('datazoom', () => {
+      this.updateChart();
+    });
+
+  }
+
+  private getYAxisRange(): { min: number; max: number } {
+    const option = this.chart.getOption();
+    const dataZoom = (option.dataZoom as DataZoomOption[]) || [];
+    const series = (option.series as any[])[0];
+    
+    // 获取当前视图范围
+    const startIndex = dataZoom[0]?.startValue ?? 0;
+    const endIndex = dataZoom[0]?.endValue ?? (series.data.length - 1);
+    
+    // 获取可见区域的数据
+    const visibleData = series.data.slice(startIndex, endIndex + 1);
+    
+    // 计算可见区域的最大最小值
+    let yMin = Infinity;
+    let yMax = -Infinity;
+    
+    visibleData.forEach((item: any) => {
+      const values = item.value || item;
+      // K线数据格式为 [open, close, low, high]
+      const low = parseFloat(values[2]);  // low
+      const high = parseFloat(values[3]); // high
+      
+      if (!isNaN(low)) yMin = Math.min(yMin, low);
+      if (!isNaN(high)) yMax = Math.max(yMax, high);
+    });
+    
+    return {
+      min: yMin,
+      max: yMax
+    };
+  }
+  
+
+  // 添加新方法用于预览时的点更新
+  private updatePreviewWithPoints(points: PreviewPoint[]): void {
+    if (!this.tempLine) return;
+
+    const option = this.chart.getOption() as EChartsOption;
+    const xAxis = (option.xAxis as XAxisOption[])[0];
+    const series = (option.series as any[]) || [];
+    const currentSeries = series[0] || {};
+
+    let previewData;
+    
+    if (this.tempLine.type === 'trend') {
+      // 保持原有趋势线预览逻辑
+      previewData = [
+        {
+          coord: [
+            this.getActualX(points[0].xRatio, xAxis),
+            points[0].yValue
+          ]
+        },
+        {
+          coord: [
+            this.getActualX(points[1].xRatio, xAxis),
+            points[1].yValue
+          ]
+        }
+      ];
+    } else if (this.tempLine.type === 'trendExtended') {
+      const chartOption = this.chart.getOption();
+      const dataZoom = (chartOption.dataZoom as DataZoomOption[]) || [];
+      
+      const viewStartIndex = dataZoom[0]?.startValue ?? 0;
+      const viewEndIndex = dataZoom[0]?.endValue ?? (xAxis.data.length - 1);
+      
+      const actualStartX = this.getActualX(points[0].xRatio, xAxis);
+      const actualStartY = points[0].yValue;
+      const actualEndX = this.getActualX(points[1].xRatio, xAxis);
+      const actualEndY = points[1].yValue;
+
+      const { min, max } = this.getYAxisRange();
+
+      // 使用抽取的方法计算延伸线坐标
+      const coords = this.calculateExtendedTrendLineCoords(
+        { x: actualStartX, y: actualStartY },
+        { x: actualEndX, y: actualEndY },
+        { start: viewStartIndex, end: viewEndIndex },
+        { min, max }
+      );
+
+      previewData = [
+        {
+          coord: [coords.left.x, coords.left.y]
+        },
+        {
+          coord: [coords.right.x, coords.right.y]
+        }
+      ];
+    }
+
+    if (previewData) {  // 只在有预览数据时更新
+      this.chart.setOption({
+        series: [{
+          ...currentSeries,
+          markLine: {
+            animation: false,
+            symbol: ['none', 'none'],
+            lineStyle: {
+              width: 1,
+              type: 'dashed',
+              color: '#999'
+            },
+            data: [previewData]
+          }
+        }]
+      }, { replaceMerge: ['series'] });
+    }
+  }
+
+  // 更新预览线
+  private updatePreview(): void {
+    if (!this.tempLine) return;
+
+    const option = this.chart.getOption() as EChartsOption;
+    const previewData: any[] = [];
+
+    if (this.tempLine.type === 'horizontal') {
+      previewData.push({
+        yAxis: this.tempLine.points[0].yValue,
+        lineStyle: {
+          type: 'dashed',
+          color: '#999'
+        }
+      });
+    } else if (this.tempLine.points.length === 2) {
+      const xAxis = (option.xAxis as XAxisOption[])[0];
+      const start = this.getActualX(this.tempLine.points[0].xRatio, xAxis);
+      const end = this.getActualX(this.tempLine.points[1].xRatio, xAxis);
+
+      previewData.push([{
+        coord: [start, this.tempLine.points[0].yValue]
+      }, {
+        coord: [end, this.tempLine.points[1].yValue]
+      }]);
+    }
+
+    // 获取当前的系列配置
+    const series = (option.series as any[]) || [];
+    const currentSeries = series[0] || {};
+
+    // 更新或添加 markLine 到现有系列
+    this.chart.setOption({
+      series: [{
+        ...currentSeries,  // 保留现有系列的配置
+        markLine: {
+          animation: false,
+          symbol: ['none', 'none'],
+          lineStyle: {
+            width: 1,
+            type: 'dashed',
+            color: '#999'
+          },
+          data: previewData
+        }
+      }]
+    }, { replaceMerge: ['series'] });
+  }
+
+  // 添加重绘线条的方法
+  public redrawLines(): void {
+    if (this.lines.size > 0) {
+      this.updateChart();
+    }
+  }
+
+  // 添加计算延伸趋势线坐标的方法
+  private calculateExtendedTrendLineCoords(
+    startPoint: { x: number; y: number },
+    endPoint: { x: number; y: number },
+    viewRange: { start: number; end: number },
+    yRange: { min: number; max: number }
+  ): { left: { x: number; y: number }; right: { x: number; y: number } } {
+    // 计算斜率
+    const slope = (endPoint.y - startPoint.y) / (endPoint.x - startPoint.x);
+    
+    // 计算左边延伸点
+    let leftX = viewRange.start;
+    let leftY = startPoint.y - slope * (startPoint.x - leftX);
+    
+    // 如果y值超出范围,锁定y到边界值并反推x
+    if (leftY < yRange.min || leftY > yRange.max) {
+      leftY = leftY < yRange.min ? yRange.min : yRange.max;
+      leftX = startPoint.x - (startPoint.y - leftY) / slope;
+    }
+    
+    // 计算右边延伸点
+    let rightX = viewRange.end;
+    let rightY = endPoint.y + slope * (rightX - endPoint.x);
+    
+    // 如果y值超出范围,锁定y到边界值并反推x
+    if (rightY < yRange.min || rightY > yRange.max) {
+      rightY = rightY < yRange.min ? yRange.min : yRange.max;
+      rightX = endPoint.x + (rightY - endPoint.y) / slope;
+    }
+
+    return {
+      left: { 
+        x: Math.ceil(leftX), 
+        y: leftY 
+      },
+      right: { 
+        x: Math.ceil(rightX), 
+        y: rightY 
+      }
+    };
+  }
+} 

+ 222 - 0
src/client/mobile/components/stock/components/stock-chart/src/lib/drawing/DrawingTools.ts

@@ -0,0 +1,222 @@
+import type { ChartOption } from '../../types/index';
+
+interface LineValue {
+  type: 'line' | 'dashline';
+  value: number | [{ name: string; coord: number[]; label: { show: boolean } }, { name: string; coord: number[]; label: { show: boolean } }];
+}
+
+export class DrawingTools {
+  private readonly lineValues = new Map<string, LineValue>();
+  private readonly buttons = new Map<string, any>();
+  private startPoint: number[] | null = null;
+  private lineId: string | null = null;
+  private enableDrawLine = false;
+  private enableDrawDashedLine = false;
+
+  constructor(private chart: any) {}
+
+  // 添加标记线
+  private addMutiLine(lineId: string, lineValue: LineValue): void {
+    this.lineValues.set(lineId, lineValue);
+
+    const markLine = {
+      series: [{
+        type: 'candlestick',
+        markLine: {
+          symbol: ['none', 'none'],
+          lineStyle: {
+            color: 'black',
+            width: 2,
+            type: 'solid'
+          },
+          data: [...this.lineValues.values()].map(lineValue => {
+            if (lineValue.type === 'line') {
+              return { yAxis: lineValue.value };
+            } else {
+              return lineValue.value;
+            }
+          })
+        }
+      }]
+    };
+
+    this.chart.setOption(markLine);
+  }
+
+  // 坐标转换
+  private convertFromPixel(params: { offsetX: number; offsetY: number }, seriesIndex = 0): number[] {
+    const pointInPixel = [params.offsetX, params.offsetY];
+    return this.chart.convertFromPixel({ seriesIndex }, pointInPixel);
+  }
+
+  // 清除标记线
+  clearMarkLine(): void {
+    this.lineValues.clear();
+    this.chart.setOption({ series: [{ markLine: { data: [] } }] });
+  }
+
+  // 添加水平线绘制功能
+  addDrawLineButton(): any {
+    const button = {
+      name: 'drawLineButton',
+      type: 'rect',
+      shape: {
+        x: 10,
+        y: 10,
+        width: 60,
+        height: 30
+      },
+      style: {
+        fill: this.enableDrawLine ? 'gray' : '#f00',
+        text: this.enableDrawLine ? '取消绘制' : '绘制横线',
+        font: 'bold 12px sans-serif'
+      },
+      onclick: () => this.toggleDrawLine()
+    };
+
+    return button;
+  }
+
+  // 添加斜线绘制功能
+  addDrawDashedLineButton(): any {
+    const button = {
+      name: 'drawDashedLineButton',
+      type: 'rect',
+      shape: {
+        x: 80,
+        y: 10,
+        width: 60,
+        height: 30
+      },
+      style: {
+        fill: this.enableDrawDashedLine ? 'gray' : '#f00',
+        text: this.enableDrawDashedLine ? '取消绘制' : '绘制斜线',
+        font: 'bold 12px sans-serif'
+      },
+      onclick: () => this.toggleDrawDashedLine()
+    };
+
+    return button;
+  }
+
+  // 添加清除按钮
+  addClearLinesButton(): any {
+    const button = {
+      name: 'clearLinesButton',
+      type: 'rect',
+      shape: {
+        x: 150,
+        y: 10,
+        width: 60,
+        height: 30
+      },
+      style: {
+        fill: '#f00',
+        text: '清除线条',
+        font: 'bold 12px sans-serif'
+      },
+      onclick: () => this.clearMarkLine()
+    };
+
+    return button;
+  }
+
+  // 更新图形元素
+  private updateGraphics(name: string, graphic: any): void {
+    this.buttons.set(name, graphic);
+
+    const option = {
+      graphic: {
+        elements: Array.from(this.buttons.values())
+      }
+    };
+
+    this.chart.setOption(option, { replaceMerge: 'graphic' });
+  }
+
+  // 切换水平线绘制状态
+  private toggleDrawLine(): void {
+    this.enableDrawLine = !this.enableDrawLine;
+    this.updateGraphics('drawLineButton', this.addDrawLineButton());
+  }
+
+  // 切换斜线绘制状态
+  private toggleDrawDashedLine(): void {
+    this.enableDrawDashedLine = !this.enableDrawDashedLine;
+    this.updateGraphics('drawDashedLineButton', this.addDrawDashedLineButton());
+  }
+
+  // 处理鼠标事件
+  handleMouseEvent(event: string, params: any): void {
+    if (event === 'click' && this.enableDrawLine) {
+      this.handleDrawLineClick(params);
+    } else if (this.enableDrawDashedLine) {
+      if (event === 'mousedown') {
+        this.handleDrawDashedLineMouseDown(params);
+      } else if (event === 'mousemove') {
+        this.handleDrawDashedLineMouseMove(params);
+      }
+    }
+  }
+
+  private handleDrawLineClick(params: any): void {
+    const pointInGrid = this.convertFromPixel(params);
+    if (pointInGrid) {
+      const yValue = pointInGrid[1];
+      this.lineId = crypto.randomUUID();
+      this.addMutiLine(this.lineId, { type: 'line', value: yValue });
+    }
+  }
+
+  private handleDrawDashedLineMouseDown(params: any): void {
+    if (!this.startPoint) {
+      this.startPoint = this.convertFromPixel(params);
+      this.lineId = crypto.randomUUID();
+    } else {
+      this.startPoint = null;
+      this.lineId = null;
+    }
+  }
+
+  private handleDrawDashedLineMouseMove(params: any): void {
+    if (!this.startPoint || !this.lineId) return;
+
+    const endPoint = this.convertFromPixel(params);
+    this.addMutiLine(this.lineId, {
+      type: 'dashline',
+      value: [
+        {
+          name: 'startPoint',
+          coord: this.startPoint,
+          label: { show: false }
+        },
+        {
+          name: 'endPoint',
+          coord: endPoint,
+          label: { show: false }
+        }
+      ]
+    });
+  }
+
+  public initDrawingTools(): void {
+    
+    // 创建并缓存所有按钮
+    const drawLineButton = this.addDrawLineButton();
+    const drawDashedLineButton = this.addDrawDashedLineButton();
+    const clearLinesButton = this.addClearLinesButton();
+
+    this.buttons.set('drawLineButton', drawLineButton);
+    this.buttons.set('drawDashedLineButton', drawDashedLineButton);
+    this.buttons.set('clearLinesButton', clearLinesButton);
+
+    // 使用所有按钮更新图表
+    const option = {
+      graphic: {
+        elements: Array.from(this.buttons.values())
+      }
+    };
+
+    this.chart.setOption(option, { replaceMerge: 'graphic' });
+  }
+} 

+ 3 - 0
src/client/mobile/components/stock/components/stock-chart/src/lib/index.ts

@@ -0,0 +1,3 @@
+export { StockChart } from './StockChart';
+export { DateMemoHandler } from './DateMemoHandler';
+export * from '../types/index'; 

+ 131 - 0
src/client/mobile/components/stock/components/stock-chart/src/lib/markers/MarkerProcessor.ts

@@ -0,0 +1,131 @@
+import type { ChartOption, SplitData } from '../../types/index';
+// import type { ScatterSeriesOption } from 'echarts/types/src/chart/scatter/ScatterSeries';
+// import type { LabelOption } from 'echarts/types/src/util/types';
+import { CHART_COLORS } from '../constants/colors';
+
+export class MarkerProcessor {
+  private readonly operateColor = CHART_COLORS.OPERATE;
+  private readonly middleLineColor = CHART_COLORS.MIDDLE_LINE;
+
+  add2OnTopOfVolumeMarkers(option: ChartOption, data: SplitData): void {
+    const markersData = data.volumes
+      .map((item, index) => {
+        const previousDayVolume = index > 0 ? Number(data.volumes[index - 1][1]) : 0;
+        const todayVolume = Number(item[1]);
+        
+        if (previousDayVolume > 0 && (previousDayVolume * 1) / 2 > todayVolume) {
+          return {
+            value: [index, todayVolume],
+            symbol: 'pin',
+            symbolSize: 10,
+            label: {
+              show: true,
+              formatter: '2',
+              position: 'top',
+              color: '#ffffff',
+              textBorderColor: '#000',
+              textBorderWidth: 2,
+              fontSize: 14,
+              fontWeight: 'bolder'
+            },
+            itemStyle: {
+              color: 'transparent'
+            }
+          };
+        }
+        return null;
+      })
+      .filter(item => item !== null);
+
+    option.series.push({
+      name: 'SpecificMarkers2',
+      type: 'scatter',
+      xAxisIndex: 1,
+      yAxisIndex: 1,
+      data: markersData,
+      tooltip: { show: false }
+    } as any);
+  }
+
+  add3OnTopOfVolumeMarkers(option: ChartOption, data: SplitData): void {
+    const markersData = data.volumes
+      .map((item, index) => {
+        const previousDayVolume = index > 0 ? Number(data.volumes[index - 1][1]) : 0;
+        const todayVolume = Number(item[1]);
+        const is3 = previousDayVolume > 0 && (previousDayVolume * 2) / 3 > todayVolume;
+        const is2 = previousDayVolume > 0 && (previousDayVolume * 1) / 2 > todayVolume;
+
+        if (is3 && !is2) {
+          return {
+            value: [index, todayVolume],
+            symbol: 'pin',
+            symbolSize: 10,
+            label: {
+              show: true,
+              formatter: '3',
+              position: 'top',
+              color: '#ffffff',
+              textBorderColor: '#000',
+              textBorderWidth: 2,
+              fontSize: 14,
+              fontWeight: 'bolder'
+            } as any,
+            itemStyle: {
+              color: 'transparent'
+            }
+          };
+        }
+        return null;
+      })
+      .filter(item => item !== null);
+
+    option.series.push({
+      name: 'SpecificMarkers3',
+      type: 'scatter',
+      xAxisIndex: 1,
+      yAxisIndex: 1,
+      data: markersData,
+      tooltip: { show: false }
+    } as any);
+  }
+
+  addMiddleLinesToChartOption(option: ChartOption, data: SplitData): void {
+    const middleLinesData = data.values.map((item, index) => {
+      const open = Number(item.value[0]);
+      const close = Number(item.value[1]);
+      const changePercent = Number(item.value[4]);
+      const lineHeight = 0.01;
+      const average = (open + close) / 2;
+
+      if (changePercent > 3 || changePercent < -3) {
+        return {
+          coords: [
+            [index, average - lineHeight],
+            [index, average + lineHeight]
+          ]
+        };
+      }
+      return {
+        coords: [
+          [index, average],
+          [index, average]
+        ]
+      };
+    });
+
+    option.series.push({
+      name: 'MiddleLines',
+      type: 'lines',
+      coordinateSystem: 'cartesian2d',
+      data: middleLinesData,
+      lineStyle: {
+        color: this.middleLineColor,
+        width: 10,
+        type: 'solid'
+      },
+      effect: { show: false },
+      tooltip: { show: false },
+      z: 3
+    });
+  }
+} 

+ 36 - 0
src/client/mobile/components/stock/components/stock-chart/src/services/api.ts

@@ -0,0 +1,36 @@
+import type { StockData, DateMemo } from '../types/index.ts';
+
+const API_BASE_URL = '/api';
+
+export const stockApi = {
+  // 获取股票历史数据
+  getStockHistory: async (code?: string): Promise<StockData[]> => {
+    const url = new URL(`${API_BASE_URL}/stock/history`, window.location.origin);
+    if (code) {
+      url.searchParams.set('code', code);
+    }
+    const response = await fetch(url);
+    if (!response.ok) {
+      throw new Error('Failed to fetch stock history');
+    }
+    return response.json();
+  },
+
+  // 获取备忘录数据
+  getMemoData: async (code?: string): Promise<DateMemo[]> => {
+    const url = new URL(`${API_BASE_URL}/stock/memos`, window.location.origin);
+    if (code) {
+      url.searchParams.set('code', code);
+    }
+    const response = await fetch(url);
+    if (!response.ok) {
+      throw new Error('Failed to fetch memo data');
+    }
+    const result = await response.json();
+    if(result.success) {
+      return result.data;
+    }
+    return [];
+
+  },
+}; 

+ 210 - 0
src/client/mobile/components/stock/components/stock-chart/src/types/index.ts

@@ -0,0 +1,210 @@
+import type { SeriesOption } from 'echarts';
+
+export enum ActiveType {
+  HORIZONTAL = 'horizontal',
+  TREND = 'trend',
+  TREND_EXTENDED = 'trendExtended',
+}
+
+// 定义基础数据类型
+export interface StockData {
+  o: string; // 开盘价
+  c: string; // 收盘价
+  h: string; // 最高价
+  l: string; // 最低价
+  v: string; // 成交量
+  d: string; // 日期
+  zd: string; // 涨跌幅
+  pc?: string; // 涨跌额
+}
+
+export interface DateMemo {
+  _id: number;
+  代码: string;
+  日期: string; 
+  提示: string;
+}
+
+export interface ProcessedData {
+  categoryData: string;
+  values: {
+    value: [string, string, string, string, string];
+    itemStyle: {
+      color: string;
+      borderColor: string;
+    }
+  };
+  volumes: string;
+}
+
+export interface SplitData {
+  categoryData: string[];
+  values: Array<{
+    value: number[];
+    itemStyle: {
+      color: string;
+      borderColor: string;  
+    }
+  }>;
+  volumes: [number, string, number][];
+}
+
+// 定义图表系列的具体类型
+export interface CandlestickSeries {
+  name: 'Values';
+  type: 'candlestick';
+  data: Array<{
+    value: number[];
+    itemStyle: {
+      color: string;
+      borderColor: string;
+    }
+  }>;
+}
+
+export interface VolumeSeries {
+  name: 'Volumes';
+  type: 'bar';
+  xAxisIndex: number;
+  yAxisIndex: number;
+  data: [number, string, number][];
+  itemStyle: {
+    color: string | ((params: { value: any }) => string);
+  };
+}
+
+export interface MarkerSeries {
+  name: 'SpecificMarkers' | 'DateMemo' | 'Mark';
+  type: 'scatter';
+  xAxisIndex?: number;
+  yAxisIndex?: number;
+  data: Array<{
+    value: [number, number];
+    symbol: string;
+    symbolSize: number;
+    label: {
+      show: boolean;
+      formatter: string;
+      position: 'top' | 'bottom' | 'left' | 'right';
+      color: string;
+    };
+    itemStyle: {
+      color: string;
+    };
+  }>;
+  tooltip: {
+    show: boolean;
+  };
+}
+
+export interface MiddleLineSeries {
+  name: 'MiddleLines';
+  type: 'lines';
+  coordinateSystem: 'cartesian2d';
+  data: MiddleLine[];
+  lineStyle: {
+    color: string;
+    width: number;
+    type: string;
+  };
+  effect: {
+    show: boolean;
+  };
+  tooltip: {
+    show: boolean;
+  };
+  z: number;
+}
+
+// 图表配置类型
+export interface ChartOption {
+  series: SeriesOption[];
+  [key: string]: any;
+  markPoint?: {
+    data: TradeMarker[];
+    label?: {
+      show: boolean;
+      position: 'top' | 'bottom';
+      formatter: string;
+    };
+  };
+}
+
+// 建议添加这些类型定义
+export interface VolumeMarker {
+  value: [number, number];
+  symbol: string;
+  symbolSize: number;
+  label: {
+    show: boolean;
+    formatter: string;
+    position: string;
+    color: string;
+    // ... 其他样式属性
+  };
+  itemStyle: {
+    color: string;
+  };
+}
+
+export interface MiddleLine {
+  coords: [[number, number], [number, number]];
+}
+
+// 添加新的类型定义
+export interface TradeRecord {
+  type: 'BUY' | 'SELL';
+  price: number;      // 交易价格(收盘价)
+  timestamp: number;
+  date: string;
+  buyPrice?: number;  // 仅卖出时需要记录买入价
+}
+
+// 添加买卖记录数组的类型
+export interface TradeRecordGroup {
+  date: string;
+  type: 'BUY' | 'SELL';
+  records: TradeRecord[];
+}
+
+// 添加买卖记录的响应类型
+export interface TradeResponse {
+  success: boolean;
+  data?: TradeRecord;
+  message?: string;
+}
+
+// 修改 MarkerSeries 接口以支持买卖标记
+export interface TradeMarker {
+  value: [number, number];  // [timestamp, price]
+  symbol: 'arrow' | 'arrow-down';  // 买入用上箭头,卖出用下箭头
+  symbolSize: number;
+  itemStyle: {
+    color: string;  // 买入红色,卖出绿色
+  };
+  label?: {
+    show: boolean;
+    formatter: string;
+    position: 'top' | 'bottom';
+    color: string;
+  };
+}
+
+// 添加收益相关的类型定义
+export interface DailyProfit {
+  date: string;
+  profit: number;  // 当日收益
+  trades: TradeRecord[];  // 当日交易记录
+}
+
+export interface ProfitSummary {
+  totalProfit: number;  // 累计收益
+  dailyStats: {  // 当日行情统计
+    date: string;  // 添加日期字段
+    open: number;
+    high: number;
+    close: number;
+    low: number;
+    change: number;  // 日涨幅
+  };
+} 

+ 96 - 0
src/client/mobile/components/stock/hooks/useStockSocketClient.ts

@@ -0,0 +1,96 @@
+import { useAuth } from '@/client/mobile/hooks/AuthProvider';
+import { useEffect, useState, useCallback } from 'react';
+import { io, Socket } from 'socket.io-client';
+
+interface ExamData {
+  roomId: string;
+  question: {
+    date: string;
+    price: number;
+  };
+}
+
+interface StockSocketClient {
+  // connect(): void;
+  // disconnect(): void;
+  pushExamData(data: { roomId: string; question: { date: string; price: number } }): void;
+  error: Error | null;
+  isConnected: boolean;
+}
+
+export function useStockSocket(classRoom: string | null): StockSocketClient {
+  const { token } = useAuth();
+  const [socket, setSocket] = useState<Socket | null>(null);
+  const [error, setError] = useState<Error | null>(null);
+  const [isConnected, setIsConnected] = useState(false);
+
+  // 初始化socket连接
+  useEffect(() => {
+    if (!token || !classRoom) return;
+
+    const newSocket = io('/', {
+      path: '/socket.io',
+      transports: ['websocket'],
+      withCredentials: true,
+      query: { 
+        socket_token:token
+      },
+      reconnection: true,
+      reconnectionAttempts: 5,
+      reconnectionDelay: 1000,
+    });
+
+    newSocket.on('connect', () => {
+      console.log('Exam socket connected');
+      setIsConnected(true);
+    });
+
+    newSocket.on('disconnect', () => {
+      console.log('Exam socket disconnected');
+      setIsConnected(false);
+    });
+
+    newSocket.on('error', (err) => {
+      console.error('Exam socket error:', err);
+      setError(err);
+    });
+
+    setSocket(newSocket);
+
+    return () => {
+      newSocket.disconnect();
+    };
+  }, [token]);
+
+  // const connect = useCallback(() => {
+  //   if (socket && !socket.connected) {
+  //     socket.connect();
+  //   }
+  // }, [socket]);
+
+  // const disconnect = useCallback(() => {
+  //   if (socket && socket.connected) {
+  //     socket.disconnect();
+  //   }
+  // }, [socket]);
+
+  const pushExamData = useCallback((data: ExamData) => {
+    if (socket) {
+      socket.emit('exam:question', {
+        roomId: data.roomId,
+        question: {
+          date: data.question.date,
+          price: data.question.price
+        }
+      });
+    }
+  }, [socket]);
+
+  return {
+    // connect,
+    // disconnect,
+    pushExamData,
+    error,
+    isConnected,
+  };
+}

+ 250 - 0
src/client/mobile/components/stock/stock_main.tsx

@@ -0,0 +1,250 @@
+import React, { useRef, useState, useCallback, useEffect } from 'react';
+import { useStockSocket } from './hooks/useStockSocketClient';
+import { useSearchParams } from 'react-router';
+import { toast} from 'react-toastify';
+import { StockChart, MemoToggle, TradePanel, useTradeRecords, useStockQueries, useProfitCalculator, ProfitDisplay, useStockDataFilter, DrawingToolbar } from './components/stock-chart/mod';
+import type { StockChartRef } from './components/stock-chart/mod.ts';
+import { ActiveType } from "./components/stock-chart/src/types/index";
+
+export function StockMain() {
+  const chartRef = useRef<StockChartRef>(null);
+  const [searchParams] = useSearchParams();
+  const codeFromUrl = searchParams.get('code');
+  const [stockCode, setStockCode] = useState(codeFromUrl || undefined);//|| '001339'
+  const classroom = searchParams.get('classroom');
+  const {
+    pushExamData,
+    error,
+    isConnected
+  } = useStockSocket(classroom);
+  
+  const { 
+    stockData: fullStockData, 
+    memoData, 
+    fetchData ,
+  } = useStockQueries(stockCode);
+  const { 
+    filteredData: stockData,
+    moveToNextDay,
+    setDayNum,
+    initializeView,
+    isInitialized
+  } = useStockDataFilter(fullStockData);
+  const { 
+    trades, 
+    toggleTrade,
+    hasBought
+  } = useTradeRecords(stockData);
+
+  const { profitSummary, updateCurrentDate } = useProfitCalculator(
+    stockData,
+    trades
+  );
+
+  const handleNextDay = useCallback(async () => {
+    const nextDate = await moveToNextDay();
+    if (nextDate && profitSummary?.dailyStats) {
+      updateCurrentDate(nextDate);
+    }
+  }, [moveToNextDay, updateCurrentDate, profitSummary, pushExamData]);
+
+  useEffect(() => {
+    const currentDate = profitSummary.dailyStats.date;
+    if (classroom && isConnected && currentDate ) {
+      pushExamData({
+        roomId: classroom, 
+        question: {
+          date: currentDate,
+          price: profitSummary.dailyStats.close
+        }
+      });
+    }
+  }, [classroom, isConnected, profitSummary.dailyStats ]);
+
+  const handleDayNumChange = useCallback((days: number) => {
+    if (!isInitialized) {
+      initializeView();
+    } else {
+      setDayNum(days);
+    }
+  }, [isInitialized, initializeView, setDayNum]);
+
+  const handleQuery = useCallback(() => {
+    if(!stockCode){
+      toast.error('请先输入股票代码')
+      return;
+    }
+    if (stockCode && stockCode.trim()) {
+      fetchData().then(() => {
+        initializeView();
+      });
+    }
+  }, [stockCode, fetchData, initializeView]);
+
+  const handleStartDrawing = useCallback((type: ActiveType) => {
+    chartRef.current?.startDrawing(type);
+  }, []);
+
+  const handleStopDrawing = useCallback(() => {
+    chartRef.current?.stopDrawing();
+  }, []);
+
+  const handleClearLines = useCallback(() => {
+    chartRef.current?.clearDrawings();
+  }, []);
+ 
+  // 错误处理
+  useEffect(() => {
+    if (error) {
+      toast.error(`Socket错误: ${error.message}`);
+    }
+  }, [error]);
+
+  useEffect(() => {
+    const handleKeyPress = (event: KeyboardEvent) => {
+      switch(event.key.toLowerCase()) {
+        case 'b':
+          if (!hasBought) toggleTrade('BUY');
+          break;
+        case 's':
+          if (hasBought) toggleTrade('SELL');
+          break;
+        case 'arrowright':
+          handleNextDay();
+          break;
+      }
+    };
+
+    globalThis.addEventListener('keydown', handleKeyPress);
+    return () => globalThis.removeEventListener('keydown', handleKeyPress);
+  }, [hasBought, toggleTrade, handleNextDay]);
+
+  useEffect(() => {
+    if (codeFromUrl && codeFromUrl !== stockCode) {
+      setStockCode(codeFromUrl);
+      fetchData().then(() => {
+        initializeView();
+      });
+    }
+  }, [codeFromUrl, stockCode, fetchData, initializeView]);
+
+  // if (isLoading) {
+  //   return (
+  //     <div className="flex items-center justify-center h-screen bg-gray-900">
+  //       <div className="text-white text-xl">加载中...</div>
+  //     </div>
+  //   );
+  // }
+
+  // if (error) {
+  //   return (
+  //     <div className="flex items-center justify-center h-screen bg-gray-900">
+  //       <div className="text-red-500 text-xl">错误: {error.message}</div>
+  //     </div>
+  //   );
+  // }
+
+  return (
+    <div className="flex flex-col h-screen bg-gray-900">
+      {!isConnected && classroom && (
+        <div className="bg-yellow-600 text-white text-center py-1 text-sm">
+          正在尝试连接答题卡服务...
+        </div>
+      )}
+      {/* 顶部行情和收益信息 */}
+      <ProfitDisplay profitSummary={profitSummary} />
+
+      {/* 主图表区域 */}
+      <div className="flex-1 relative overflow-hidden">
+        <StockChart
+          ref={chartRef}
+          stockData={stockData}
+          memoData={memoData}
+          trades={trades}
+        />
+        
+        {/* 添加画线工具栏 */}
+        <DrawingToolbar
+          className="absolute top-4 right-4"
+          onStartDrawing={handleStartDrawing}
+          onStopDrawing={handleStopDrawing}
+          onClearLines={handleClearLines}
+        />
+      </div>
+
+      {/* 底部控制面板 */}
+      <div className="flex items-center justify-between p-4 bg-gray-800 border-t border-gray-700">
+        {/* 左侧区域 */}
+        <div className="flex items-center space-x-6">
+          {/* 查询输入框 */}
+          <div className="flex items-center space-x-2">
+            <input
+              type="text"
+              value={stockCode}
+              onChange={(e) => setStockCode(e.target.value)}
+              placeholder="输入股票代码"
+              className="px-3 py-2 text-sm bg-gray-700 text-white rounded-md border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
+            />
+            <button
+              onClick={handleQuery}
+              className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
+            >
+              查询
+            </button>
+          </div>
+
+          {/* 交易面板 */}
+          <TradePanel
+            hasBought={hasBought}
+            onToggleTrade={toggleTrade}
+          />
+
+          {/* 下一天按钮 */}
+          <div className="flex items-center space-x-2">
+            <button 
+              onClick={handleNextDay}
+              disabled={!stockData.length}
+              className={`px-4 py-2 text-sm font-medium text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors
+                ${!stockData.length 
+                  ? 'bg-gray-600 cursor-not-allowed' 
+                  : 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500'}`}
+            >
+              下一天
+            </button>
+            <span className="text-gray-400 text-xs">→</span>
+          </div>
+
+          {/* 天数快捷按钮组 */}
+          <div className="flex items-center space-x-2">
+            {[120, 30, 60].map((days) => (
+              <button
+                key={days}
+                onClick={() => handleDayNumChange(days)}
+                className="px-3 py-1 text-sm font-medium text-white bg-gray-700 rounded-md hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors"
+              >
+                {days}天
+              </button>
+            ))}
+          </div>
+        </div>
+
+        {/* 右侧快捷键说明和能按钮 */}
+        <div className="flex items-center space-x-8">
+          <div className="text-xs text-gray-400 leading-relaxed">
+            <div>快捷键:</div>
+            <div>B - 买入</div>
+            <div>S - 卖出</div>
+            {/* <div>ESC - 取消</div> */}
+            <div>→ - 下一天</div>
+          </div>
+          <MemoToggle
+            onToggle={(visible: boolean) => {
+              chartRef.current?.toggleMemoVisibility(visible);
+            }}
+            className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors whitespace-nowrap"
+          />
+        </div>
+      </div>
+    </div>
+  );
+}

+ 65 - 0
src/client/mobile/components/stock/types/exam.ts

@@ -0,0 +1,65 @@
+import type { SocketMessage as BaseSocketMessage, SocketMessageType } from '@d8d-appcontainer/types';
+
+// 基础答题记录
+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;
+} 

+ 140 - 0
src/client/mobile/hooks/AuthProvider.tsx

@@ -0,0 +1,140 @@
+import React, { useState, useEffect, createContext, useContext } from 'react';
+
+import {
+  useQuery,
+  useQueryClient,
+} from '@tanstack/react-query';
+import axios from 'axios';
+import 'dayjs/locale/zh-cn';
+import type {
+  AuthContextType
+} from '@/share/types';
+import { authClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+
+export type User = InferResponseType<typeof authClient.me.$get, 200>;
+
+
+// 创建认证上下文
+const AuthContext = createContext<AuthContextType<User> | null>(null);
+
+// 认证提供器组件
+export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+  const [user, setUser] = useState<User | null>(null);
+  const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
+  const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
+  const queryClient = useQueryClient();
+
+  // 声明handleLogout函数
+  const handleLogout = async () => {
+    try {
+      // 如果已登录,调用登出API
+      if (token) {
+        await authClient.logout.$post();
+      }
+    } catch (error) {
+      console.error('登出请求失败:', error);
+    } finally {
+      // 清除本地状态
+      setToken(null);
+      setUser(null);
+      setIsAuthenticated(false);
+      localStorage.removeItem('token');
+      // 清除Authorization头
+      delete axios.defaults.headers.common['Authorization'];
+      console.log('登出时已删除全局Authorization头');
+      // 清除所有查询缓存
+      queryClient.clear();
+    }
+  };
+
+  // 使用useQuery检查登录状态
+  const { isLoading } = useQuery({
+    queryKey: ['auth', 'status', token],
+    queryFn: async () => {
+      if (!token) {
+        setIsAuthenticated(false);
+        setUser(null);
+        return null;
+      }
+
+      try {
+        // 设置全局默认请求头
+        axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
+        // 使用API验证当前用户
+        const res = await authClient.me.$get();
+        if (res.status !== 200) {
+          const result = await res.json();
+          throw new Error(result.message)
+        }
+        const currentUser = await res.json();
+        setUser(currentUser);
+        setIsAuthenticated(true);
+        return { isValid: true, user: currentUser };
+      } catch (error) {
+        return { isValid: false };
+      }
+    },
+    enabled: !!token,
+    refetchOnWindowFocus: false,
+    retry: false
+  });
+
+  const handleLogin = async (username: string, password: string, latitude?: number, longitude?: number): Promise<User> => {
+    try {
+      // 使用AuthAPI登录
+      const response = await authClient.login.$post({
+        json: {
+          username,
+          password
+        }
+      })
+      if (response.status !== 200) {
+        const result = await response.json()
+        throw new Error(result.message);
+      }
+
+      const result = await response.json()
+
+      // 保存token和用户信息
+      const { token: newToken, user: newUser } = result;
+
+      // 设置全局默认请求头
+      axios.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
+
+      // 保存状态
+      setToken(newToken);
+      setUser(newUser);
+      setIsAuthenticated(true);
+      localStorage.setItem('token', newToken);
+      return newUser;
+    } catch (error) {
+      console.error('登录失败:', error);
+      throw error;
+    }
+  };
+
+  return (
+    <AuthContext.Provider
+      value={{
+        user,
+        token,
+        login: handleLogin,
+        logout: handleLogout,
+        isAuthenticated,
+        isLoading
+      }}
+    >
+      {children}
+    </AuthContext.Provider>
+  );
+};
+
+// 使用上下文的钩子
+export const useAuth = () => {
+  const context = useContext(AuthContext);
+  if (!context) {
+    throw new Error('useAuth必须在AuthProvider内部使用');
+  }
+  return context;
+};

+ 41 - 0
src/client/mobile/index.tsx

@@ -0,0 +1,41 @@
+import { createRoot } from 'react-dom/client'
+import { RouterProvider } from 'react-router';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import dayjs from 'dayjs';
+import weekday from 'dayjs/plugin/weekday';
+import localeData from 'dayjs/plugin/localeData';
+import 'dayjs/locale/zh-cn';
+
+import { AuthProvider } from './hooks/AuthProvider';
+import { router } from './routes';
+import { ToastContainer } from 'react-toastify';
+
+// 配置 dayjs 插件
+dayjs.extend(weekday);
+dayjs.extend(localeData);
+
+// 设置 dayjs 语言
+dayjs.locale('zh-cn');
+
+// 创建QueryClient实例
+const queryClient = new QueryClient();
+
+// 应用入口组件
+const App = () => {
+  return (
+    <QueryClientProvider client={queryClient}>
+      <AuthProvider>
+        <RouterProvider router={router} />
+      </AuthProvider>
+      <ToastContainer />
+    </QueryClientProvider>
+  )
+};
+
+const rootElement = document.getElementById('root')
+if (rootElement) {
+  const root = createRoot(rootElement)
+  root.render(
+    <App />
+  )
+}

+ 212 - 0
src/client/mobile/pages/ClassroomPage.tsx

@@ -0,0 +1,212 @@
+import React, { useState, useEffect } from 'react';
+import { useAuth } from '@/client/mobile/hooks/AuthProvider';
+import { useNavigate, useSearchParams } from 'react-router';
+import { Role, ClassStatus } from '@/client/mobile/components/Classroom/useClassroom';
+import { ClassroomLayout } from '@/client/mobile/components/Classroom/ClassroomLayout';
+import { AuthLayout } from '@/client/mobile/components/Classroom/AuthLayout';
+import { ClassroomProvider, useClassroomContext } from "@/client/mobile/components/Classroom/ClassroomProvider";
+import { ToastContainer } from 'react-toastify';
+
+const RoleSelection = () => {
+  const { setRole, login } = useClassroomContext();
+  const chooseRole = (role: Role) => {
+
+    setRole(role);
+    login(role);
+  }
+  
+  return (
+    <div className="flex flex-col items-center justify-center h-full">
+      <h2 className="text-2xl font-bold mb-8">请选择您的角色</h2>
+      <div className="flex space-x-4">
+        <button
+          type="button"
+          onClick={() => chooseRole(Role.Teacher)}
+          className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
+        >
+          我是老师
+        </button>
+        <button
+          type="button"
+          onClick={() => chooseRole(Role.Student)}
+          className="px-6 py-3 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
+        >
+          我是学生
+        </button>
+      </div>
+    </div>
+  );
+};
+
+const JoinClassSection = () => {
+  const { joinClass } = useClassroomContext();
+  const [classId, setClassId] = useState('');
+
+  const handleJoinClass = async () => {
+    if (!classId.trim()) return;
+    await joinClass(classId);
+  };
+
+  return (
+    <div className="bg-white p-4 rounded shadow mb-4">
+      <h3 className="font-bold mb-2">加入课堂</h3>
+      <div className="flex space-x-2">
+        <input
+          type="text"
+          value={classId}
+          onChange={(e) => setClassId(e.target.value)}
+          placeholder="输入课堂ID"
+          className="flex-1 px-3 py-2 border rounded"
+        />
+        <button
+          type="button"
+          onClick={handleJoinClass}
+          className="px-4 py-2 bg-blue-500 text-white rounded"
+        >
+          加入课堂
+        </button>
+      </div>
+    </div>
+  );
+};
+
+const CreateClassSection = () => {
+  const { classStatus, createClass, className, setClassName, role } = useClassroomContext();
+  const navigate = useNavigate();
+  
+  const handleCreateClass = async () => {
+    if (!className.trim()) return;
+    const classId = await createClass(className);
+    if (classId) {
+      navigate(`/mobile/classroom/${classId}/${role === Role.Teacher ? Role.Teacher : Role.Student}`, { replace: true });
+    }
+  };
+
+  if (classStatus !== ClassStatus.NOT_STARTED) return null;
+
+  return (
+    <div className="bg-white p-4 rounded shadow mb-4">
+      <h3 className="font-bold mb-2">创建新课堂</h3>
+      <div className="flex space-x-2">
+        <input
+          type="text"
+          value={className}
+          onChange={(e) => setClassName(e.target.value)}
+          placeholder="输入课堂名称"
+          className="flex-1 px-3 py-2 border rounded"
+        />
+        <button
+          type="button"
+          onClick={handleCreateClass}
+          className="px-4 py-2 bg-green-500 text-white rounded"
+        >
+          创建课堂
+        </button>
+      </div>
+    </div>
+  );
+};
+
+
+const Classroom = () => {
+  const context = useClassroomContext();
+  const { role, classStatus, isLoggedIn, login, classId, joinClass, setRole } = context;
+  const [searchParams] = useSearchParams();
+
+  useEffect(() => {
+    // 处理URL中的role参数
+    const urlRole = searchParams.get('role');
+    if (urlRole && !role) {
+      const roleValue = urlRole === 'admin' ? Role.Teacher : Role.Student;
+      setRole(roleValue);
+      login(roleValue);
+    }
+  }, [searchParams]);
+
+  useEffect(() => {
+    
+    if (!isLoggedIn && role && classId) {
+      (async () => {
+        await login(role);
+        await joinClass(classId);
+      })()
+      
+    }
+  }, [isLoggedIn, role , classId]);
+
+  if (!role) {
+    return (
+      <AuthLayout>
+        <RoleSelection />
+      </AuthLayout>
+    );
+  }
+
+  if (!isLoggedIn) {
+    return (
+      <AuthLayout>
+        <div className="flex items-center justify-center h-full">
+          <p>正在自动登录中...</p>
+        </div>
+      </AuthLayout>
+    );
+  }
+
+  if (role === Role.Teacher && !context.isJoinedClass && !classId) {
+    return (
+      <>
+        <AuthLayout>
+          <CreateClassSection />
+        </AuthLayout>
+      </>
+    );
+  }
+
+  if (role === Role.Student && !context.isJoinedClass && !classId) {
+    return (
+      <>
+        <AuthLayout>
+          <JoinClassSection />
+        </AuthLayout>
+      </>
+    );
+  }
+
+  if (classStatus === ClassStatus.ENDED) {
+    return (
+      <div className="text-center py-8">
+        <h2 className="text-xl font-bold">课堂已结束</h2>
+        <p>感谢参与本次课堂</p>
+      </div>
+    );
+  }
+
+  return (
+    <ClassroomLayout role={role}>
+      {/* {role === Role.Teacher ? <TeacherView /> : <StudentView />} */}
+      <></>
+    </ClassroomLayout>
+  );
+};
+
+export const ClassroomPage = () => {
+  const { user } = useAuth();
+  return (
+    <>
+      <ClassroomProvider user={user!}>
+        <Classroom />
+      </ClassroomProvider>
+      <ToastContainer
+        position="top-right"
+        autoClose={500}
+        hideProgressBar={false}
+        newestOnTop={false}
+        closeOnClick
+        rtl={false}
+        pauseOnFocusLoss
+        draggable
+        pauseOnHover
+      />
+    </>
+  )
+}

+ 144 - 0
src/client/mobile/pages/Login.tsx

@@ -0,0 +1,144 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router';
+import { ArrowRightIcon, LockClosedIcon, UserIcon } from '@heroicons/react/24/outline';
+import { useAuth } from '@/client/mobile/hooks/AuthProvider';
+import { getGlobalConfig } from '@/client/utils/utils';
+
+
+// 登录页面组件
+export const LoginPage: React.FC = () => {
+  const { login } = useAuth();
+  const navigate = useNavigate();
+  const [username, setUsername] = useState('');
+  const [password, setPassword] = useState('');
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const handleLogin = async (e: React.FormEvent) => {
+    e.preventDefault();
+    
+    if (!username.trim() || !password.trim()) {
+      setError('用户名和密码不能为空');
+      return;
+    }
+    
+    setLoading(true);
+    setError(null);
+    
+    try {
+      
+      const user = await login(username, password);
+      navigate(user.roles?.some(role => role.name === 'admin') ? '/' : '/mobile/classroom');
+    } catch (err) {
+      setError( err instanceof Error ? err.message : '登录失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div className="min-h-screen flex flex-col bg-gradient-to-b from-blue-500 to-blue-700 p-6">
+      {/* 顶部Logo和标题 */}
+      <div className="flex flex-col items-center justify-center mt-10 mb-8">
+        <div className="w-20 h-20 bg-white rounded-2xl flex items-center justify-center shadow-lg mb-4">
+          <svg className="w-12 h-12 text-blue-600" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <path d="M12 2L2 7L12 12L22 7L12 2Z" fill="currentColor" />
+            <path d="M2 17L12 22L22 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
+            <path d="M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
+          </svg>
+        </div>
+        <h1 className="text-3xl font-bold text-white">
+          {getGlobalConfig('APP_NAME') || '移动应用'}
+        </h1>
+        <p className="text-blue-100 mt-2">登录您的账户</p>
+      </div>
+
+      {/* 登录表单 */}
+      <div className="bg-white rounded-xl shadow-xl p-6 w-full">
+        {error && (
+          <div className="bg-red-50 text-red-700 p-3 rounded-lg mb-4 text-sm">
+            {error}
+          </div>
+        )}
+        
+        <form onSubmit={handleLogin}>
+          <div className="mb-4">
+            <label className="block text-gray-700 text-sm font-medium mb-2" htmlFor="username">
+              用户名
+            </label>
+            <div className="relative">
+              <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
+                <UserIcon className="h-5 w-5 text-gray-400" />
+              </div>
+              <input
+                id="username"
+                type="text"
+                value={username}
+                onChange={(e) => setUsername(e.target.value)}
+                className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                placeholder="请输入用户名"
+              />
+            </div>
+          </div>
+          
+          <div className="mb-6">
+            <label className="block text-gray-700 text-sm font-medium mb-2" htmlFor="password">
+              密码
+            </label>
+            <div className="relative">
+              <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
+                <LockClosedIcon className="h-5 w-5 text-gray-400" />
+              </div>
+              <input
+                id="password"
+                type="password"
+                value={password}
+                onChange={(e) => setPassword(e.target.value)}
+                className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                placeholder="请输入密码"
+              />
+            </div>
+          </div>
+          
+          <button
+            type="submit"
+            disabled={loading}
+            className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 flex items-center justify-center"
+          >
+            {loading ? (
+              <svg className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
+                <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
+                <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+              </svg>
+            ) : (
+              <ArrowRightIcon className="h-5 w-5 mr-2" />
+            )}
+            {loading ? '登录中...' : '登录'}
+          </button>
+        </form>
+        
+        <div className="mt-6 flex items-center justify-between">
+          <button
+            type="button"
+            className="text-sm text-blue-600 hover:text-blue-700"
+            onClick={() => navigate('/mobile/register')}
+          >
+            注册账号
+          </button>
+          <button
+            type="button"
+            className="text-sm text-blue-600 hover:text-blue-700"
+          >
+            忘记密码?
+          </button>
+        </div>
+      </div>
+      
+      {/* 底部文本 */}
+      <div className="mt-auto pt-8 text-center text-blue-100 text-sm">
+        &copy; {new Date().getFullYear()} {getGlobalConfig('APP_NAME') || '移动应用'} 
+        <p className="mt-1">保留所有权利</p>
+      </div>
+    </div>
+  );
+};

+ 44 - 0
src/client/mobile/pages/StockHomePage.tsx

@@ -0,0 +1,44 @@
+import React from "react";
+import { useNavigate } from "react-router";
+import { useAuth } from "@/client/mobile/hooks/AuthProvider";
+
+export default function StockHomePage() {
+  const { user } = useAuth();
+  const navigate = useNavigate();
+
+  const handleClassroomClick = () => {
+    if (user?.roles?.some(role => role.name === 'admin')) {
+      navigate('/mobile/classroom?role=admin');
+    } else {
+      navigate('/mobile/classroom?role=student');
+    }
+  };
+
+  return (
+    <div className="min-h-screen bg-gray-50 p-4 md:p-8">
+      <h1 className="text-3xl font-bold text-center text-gray-800 mb-8 md:mb-12">
+        股票训练系统
+      </h1>
+      <div className="flex flex-col gap-4 max-w-md mx-auto">
+        <button
+          onClick={handleClassroomClick}
+          className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg shadow-md transition-colors duration-200 text-center"
+        >
+          解盘室
+        </button>
+        <button
+          onClick={() => navigate('/mobile/exam')}
+          className="bg-green-600 hover:bg-green-700 text-white font-medium py-3 px-6 rounded-lg shadow-md transition-colors duration-200 text-center"
+        >
+          考试模式
+        </button>
+        <button
+          onClick={() => navigate('/mobile/xunlian')}
+          className="bg-purple-600 hover:bg-purple-700 text-white font-medium py-3 px-6 rounded-lg shadow-md transition-colors duration-200 text-center"
+        >
+          训练模式
+        </button>
+      </div>
+    </div>
+  );
+}

+ 121 - 0
src/client/mobile/pages/XunlianPage.tsx

@@ -0,0 +1,121 @@
+import React, { useState } from "react";
+import dayjs from "dayjs";
+import { useQuery } from "@tanstack/react-query";
+// import type { XunlianCode } from "../share/types_stock.ts";
+import { useNavigate } from "react-router";
+import { stockXunlianCodesClient } from "@/client/api";
+import { InferResponseType } from "hono";
+
+type XunlianCode = InferResponseType<typeof stockXunlianCodesClient.$get, 200>['data'][0];
+
+export function XunlianPage() {
+  const [visibleStocks, setVisibleStocks] = useState<Record<number, boolean>>({});
+  const navigate = useNavigate();
+
+  const { data: codes, isLoading } = useQuery({
+    queryKey: ["xunlian-codes"],
+    queryFn: async (): Promise<XunlianCode[]> => {
+      const res = await stockXunlianCodesClient.$get({
+        query:{}
+      })
+      if(!res.ok){
+        const { message } = await res.json();
+        throw new Error(message)
+      }
+      const response = await res.json();
+      return response.data;
+    },
+    initialData: []
+  });
+
+  const toggleStockVisibility = (id: number, e: React.MouseEvent) => {
+    e.stopPropagation();
+    setVisibleStocks((prev: Record<number, boolean>) => ({
+      ...prev,
+      [id]: !prev[id]
+    }));
+  };
+
+  const handleCardClick = (code: XunlianCode) => {
+    navigate(`/mobile/stock?code=${code.code}`);
+  };
+
+  return (
+    <div className="p-4">
+      <div className="flex justify-between items-center mb-4">
+        <h1 className="text-2xl font-bold">训练案例</h1>
+      </div>
+
+      <div className="grid gap-4">
+        {isLoading ? (
+          <div className="text-center text-gray-500 py-8">
+            <div className="animate-spin inline-block w-6 h-6 border-[3px] border-current border-t-transparent text-blue-600 rounded-full" role="status" aria-label="loading">
+              <span className="sr-only">加载中...</span>
+            </div>
+            <div className="mt-2">加载中...</div>
+          </div>
+        ) : codes.length === 0 ? (
+          <div className="text-center text-gray-500 py-8">
+            暂无训练案例
+          </div>
+        ) : (
+          codes.map((code: XunlianCode) => (
+            <div
+              key={code.id}
+              className="border rounded-lg p-4 hover:shadow-lg transition-shadow cursor-pointer"
+              onClick={() => handleCardClick(code)}
+            >
+              <div className="flex justify-between items-start">
+                <div>
+                  <h2 className="text-lg font-semibold flex items-center gap-2">
+                    {visibleStocks[code.id] ? (
+                      <>
+                        {code.stockName} {code.name} ({code.code})
+                        <button
+                          onClick={(e) => toggleStockVisibility(code.id, e)}
+                          className="text-gray-500 hover:text-gray-700"
+                          title="隐藏股票信息"
+                        >
+                          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
+                            <path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
+                            <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+                          </svg>
+                        </button>
+                      </>
+                    ) : (
+                      <>
+                        {code.name}
+                        <button
+                          onClick={(e) => toggleStockVisibility(code.id, e)}
+                          className="text-gray-400 hover:text-gray-600"
+                          title="显示股票信息"
+                        >
+                          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
+                            <path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
+                          </svg>
+                        </button>
+                      </>
+                    )}
+                  </h2>
+                  {code.description && (
+                    <p className="text-gray-600 mt-1">{code.description}</p>
+                  )}
+                </div>
+                <div className="text-sm text-gray-500">
+                  <div>{dayjs(code.tradeDate).format("YYYY-MM-DD")}</div>
+                </div>
+              </div>
+              {code.type && (
+                <div className="mt-2">
+                  <span className="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">
+                    {code.type}
+                  </span>
+                </div>
+              )}
+            </div>
+          ))
+        )}
+      </div>
+    </div>
+  );
+}

+ 90 - 0
src/client/mobile/routes.tsx

@@ -0,0 +1,90 @@
+import React from 'react';
+import { createBrowserRouter, Navigate } from 'react-router';
+import { ProtectedRoute } from './components/ProtectedRoute';
+import { ErrorPage } from './components/ErrorPage';
+import { NotFoundPage } from './components/NotFoundPage';
+// import { MainLayout } from './layouts/MainLayout';
+import { ClassroomPage } from './pages/ClassroomPage';
+// import { SubmissionRecordsPage } from './pages/SubmissionRecordsPage';
+// import { StockDataPage } from './pages/StockDataPage';
+// import { StockXunlianCodesPage } from './pages/StockXunlianCodesPage';
+// import { DateNotesPage } from './pages/DateNotesPage';
+import { LoginPage } from './pages/Login';
+import StockHomePage from './pages/StockHomePage';
+import { XunlianPage } from './pages/XunlianPage';
+import { StockMain } from './components/stock/stock_main';
+import ExamIndex from './components/Exam/ExamIndex';
+import ExamAdmin from './components/Exam/ExamAdmin';
+import ExamCard from './components/Exam/ExamCard';
+
+export const router = createBrowserRouter([
+  {
+    path: '/',
+    element: <Navigate to="/mobile" replace />
+  },
+  {
+    path: '/mobile/login',
+    element: <LoginPage />
+  },
+  {
+    path: '/mobile',
+    element: (
+      <ProtectedRoute>
+        <StockHomePage />
+      </ProtectedRoute>
+    ),
+    children: [
+      {
+        path: '*',
+        element: <NotFoundPage />,
+        errorElement: <ErrorPage />
+      },
+    ],
+  },
+  // {
+  //   path: '/mobile/classroom',
+  //   element: (
+  //     <ProtectedRoute>
+  //       <ClassroomPage />
+  //     </ProtectedRoute>
+  //   ),
+  // },
+  {
+    path: '/mobile/xunlian',
+    element: (
+      <ProtectedRoute>
+        <XunlianPage />
+      </ProtectedRoute>
+    ),
+  },
+  {
+    path: '/mobile/classroom/:id?/:role?',
+    element: <ProtectedRoute><ClassroomPage /></ProtectedRoute>,
+    errorElement: <ErrorPage />
+  },
+  {
+    path: '/mobile/stock',
+    element: <ProtectedRoute><StockMain /></ProtectedRoute>,
+    errorElement: <ErrorPage />
+  },
+  {
+    path: '/mobile/exam/card',
+    element: <ProtectedRoute><ExamCard /></ProtectedRoute>,
+    errorElement: <ErrorPage />
+  },
+  {
+    path: '/mobile/exam/:id',
+    element: <ProtectedRoute><ExamAdmin /></ProtectedRoute>,
+    errorElement: <ErrorPage />
+  },
+  {
+    path: '/mobile/exam',
+    element: <ProtectedRoute><ExamIndex /></ProtectedRoute>,
+    errorElement: <ErrorPage />
+  },
+  {
+    path: '*',
+    element: <NotFoundPage />,
+    errorElement: <ErrorPage />
+  },
+]);