diff --git a/README.md b/README.md index 83c3520..648679a 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ docker compose up -d --build - `GET /api/assignments?username=xxx` - `POST /api/assignments` `multipart/form-data`:`username`、`title`、`images`(可多张,按上传顺序) +- `PUT /api/assignments/{id}` `multipart/form-data`:`username`、`title`、`images`(重新上传并重新批改) - `GET /api/assignments/{id}?username=xxx` - `DELETE /api/assignments/{id}?username=xxx` diff --git a/backend/main.go b/backend/main.go index 6212674..4200a07 100644 --- a/backend/main.go +++ b/backend/main.go @@ -123,6 +123,7 @@ func main() { r.Get("/assignments/{id}", handleGetAssignment(db)) r.Get("/assignments/{id}/image", handleGetAssignmentImage(db, cfg.UploadDir)) r.Post("/assignments", handleCreateAssignment(db, llmClient, cfg.UploadDir)) + r.Put("/assignments/{id}", handleUpdateAssignment(db, llmClient, cfg.UploadDir)) r.Delete("/assignments/{id}", handleDeleteAssignment(db)) }) @@ -396,6 +397,153 @@ func handleCreateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.H } } +func handleUpdateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + contentType := r.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "multipart/form-data") { + writeError(w, http.StatusBadRequest, "multipart form required") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 50<<20) + if err := r.ParseMultipartForm(60 << 20); err != nil { + writeError(w, http.StatusBadRequest, "invalid multipart form") + return + } + + username := strings.TrimSpace(r.FormValue("username")) + if username == "" { + username = strings.TrimSpace(r.URL.Query().Get("username")) + } + if username == "" { + writeError(w, http.StatusBadRequest, "username is required") + return + } + + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid assignment id") + return + } + + var existingTitle string + var createdAt time.Time + var existingImagePath sql.NullString + row := db.QueryRow(` + SELECT title, created_at, image_path + FROM assignments + WHERE id = ? AND username = ? + LIMIT 1 + `, id, username) + if err := row.Scan(&existingTitle, &createdAt, &existingImagePath); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "assignment not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to query assignment") + return + } + + title := strings.TrimSpace(r.FormValue("title")) + if title == "" { + title = existingTitle + } + if title == "" { + title = fmt.Sprintf("%s %s", defaultTitlePrefix, time.Now().Format("2006-01-02 15:04")) + } + + files := r.MultipartForm.File["images"] + if len(files) == 0 { + files = r.MultipartForm.File["image"] + } + if len(files) == 0 { + writeError(w, http.StatusBadRequest, "image is required") + return + } + + if err := os.MkdirAll(uploadDir, 0o755); err != nil { + writeError(w, http.StatusInternalServerError, "failed to prepare upload dir") + return + } + + imagePaths := make([]string, 0, len(files)) + images := make([]LLMImage, 0, len(files)) + for _, header := range files { + file, err := header.Open() + if err != nil { + writeError(w, http.StatusBadRequest, "failed to read image") + return + } + + data, err := io.ReadAll(file) + file.Close() + if err != nil || len(data) == 0 { + writeError(w, http.StatusBadRequest, "failed to read image") + return + } + + mimeType := header.Header.Get("Content-Type") + if mimeType == "" { + mimeType = http.DetectContentType(data) + } + if !strings.HasPrefix(mimeType, "image/") { + writeError(w, http.StatusBadRequest, "invalid image type") + return + } + + filename := fmt.Sprintf("%s_%s%s", time.Now().Format("20060102_150405"), randomToken(6), extFromMime(mimeType)) + imagePath := filepath.Join(uploadDir, filename) + if err := os.WriteFile(imagePath, data, 0o644); err != nil { + writeError(w, http.StatusInternalServerError, "failed to save image") + return + } + + imagePaths = append(imagePaths, imagePath) + images = append(images, LLMImage{ + MimeType: mimeType, + Base64: base64.StdEncoding.EncodeToString(data), + }) + } + + markdown, feedback, score, err := llm.FormatAndGradeFromImages(r.Context(), title, images) + if err != nil { + writeError(w, http.StatusBadGateway, fmt.Sprintf("llm failed: %v", err)) + return + } + + imagePathsJSON, _ := json.Marshal(imagePaths) + + _, err = db.Exec(` + UPDATE assignments + SET title = ?, ocr_text = ?, markdown = ?, feedback = ?, score = ?, image_path = ? + WHERE id = ? AND username = ? + `, title, "", markdown, feedback, score, string(imagePathsJSON), id, username) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update assignment") + return + } + + if existingImagePath.Valid { + cleanupImages(uploadDir, existingImagePath.String) + } + + assignment := Assignment{ + ID: id, + Username: username, + Title: title, + OCRText: "", + Markdown: markdown, + Feedback: feedback, + Score: score, + ImagePath: firstPath(imagePaths), + ImagePaths: imagePaths, + CreatedAt: createdAt, + } + + writeJSON(w, http.StatusOK, assignment) + } +} + func handleCreateAssignmentWithImage(db *sql.DB, llm *LLMClient, uploadDir string, w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 50<<20) if err := r.ParseMultipartForm(60 << 20); err != nil { @@ -566,7 +714,7 @@ func (c *LLMClient) FormatAndGrade(ctx context.Context, title, ocrText string) ( return "", "", 0, errors.New("LLM_BASE_URL or LLM_API_KEY missing") } - systemPrompt := "你是一位细致的老师。请把 OCR 文字整理成清晰的 Markdown,保持原有题号、分点、段落与格式。然后给出作业批改意见。只输出 JSON,包含 markdown、feedback、score(0-100整数)。不要加代码块或多余文字。" + systemPrompt := "你是一位严谨的阅卷老师。请把 OCR 文字整理成清晰的 Markdown,保持原有题号、分点、段落与格式。然后进行详细批改:对每一道题给出参考答案、解题过程拆解、学生答案摘录、正确性判定(正确/部分正确/错误)、正确答案对比说明、考察知识点说明。最后给出整体评价与改进建议。仅输出 JSON,字段为 markdown、feedback、score(0-100整数)。不要加代码块或多余文字。" userPrompt := fmt.Sprintf("标题: %s\n\nOCR文本:\n%s", title, ocrText) payload := map[string]any{ @@ -614,7 +762,7 @@ func (c *LLMClient) FormatAndGradeFromImages(ctx context.Context, title string, return "", "", 0, errors.New("no images provided") } - systemPrompt := "你是一位细致的老师。请根据作业图片识别内容,整理为清晰的 Markdown,保持题号、分点、段落与格式。然后给出作业批改意见。只输出 JSON,包含 markdown、feedback、score(0-100整数)。不要加代码块或多余文字。" + systemPrompt := "你是一位严谨的阅卷老师。请根据作业图片识别内容,整理为清晰的 Markdown,保持题号、分点、段落与格式。然后进行详细批改:对每一道题给出参考答案、解题过程拆解、学生答案摘录、正确性判定(正确/部分正确/错误)、正确答案对比说明、考察知识点说明。最后给出整体评价与改进建议。若题目或答案无法识别,请明确标注“无法辨识”。仅输出 JSON,字段为 markdown、feedback、score(0-100整数)。不要加代码块或多余文字。" userText := fmt.Sprintf("标题: %s\n以下图片按上传顺序依次为第1页、第2页……请综合批改。", title) contentParts := make([]map[string]any, 0, len(images)+1) @@ -801,6 +949,29 @@ func firstPath(paths []string) string { return paths[0] } +func cleanupImages(uploadDir, raw string) { + paths := parseImagePaths(raw) + if len(paths) == 0 { + return + } + + absUpload, err := filepath.Abs(uploadDir) + if err != nil { + return + } + + for _, path := range paths { + absImage, err := filepath.Abs(path) + if err != nil { + continue + } + if !strings.HasPrefix(absImage, absUpload+string(os.PathSeparator)) && absImage != absUpload { + continue + } + _ = os.Remove(absImage) + } +} + func randomToken(length int) string { const charset = "abcdefghijklmnopqrstuvwxyz0123456789" buf := make([]byte, length) @@ -845,7 +1016,7 @@ func basicAuth(user, pass string) func(http.Handler) http.Handler { func corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 8481ea6..07ac0da 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -210,6 +210,17 @@ textarea.input { background: rgba(255, 255, 255, 0.7); } +.edit-banner { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + padding: 12px 14px; + border-radius: 14px; + border: 1px dashed rgba(31, 29, 26, 0.3); + background: rgba(15, 76, 92, 0.08); +} + .preview { width: 100%; border-radius: 16px; diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 97cd43f..5f86dc8 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -40,6 +40,7 @@ export default function HomePage() { const [assignments, setAssignments] = useState([]); const [selectedId, setSelectedId] = useState(null); const [selected, setSelected] = useState(null); + const [editingId, setEditingId] = useState(null); const [title, setTitle] = useState(""); const [images, setImages] = useState([]); @@ -255,6 +256,23 @@ export default function HomePage() { ); }; + const beginEdit = async (item: AssignmentSummary) => { + setError(""); + clearImages(); + setTitle(item.title); + setEditingId(item.id); + setSelectedId(item.id); + setInfo("已进入重新修改模式,请上传新的作业图片。"); + await loadAssignmentDetail(item.id); + }; + + const cancelEdit = () => { + setEditingId(null); + setTitle(""); + clearImages(); + setInfo(""); + }; + const startCamera = async () => { setError(""); try { @@ -308,6 +326,7 @@ export default function HomePage() { setIsSubmitting(true); setInfo("正在提交图片并调用 LLM 批改,请稍候..."); try { + const isEditing = editingId !== null; const formData = new FormData(); formData.append("username", savedUser); if (title.trim()) { @@ -319,8 +338,11 @@ export default function HomePage() { formData.append("images", blob, name); }); - const res = await fetch(`${apiBase}/assignments`, { - method: "POST", + const endpoint = isEditing + ? `${apiBase}/assignments/${editingId}` + : `${apiBase}/assignments`; + const res = await fetch(endpoint, { + method: isEditing ? "PUT" : "POST", body: formData, }); if (!res.ok) { @@ -328,12 +350,19 @@ export default function HomePage() { throw new Error(payload.error || "提交失败"); } const created = (await res.json()) as AssignmentDetail; - setAssignments((prev) => [created, ...prev]); + if (isEditing) { + setAssignments((prev) => + prev.map((item) => (item.id === created.id ? { ...item, ...created } : item)) + ); + } else { + setAssignments((prev) => [created, ...prev]); + } setSelectedId(created.id); setSelected(created); setTitle(""); clearImages(); - setInfo("批改完成,可在右侧查看结果。"); + setEditingId(null); + setInfo(isEditing ? "修改完成,已重新批改。" : "批改完成,可在右侧查看结果。"); } catch (err) { setError(err instanceof Error ? err.message : "提交失败"); } finally { @@ -354,6 +383,9 @@ export default function HomePage() { setSelectedId(null); setSelected(null); } + if (editingId === id) { + cancelEdit(); + } } catch (err) { setError(err instanceof Error ? err.message : "删除失败"); } @@ -414,6 +446,18 @@ export default function HomePage() { IMAGE + LLM
+ {editingId && ( +
+
+ 正在重新修改: + {selected?.title || title || `作业 #${editingId}`} +
请上传新的作业图片后重新批改。
+
+ +
+ )} - {isSubmitting ? "批改中..." : "提交批改"} + {isSubmitting ? "批改中..." : editingId ? "重新提交批改" : "提交批改"} {error &&
{error}
} @@ -530,6 +574,15 @@ export default function HomePage() { 保存图片{imageCountFor(item) > 1 ? ` (${imageCountFor(item)}张)` : ""} )} +