文档版本: v1.0
创建日期: 2025-01-29
设计者: Frontend Team
状态: 设计阶段
路径: /toolbox/photo-answer
页面组件: frontend/pages/PhotoAnswer.tsx
┌─────────────────────────────────────────────────────────────┐
│ ToolboxAppLayout │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Header: 拍照解题 + 返回按钮 │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────┬─────────────────┬─────────────────┐ │
│ │ 图片上传区 │ 参数设置区 │ │
│ │ (2列) │ │ │
│ │ 开始按钮 │ (1列) │
│ └─────────────────┴─────────────────┴─────────────────┘ │
│ │
│ ┌─────────────────┬─────────────────────────────────────┐ │
│ │ 历史记录列表 │ 解答结果展示区 │ │
│ │ (1列) │ (2列) │ │
│ └─────────────────┴─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
状态管理:
// 图片上传
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 公式)功能: 选择年级、学段、学科
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-700bg-gray-50 border-gray-200 text-gray-700 hover:border-blue-300功能: 流式展示解题过程,支持 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>
流式渲染优化:
MarkdownRenderer 的 isStreaming 属性功能: 展示解题历史,支持查看、下载、删除
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>
上传题目图片
设置解题参数(可选)
开始解题
<Loader2 /> 解题中...流式展示解答
MarkdownRenderer 渲染解答完成
上传试卷图片
自动切分题目
选择题目解答
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();
| 用途 | 颜色类 | 说明 |
|---|---|---|
| 主色调 | 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 |
错误提示 |
rounded-2xlrounded-lgrounded-lgshadow-smshadow-lg shadow-blue-500/20p-4space-y-4 或 space-x-2gap-4< 768px768px - 1024px> 1024px桌面端 (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>
移动端:
React.memo 包裹 MarkdownRenderer| 错误场景 | 提示信息 | 处理方式 |
|---|---|---|
| 图片上传失败 | "图片上传失败,请重试" | 显示错误提示,允许重新上传 |
| 图片格式不支持 | "仅支持 JPG、PNG 格式" | 阻止上传,提示用户 |
| 图片过大 | "图片大小不能超过 10MB" | 阻止上传,提示压缩 |
| API 调用失败 | "解题失败,请稍后重试" | 显示错误提示,保留输入 |
| SSE 连接中断 | "连接中断,正在重试..." | 自动重连(最多 3 次) |
| 识别失败 | "题目识别失败,请上传更清晰的图片" | 提示用户重新上传 |
{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>
)}
alt 属性aria-labelaria-liveopacity-50)| 阶段 | 组件 | 预计时间 |
|---|---|---|
| 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 天
已有依赖:
react-markdown: Markdown 渲染 ✅katex: 数学公式渲染 ✅lucide-react: 图标库 ✅axios: HTTP 请求 ✅无需新增依赖 ✅
frontend/
├── pages/
│ └── PhotoAnswer.tsx # 主页面
├── components/
│ └── photo-answer/
│ ├── AnswerParamsSelector.tsx # 参数选择器
│ ├── AnswerDisplay.tsx # 解答展示
│ └── AnswerHistory.tsx # 历史记录
├── services/
│ └── photoAnswerApi.ts # API 服务
└── types/
└── photoAnswer.ts # TypeScript 类型定义
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;
};
}
文档结束