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 新能力前需先查官方文档”的约束。
This commit is contained in:
Losita
2026-04-24 23:33:43 +08:00
parent 8daae62812
commit 04b5836b39
23 changed files with 3539 additions and 171 deletions

View File

@@ -0,0 +1,540 @@
# 课表图片识别与正式导入前端对接说明
## 目标
这套流程拆成两步:
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`