Files
smartmate/docs/frontend/course-image-import-对接说明.md
Losita 04b5836b39 Version: 0.9.42.dev.260424
后端:
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 新能力前需先查官方文档”的约束。
2026-04-24 23:33:43 +08:00

541 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 课表图片识别与正式导入前端对接说明
## 目标
这套流程拆成两步:
1. 先调用识图接口,把“总课表图片”转成前端可编辑的草稿表格数据。
2. 用户确认无误后,再调用正式导入接口,把课程写入后端日程表。
这样做的好处是:
- 识图接口保持无状态,只负责“图片 -> 草稿”
- 前端完全掌握编辑态,不依赖后端保存临时草稿
- 正式落库仍统一走现有 `/api/v1/course/import`
## 总体流程
推荐前端流程:
1. 用户上传总课表图片。
2. 前端调用 `/api/v1/course/parse-image` 获取识别草稿。
3. 前端把 `rows` 存在本地页面状态中,渲染成可编辑表格。
4. 用户修改并确认所有行数据。
5. 前端把表格行数据组装成 `/api/v1/course/import` 所需结构。
6. 前端携带 `X-Idempotency-Key` 调用正式导入接口。
7. 导入成功后刷新课表页;若有冲突则提示用户处理。
## 一、识图接口
### 1.1 接口定义
- 方法:`POST`
- 路径:`/api/v1/course/parse-image`
- 鉴权:需要登录态
- Content-Type`multipart/form-data`
- 文件字段名:`image`
说明:
- 该接口不需要 `X-Idempotency-Key`
- 该接口不落库,只返回草稿
### 1.2 请求示例
```ts
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 成功响应
外层仍复用项目统一响应结构:
```json
{
"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`
仅允许以下三种值:
- `all`
- `odd`
- `even`
- `confidence`
0 到 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 请求体结构
后端正式导入接口需要的是:
```json
{
"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
推荐规则:
1. 先过滤掉仍未补齐必填字段的行。
2.`course_name + location + is_allow_tasks` 作为分组键。
3. 同组行合并为同一个 `course`
4. 每一行转成该 `course` 下的一个 `arrangements` 项。
推荐的前端类型:
```ts
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'
}>
}>
}
```
组装示例:
```ts
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 调用正式导入接口示例
```ts
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 正式导入成功响应
```json
{
"status": "10000",
"info": "success"
}
```
## 三、前端在正式导入前应做的本地校验
建议在调用 `/api/v1/course/import` 前先做一次本地校验,避免把明显不完整的数据提交给后端。
建议校验项:
1. `course_name` 非空
2. `start_week` / `end_week` 非空,且 `1 <= start_week <= end_week <= 24`
3. `day_of_week``1-7`
4. `start_section` / `end_section` 非空,且 `1 <= start_section <= end_section <= 12`
5. `week_type` 只能是 `all | odd | even`
如果校验失败,建议直接在表格中标红,不要发请求。
## 四、正式导入接口的常见失败响应
### 4.1 缺少幂等键
HTTP 状态:`400`
```json
{
"status": "40037",
"info": "missing idempotency key"
}
```
前端处理建议:
- 检查是否传了 `X-Idempotency-Key`
- 每次“点击确认导入”生成一个新的 key
- 不要在一次导入流程里反复变化同一个 key
### 4.2 请求处理中
HTTP 状态:`409`
```json
{
"status": "40038",
"info": "request is processing, please do not repeat click"
}
```
前端处理建议:
- 导入按钮进入 loading 态
- 阻止用户连续点击
- 若收到该错误,提示“正在导入,请勿重复提交”
### 4.3 课程结构不合法
HTTP 状态:`400`
```json
{
"status": "40019",
"info": "wrong course info"
}
```
前端处理建议:
- 说明当前编辑结果不满足导入约束
- 引导用户重点检查周次、星期、节次、周类型
### 4.4 重复导入课程
HTTP 状态:`400`
```json
{
"status": "40029",
"info": "insert course twice"
}
```
前端处理建议:
- 提示用户当前课程可能已经导入过
- 可提供“返回课表页查看现有课程”的入口
### 4.5 与已有非课程日程冲突
HTTP 状态:`409`
响应示例:
```json
{
"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`
- 成功后提示并刷新课表
## 六、可直接复用的页面状态建议
```ts
type CourseImageImportPageState = {
imageFile: File | null
parseLoading: boolean
importLoading: boolean
draftStatus: 'success' | 'partial' | 'reject' | null
draftMessage: string
warnings: string[]
rows: CourseDraftRow[]
}
```
## 七、配置提醒
后端需要额外配置一个多模态模型:
```yaml
courseImport:
visionModel: "你的多模态模型名"
maxImageBytes: 5242880
maxTokens: 8192
```
`visionModel` 留空,则主服务仍可启动,但 `/course/parse-image` 会返回“识图模型未配置”。
若后端日志出现 `finish_reason="length"`,说明模型输出被长度上限截断,应优先增大 `courseImport.maxTokens`