后端: 1. 新增课表图片识别接口,支持上传截图后返回“可编辑草稿”(success / partial / reject),并补齐大图、空图、格式不支持、识别能力未配置等错误分支。 2. 课表识别服务接入多模态 Responses 链路,完善图片请求归一化与安全校验(大小、MIME、内容探测),并对识别结果做结构化清洗、强/弱约束校验、告警去重与默认文案兜底。 3. 新增 Ark Responses 统一客户端抽象,支持文本+图片输入、JSON对象输出、usage统计透传与不完整输出识别;同时补齐模型返回 finish_reason 透传,便于定位截断问题。 4. 启动阶段增加课表识图模型与参数注入(模型名、最大图片字节、最大输出token),并将配置示例收敛为“仅保留当前代码实际读取项”。 前端: 5. 课表中心新增“导入课表”完整闭环:上传图片识别、草稿编辑校对、正式导入落库;并新增对应 API 与类型定义。 6. 导入弹窗支持识别中止、全局告警与行级告警展示、低置信度提示、行内编辑、手动新增、删除、拖拽排序、本地校验与提交前二次确认。 7. 正式导入前将草稿按“课程名+地点+是否允许嵌入”聚合为导入结构,并统一携带幂等键请求头,降低重复提交风险。 8. 周课表画板修复跨节次事件遮挡导致的网格错位问题,改进“完全遮挡/部分遮挡”渲染判定与 grid 行定位。 9. 助手流式区域优化“思考中”指示逻辑与样式,避免已有正文时仍展示回答中占位;同时补充全局组件视觉统一(弹窗/按钮)样式。 仓库: 10. 新增课表图片识别前端对接说明文档,补充主动优化能力 PRD 讨论稿,并在协作规范中新增“实现 Eino 新能力前需先查官方文档”的约束。
13 KiB
课表图片识别与正式导入前端对接说明
目标
这套流程拆成两步:
- 先调用识图接口,把“总课表图片”转成前端可编辑的草稿表格数据。
- 用户确认无误后,再调用正式导入接口,把课程写入后端日程表。
这样做的好处是:
- 识图接口保持无状态,只负责“图片 -> 草稿”
- 前端完全掌握编辑态,不依赖后端保存临时草稿
- 正式落库仍统一走现有
/api/v1/course/import
总体流程
推荐前端流程:
- 用户上传总课表图片。
- 前端调用
/api/v1/course/parse-image获取识别草稿。 - 前端把
rows存在本地页面状态中,渲染成可编辑表格。 - 用户修改并确认所有行数据。
- 前端把表格行数据组装成
/api/v1/course/import所需结构。 - 前端携带
X-Idempotency-Key调用正式导入接口。 - 导入成功后刷新课表页;若有冲突则提示用户处理。
一、识图接口
1.1 接口定义
- 方法:
POST - 路径:
/api/v1/course/parse-image - 鉴权:需要登录态
- Content-Type:
multipart/form-data - 文件字段名:
image
说明:
- 该接口不需要
X-Idempotency-Key - 该接口不落库,只返回草稿
1.2 请求示例
async function parseCourseImage(file: File, token: string) {
const formData = new FormData()
formData.append('image', file)
const response = await fetch('/api/v1/course/parse-image', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
})
const result = await response.json()
if (!response.ok) {
throw new Error(result?.info || '课表图片识别失败')
}
return result.data
}
1.3 成功响应
外层仍复用项目统一响应结构:
{
"status": "10000",
"info": "success",
"data": {
"draft_status": "success",
"message": "已识别 12 条课程安排,请重点核对周次、星期和节次。",
"warnings": [
"图片右下角疑似被裁切,请重点检查最后几列课程。"
],
"rows": [
{
"row_id": "row_001",
"course_name": "高等数学",
"location": "教一203",
"is_allow_tasks": false,
"start_week": 1,
"end_week": 16,
"day_of_week": 1,
"start_section": 1,
"end_section": 2,
"week_type": "all",
"confidence": 0.92,
"raw_text": "高等数学 1-16周 周一1-2节 教一203",
"row_warnings": []
}
]
}
}
1.4 draft_status 语义
-
success说明图片整体较完整,前端可直接进入表格核对。 -
partial说明后端识别到了部分安排,但仍存在不确定字段。前端仍可进入表格核对,但应醒目展示warnings与row_warnings。 -
reject说明图片不适合继续使用,例如裁切严重、模糊、主体不是课表等。此时rows为空数组,前端应提示用户重新上传,而不是进入编辑表格。
1.5 行字段说明
每一行表示一个“课程安排片段”,不是一门课的完整聚合对象。
-
row_id前端表格的稳定 key。即使用户编辑字段,也建议保留该 key。 -
course_name课程名。success场景下通常应有值;partial场景下可能为空,前端要允许人工补齐。 -
location地点。允许为空字符串。 -
is_allow_tasks当前后端默认给false。因为该字段无法从课表图片稳定识别,建议前端提供开关让用户手改。 -
start_week/end_week周次范围。可能为null,前端应允许编辑。 -
day_of_week星期,取值1-7,分别对应周一到周日。可能为null。 -
start_section/end_section原子节次编号,例如1-2 节应表示为1和2。可能为null。 -
week_type仅允许以下三种值:alloddeven
-
confidence0 到 1 之间的小数,用于前端做低置信度高亮。建议把<0.75的行重点提示用户复核。 -
raw_text模型从图片中抽出的近似原文,建议在“展开详情”或 tooltip 中展示,帮助用户比对。 -
row_warnings当前行的局部问题提示,例如“单双周不清晰”“教室模糊”等。
1.6 前端表格建议列
建议首版直接用可编辑表格,不必先做课表画布。
推荐列:
- 课程名
- 地点
- 起始周
- 结束周
- 星期
- 开始节次
- 结束节次
- 周类型
- 允许嵌入任务
- 置信度
- 原文
- 行级警告
1.7 识图接口常见失败响应
-
50003:course image parser is not configured后端尚未配置多模态模型。 -
40064:course image too large图片超过后端配置的字节上限。 -
40065:unsupported course image format当前仅支持jpg/jpeg/png/webp。 -
40066:course image is empty上传文件为空。
1.8 前端交互建议
draft_status=reject时,不进入表格页,直接提示重新上传。draft_status=partial时,允许进入表格页,但默认展开 warning 区域。- 当某行
confidence < 0.75或row_warnings非空时,给该行加醒目边框或标签。
二、正式导入接口
2.1 接口定义
- 方法:
POST - 路径:
/api/v1/course/import - 鉴权:需要登录态
- Content-Type:
application/json - 必传请求头:
X-Idempotency-Key
说明:
- 正式导入接口会真正写库
- 该接口已经挂了幂等中间件,前端必须传
X-Idempotency-Key
2.2 请求体结构
后端正式导入接口需要的是:
{
"courses": [
{
"course_name": "高等数学",
"location": "教一203",
"is_allow_tasks": false,
"arrangements": [
{
"start_week": 1,
"end_week": 16,
"day_of_week": 1,
"start_section": 1,
"end_section": 2,
"week_type": "all"
}
]
}
]
}
字段语义:
course_name: 课程名location: 地点is_allow_tasks: 是否允许该课程时段嵌入任务arrangements: 该课程的所有上课安排
2.3 前端如何从草稿行表组装正式导入 payload
推荐规则:
- 先过滤掉仍未补齐必填字段的行。
- 以
course_name + location + is_allow_tasks作为分组键。 - 同组行合并为同一个
course。 - 每一行转成该
course下的一个arrangements项。
推荐的前端类型:
type CourseDraftRow = {
row_id: string
course_name: string
location: string
is_allow_tasks: boolean
start_week: number | null
end_week: number | null
day_of_week: number | null
start_section: number | null
end_section: number | null
week_type: 'all' | 'odd' | 'even' | ''
confidence: number
raw_text: string
row_warnings: string[]
}
type CourseImportPayload = {
courses: Array<{
course_name: string
location: string
is_allow_tasks: boolean
arrangements: Array<{
start_week: number
end_week: number
day_of_week: number
start_section: number
end_section: number
week_type: 'all' | 'odd' | 'even'
}>
}>
}
组装示例:
function buildCourseImportPayload(rows: CourseDraftRow[]): CourseImportPayload {
const validRows = rows.filter((row) =>
row.course_name.trim() &&
row.start_week != null &&
row.end_week != null &&
row.day_of_week != null &&
row.start_section != null &&
row.end_section != null &&
(row.week_type === 'all' || row.week_type === 'odd' || row.week_type === 'even'),
)
const grouped = new Map<string, CourseImportPayload['courses'][number]>()
for (const row of validRows) {
const key = `${row.course_name}__${row.location}__${row.is_allow_tasks}`
const arrangement = {
start_week: row.start_week!,
end_week: row.end_week!,
day_of_week: row.day_of_week!,
start_section: row.start_section!,
end_section: row.end_section!,
week_type: row.week_type as 'all' | 'odd' | 'even',
}
if (!grouped.has(key)) {
grouped.set(key, {
course_name: row.course_name.trim(),
location: row.location.trim(),
is_allow_tasks: row.is_allow_tasks,
arrangements: [arrangement],
})
continue
}
grouped.get(key)!.arrangements.push(arrangement)
}
return {
courses: Array.from(grouped.values()),
}
}
2.4 调用正式导入接口示例
function createIdempotencyKey(prefix = 'course-import') {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
async function importCourses(
payload: CourseImportPayload,
token: string,
idempotencyKey = createIdempotencyKey(),
) {
const response = await fetch('/api/v1/course/import', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'X-Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
const error = new Error(result?.info || '课程导入失败')
;(error as any).status = response.status
;(error as any).payload = result
throw error
}
return result
}
2.5 正式导入成功响应
{
"status": "10000",
"info": "success"
}
三、前端在正式导入前应做的本地校验
建议在调用 /api/v1/course/import 前先做一次本地校验,避免把明显不完整的数据提交给后端。
建议校验项:
course_name非空start_week/end_week非空,且1 <= start_week <= end_week <= 24day_of_week在1-7start_section/end_section非空,且1 <= start_section <= end_section <= 12week_type只能是all | odd | even
如果校验失败,建议直接在表格中标红,不要发请求。
四、正式导入接口的常见失败响应
4.1 缺少幂等键
HTTP 状态:400
{
"status": "40037",
"info": "missing idempotency key"
}
前端处理建议:
- 检查是否传了
X-Idempotency-Key - 每次“点击确认导入”生成一个新的 key
- 不要在一次导入流程里反复变化同一个 key
4.2 请求处理中
HTTP 状态:409
{
"status": "40038",
"info": "request is processing, please do not repeat click"
}
前端处理建议:
- 导入按钮进入 loading 态
- 阻止用户连续点击
- 若收到该错误,提示“正在导入,请勿重复提交”
4.3 课程结构不合法
HTTP 状态:400
{
"status": "40019",
"info": "wrong course info"
}
前端处理建议:
- 说明当前编辑结果不满足导入约束
- 引导用户重点检查周次、星期、节次、周类型
4.4 重复导入课程
HTTP 状态:400
{
"status": "40029",
"info": "insert course twice"
}
前端处理建议:
- 提示用户当前课程可能已经导入过
- 可提供“返回课表页查看现有课程”的入口
4.5 与已有非课程日程冲突
HTTP 状态:409
响应示例:
{
"status": "40026",
"info": "schedule conflict",
"data": [
{
"event_id": 101,
"name": "实验报告",
"location": "图书馆",
"day_of_week": 1,
"week": 3,
"sections": [1, 2],
"start_section": 1,
"end_section": 2,
"type": "task",
"embedded_tasks": []
}
]
}
前端处理建议:
- 不要把冲突吞掉
- 可以弹窗列出冲突项
- 提示用户先处理已有日程,再重新导入
五、推荐的前端调用顺序
推荐把整个导入流程写成这三个阶段:
阶段一:识图
- 上传图片
- 调用
/course/parse-image - 若
draft_status=reject,直接结束 - 若
success或partial,进入草稿编辑页
阶段二:草稿编辑
- 在本地状态中维护
rows - 支持用户改课程名、周次、节次、地点、周类型、
is_allow_tasks - 在页面上展示
warnings、row_warnings、confidence
阶段三:正式导入
- 先做本地校验
- 再调用
buildCourseImportPayload(rows) - 生成
X-Idempotency-Key - 调用
/course/import - 成功后提示并刷新课表
六、可直接复用的页面状态建议
type CourseImageImportPageState = {
imageFile: File | null
parseLoading: boolean
importLoading: boolean
draftStatus: 'success' | 'partial' | 'reject' | null
draftMessage: string
warnings: string[]
rows: CourseDraftRow[]
}
七、配置提醒
后端需要额外配置一个多模态模型:
courseImport:
visionModel: "你的多模态模型名"
maxImageBytes: 5242880
maxTokens: 8192
若 visionModel 留空,则主服务仍可启动,但 /course/parse-image 会返回“识图模型未配置”。
若后端日志出现 finish_reason="length",说明模型输出被长度上限截断,应优先增大 courseImport.maxTokens。