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:
540
docs/frontend/course-image-import-对接说明.md
Normal file
540
docs/frontend/course-image-import-对接说明.md
Normal 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`。
|
||||
Reference in New Issue
Block a user