2025-01-29-photo-answer-frontend-design.md 19 KB

拍照解题功能 - 前端设计文档

文档版本: v1.0
创建日期: 2025-01-29
设计者: Frontend Team
状态: 设计阶段


一、设计原则

1.1 复用现有组件

  • ToolboxAppLayout: 统一的工具箱应用布局
  • ImageUploadZone: 图片上传组件(来自 OCR 功能)
  • MarkdownRenderer: Markdown 渲染组件(支持数学公式)
  • Toast: 全局提示组件
  • 参考 QwenOCR 和 ImageTranslation 的交互模式

1.2 保持风格一致

  • 使用 Tailwind CSS 实用类
  • 遵循现有的颜色方案(蓝色主题)
  • 保持圆角、阴影、过渡动画的一致性
  • 使用 Lucide React 图标库

二、页面结构设计

2.1 路由配置

路径: /toolbox/photo-answer

页面组件: frontend/pages/PhotoAnswer.tsx

2.2 整体布局

┌─────────────────────────────────────────────────────────────┐
│  ToolboxAppLayout                                           │
│  ┌───────────────────────────────────────────────────────┐  │
│  │  Header: 拍照解题 + 返回按钮                          │  │
│  └───────────────────────────────────────────────────────┘  │
│                                                             │
│  ┌─────────────────┬─────────────────┬─────────────────┐  │
│  │  图片上传区                        │   参数设置区     │  │                 
│  │  (2列)                             │                 │  │
│  │     开始按钮                         │      (1列)      │                      
│  └─────────────────┴─────────────────┴─────────────────┘  │
│                                                             │
│  ┌─────────────────┬─────────────────────────────────────┐  │
│  │  历史记录列表   │  解答结果展示区                     │  │
│  │  (1列)          │  (2列)                              │  │
│  └─────────────────┴─────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

三、核心组件设计

3.1 主页面组件 (PhotoAnswer.tsx)

状态管理:

// 图片上传
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([]);
const [viewingImage, setViewingImage] = useState<string | null>(null);

// 参数设置
const [grade, setGrade] = useState<number>(0);
const [stage, setStage] = useState<string>('other');
const [subject, setSubject] = useState<string>('other');

// 任务状态
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);

// 解答结果
const [currentAnswer, setCurrentAnswer] = useState<string>('');
const [answerChunks, setAnswerChunks] = useState<string[]>([]);

// 历史记录
const [history, setHistory] = useState<AnswerHistoryItem[]>([]);
const [selectedHistoryId, setSelectedHistoryId] = useState<string | null>(null);

复用组件:

  • ImageUploadZone: 图片上传(maxImages=1,单题模式)
  • ToolboxAppLayout: 页面布局
  • MarkdownRenderer: 解答内容渲染(支持 LaTeX 公式)

3.2 参数设置组件 (AnswerParamsSelector.tsx)

功能: 选择年级、学段、学科

UI 设计:

<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm">
  <h3 className="text-sm font-bold text-gray-700 mb-3">解题参数</h3>
  
  {/* 年级选择 */}
  <div className="mb-3">
    <label className="text-xs text-gray-600 mb-1 block">年级</label>
    <select className="w-full p-2 border border-gray-200 rounded-lg text-sm">
      <option value="0">不限</option>
      <option value="1">一年级</option>
      ...
      <option value="12">高三</option>
    </select>
  </div>
  
  {/* 学段选择 */}
  <div className="mb-3">
    <label className="text-xs text-gray-600 mb-1 block">学段</label>
    <div className="grid grid-cols-2 gap-2">
      <button className="p-2 border rounded-lg text-xs">小学</button>
      <button className="p-2 border rounded-lg text-xs">初中</button>
      <button className="p-2 border rounded-lg text-xs">高中</button>
      <button className="p-2 border rounded-lg text-xs">其他</button>
    </div>
  </div>
  
  {/* 学科选择 */}
  <div>
    <label className="text-xs text-gray-600 mb-1 block">学科</label>
    <div className="grid grid-cols-2 gap-2">
      <button className="p-2 border rounded-lg text-xs">数学</button>
      <button className="p-2 border rounded-lg text-xs">物理</button>
      <button className="p-2 border rounded-lg text-xs">化学</button>
      <button className="p-2 border rounded-lg text-xs">英语</button>
      <button className="p-2 border rounded-lg text-xs">语文</button>
      <button className="p-2 border rounded-lg text-xs">其他</button>
    </div>
  </div>
</div>

样式特点:

  • 按钮式选择器,选中状态:bg-blue-50 border-blue-500 text-blue-700
  • 未选中状态:bg-gray-50 border-gray-200 text-gray-700 hover:border-blue-300

3.3 解答结果组件 (AnswerDisplay.tsx)

功能: 流式展示解题过程,支持 Markdown 和 LaTeX

UI 设计:

<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm">
  <div className="flex items-center justify-between mb-3">
    <h3 className="text-sm font-bold text-gray-700">解题过程</h3>
    <div className="flex items-center space-x-2">
      <button className="text-xs text-gray-600 hover:text-blue-600">
        <Copy className="w-3.5 h-3.5" />
      </button>
      <button className="text-xs text-gray-600 hover:text-blue-600">
        <Download className="w-3.5 h-3.5" />
      </button>
    </div>
  </div>
  
  {/* 流式输出区域 */}
  <div className="bg-gray-50 border border-gray-100 rounded-lg p-4 max-h-[500px] overflow-y-auto">
    {isProcessing && !currentAnswer && (
      <div className="flex items-center space-x-2 text-gray-400">
        <Loader2 className="w-4 h-4 animate-spin" />
        <span className="text-sm">AI 正在思考...</span>
      </div>
    )}
    
    {currentAnswer && (
      <MarkdownRenderer 
        content={currentAnswer} 
        isStreaming={isProcessing}
      />
    )}
    
    {!currentAnswer && !isProcessing && (
      <div className="text-center py-8 text-gray-400 text-sm">
        上传题目图片并点击"开始解题"
      </div>
    )}
  </div>
</div>

流式渲染优化:

  • 使用 MarkdownRendererisStreaming 属性
  • 流式输出时简化渲染,完成后才做完整 Markdown 解析
  • 自动滚动到底部(参考 TextInteraction 的实现)

3.4 历史记录组件 (AnswerHistory.tsx)

功能: 展示解题历史,支持查看、下载、删除

UI 设计:

<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm">
  <h3 className="text-sm font-bold text-gray-700 mb-3 flex items-center space-x-2">
    <Clock className="w-4 h-4 text-blue-600" />
    <span>解题历史</span>
    <span className="text-xs text-gray-400 font-normal">({history.length})</span>
  </h3>
  
  <div className="space-y-2 max-h-[500px] overflow-y-auto">
    {history.map((item) => (
      <div 
        key={item.id}
        onClick={() => handleSelectHistory(item)}
        className={`p-3 rounded-lg border cursor-pointer transition-all ${
          selectedHistoryId === item.id
            ? 'border-blue-500 bg-blue-50'
            : 'border-gray-100 hover:border-blue-200 hover:bg-gray-50'
        }`}
      >
        <div className="flex items-start justify-between">
          <div className="flex-1 min-w-0">
            <div className="text-xs font-bold text-gray-700 truncate">
              {item.question_preview}
            </div>
            <div className="flex items-center space-x-2 mt-1">
              <span className="text-[10px] text-gray-400">{item.subject}</span>
              <span className="text-[10px] text-gray-400">·</span>
              <span className="text-[10px] text-gray-400">{formatTime(item.created_at)}</span>
            </div>
          </div>
          <div className="flex items-center space-x-1">
            <button className="p-1 text-gray-400 hover:text-blue-600">
              <Download className="w-3.5 h-3.5" />
            </button>
            <button className="p-1 text-gray-400 hover:text-red-600">
              <Trash2 className="w-3.5 h-3.5" />
            </button>
          </div>
        </div>
        
        {/* 题目缩略图 */}
        {item.thumbnail && (
          <img 
            src={item.thumbnail} 
            alt="题目缩略图"
            className="w-full h-20 object-cover rounded mt-2 border border-gray-200"
          />
        )}
      </div>
    ))}
  </div>
</div>

四、交互流程设计

4.1 单题解答流程

  1. 上传题目图片

    • 用户点击或拖拽上传图片
    • 显示图片预览(支持全屏查看)
    • 图片上传至 OSS,获取公网 URL
  2. 设置解题参数(可选)

    • 选择年级、学段、学科
    • 参数会影响解题的针对性
  3. 开始解题

    • 点击"开始解题"按钮
    • 按钮状态变为 <Loader2 /> 解题中...
    • 调用后端 SSE 流式接口
  4. 流式展示解答

    • 实时接收解答内容
    • 使用 MarkdownRenderer 渲染
    • 支持数学公式(LaTeX)
    • 自动滚动到底部
  5. 解答完成

    • 显示完整解答内容
    • 保存到历史记录
    • 支持复制、下载

4.2 试卷切题流程(可选功能)

  1. 上传试卷图片

    • 支持多页试卷上传
    • 显示上传进度
  2. 自动切分题目

    • 调用切题 API
    • 显示切分结果(题目列表)
  3. 选择题目解答

    • 点击题目卡片
    • 自动填充到解答区域
    • 开始解题流程

五、API 服务层设计

5.1 API 服务文件 (frontend/services/photoAnswerApi.ts)

import axios from 'axios';
import { authService } from './authService';

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;

export interface AnswerRequest {
  image_url: string;
  parameters?: {
    grade?: number;
    stage?: string;
    subject?: string;
  };
}

export interface AnswerHistoryItem {
  id: string;
  image_url: string;
  question_preview: string;
  answer_content: string;
  grade: number;
  subject: string;
  created_at: string;
  thumbnail?: string;
}

class PhotoAnswerApi {
  // SSE 流式解答
  async answerStream(
    request: AnswerRequest,
    onChunk: (chunk: string) => void,
    onError: (error: Error) => void,
    onComplete: () => void
  ): Promise<void> {
    const eventSource = new EventSource(
      `${API_BASE_URL}/api/edu/answer?` + 
      new URLSearchParams({
        image_url: request.image_url,
        grade: String(request.parameters?.grade || 0),
        stage: request.parameters?.stage || 'other',
        subject: request.parameters?.subject || 'other',
      })
    );

    eventSource.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        if (data.type === 'chunk') {
          onChunk(data.content);
        } else if (data.type === 'finish') {
          onComplete();
          eventSource.close();
        }
      } catch (err) {
        onError(err as Error);
      }
    };

    eventSource.onerror = (err) => {
      onError(new Error('SSE 连接错误'));
      eventSource.close();
    };
  }

  // 获取历史记录
  async getHistory(page: number = 1, pageSize: number = 20) {
    const response = await axios.get(`${API_BASE_URL}/api/edu/answer/history`, {
      params: { page, page_size: pageSize },
      headers: { Authorization: authService.getAuthHeader() || '' },
    });
    return response.data;
  }

  // 删除历史记录
  async deleteHistory(id: string) {
    const response = await axios.delete(
      `${API_BASE_URL}/api/edu/answer/history/${id}`,
      { headers: { Authorization: authService.getAuthHeader() || '' } }
    );
    return response.data;
  }
}

export const photoAnswerApi = new PhotoAnswerApi();

六、样式规范

6.1 颜色方案

用途 颜色类 说明
主色调 bg-blue-600 按钮、强调元素
选中状态 bg-blue-50 border-blue-500 text-blue-700 参数选择器
背景 bg-gray-50 卡片背景
边框 border-gray-100 默认边框
文字 text-gray-700 主要文字
次要文字 text-gray-400 辅助信息
错误 bg-red-50 border-red-200 text-red-700 错误提示

6.2 圆角和阴影

  • 卡片圆角:rounded-2xl
  • 按钮圆角:rounded-lg
  • 输入框圆角:rounded-lg
  • 卡片阴影:shadow-sm
  • 按钮阴影:shadow-lg shadow-blue-500/20

6.3 间距规范

  • 卡片内边距:p-4
  • 元素间距:space-y-4space-x-2
  • 栅格间距:gap-4

七、响应式设计

7.1 断点设置

  • 移动端:< 768px
  • 平板:768px - 1024px
  • 桌面:> 1024px

7.2 布局适配

桌面端 (lg:):

<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
  <div className="lg:col-span-2">图片上传</div>
  <div className="lg:col-span-1">参数设置</div>
  <div className="lg:col-span-1">操作按钮</div>
</div>

<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
  <div className="lg:col-span-1">历史记录</div>
  <div className="lg:col-span-2">解答结果</div>
</div>

移动端:

  • 所有区域垂直堆叠
  • 历史记录折叠为抽屉式
  • 图片预览全屏显示

八、性能优化

8.1 图片优化

  • 上传前压缩图片(< 2MB)
  • 使用 WebP 格式(如果浏览器支持)
  • 缩略图懒加载

8.2 渲染优化

  • 使用 React.memo 包裹 MarkdownRenderer
  • 流式输出时简化 Markdown 渲染
  • 虚拟滚动历史记录列表(如果数量 > 50)

8.3 网络优化

  • SSE 连接超时处理(100s)
  • 失败自动重试(最多 3 次)
  • 请求取消机制(用户中断)

九、错误处理

9.1 错误类型

错误场景 提示信息 处理方式
图片上传失败 "图片上传失败,请重试" 显示错误提示,允许重新上传
图片格式不支持 "仅支持 JPG、PNG 格式" 阻止上传,提示用户
图片过大 "图片大小不能超过 10MB" 阻止上传,提示压缩
API 调用失败 "解题失败,请稍后重试" 显示错误提示,保留输入
SSE 连接中断 "连接中断,正在重试..." 自动重连(最多 3 次)
识别失败 "题目识别失败,请上传更清晰的图片" 提示用户重新上传

9.2 错误提示组件

{error && (
  <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl flex items-center space-x-2">
    <AlertCircle className="w-5 h-5 flex-shrink-0" />
    <span>{error}</span>
    <button onClick={() => setError(null)} className="ml-auto">
      <X className="w-4 h-4" />
    </button>
  </div>
)}

十、可访问性 (A11y)

10.1 键盘导航

  • 所有交互元素支持 Tab 键导航
  • 按钮支持 Enter/Space 触发
  • 图片预览支持 Esc 关闭

10.2 屏幕阅读器

  • 图片添加 alt 属性
  • 按钮添加 aria-label
  • 状态变化使用 aria-live

10.3 对比度

  • 文字与背景对比度 ≥ 4.5:1
  • 按钮禁用状态明显(opacity-50

十一、开发计划

11.1 组件开发顺序

阶段 组件 预计时间
Phase 1 PhotoAnswer 主页面 + ImageUploadZone 集成 0.5 天
Phase 2 AnswerParamsSelector 参数选择器 0.5 天
Phase 3 AnswerDisplay 解答展示 + SSE 流式 1 天
Phase 4 AnswerHistory 历史记录 0.5 天
Phase 5 photoAnswerApi 服务层 0.5 天
Phase 6 响应式适配 + 错误处理 0.5 天
Phase 7 测试 + 优化 0.5 天

总计: 4 天

11.2 依赖检查

已有依赖:

  • react-markdown: Markdown 渲染 ✅
  • katex: 数学公式渲染 ✅
  • lucide-react: 图标库 ✅
  • axios: HTTP 请求 ✅

无需新增依赖


十二、测试策略

12.1 单元测试

  • 参数选择器状态管理
  • 历史记录列表渲染
  • 错误处理逻辑

12.2 集成测试

  • 完整解题流程
  • SSE 流式接收
  • 图片上传 + OSS

12.3 用户测试

  • 不同设备适配(手机、平板、桌面)
  • 不同题型识别准确率
  • 流式输出流畅度

十三、后续优化方向

13.1 功能增强

  • 支持手写题目拍照识别
  • 支持拍照后本地裁剪
  • 支持多题批量解答
  • 支持语音播报解答过程

13.2 交互优化

  • 解答过程可暂停/继续
  • 支持追问(多轮对话)
  • 支持收藏优质解答
  • 支持分享解答链接

13.3 智能化

  • 根据历史推荐相似题目
  • 智能识别薄弱知识点
  • 生成个性化练习题

附录

A. 文件结构

frontend/
├── pages/
│   └── PhotoAnswer.tsx              # 主页面
├── components/
│   └── photo-answer/
│       ├── AnswerParamsSelector.tsx # 参数选择器
│       ├── AnswerDisplay.tsx        # 解答展示
│       └── AnswerHistory.tsx        # 历史记录
├── services/
│   └── photoAnswerApi.ts            # API 服务
└── types/
    └── photoAnswer.ts               # TypeScript 类型定义

B. 类型定义 (frontend/types/photoAnswer.ts)

export interface AnswerRequest {
  image_url: string;
  parameters?: {
    grade?: number;
    stage?: string;
    subject?: string;
  };
}

export interface AnswerHistoryItem {
  id: string;
  image_url: string;
  question_preview: string;
  answer_content: string;
  grade: number;
  subject: string;
  created_at: string;
  thumbnail?: string;
  input_tokens: number;
  output_tokens: number;
}

export interface AnswerChunk {
  type: 'chunk' | 'finish';
  content?: string;
  finish_reason?: string;
  tokens?: {
    input: number;
    output: number;
  };
}

C. 参考页面

  • QwenOCR.tsx: 图片上传 + 历史记录布局
  • ImageTranslation.tsx: 参数选择 + 结果展示
  • TextInteraction.tsx: 流式输出 + 自动滚动

文档结束