瀏覽代碼

迁移入股票训练模块

yourname 7 月之前
父節點
當前提交
7d3d141efa
共有 28 個文件被更改,包括 3717 次插入531 次删除
  1. 14 0
      client/stock/components/stock-chart/mod.ts
  2. 74 0
      client/stock/components/stock-chart/src/components/DrawingToolbar.tsx
  3. 26 0
      client/stock/components/stock-chart/src/components/MemoToggle.tsx
  4. 65 0
      client/stock/components/stock-chart/src/components/ProfitDisplay.tsx
  5. 246 0
      client/stock/components/stock-chart/src/components/StockChart.tsx
  6. 33 0
      client/stock/components/stock-chart/src/components/TradePanel.tsx
  7. 85 0
      client/stock/components/stock-chart/src/hooks/useProfitCalculator.ts
  8. 62 0
      client/stock/components/stock-chart/src/hooks/useStockDataFilter.ts
  9. 63 0
      client/stock/components/stock-chart/src/hooks/useStockQueries.ts
  10. 63 0
      client/stock/components/stock-chart/src/hooks/useTradeRecords.ts
  11. 2 0
      client/stock/components/stock-chart/src/hooks/useTradeSimulator.ts
  12. 77 0
      client/stock/components/stock-chart/src/lib/DateMemoHandler.ts
  13. 114 0
      client/stock/components/stock-chart/src/lib/StockChart.ts
  14. 153 0
      client/stock/components/stock-chart/src/lib/config/ChartBaseConfig.ts
  15. 9 0
      client/stock/components/stock-chart/src/lib/constants/colors.ts
  16. 67 0
      client/stock/components/stock-chart/src/lib/data/DataProcessor.ts
  17. 518 0
      client/stock/components/stock-chart/src/lib/drawing/ChartDrawingTools.ts
  18. 222 0
      client/stock/components/stock-chart/src/lib/drawing/DrawingTools.ts
  19. 3 0
      client/stock/components/stock-chart/src/lib/index.ts
  20. 131 0
      client/stock/components/stock-chart/src/lib/markers/MarkerProcessor.ts
  21. 36 0
      client/stock/components/stock-chart/src/services/api.ts
  22. 204 0
      client/stock/components/stock-chart/src/types/index.ts
  23. 675 0
      client/stock/hooks/useSocketClient.ts
  24. 242 0
      client/stock/stock_app.tsx
  25. 65 0
      client/stock/types/exam.ts
  26. 3 4
      deno.json
  27. 462 527
      deno.lock
  28. 3 0
      版本迭代需求.md

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

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

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

@@ -0,0 +1,74 @@
+import React from 'react';
+
+interface DrawingToolbarProps {
+  onStartDrawing: (type: 'horizontal' | 'trend' | 'trendExtended') => void;
+  onStopDrawing: () => void;
+  onClearLines: () => void;
+  className?: string;
+}
+
+export const DrawingToolbar: React.FC<DrawingToolbarProps> = ({
+  onStartDrawing,
+  onStopDrawing,
+  onClearLines,
+  className = ''
+}: DrawingToolbarProps) => {
+  const [activeType, setActiveType] = React.useState<'horizontal' | 'trend' | 'trendExtended' | null>(null);
+
+  const handleToolClick = (type: 'horizontal' | 'trend' | 'trendExtended') => {
+    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('horizontal')}
+        className={`px-3 py-1 text-sm font-medium rounded-md transition-colors
+          ${activeType === 'horizontal'
+            ? 'bg-blue-600 text-white'
+            : 'bg-gray-700 text-gray-200 hover:bg-gray-600'
+          }`}
+      >
+        水平线
+      </button>
+      <button
+        onClick={() => handleToolClick('trend')}
+        className={`px-3 py-1 text-sm font-medium rounded-md transition-colors
+          ${activeType === 'trend'
+            ? 'bg-blue-600 text-white'
+            : 'bg-gray-700 text-gray-200 hover:bg-gray-600'
+          }`}
+      >
+        斜横线
+      </button>
+      <button
+        onClick={() => handleToolClick('trendExtended')}
+        className={`px-3 py-1 text-sm font-medium rounded-md transition-colors
+          ${activeType === 'trendExtended'
+            ? '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
client/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
client/stock/components/stock-chart/src/components/ProfitDisplay.tsx

@@ -0,0 +1,65 @@
+import React from 'react';
+import type { ProfitSummary } from '../types/index.ts';
+
+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
client/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.ts';
+import type { StockData, DateMemo, TradeRecord } from '../types/index.ts';
+import { ChartDrawingTools } from '../lib/drawing/ChartDrawingTools.ts';
+
+// 将 StockChartRef 接口移到 Props 定义之前
+interface StockChartRef {
+  toggleMemoVisibility: (visible: boolean) => void;
+  startDrawing: (type: 'horizontal' | 'trend') => 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: 'horizontal' | 'trend') => {
+      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
client/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
client/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.ts';
+
+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
client/stock/components/stock-chart/src/hooks/useStockDataFilter.ts

@@ -0,0 +1,62 @@
+import { useState, useCallback } from 'react';
+import type { StockData } from '../types/index.ts';
+
+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
+  };
+} 

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

@@ -0,0 +1,63 @@
+import { useQuery } from '@tanstack/react-query';
+import { stockApi } from '../services/api.ts';
+import type { StockData, DateMemo } from '../types/index.ts';
+import { message } from 'antd';
+import { useEffect } from 'react';
+
+export function useStockQueries(code?: string) {
+  // 查询股票历史数据
+  const { 
+    data: stockData = [], 
+    isLoading: isLoadingStock,
+    error: stockError,
+    refetch: refetchStock
+  } = useQuery<StockData[]>({
+    queryKey: ['stockHistory', code],
+    queryFn: () => stockApi.getStockHistory(code),
+    enabled: false, // 默认不自动查询
+    retry: 0,
+  });
+
+  // 查询备忘录数据
+  const { 
+    data: memoData = [], 
+    isLoading: isLoadingMemo,
+    error: memoError,
+    refetch: refetchMemo
+  } = useQuery<DateMemo[]>({
+    queryKey: ['memoData', code],
+    queryFn: () => stockApi.getMemoData(code),
+    enabled: false, // 默认不自动查询
+  });
+
+  const isLoading = isLoadingStock || isLoadingMemo;
+  const error = stockError || memoError;
+
+  useEffect(() => {
+    if (isLoading) {
+      message.loading({ content: '正在加载数据...', key: 'stockLoading', duration: 0 });
+    } else {
+      message.destroy('stockLoading');
+      if (error instanceof Error) {
+        message.error('加载数据失败,请稍后重试');
+      }
+    }
+  }, [isLoading, error]);
+
+  // 提供一个函数来手动触发查询
+  const fetchData = async () => {
+    if (!code) return;
+    await Promise.all([
+      refetchStock(),
+      refetchMemo()
+    ]);
+  };
+
+  return {
+    stockData,
+    memoData,
+    isLoading,
+    error,
+    fetchData,
+  };
+} 

+ 63 - 0
client/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.ts';
+
+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,
+  };
+} 

+ 2 - 0
client/stock/components/stock-chart/src/hooks/useTradeSimulator.ts

@@ -0,0 +1,2 @@
+// ... 删除整个文件
+ 

+ 77 - 0
client/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
+        }
+      });
+    }
+
+    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
client/stock/components/stock-chart/src/lib/StockChart.ts

@@ -0,0 +1,114 @@
+import { ChartBaseConfig } from './config/ChartBaseConfig.ts';
+import { DataProcessor } from './data/DataProcessor.ts';
+import { MarkerProcessor } from './markers/MarkerProcessor.ts';
+import type { StockData, DateMemo, ChartOption, SplitData } from '../types/index.ts';
+import { DrawingTools } from './drawing/DrawingTools.ts';
+import { DateMemoHandler } from './DateMemoHandler.ts';
+
+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
client/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.ts';
+
+export class ChartBaseConfig {
+  static createBaseOption(categoryData: string[]): EChartsOption {
+    return {
+      tooltip: {
+        trigger: 'axis',
+        axisPointer: { type: 'cross' },
+        formatter: (params: CallbackDataParams | CallbackDataParams[]) => {
+          // 确保 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
client/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
client/stock/components/stock-chart/src/lib/data/DataProcessor.ts

@@ -0,0 +1,67 @@
+import type { StockData, ProcessedData, SplitData } from '../../types/index.ts';
+import { CHART_COLORS } from '../constants/colors.ts';
+
+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
client/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
client/stock/components/stock-chart/src/lib/drawing/DrawingTools.ts

@@ -0,0 +1,222 @@
+import type { ChartOption } from '../../types/index.ts';
+
+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
client/stock/components/stock-chart/src/lib/index.ts

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

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

@@ -0,0 +1,131 @@
+import type { ChartOption, SplitData } from '../../types/index.ts';
+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.ts';
+
+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'
+            } as LabelOption,
+            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 ScatterSeriesOption);
+  }
+
+  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 LabelOption,
+            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 ScatterSeriesOption);
+  }
+
+  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
client/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 [];
+
+  },
+}; 

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

@@ -0,0 +1,204 @@
+import type { EChartsOption, SeriesOption } from 'echarts';
+
+// 定义基础数据类型
+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;  // 日涨幅
+  };
+} 

+ 675 - 0
client/stock/hooks/useSocketClient.ts

@@ -0,0 +1,675 @@
+import { useEffect, useState, useCallback } from 'react';
+import { APIClient } from '@d8d-appcontainer/api';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import type {
+  QuizContent,
+  QuizState,
+  ExamSocketMessage,
+  ExamSocketRoomMessage,
+  Answer
+} from '../types/exam.ts';
+
+interface LoaderData {
+  token: string;
+  serverUrl: 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
+  };
+}
+
+// 使用react-query管理socket客户端
+export function useSocketClient(roomId: string | null) {
+  const { token, serverUrl } = {
+    token: '',
+    serverUrl: '/wss'
+  };
+
+  const { data: client } = useQuery({
+    queryKey: ['socket-client'],
+    queryFn: async () => {
+      if (!token || !serverUrl) return null;
+
+      const apiClient = new APIClient({
+        scope: 'user',
+        config: {
+          serverUrl,
+          type: 'socket',
+          token,
+        }
+      });
+
+      await apiClient.connect();
+      return apiClient;
+    },
+    enabled: !!token && !!serverUrl && !!roomId,
+    staleTime: Infinity,
+    retry: 3,
+    gcTime: 0
+  });
+
+  const joinRoom = useCallback(async (roomId: string) => {
+    if (client) {
+      await client.socket.joinRoom(roomId);
+    }
+  }, [client]);
+
+  const leaveRoom = useCallback(async (roomId: string) => {
+    if (client) {
+      await client.socket.leaveRoom(roomId);
+    }
+  }, [client]);
+
+  const sendRoomMessage = useCallback(async (roomId: string, message: ExamSocketMessage) => {
+    if (client) {
+      await client.socket.sendRoomMessage(roomId, message as any);
+    }
+  }, [client]);
+
+  const onRoomMessage = useCallback((callback: (data: ExamSocketRoomMessage) => void) => {
+    if (client) {
+      client.socket.onRoomMessage(callback);
+    }
+  }, [client]);
+
+  const getAnswers = useCallback(async (roomId: string, questionId: string): Promise<Answer[]> => {
+    if (!client) return [];
+
+    return handleAsyncOperation(async () => {
+      const answersData = await client.redis.hgetall(`quiz:${roomId}:answers:${questionId}`);
+      if (!answersData) return [];
+      
+      return Object.entries(answersData).map(([userId, data]) => ({
+        ...(JSON.parse(data) as QuizContent),
+        userId,
+      })) as Answer[];
+    }, '获取答案失败');
+  }, [client]);
+
+  const storeAnswer = useCallback(async (roomId: string, questionId: string, userId: string, answer: QuizContent) => {
+    if (!client) return;
+
+    // 获取历史价格数据
+    const pricesData = await client.redis.hgetall(`quiz:${roomId}:prices`);
+    if (!pricesData) {
+      // 如果没有历史数据,存储初始答案
+      const initialAnswer: Answer = {
+        ...answer,
+        userId,
+        profitAmount: 0,
+        profitPercent: 0,
+        totalProfitAmount: 0,
+        totalProfitPercent: 0
+      };
+      
+      await client.redis.hset(
+        `quiz:${roomId}:answers:${questionId}`,
+        userId,
+        JSON.stringify(initialAnswer)
+      );
+      return;
+    }
+
+    // 获取该用户的所有历史答案
+    const dates = Object.keys(pricesData).sort();
+    const allAnswers = await Promise.all(
+      dates.map(date => getAnswers(roomId, date))
+    );
+
+    // 过滤出当前用户的答案并添加价格信息
+    const userAnswers = allAnswers
+      .flat()
+      .filter((a: Answer) => a.userId === userId)
+      .map((a: Answer) => {
+        if (!a.date) return a;
+        const priceData = JSON.parse(pricesData[a.date] || '{"price":"0"}');
+        return {
+          ...a,
+          price: priceData.price
+        };
+      })
+      .sort((a: Answer, b: Answer) => new Date(a.date || '').getTime() - new Date(b.date || '').getTime());
+
+    // 计算收益
+    let totalProfitAmount = 0;
+    let totalProfitPercent = 0;
+    
+    if (userAnswers.length > 0) {
+      const prevAnswer = userAnswers[userAnswers.length - 1];
+      const { profitAmount, profitPercent } = calculateProfit(
+        parseFloat(String(answer.price)),
+        parseFloat(String(prevAnswer.price)),
+        prevAnswer.holdingStock as string
+      );
+      
+      totalProfitAmount = (prevAnswer.totalProfitAmount || 0) + profitAmount;
+      totalProfitPercent = (prevAnswer.totalProfitPercent || 0) + profitPercent;
+    }
+
+    // 存储带有收益信息的答案
+    const answerWithProfit: Answer = {
+      ...answer,
+      userId,
+      profitAmount: userAnswers.length > 0 ? totalProfitAmount - (userAnswers[userAnswers.length - 1].totalProfitAmount || 0) : 0,
+      profitPercent: userAnswers.length > 0 ? totalProfitPercent - (userAnswers[userAnswers.length - 1].totalProfitPercent || 0) : 0,
+      totalProfitAmount,
+      totalProfitPercent
+    };
+
+    if (client?.redis) {
+      await client.redis.hset(
+        `quiz:${roomId}:answers:${questionId}`,
+        userId,
+        JSON.stringify(answerWithProfit)
+      );
+    }
+  }, [client, getAnswers]);
+
+  const cleanupRoom = useCallback(async (roomId: string, questionId?: string) => {
+    if (!client) return;
+
+    await handleAsyncOperation(async () => {
+      if (questionId) {
+        await client.redis.del(`quiz:${roomId}:answers:${questionId}`);
+      } else {
+        await Promise.all([
+          client.redis.delByPattern(`quiz:${roomId}:answers:*`),
+          client.redis.del(`quiz:${roomId}:prices`)
+        ]);
+      }
+    }, '清理房间数据失败');
+  }, [client]);
+
+  const sendNextQuestion = useCallback(async (roomId: string, state: QuizState) => {
+    if (!client) return;
+
+    return handleAsyncOperation(async () => {
+      const message = {
+        type: 'question',
+        content: {
+          date: state.date,
+          price: state.price
+        }
+      };
+
+      // 存储当前问题状态
+      await storeAnswer(roomId, 'current_state', 'system', {
+        date: state.date,
+        price: state.price
+      });
+
+      // 存储价格历史记录
+      await client.redis.hset(
+        `quiz:${roomId}:prices`,
+        state.date,
+        JSON.stringify({ price: state.price })
+      );
+
+      await sendRoomMessage(roomId, message);
+    }, '发送题目失败');
+  }, [client, sendRoomMessage, storeAnswer]);
+
+  const getCurrentQuestion = useCallback(async (roomId: string): Promise<QuizState | null> => {
+    if (!client) return null;
+
+    return handleAsyncOperation(async () => {
+      const answers = await getAnswers(roomId, 'current_state');
+      const currentState = answers[0];
+      if (currentState) {
+        return {
+          date: currentState.date || '',
+          price: currentState.price || '0'
+        };
+      }
+      return null;
+    }, '获取当前题目状态失败');
+  }, [client, getAnswers]);
+
+  // 添加获取历史价格的函数
+  const getPriceHistory = useCallback(async (roomId: string, date: string): Promise<string> => {
+    if (!client) return '0';
+
+    return handleAsyncOperation(async () => {
+      const priceData = await client.redis.hget(`quiz:${roomId}:prices`, date);
+      if (!priceData) return '0';
+      
+      const { price } = JSON.parse(priceData);
+      return String(price);
+    }, '获取历史价格失败');
+  }, [client]);
+
+  return {
+    client,
+    joinRoom,
+    leaveRoom,
+    sendRoomMessage,
+    onRoomMessage,
+    storeAnswer,
+    getAnswers,
+    cleanupRoom,
+    sendNextQuestion,
+    getCurrentQuestion,
+    getPriceHistory
+  };
+}
+
+// Socket Room Hook
+export function useSocketRoom(roomId: string | null) {
+  const socketClient = useSocketClient(roomId);
+
+  const { data: roomConnection } = useQuery({
+    queryKey: ['socket-room', roomId],
+    queryFn: async () => {
+      if (!roomId || !socketClient.client) return null;
+      await socketClient.joinRoom(roomId);
+      console.log(`Connected to room: ${roomId}`);
+      return { roomId, connected: true };
+    },
+    enabled: !!roomId && !!socketClient.client,
+    staleTime: Infinity,
+    gcTime: Infinity,
+    retry: false
+  });
+
+  return {
+    connected: roomConnection?.connected || false,
+    socketClient
+  };
+}
+
+// 使用react-query管理当前题目状态
+export function useCurrentQuestion(roomId: string | null) {
+  const socketClient = useSocketClient(roomId);
+  
+  const { data: currentQuestion, refetch } = useQuery({
+    queryKey: ['current-question', roomId],
+    queryFn: async () => {
+      if (!roomId || !socketClient) return null;
+      return socketClient.getCurrentQuestion(roomId);
+    },
+    enabled: !!roomId && !!socketClient,
+    staleTime: 0
+  });
+
+  return {
+    currentQuestion,
+    refetchQuestion: refetch
+  };
+}
+
+// 使用react-query管理房间消息
+export function useRoomMessages(roomId: string | null) {
+  const socketClient = useSocketClient(roomId);
+  const queryClient = useQueryClient();
+  const [lastMessage, setLastMessage] = useState<ExamSocketRoomMessage | null>(null);
+
+  useEffect(() => {
+    if (!roomId || !socketClient) return;
+
+    const handleMessage = (data: ExamSocketRoomMessage) => {
+      setLastMessage(data);
+      const { type, content } = data.message;
+
+      // 处理不同类型的消息
+      switch (type) {
+        case 'question':
+          queryClient.invalidateQueries({ queryKey: ['current-question', roomId] });
+          break;
+        case 'answer':
+          const { date } = content;
+          queryClient.invalidateQueries({ queryKey: ['answers', roomId, date] });
+          break;
+        case 'settlement':
+        case 'submit':
+          // 刷新所有用户的答题历史
+          queryClient.invalidateQueries({ 
+            queryKey: ['user-answers'],
+            predicate: (query) => query.queryKey[1] === roomId 
+          });
+          // 刷新当前答案缓存
+          queryClient.invalidateQueries({ 
+            queryKey: ['answers', roomId] 
+          });
+          break;
+        case 'restart':
+          // 重置所有相关查询
+          queryClient.invalidateQueries({ queryKey: ['current-question', roomId] });
+          queryClient.invalidateQueries({ queryKey: ['answers', roomId] });
+          queryClient.invalidateQueries({ 
+            queryKey: ['user-answers'],
+            predicate: (query) => query.queryKey[1] === roomId 
+          });
+          queryClient.invalidateQueries({ queryKey: ['training-results', roomId] });
+          break;
+      }
+    };
+
+    socketClient.onRoomMessage(handleMessage);
+  }, [roomId, socketClient, queryClient]);
+
+  return lastMessage;
+}
+
+// 使用react-query管理答案缓存
+export function useAnswerCache(roomId: string | null, date: string | null) {
+  const socketClient = useSocketClient(roomId);
+
+  const { data: answers, refetch: refetchAnswers } = useQuery({
+    queryKey: ['answers', roomId, date],
+    queryFn: async () => {
+      if (!roomId || !date || !socketClient) return [];
+      const answers = await socketClient.getAnswers(roomId, date);
+      const priceData = await socketClient.client?.redis.hget(`quiz:${roomId}:prices`, date);
+      if (!priceData) return answers;
+
+      const { price } = JSON.parse(priceData);
+      return answers.map((answer: Answer) => ({
+        ...answer,
+        price
+      }));
+    },
+    enabled: !!roomId && !!date && !!socketClient,
+    staleTime: 0
+  });
+
+  return {
+    answers: answers || [],
+    refetchAnswers
+  };
+}
+
+// 使用react-query管理用户答案历史
+export function useUserAnswerHistory(roomId: string | null, userId: string | null) {
+  const socketClient = useSocketClient(roomId);
+
+  const { data: userAnswers, refetch: refetchUserAnswers } = useQuery({
+    queryKey: ['user-answers', roomId, userId],
+    queryFn: async () => {
+      if (!roomId || !userId || !socketClient) return [];
+      
+      // 先获取所有价格记录
+      const pricesData = await socketClient.client?.redis.hgetall(`quiz:${roomId}:prices`);
+      if (!pricesData) return [];
+      
+      // 获取所有日期的答案
+      const allAnswers = await Promise.all(
+        Object.keys(pricesData).map(date => socketClient.getAnswers(roomId, date))
+      );
+      
+      // 过滤出当前用户的答案并添加价格信息
+      const userAnswersWithPrice = allAnswers
+        .flat()
+        .filter((answer: Answer) => answer.userId === userId)
+        .map((answer: Answer) => {
+          if (!answer.date) return answer;
+          const priceData = JSON.parse(pricesData[answer.date] || '{"price":"0"}');
+          return {
+            ...answer,
+            price: priceData.price
+          };
+        })
+        .sort((a: Answer, b: Answer) => new Date(a.date || '').getTime() - new Date(b.date || '').getTime()); // 按日期排序
+
+      // 计算每条记录的收益
+      let totalProfitAmount = 0;
+      let totalProfitPercent = 0;
+      const answersWithProfit = userAnswersWithPrice.map((answer: Answer, index: number) => {
+        if (index === 0) {
+          return { 
+            ...answer, 
+            profitAmount: 0,
+            profitPercent: 0,
+            totalProfitAmount: 0,
+            totalProfitPercent: 0
+          };
+        }
+
+        const prevAnswer = userAnswersWithPrice[index - 1];
+        const { profitAmount, profitPercent } = calculateProfit(
+          parseFloat(answer.price as string),
+          parseFloat(prevAnswer.price as string),
+          prevAnswer.holdingStock as string
+        );
+        
+        totalProfitAmount += profitAmount;
+        totalProfitPercent += profitPercent;
+
+        return {
+          ...answer,
+          profitAmount,
+          profitPercent,
+          totalProfitAmount,
+          totalProfitPercent
+        };
+      });
+
+      return answersWithProfit;
+    },
+    enabled: !!roomId && !!userId && !!socketClient,
+    staleTime: 0
+  });
+
+  return {
+    userAnswers: userAnswers || [],
+    refetchUserAnswers
+  };
+}
+
+// 使用react-query管理答案提交
+export function useAnswerSubmission(roomId: string | null) {
+  const { client, sendRoomMessage, storeAnswer } = useSocketClient(roomId);
+  const queryClient = useQueryClient();
+
+  const submitAnswer = useCallback(async (date: string, nickname: string, answer: any) => {
+    if (!client) return;
+
+    return handleAsyncOperation(async () => {
+      await storeAnswer(roomId, date, nickname, answer);
+      await sendRoomMessage(roomId, {
+        type: 'answer',
+        content: answer
+      });
+      queryClient.invalidateQueries({ queryKey: ['answers', roomId, date] });
+      queryClient.invalidateQueries({ queryKey: ['user-answers', roomId, nickname] });
+    }, '提交答案失败');
+  }, [roomId, client, storeAnswer, sendRoomMessage, queryClient]);
+
+  return { submitAnswer };
+}
+
+// 使用react-query管理答案提交到后端
+export function useAnswerManagement(roomId: string | null) {
+  const socketClient = useSocketClient(roomId);
+  const queryClient = useQueryClient();
+
+  // 添加自动结算函数
+  const autoSettlement = useCallback(async (date: string) => {
+    if (!socketClient?.client) return;
+
+    return handleAsyncOperation(async () => {
+      // 获取当前所有答案
+      const answers = await socketClient.getAnswers(roomId, date);
+      const currentPrice = answers[0]?.price;  // 使用当前价格作为结算价格
+      
+      if (!currentPrice) return;
+
+      // 找出所有持股的用户
+      const holdingStockUsers = answers.filter((answer: Answer) => answer.holdingStock === '1');
+      
+      // 为每个持股用户创建一个结算记录
+      await Promise.all(holdingStockUsers.map(async (answer: Answer) => {
+        const settlementAnswer = {
+          ...answer,
+          date,
+          holdingStock: '0',  // 清仓
+          holdingCash: '1',   // 全部持币
+          price: currentPrice,
+          userId: answer.userId,
+        };
+        
+        // 存储结算记录
+        await socketClient.storeAnswer(roomId, date, answer.userId, settlementAnswer);
+      }));
+
+      // 发送结算消息通知客户端刷新
+      await socketClient.sendRoomMessage(roomId, {
+        type: 'settlement',
+        content: {
+          date,
+          price: currentPrice
+        }
+      });
+
+      // 刷新当前页面的数据
+      queryClient.invalidateQueries({ queryKey: ['answers', roomId, date] });
+    }, '自动结算失败');
+  }, [roomId, socketClient, queryClient]);
+
+  const submitAnswersToBackend = useCallback(async (date: string) => {
+    if (!socketClient) return;
+
+    return handleAsyncOperation(async () => {
+      const allAnswers = await socketClient.getAnswers(roomId, date);
+      
+      // 检查是否还有持股的用户
+      const hasHoldingStock = allAnswers.some((answer: Answer) => answer.holdingStock === '1');
+      if (hasHoldingStock) {
+        throw new Error('还有用户持股中,请先进行结算');
+      }
+
+      const priceData = await socketClient.client?.redis.hget(`quiz:${roomId}:prices`, date);
+      const { price } = priceData ? JSON.parse(priceData) : { price: '0' };
+      
+      // 获取前一天的价格
+      const allPrices = await socketClient.client?.redis.hgetall(`quiz:${roomId}:prices`);
+      const dates = Object.keys(allPrices || {}).sort();
+      const currentDateIndex = dates.indexOf(date);
+      const prevPrice = currentDateIndex > 0 
+        ? JSON.parse(allPrices![dates[currentDateIndex - 1]]).price 
+        : price;
+
+      // 计算每个用户的收益
+      const answersWithProfit = allAnswers.map((answer: Answer) => ({
+        ...answer,
+        price,
+        profit: calculateProfit(parseFloat(price), parseFloat(prevPrice), answer.holdingStock || '0')
+      }));
+      
+      const response = await fetch('/api/v1/classroom-answers', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify({
+          classroom_no: roomId,
+          date,
+          answers: answersWithProfit
+        })
+      });
+
+      const data = await response.json();
+      if (!data.success) {
+        throw new Error(data.message || '提交失败');
+      }
+
+      // 发送收卷消息通知客户端
+      await socketClient.sendRoomMessage(roomId, {
+        type: 'submit',
+        content: {
+          date,
+          price
+        }
+      });
+
+      await socketClient.cleanupRoom(roomId, date);
+      return data;
+    }, '提交答案到后端失败');
+  }, [roomId, socketClient]);
+
+  const { data: results, refetch: refetchResults } = useQuery({
+    queryKey: ['training-results', roomId],
+    queryFn: async () => {
+      if (!roomId) return null;
+      const response = await fetch(`/api/v1/classroom-results?classroom_no=${roomId}`);
+      const data = await response.json();
+      if (!data.success) {
+        throw new Error(data.message || '获取结果失败');
+      }
+      return data.data;
+    },
+    enabled: false
+  });
+
+  const restartTraining = useCallback(async () => {
+    if (!socketClient) return;
+
+    return handleAsyncOperation(async () => {
+      await socketClient.cleanupRoom(roomId);
+      // 发送重开消息
+      await socketClient.sendRoomMessage(roomId, {
+        type: 'restart',
+        content: {}
+      });
+      queryClient.invalidateQueries({ queryKey: ['current-question', roomId] });
+      queryClient.invalidateQueries({ queryKey: ['answers', roomId] });
+      queryClient.invalidateQueries({ queryKey: ['training-results', roomId] });
+    }, '重启训练失败');
+  }, [roomId, socketClient, queryClient]);
+
+  return {
+    autoSettlement,  // 暴露结算函数
+    submitAnswersToBackend,
+    results,
+    refetchResults,
+    restartTraining
+  };
+}
+
+// 使用react-query管理题目发送 - 直接使用 useSocketClient 中的 sendNextQuestion
+export function useQuestionManagement(roomId: string | null) {
+  const socketClient = useSocketClient(roomId);
+  const queryClient = useQueryClient();
+
+  const sendNextQuestion = useCallback(async (state: QuizState) => {
+    if (!socketClient) return;
+
+    return handleAsyncOperation(async () => {
+      await socketClient.sendNextQuestion(roomId, state);
+      queryClient.invalidateQueries({ queryKey: ['current-question', roomId] });
+    }, '发送题目失败');
+  }, [roomId, socketClient, queryClient]);
+
+  return {
+    sendNextQuestion
+  };
+} 

+ 242 - 0
client/stock/stock_app.tsx

@@ -0,0 +1,242 @@
+import React, { useRef, useState, useCallback, useEffect } from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { StockChart, MemoToggle, TradePanel, useTradeRecords, useStockQueries, useProfitCalculator, ProfitDisplay, useStockDataFilter, DrawingToolbar } from './components/stock-chart/mod.ts';
+import type { StockChartRef } from './components/stock-chart/mod.ts';
+import { useSocketRoom, useQuestionManagement } from './hooks/useSocketClient.ts';
+import { useSearchParams } from 'react-router';
+import { message } from 'antd';
+
+const queryClient = new QueryClient();
+
+function StockApp() {
+  const chartRef = useRef<StockChartRef>(null);
+  const lastSentDateRef = useRef('');
+  const [searchParams] = useSearchParams();
+  const codeFromUrl = searchParams.get('code');
+  const [stockCode, setStockCode] = useState(codeFromUrl);// || '001339'
+  const classroom = searchParams.get('classroom');
+  const { connected } = useSocketRoom(classroom);
+  const { sendNextQuestion } = useQuestionManagement(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) {
+      updateCurrentDate(nextDate);
+    }
+  }, [moveToNextDay, updateCurrentDate]);
+
+  useEffect(() => {
+    const currentDate = profitSummary.dailyStats.date;
+    if (classroom && connected && currentDate && lastSentDateRef.current !== currentDate) {
+      lastSentDateRef.current = currentDate;
+      sendNextQuestion({
+        date: currentDate,
+        price: profitSummary.dailyStats.close
+      }).catch(() => {
+        message.error('发送题目失败');
+      });
+    }
+  }, [classroom, connected, profitSummary.dailyStats, sendNextQuestion]);
+
+  const handleDayNumChange = useCallback((days: number) => {
+    if (!isInitialized) {
+      initializeView(days);
+    } else {
+      setDayNum(days, days);
+    }
+  }, [isInitialized, initializeView, setDayNum]);
+
+  const handleQuery = useCallback(() => {
+    if (stockCode.trim()) {
+      fetchData().then(() => {
+        initializeView(120);
+      });
+    }
+  }, [stockCode, fetchData, initializeView]);
+
+  const handleStartDrawing = useCallback((type: 'horizontal' | 'trend') => {
+    chartRef.current?.startDrawing(type);
+  }, []);
+
+  const handleStopDrawing = useCallback(() => {
+    chartRef.current?.stopDrawing();
+  }, []);
+
+  const handleClearLines = useCallback(() => {
+    chartRef.current?.clearDrawings();
+  }, []);
+
+  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(120);
+      });
+    }
+  }, [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">
+      {/* 顶部行情和收益信息 */}
+      <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>
+  );
+}
+
+export default function App() {
+  return (
+    <QueryClientProvider client={queryClient}>
+      <StockApp />
+    </QueryClientProvider>
+  );
+} 

+ 65 - 0
client/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;
+} 

+ 3 - 4
deno.json

@@ -36,10 +36,9 @@
     "@heroicons/react/24/solid": "https://esm.d8d.fun/@heroicons/react@2.1.1/24/solid?dev&deps=react@19.0.0,react-dom@19.0.0",
     "react-toastify": "https://esm.d8d.fun/react-toastify@11.0.5?dev&deps=react@19.0.0,react-dom@19.0.0",
     "aliyun-rtc-sdk":"https://esm.d8d.fun/aliyun-rtc-sdk@6.14.6?standalone",
-    "@testing-library/react": "https://esm.d8d.fun/@testing-library/react@16.3.0?dev&deps=react@19.0.0,react-dom@19.0.0",
-    "@testing-library/user-event":"npm:@testing-library/user-event@14.6.1",
-    "jsdom":"npm:jsdom@26.0.0",
-    "@playwright/test":"npm:@playwright/test@1.51.1"
+    "decimal.js":"https://esm.d8d.fun/decimal.js@10.4.3",
+    "echarts":"https://esm.d8d.fun/echarts@5.5.1",
+    "echarts/types/src/util/types":"https://esm.d8d.fun/echarts@5.5.1/types/src/util/types"
   },
   "compilerOptions": {
     "lib": ["dom", "dom.iterable", "esnext", "deno.ns"]

文件差異過大導致無法顯示
+ 462 - 527
deno.lock


+ 3 - 0
版本迭代需求.md

@@ -1,3 +1,6 @@
+2025.05.15 0.1.1
+迁移入股票训练模块
+
 2025.05.13 0.1.0
 在移动端加入课堂入口
 

部分文件因文件數量過多而無法顯示