feat: homework multi-image upload and crop
这个提交包含在:
14
.env.example
普通文件
14
.env.example
普通文件
@@ -0,0 +1,14 @@
|
||||
DOMAIN=homework.hao.work
|
||||
|
||||
LLM_BASE_URL=https://one.hao.work/v1/chat/completions
|
||||
LLM_API_KEY=your_api_key_here
|
||||
LLM_MODEL=qwen3-max
|
||||
LLM_TIMEOUT_SECONDS=90
|
||||
LLM_MAX_RETRIES=3
|
||||
UPLOAD_DIR=/data/uploads
|
||||
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASS=whoami139
|
||||
|
||||
BACKEND_PORT=8080
|
||||
NEXT_PUBLIC_API_BASE=/api
|
||||
6
.gitignore
vendored
普通文件
6
.gitignore
vendored
普通文件
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
backend/data/
|
||||
backend/homework-backend
|
||||
frontend/.next/
|
||||
frontend/node_modules/
|
||||
backend/bin/
|
||||
7
Caddyfile
普通文件
7
Caddyfile
普通文件
@@ -0,0 +1,7 @@
|
||||
{} {
|
||||
encode gzip
|
||||
|
||||
reverse_proxy /api/* backend:8080
|
||||
reverse_proxy /backend* backend:8080
|
||||
reverse_proxy /* frontend:3000
|
||||
}
|
||||
34
README.md
普通文件
34
README.md
普通文件
@@ -0,0 +1,34 @@
|
||||
# 作业工坊
|
||||
|
||||
前端使用 Next.js,后端使用 Go + SQLite。前端用户无需登录,通过输入用户名管理自己的作业;支持多张图片上传或在线拍照,可选裁剪,直接提交给 LLM 批改并输出 Markdown。
|
||||
|
||||
## 快速启动(生产)
|
||||
|
||||
1. 确保 `homework.hao.work` DNS 指向部署服务器。
|
||||
2. 在根目录配置 `.env`(已提供示例)。
|
||||
3. 运行:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
4. Nginx 反向代理(示例已配置在 `/etc/nginx/sites-enabled/homework.hao.work`):
|
||||
- 前台代理:`http://127.0.0.1:3000`
|
||||
- 后端代理:`http://127.0.0.1:8080`
|
||||
- HTTPS 证书由 certbot 自动续期(证书路径 `/etc/letsencrypt/live/homework.hao.work/`)。
|
||||
|
||||
访问:
|
||||
- 前台:https://homework.hao.work
|
||||
- 后台:https://homework.hao.work/backend(默认账号:admin / whoami139)
|
||||
|
||||
## 主要接口
|
||||
|
||||
- `GET /api/assignments?username=xxx`
|
||||
- `POST /api/assignments` `multipart/form-data`:`username`、`title`、`images`(可多张,按上传顺序)
|
||||
- `GET /api/assignments/{id}?username=xxx`
|
||||
- `DELETE /api/assignments/{id}?username=xxx`
|
||||
|
||||
## 说明
|
||||
|
||||
- SQLite 数据库存储在 `backend/data/homework.db`,图片存储在 `backend/data/uploads`。
|
||||
- LLM 500 错误会自动重试(次数由 `.env` 控制)。
|
||||
21
backend/Dockerfile
普通文件
21
backend/Dockerfile
普通文件
@@ -0,0 +1,21 @@
|
||||
FROM golang:1.22-alpine AS build
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /bin/homework-backend ./
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
WORKDIR /app
|
||||
ENV DB_PATH=/data/homework.db
|
||||
|
||||
COPY --from=build /bin/homework-backend /app/server
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["/app/server"]
|
||||
91
backend/admin.html
普通文件
91
backend/admin.html
普通文件
@@ -0,0 +1,91 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>作业后台</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #1f2328;
|
||||
--paper: #fff7e6;
|
||||
--accent: #f05a28;
|
||||
--muted: #6b6b6b;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Noto Serif SC", "Songti SC", serif;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
}
|
||||
header {
|
||||
padding: 32px 40px 16px;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 28px;
|
||||
}
|
||||
p { margin: 0; color: var(--muted); }
|
||||
main { padding: 24px 40px 48px; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 30px rgba(0,0,0,0.08);
|
||||
}
|
||||
th, td {
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
font-size: 14px;
|
||||
}
|
||||
th {
|
||||
background: #fff1d6;
|
||||
font-weight: 600;
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(240,90,40,0.12);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>作业管理后台</h1>
|
||||
<p>基础账号: admin / whoami139</p>
|
||||
</header>
|
||||
<main>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>标题</th>
|
||||
<th>评分</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Assignments}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Username}}</td>
|
||||
<td>{{.Title}}</td>
|
||||
<td><span class="badge">{{.Score}}</span></td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
8
backend/go.mod
普通文件
8
backend/go.mod
普通文件
@@ -0,0 +1,8 @@
|
||||
module homework-backend
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
)
|
||||
4
backend/go.sum
普通文件
4
backend/go.sum
普通文件
@@ -0,0 +1,4 @@
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
884
backend/main.go
普通文件
884
backend/main.go
普通文件
@@ -0,0 +1,884 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
//go:embed admin.html
|
||||
var adminHTML string
|
||||
|
||||
const defaultTitlePrefix = "作业"
|
||||
|
||||
var adminTemplate = template.Must(template.New("admin").Parse(adminHTML))
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
DBPath string
|
||||
LLMBaseURL string
|
||||
LLMAPIKey string
|
||||
LLMModel string
|
||||
LLMTimeout time.Duration
|
||||
LLMMaxRetries int
|
||||
UploadDir string
|
||||
AdminUser string
|
||||
AdminPass string
|
||||
}
|
||||
|
||||
type Assignment struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Title string `json:"title"`
|
||||
OCRText string `json:"ocrText"`
|
||||
Markdown string `json:"markdown"`
|
||||
Feedback string `json:"feedback"`
|
||||
Score int `json:"score"`
|
||||
ImagePath string `json:"imagePath"`
|
||||
ImagePaths []string `json:"imagePaths"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type createAssignmentRequest struct {
|
||||
Username string `json:"username"`
|
||||
Title string `json:"title"`
|
||||
OCRText string `json:"ocrText"`
|
||||
}
|
||||
|
||||
type LLMResult struct {
|
||||
Markdown string `json:"markdown"`
|
||||
Feedback string `json:"feedback"`
|
||||
Score int `json:"score"`
|
||||
}
|
||||
|
||||
type LLMImage struct {
|
||||
MimeType string
|
||||
Base64 string
|
||||
}
|
||||
|
||||
type LLMClient struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
model string
|
||||
httpClient *http.Client
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
|
||||
db, err := initDB(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("db init failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := os.MkdirAll(cfg.UploadDir, 0o755); err != nil {
|
||||
log.Fatalf("upload dir init failed: %v", err)
|
||||
}
|
||||
|
||||
llmClient := &LLMClient{
|
||||
baseURL: cfg.LLMBaseURL,
|
||||
apiKey: cfg.LLMAPIKey,
|
||||
model: cfg.LLMModel,
|
||||
httpClient: &http.Client{
|
||||
Timeout: cfg.LLMTimeout,
|
||||
},
|
||||
maxRetries: cfg.LLMMaxRetries,
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.RequestID)
|
||||
router.Use(middleware.RealIP)
|
||||
router.Use(middleware.Logger)
|
||||
router.Use(middleware.Recoverer)
|
||||
router.Use(corsMiddleware)
|
||||
|
||||
router.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
router.Route("/api", func(r chi.Router) {
|
||||
r.Get("/assignments", handleListAssignments(db))
|
||||
r.Get("/assignments/{id}", handleGetAssignment(db))
|
||||
r.Get("/assignments/{id}/image", handleGetAssignmentImage(db, cfg.UploadDir))
|
||||
r.Post("/assignments", handleCreateAssignment(db, llmClient, cfg.UploadDir))
|
||||
r.Delete("/assignments/{id}", handleDeleteAssignment(db))
|
||||
})
|
||||
|
||||
router.Route("/backend", func(r chi.Router) {
|
||||
r.Use(basicAuth(cfg.AdminUser, cfg.AdminPass))
|
||||
r.Get("/", handleAdminDashboard(db))
|
||||
})
|
||||
|
||||
router.With(basicAuth(cfg.AdminUser, cfg.AdminPass)).Get("/backend", handleAdminDashboard(db))
|
||||
|
||||
addr := ":" + cfg.Port
|
||||
log.Printf("backend listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, router); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() Config {
|
||||
timeoutSeconds := getEnvAsInt("LLM_TIMEOUT_SECONDS", 90)
|
||||
maxRetries := getEnvAsInt("LLM_MAX_RETRIES", 3)
|
||||
|
||||
return Config{
|
||||
Port: getEnv("BACKEND_PORT", "8080"),
|
||||
DBPath: getEnv("DB_PATH", "/data/homework.db"),
|
||||
LLMBaseURL: getEnv("LLM_BASE_URL", ""),
|
||||
LLMAPIKey: getEnv("LLM_API_KEY", ""),
|
||||
LLMModel: getEnv("LLM_MODEL", "qwen3-max"),
|
||||
LLMTimeout: time.Duration(timeoutSeconds) * time.Second,
|
||||
LLMMaxRetries: maxRetries,
|
||||
UploadDir: getEnv("UPLOAD_DIR", "/data/uploads"),
|
||||
AdminUser: getEnv("ADMIN_USER", "admin"),
|
||||
AdminPass: getEnv("ADMIN_PASS", "whoami139"),
|
||||
}
|
||||
}
|
||||
|
||||
func initDB(path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS assignments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
ocr_text TEXT,
|
||||
markdown TEXT,
|
||||
feedback TEXT,
|
||||
score INTEGER,
|
||||
image_path TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureColumn(db, "assignments", "image_path", "TEXT"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func handleListAssignments(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username := strings.TrimSpace(r.URL.Query().Get("username"))
|
||||
if username == "" {
|
||||
writeError(w, http.StatusBadRequest, "username is required")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT id, username, title, score, image_path, created_at
|
||||
FROM assignments
|
||||
WHERE username = ?
|
||||
ORDER BY created_at DESC
|
||||
`, username)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to query assignments")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
assignments := make([]Assignment, 0)
|
||||
for rows.Next() {
|
||||
var a Assignment
|
||||
var rawImagePath string
|
||||
if err := rows.Scan(&a.ID, &a.Username, &a.Title, &a.Score, &rawImagePath, &a.CreatedAt); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to read assignments")
|
||||
return
|
||||
}
|
||||
setImageFields(&a, rawImagePath)
|
||||
assignments = append(assignments, a)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, assignments)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetAssignment(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
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 a Assignment
|
||||
var rawImagePath string
|
||||
row := db.QueryRow(`
|
||||
SELECT id, username, title, ocr_text, markdown, feedback, score, image_path, created_at
|
||||
FROM assignments
|
||||
WHERE id = ? AND username = ?
|
||||
LIMIT 1
|
||||
`, id, username)
|
||||
if err := row.Scan(&a.ID, &a.Username, &a.Title, &a.OCRText, &a.Markdown, &a.Feedback, &a.Score, &rawImagePath, &a.CreatedAt); 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
|
||||
}
|
||||
setImageFields(&a, rawImagePath)
|
||||
|
||||
writeJSON(w, http.StatusOK, a)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetAssignmentImage(db *sql.DB, uploadDir string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
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 imagePath sql.NullString
|
||||
row := db.QueryRow(`
|
||||
SELECT image_path
|
||||
FROM assignments
|
||||
WHERE id = ? AND username = ?
|
||||
LIMIT 1
|
||||
`, id, username)
|
||||
if err := row.Scan(&imagePath); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "assignment not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to query image")
|
||||
return
|
||||
}
|
||||
|
||||
if !imagePath.Valid || strings.TrimSpace(imagePath.String) == "" {
|
||||
writeError(w, http.StatusNotFound, "image not found")
|
||||
return
|
||||
}
|
||||
|
||||
paths := parseImagePaths(imagePath.String)
|
||||
if len(paths) == 0 {
|
||||
writeError(w, http.StatusNotFound, "image not found")
|
||||
return
|
||||
}
|
||||
|
||||
index := 0
|
||||
if indexStr := r.URL.Query().Get("index"); indexStr != "" {
|
||||
if parsed, err := strconv.Atoi(indexStr); err == nil {
|
||||
index = parsed
|
||||
}
|
||||
}
|
||||
if index < 0 || index >= len(paths) {
|
||||
writeError(w, http.StatusNotFound, "image not found")
|
||||
return
|
||||
}
|
||||
|
||||
absImage, err := filepath.Abs(paths[index])
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "invalid image path")
|
||||
return
|
||||
}
|
||||
absUpload, err := filepath.Abs(uploadDir)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "invalid upload dir")
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(absImage, absUpload+string(os.PathSeparator)) && absImage != absUpload {
|
||||
writeError(w, http.StatusForbidden, "invalid image path")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(absImage); err != nil {
|
||||
writeError(w, http.StatusNotFound, "image not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
http.ServeFile(w, r, absImage)
|
||||
}
|
||||
}
|
||||
|
||||
func handleCreateAssignment(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") {
|
||||
handleCreateAssignmentWithImage(db, llm, uploadDir, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var req createAssignmentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
req.Title = strings.TrimSpace(req.Title)
|
||||
req.OCRText = strings.TrimSpace(req.OCRText)
|
||||
|
||||
if req.Username == "" {
|
||||
writeError(w, http.StatusBadRequest, "username is required")
|
||||
return
|
||||
}
|
||||
if req.OCRText == "" {
|
||||
writeError(w, http.StatusBadRequest, "ocrText is required")
|
||||
return
|
||||
}
|
||||
if req.Title == "" {
|
||||
req.Title = fmt.Sprintf("%s %s", defaultTitlePrefix, time.Now().Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
markdown, feedback, score, err := llm.FormatAndGrade(r.Context(), req.Title, req.OCRText)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, fmt.Sprintf("llm failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO assignments (username, title, ocr_text, markdown, feedback, score, image_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, req.Username, req.Title, req.OCRText, markdown, feedback, score, "[]")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to save assignment")
|
||||
return
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
assignment := Assignment{
|
||||
ID: id,
|
||||
Username: req.Username,
|
||||
Title: req.Title,
|
||||
OCRText: req.OCRText,
|
||||
Markdown: markdown,
|
||||
Feedback: feedback,
|
||||
Score: score,
|
||||
ImagePath: "",
|
||||
ImagePaths: []string{},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
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 {
|
||||
writeError(w, http.StatusBadRequest, "invalid multipart form")
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
title := strings.TrimSpace(r.FormValue("title"))
|
||||
if username == "" {
|
||||
writeError(w, http.StatusBadRequest, "username is required")
|
||||
return
|
||||
}
|
||||
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)
|
||||
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO assignments (username, title, ocr_text, markdown, feedback, score, image_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, username, title, "", markdown, feedback, score, string(imagePathsJSON))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to save assignment")
|
||||
return
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
assignment := Assignment{
|
||||
ID: id,
|
||||
Username: username,
|
||||
Title: title,
|
||||
OCRText: "",
|
||||
Markdown: markdown,
|
||||
Feedback: feedback,
|
||||
Score: score,
|
||||
ImagePath: firstPath(imagePaths),
|
||||
ImagePaths: imagePaths,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, assignment)
|
||||
}
|
||||
|
||||
func handleDeleteAssignment(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
|
||||
res, err := db.Exec(`DELETE FROM assignments WHERE id = ? AND username = ?`, id, username)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete assignment")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
writeError(w, http.StatusNotFound, "assignment not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminDashboard(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, username, title, score, created_at
|
||||
FROM assignments
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
`)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to load assignments", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
assignments := make([]Assignment, 0)
|
||||
for rows.Next() {
|
||||
var a Assignment
|
||||
if err := rows.Scan(&a.ID, &a.Username, &a.Title, &a.Score, &a.CreatedAt); err != nil {
|
||||
http.Error(w, "failed to read assignments", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
assignments = append(assignments, a)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := adminTemplate.Execute(w, map[string]any{"Assignments": assignments}); err != nil {
|
||||
http.Error(w, "failed to render", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LLMClient) FormatAndGrade(ctx context.Context, title, ocrText string) (string, string, int, error) {
|
||||
if c.baseURL == "" || c.apiKey == "" {
|
||||
return "", "", 0, errors.New("LLM_BASE_URL or LLM_API_KEY missing")
|
||||
}
|
||||
|
||||
systemPrompt := "你是一位细致的老师。请把 OCR 文字整理成清晰的 Markdown,保持原有题号、分点、段落与格式。然后给出作业批改意见。只输出 JSON,包含 markdown、feedback、score(0-100整数)。不要加代码块或多余文字。"
|
||||
userPrompt := fmt.Sprintf("标题: %s\n\nOCR文本:\n%s", title, ocrText)
|
||||
|
||||
payload := map[string]any{
|
||||
"model": c.model,
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": systemPrompt},
|
||||
{"role": "user", "content": userPrompt},
|
||||
},
|
||||
"stream": false,
|
||||
}
|
||||
|
||||
content, err := c.chatCompletionWithRetry(ctx, payload)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
|
||||
result := LLMResult{}
|
||||
if err := decodeLLMResult(content, &result); err != nil {
|
||||
fallback := strings.TrimSpace(content)
|
||||
if fallback == "" {
|
||||
fallback = ocrText
|
||||
}
|
||||
return fallback, "LLM 返回无法解析为 JSON,已返回原始内容。", 0, nil
|
||||
}
|
||||
|
||||
markdown := strings.TrimSpace(result.Markdown)
|
||||
feedback := strings.TrimSpace(result.Feedback)
|
||||
score := result.Score
|
||||
if markdown == "" {
|
||||
markdown = ocrText
|
||||
}
|
||||
|
||||
return markdown, feedback, score, nil
|
||||
}
|
||||
|
||||
func (c *LLMClient) FormatAndGradeFromImage(ctx context.Context, title, mimeType, imageBase64 string) (string, string, int, error) {
|
||||
return c.FormatAndGradeFromImages(ctx, title, []LLMImage{{MimeType: mimeType, Base64: imageBase64}})
|
||||
}
|
||||
|
||||
func (c *LLMClient) FormatAndGradeFromImages(ctx context.Context, title string, images []LLMImage) (string, string, int, error) {
|
||||
if c.baseURL == "" || c.apiKey == "" {
|
||||
return "", "", 0, errors.New("LLM_BASE_URL or LLM_API_KEY missing")
|
||||
}
|
||||
if len(images) == 0 {
|
||||
return "", "", 0, errors.New("no images provided")
|
||||
}
|
||||
|
||||
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)
|
||||
contentParts = append(contentParts, map[string]any{"type": "text", "text": userText})
|
||||
for _, img := range images {
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]string{
|
||||
"url": fmt.Sprintf("data:%s;base64,%s", img.MimeType, img.Base64),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"model": c.model,
|
||||
"messages": []map[string]any{
|
||||
{"role": "system", "content": systemPrompt},
|
||||
{"role": "user", "content": contentParts},
|
||||
},
|
||||
"stream": false,
|
||||
}
|
||||
|
||||
content, err := c.chatCompletionWithRetry(ctx, payload)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
|
||||
result := LLMResult{}
|
||||
if err := decodeLLMResult(content, &result); err != nil {
|
||||
return "", "LLM 返回无法解析为 JSON。", 0, nil
|
||||
}
|
||||
|
||||
markdown := strings.TrimSpace(result.Markdown)
|
||||
feedback := strings.TrimSpace(result.Feedback)
|
||||
score := result.Score
|
||||
if markdown == "" {
|
||||
markdown = "未识别到有效内容。"
|
||||
}
|
||||
|
||||
return markdown, feedback, score, nil
|
||||
}
|
||||
|
||||
func (c *LLMClient) chatCompletionWithRetry(ctx context.Context, payload map[string]any) (string, error) {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
sleep := time.Duration(1<<uint(attempt-1)) * time.Second
|
||||
time.Sleep(sleep)
|
||||
}
|
||||
|
||||
content, status, err := c.chatCompletion(ctx, payload)
|
||||
if err == nil {
|
||||
if status >= 500 {
|
||||
lastErr = fmt.Errorf("llm server error: %d", status)
|
||||
continue
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
if status >= 500 || status == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("llm request failed")
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
func (c *LLMClient) chatCompletion(ctx context.Context, payload map[string]any) (string, int, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", resp.StatusCode, err
|
||||
}
|
||||
if len(result.Choices) == 0 {
|
||||
return "", resp.StatusCode, errors.New("llm response missing choices")
|
||||
}
|
||||
|
||||
return result.Choices[0].Message.Content, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func decodeLLMResult(content string, out *LLMResult) error {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return errors.New("empty content")
|
||||
}
|
||||
|
||||
if json.Unmarshal([]byte(content), out) == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
start := strings.Index(content, "{")
|
||||
end := strings.LastIndex(content, "}")
|
||||
if start == -1 || end == -1 || end <= start {
|
||||
return errors.New("json not found")
|
||||
}
|
||||
|
||||
snippet := content[start : end+1]
|
||||
if err := json.Unmarshal([]byte(snippet), out); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureColumn(db *sql.DB, table, column, columnType string) error {
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s);", table))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name string
|
||||
var ctype string
|
||||
var notnull int
|
||||
var dfltValue sql.NullString
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == column {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_, err = db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s;", table, column, columnType))
|
||||
return err
|
||||
}
|
||||
|
||||
func parseImagePaths(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(raw, "[") {
|
||||
var paths []string
|
||||
if json.Unmarshal([]byte(raw), &paths) == nil {
|
||||
return paths
|
||||
}
|
||||
}
|
||||
return []string{raw}
|
||||
}
|
||||
|
||||
func setImageFields(a *Assignment, raw string) {
|
||||
paths := parseImagePaths(raw)
|
||||
a.ImagePaths = paths
|
||||
a.ImagePath = firstPath(paths)
|
||||
}
|
||||
|
||||
func firstPath(paths []string) string {
|
||||
if len(paths) == 0 {
|
||||
return ""
|
||||
}
|
||||
return paths[0]
|
||||
}
|
||||
|
||||
func randomToken(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
buf := make([]byte, length)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
for i, b := range buf {
|
||||
buf[i] = charset[int(b)%len(charset)]
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
func extFromMime(mimeType string) string {
|
||||
switch mimeType {
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
default:
|
||||
return ".img"
|
||||
}
|
||||
}
|
||||
|
||||
func basicAuth(user, pass string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok || username != user || password != pass {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Homework Admin"`)
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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-Headers", "Content-Type, Authorization")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
log.Printf("failed to write json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvAsInt(key string, fallback int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if parsed, err := strconv.Atoi(value); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
16
docker-compose.yml
普通文件
16
docker-compose.yml
普通文件
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
env_file: ./.env
|
||||
volumes:
|
||||
- ./backend/data:/data
|
||||
ports:
|
||||
- '127.0.0.1:8080:8080'
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
env_file: ./.env
|
||||
ports:
|
||||
- '127.0.0.1:3000:3000'
|
||||
22
frontend/Dockerfile
普通文件
22
frontend/Dockerfile
普通文件
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_PUBLIC_API_BASE=/api
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
384
frontend/app/globals.css
普通文件
384
frontend/app/globals.css
普通文件
@@ -0,0 +1,384 @@
|
||||
:root {
|
||||
--ink: #1f1d1a;
|
||||
--paper: #f8f1e5;
|
||||
--paper-2: #f2e6d2;
|
||||
--accent: #f05a28;
|
||||
--accent-dark: #c03d12;
|
||||
--teal: #0f4c5c;
|
||||
--shadow: rgba(31, 29, 26, 0.18);
|
||||
--radius: 22px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(1200px 600px at 10% 10%, rgba(240, 90, 40, 0.08), transparent 60%),
|
||||
radial-gradient(1000px 500px at 90% 20%, rgba(15, 76, 92, 0.12), transparent 60%),
|
||||
var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body), serif;
|
||||
line-height: 1.6;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(31, 29, 26, 0.03),
|
||||
rgba(31, 29, 26, 0.03) 1px,
|
||||
transparent 1px,
|
||||
transparent 24px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(31, 29, 26, 0.02),
|
||||
rgba(31, 29, 26, 0.02) 1px,
|
||||
transparent 1px,
|
||||
transparent 24px
|
||||
);
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
main {
|
||||
position: relative;
|
||||
padding: 64px 8vw 80px;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: var(--font-display), serif;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--font-mono), monospace;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
padding: 36px 40px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.85), rgba(246, 232, 210, 0.75));
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 30px 80px var(--shadow);
|
||||
border: 1px solid rgba(31, 29, 26, 0.08);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: clamp(2.4rem, 4vw, 3.4rem);
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 12px 0 0;
|
||||
font-size: 1.05rem;
|
||||
color: rgba(31, 29, 26, 0.78);
|
||||
}
|
||||
|
||||
.hero .stamp {
|
||||
justify-self: end;
|
||||
border: 2px dashed var(--accent);
|
||||
padding: 16px 20px;
|
||||
border-radius: 18px;
|
||||
transform: rotate(-2deg);
|
||||
background: rgba(240, 90, 40, 0.08);
|
||||
font-family: var(--font-mono), monospace;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(31, 29, 26, 0.08);
|
||||
box-shadow: 0 20px 50px rgba(31, 29, 26, 0.12);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.card header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 0.72rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(31, 29, 26, 0.1);
|
||||
background: rgba(15, 76, 92, 0.08);
|
||||
color: var(--teal);
|
||||
}
|
||||
|
||||
.username-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1 1 220px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(31, 29, 26, 0.2);
|
||||
background: #fffefb;
|
||||
font-size: 0.98rem;
|
||||
font-family: var(--font-body), serif;
|
||||
}
|
||||
|
||||
textarea.input {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 12px 18px;
|
||||
border-radius: 14px;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: rgba(15, 76, 92, 0.12);
|
||||
color: var(--teal);
|
||||
border: 1px solid rgba(15, 76, 92, 0.3);
|
||||
}
|
||||
|
||||
.button.ghost {
|
||||
background: transparent;
|
||||
border: 1px dashed rgba(31, 29, 26, 0.3);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.button:not(:disabled):hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 24px rgba(240, 90, 40, 0.25);
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed rgba(31, 29, 26, 0.25);
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.preview {
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(31, 29, 26, 0.1);
|
||||
object-fit: cover;
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
.thumbnail-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.thumb-card {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(31, 29, 26, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.thumb-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.thumb-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(31, 29, 26, 0.65);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.assignments {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.image-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.crop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.crop-modal {
|
||||
width: min(90vw, 720px);
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.crop-area {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.crop-controls {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.crop-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.assignment-item {
|
||||
border: 1px solid rgba(31, 29, 26, 0.08);
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
transition: border 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.assignment-item.active {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 22px rgba(240, 90, 40, 0.18);
|
||||
}
|
||||
|
||||
.assignment-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(31, 29, 26, 0.6);
|
||||
}
|
||||
|
||||
.markdown {
|
||||
background: #fffefb;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(31, 29, 26, 0.08);
|
||||
}
|
||||
|
||||
.markdown h1, .markdown h2, .markdown h3 {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.markdown code {
|
||||
font-family: var(--font-mono), monospace;
|
||||
background: rgba(31, 29, 26, 0.08);
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
border-left: 4px solid var(--accent);
|
||||
padding: 12px 16px;
|
||||
background: rgba(240, 90, 40, 0.08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.camera {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.camera video {
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(31, 29, 26, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hero .stamp {
|
||||
justify-self: start;
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
39
frontend/app/layout.tsx
普通文件
39
frontend/app/layout.tsx
普通文件
@@ -0,0 +1,39 @@
|
||||
import "./globals.css";
|
||||
import { Fraunces, Source_Serif_4, IBM_Plex_Mono } from "next/font/google";
|
||||
|
||||
const display = Fraunces({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-display",
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
const body = Source_Serif_4({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-body",
|
||||
weight: ["400", "500", "600"],
|
||||
});
|
||||
|
||||
const mono = IBM_Plex_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
weight: ["400", "500"],
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: "作业工坊",
|
||||
description: "拍照或上传作业,自动转为 Markdown 并批改",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh">
|
||||
<body className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
638
frontend/app/page.tsx
普通文件
638
frontend/app/page.tsx
普通文件
@@ -0,0 +1,638 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import Cropper from "react-easy-crop";
|
||||
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_BASE || "/api";
|
||||
|
||||
type AssignmentSummary = {
|
||||
id: number;
|
||||
username: string;
|
||||
title: string;
|
||||
score: number;
|
||||
imagePath?: string;
|
||||
imagePaths?: string[];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type AssignmentDetail = AssignmentSummary & {
|
||||
ocrText?: string;
|
||||
markdown: string;
|
||||
feedback: string;
|
||||
imagePath?: string;
|
||||
imagePaths?: string[];
|
||||
};
|
||||
|
||||
type ImageItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
file: Blob;
|
||||
previewUrl: string;
|
||||
croppedBlob?: Blob;
|
||||
croppedUrl?: string;
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [savedUser, setSavedUser] = useState("");
|
||||
const [assignments, setAssignments] = useState<AssignmentSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [selected, setSelected] = useState<AssignmentDetail | null>(null);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [images, setImages] = useState<ImageItem[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [info, setInfo] = useState("");
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const imagesRef = useRef<ImageItem[]>([]);
|
||||
|
||||
const [cropTarget, setCropTarget] = useState<ImageItem | null>(null);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem("hw_username");
|
||||
if (stored) {
|
||||
setUsername(stored);
|
||||
setSavedUser(stored);
|
||||
void loadAssignments(stored);
|
||||
}
|
||||
return () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
imagesRef.current.forEach((item) => {
|
||||
URL.revokeObjectURL(item.previewUrl);
|
||||
if (item.croppedUrl) URL.revokeObjectURL(item.croppedUrl);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
imagesRef.current = images;
|
||||
}, [images]);
|
||||
|
||||
const loadAssignments = async (user: string) => {
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/assignments?username=${encodeURIComponent(user)}`);
|
||||
if (!res.ok) throw new Error("加载作业失败");
|
||||
const data = (await res.json()) as AssignmentSummary[];
|
||||
setAssignments(data);
|
||||
if (data.length > 0) {
|
||||
setSelectedId(data[0].id);
|
||||
await loadAssignmentDetail(data[0].id, user);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const loadAssignmentDetail = async (id: number, user = savedUser) => {
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${apiBase}/assignments/${id}?username=${encodeURIComponent(user)}`
|
||||
);
|
||||
if (!res.ok) throw new Error("加载详情失败");
|
||||
const data = (await res.json()) as AssignmentDetail;
|
||||
setSelected(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveUser = async () => {
|
||||
if (!username.trim()) return;
|
||||
const user = username.trim();
|
||||
setSavedUser(user);
|
||||
window.localStorage.setItem("hw_username", user);
|
||||
await loadAssignments(user);
|
||||
};
|
||||
|
||||
const createId = () =>
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const revokeItemUrls = (item: ImageItem) => {
|
||||
URL.revokeObjectURL(item.previewUrl);
|
||||
if (item.croppedUrl) URL.revokeObjectURL(item.croppedUrl);
|
||||
};
|
||||
|
||||
const createImageItem = (file: Blob, name: string): ImageItem => ({
|
||||
id: createId(),
|
||||
name,
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
});
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setError("");
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
const newItems = files.map((file) =>
|
||||
createImageItem(file, file.name || `upload-${Date.now()}.png`)
|
||||
);
|
||||
setImages((prev) => [...prev, ...newItems]);
|
||||
setInfo(`已选择 ${files.length} 张图片,可继续追加上传。`);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const clearImages = () => {
|
||||
setImages((prev) => {
|
||||
prev.forEach(revokeItemUrls);
|
||||
return [];
|
||||
});
|
||||
setCropTarget(null);
|
||||
setCroppedAreaPixels(null);
|
||||
};
|
||||
|
||||
const removeImage = (id: string) => {
|
||||
setImages((prev) => {
|
||||
const target = prev.find((item) => item.id === id);
|
||||
if (target) revokeItemUrls(target);
|
||||
return prev.filter((item) => item.id !== id);
|
||||
});
|
||||
if (cropTarget?.id === id) {
|
||||
setCropTarget(null);
|
||||
setCroppedAreaPixels(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onCropComplete = useCallback((_: unknown, areaPixels: any) => {
|
||||
setCroppedAreaPixels(areaPixels);
|
||||
}, []);
|
||||
|
||||
const createImage = (url: string) =>
|
||||
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.addEventListener("load", () => resolve(image));
|
||||
image.addEventListener("error", (err) => reject(err));
|
||||
image.setAttribute("crossOrigin", "anonymous");
|
||||
image.src = url;
|
||||
});
|
||||
|
||||
const getCroppedImg = async (imageSrc: string, cropArea: { x: number; y: number; width: number; height: number }) => {
|
||||
const image = await createImage(imageSrc);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = cropArea.width;
|
||||
canvas.height = cropArea.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas not supported");
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
cropArea.x,
|
||||
cropArea.y,
|
||||
cropArea.width,
|
||||
cropArea.height,
|
||||
0,
|
||||
0,
|
||||
cropArea.width,
|
||||
cropArea.height
|
||||
);
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Crop failed"));
|
||||
return;
|
||||
}
|
||||
resolve(blob);
|
||||
}, "image/png");
|
||||
});
|
||||
};
|
||||
|
||||
const applyCrop = async () => {
|
||||
if (!cropTarget || !croppedAreaPixels) return;
|
||||
try {
|
||||
const blob = await getCroppedImg(cropTarget.previewUrl, croppedAreaPixels);
|
||||
const croppedUrl = URL.createObjectURL(blob);
|
||||
setImages((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id !== cropTarget.id) return item;
|
||||
if (item.croppedUrl) URL.revokeObjectURL(item.croppedUrl);
|
||||
return { ...item, croppedBlob: blob, croppedUrl };
|
||||
})
|
||||
);
|
||||
setInfo("裁剪已应用,可直接提交或继续裁剪。");
|
||||
} catch {
|
||||
setError("裁剪失败,请重试");
|
||||
} finally {
|
||||
setCropTarget(null);
|
||||
setCroppedAreaPixels(null);
|
||||
setZoom(1);
|
||||
setCrop({ x: 0, y: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
const openCrop = (item: ImageItem) => {
|
||||
setCropTarget(item);
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setCroppedAreaPixels(null);
|
||||
};
|
||||
|
||||
const restoreCrop = (id: string) => {
|
||||
setImages((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id !== id) return item;
|
||||
if (item.croppedUrl) URL.revokeObjectURL(item.croppedUrl);
|
||||
return { ...item, croppedBlob: undefined, croppedUrl: undefined };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const startCamera = async () => {
|
||||
setError("");
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: "environment" },
|
||||
});
|
||||
streamRef.current = stream;
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
}
|
||||
} catch (err) {
|
||||
setError("无法打开摄像头,请检查权限设置");
|
||||
}
|
||||
};
|
||||
|
||||
const capturePhoto = async () => {
|
||||
setError("");
|
||||
if (!videoRef.current) return;
|
||||
const video = videoRef.current;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
const blob = await (await fetch(dataUrl)).blob();
|
||||
const item = createImageItem(blob, `camera-${Date.now()}.png`);
|
||||
setImages((prev) => [...prev, item]);
|
||||
setInfo("拍照完成,可直接提交或继续拍照。");
|
||||
};
|
||||
|
||||
const stopCamera = () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!savedUser) {
|
||||
setError("请先设置用户名");
|
||||
return;
|
||||
}
|
||||
if (images.length === 0) {
|
||||
setError("请先上传或拍照作业图片");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
setInfo("正在提交图片并调用 LLM 批改,请稍候...");
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("username", savedUser);
|
||||
if (title.trim()) {
|
||||
formData.append("title", title.trim());
|
||||
}
|
||||
images.forEach((item, index) => {
|
||||
const blob = item.croppedBlob ?? item.file;
|
||||
const name = item.name || `page-${index + 1}.png`;
|
||||
formData.append("images", blob, name);
|
||||
});
|
||||
|
||||
const res = await fetch(`${apiBase}/assignments`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
throw new Error(payload.error || "提交失败");
|
||||
}
|
||||
const created = (await res.json()) as AssignmentDetail;
|
||||
setAssignments((prev) => [created, ...prev]);
|
||||
setSelectedId(created.id);
|
||||
setSelected(created);
|
||||
setTitle("");
|
||||
clearImages();
|
||||
setInfo("批改完成,可在右侧查看结果。");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "提交失败");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!savedUser) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${apiBase}/assignments/${id}?username=${encodeURIComponent(savedUser)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!res.ok) throw new Error("删除失败");
|
||||
setAssignments((prev) => prev.filter((item) => item.id !== id));
|
||||
if (selectedId === id) {
|
||||
setSelectedId(null);
|
||||
setSelected(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const imageUrlFor = (id: number, index = 0) =>
|
||||
`${apiBase}/assignments/${id}/image?username=${encodeURIComponent(savedUser)}&index=${index}`;
|
||||
|
||||
const imageCountFor = (item: AssignmentSummary) =>
|
||||
item.imagePaths?.length ?? (item.imagePath ? 1 : 0);
|
||||
|
||||
const selectedImagePaths =
|
||||
selected?.imagePaths && selected.imagePaths.length > 0
|
||||
? selected.imagePaths
|
||||
: selected?.imagePath
|
||||
? [selected.imagePath]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="page">
|
||||
<section className="hero">
|
||||
<div>
|
||||
<h1 className="hero-title">作业工坊</h1>
|
||||
<p>
|
||||
上传或拍照作业,直接交给 LLM 识别与批改,返回 Markdown 与反馈。
|
||||
前端无需登录,只需一个用户名即可管理自己的作业记录。
|
||||
</p>
|
||||
</div>
|
||||
<div className="stamp">Homework Atelier</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<header>
|
||||
<h2 className="section-title">我的身份</h2>
|
||||
<span className="tag">NO LOGIN REQUIRED</span>
|
||||
</header>
|
||||
<div className="username-card">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="输入用户名(例如:小明)"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
<button className="button" onClick={handleSaveUser}>
|
||||
开始管理
|
||||
</button>
|
||||
{savedUser && (
|
||||
<span className="muted">当前用户:{savedUser}</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid">
|
||||
<div className="card">
|
||||
<header>
|
||||
<h2 className="section-title">上传作业</h2>
|
||||
<span className="tag">IMAGE + LLM</span>
|
||||
</header>
|
||||
<div className="upload-area">
|
||||
<label className="muted">作业标题</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="例如:数学作业 第5次"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
|
||||
<label className="muted">上传图片(支持多页)</label>
|
||||
<input type="file" accept="image/*" multiple onChange={handleFileChange} />
|
||||
<div className="muted">默认不裁剪,可选择“裁剪”后提交。</div>
|
||||
|
||||
<div className="camera">
|
||||
<label className="muted">在线拍照</label>
|
||||
<video ref={videoRef} playsInline muted />
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button className="button secondary" onClick={startCamera}>
|
||||
打开摄像头
|
||||
</button>
|
||||
<button className="button" onClick={capturePhoto}>
|
||||
拍照提交
|
||||
</button>
|
||||
<button className="button ghost" onClick={stopCamera}>
|
||||
关闭摄像头
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="thumbnail-grid">
|
||||
{images.map((item, index) => (
|
||||
<div key={item.id} className="thumb-card">
|
||||
<img
|
||||
className="preview"
|
||||
src={item.croppedUrl ?? item.previewUrl}
|
||||
alt={`第${index + 1}张`}
|
||||
/>
|
||||
<div className="thumb-actions">
|
||||
<span className="muted">第 {index + 1} 张</span>
|
||||
<div className="thumb-buttons">
|
||||
<button className="button secondary" onClick={() => openCrop(item)}>
|
||||
裁剪
|
||||
</button>
|
||||
{item.croppedBlob && (
|
||||
<button
|
||||
className="button ghost"
|
||||
onClick={() => restoreCrop(item.id)}
|
||||
>
|
||||
恢复原图
|
||||
</button>
|
||||
)}
|
||||
<button className="button ghost" onClick={() => removeImage(item.id)}>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length > 0 && (
|
||||
<button className="button ghost" onClick={clearImages}>
|
||||
清空图片
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "批改中..." : "提交批改"}
|
||||
</button>
|
||||
|
||||
{error && <div className="feedback">{error}</div>}
|
||||
{info && <div className="feedback">{info}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<header>
|
||||
<h2 className="section-title">我的作业档案</h2>
|
||||
<span className="tag">HISTORY</span>
|
||||
</header>
|
||||
<div className="assignments">
|
||||
{assignments.length === 0 && (
|
||||
<div className="muted">暂无作业记录</div>
|
||||
)}
|
||||
{assignments.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={clsx("assignment-item", {
|
||||
active: item.id === selectedId,
|
||||
})}
|
||||
onClick={async () => {
|
||||
setSelectedId(item.id);
|
||||
await loadAssignmentDetail(item.id);
|
||||
}}
|
||||
>
|
||||
<strong>{item.title}</strong>
|
||||
<div className="assignment-meta">
|
||||
<span>评分:{item.score || "-"}</span>
|
||||
<span>{new Date(item.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 8 }}>
|
||||
{imageCountFor(item) > 0 && (
|
||||
<a
|
||||
className="button secondary"
|
||||
href={imageUrlFor(item.id, 0)}
|
||||
download={`assignment-${item.id}-1.png`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
保存图片{imageCountFor(item) > 1 ? ` (${imageCountFor(item)}张)` : ""}
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
className="button ghost"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(item.id);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<header>
|
||||
<h2 className="section-title">批改结果</h2>
|
||||
<span className="tag">MARKDOWN</span>
|
||||
</header>
|
||||
{!selected && <div className="muted">选择一条作业查看详情</div>}
|
||||
{selected && (
|
||||
<div style={{ display: "grid", gap: 16 }}>
|
||||
<div className="assignment-meta">
|
||||
<span>标题:{selected.title}</span>
|
||||
<span>评分:{selected.score || "-"}</span>
|
||||
</div>
|
||||
{selectedImagePaths.length > 0 && (
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
<div className="assignment-meta">
|
||||
<span>原始图片(共 {selectedImagePaths.length} 张)</span>
|
||||
</div>
|
||||
<div className="image-grid">
|
||||
{selectedImagePaths.map((_, index) => (
|
||||
<div key={`${selected.id}-${index}`} className="image-card">
|
||||
<img
|
||||
className="preview"
|
||||
src={imageUrlFor(selected.id, index)}
|
||||
alt={`作业原图 ${index + 1}`}
|
||||
/>
|
||||
<a
|
||||
className="button secondary"
|
||||
href={imageUrlFor(selected.id, index)}
|
||||
download={`assignment-${selected.id}-${index + 1}.png`}
|
||||
>
|
||||
保存第 {index + 1} 张
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="markdown">
|
||||
<ReactMarkdown>{selected.markdown}</ReactMarkdown>
|
||||
</div>
|
||||
{selected.feedback && (
|
||||
<div className="feedback">
|
||||
<strong>批改意见:</strong>
|
||||
<div>{selected.feedback}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{cropTarget && (
|
||||
<div className="crop-overlay">
|
||||
<div className="crop-modal">
|
||||
<div className="crop-area">
|
||||
<Cropper
|
||||
image={cropTarget.previewUrl}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={4 / 3}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
</div>
|
||||
<div className="crop-controls">
|
||||
<label className="muted">缩放</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.05}
|
||||
value={zoom}
|
||||
onChange={(event) => setZoom(Number(event.target.value))}
|
||||
/>
|
||||
<div className="crop-buttons">
|
||||
<button className="button secondary" onClick={applyCrop}>
|
||||
应用裁剪
|
||||
</button>
|
||||
<button className="button ghost" onClick={() => setCropTarget(null)}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
5
frontend/next-env.d.ts
vendored
普通文件
5
frontend/next-env.d.ts
vendored
普通文件
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
7
frontend/next.config.js
普通文件
7
frontend/next.config.js
普通文件
@@ -0,0 +1,7 @@
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone"
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
1730
frontend/package-lock.json
自动生成的
普通文件
1730
frontend/package-lock.json
自动生成的
普通文件
文件差异内容过多而无法显示
加载差异
25
frontend/package.json
普通文件
25
frontend/package.json
普通文件
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "homework-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"next": "14.2.10",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-easy-crop": "^5.0.6",
|
||||
"react-markdown": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.30",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
0
frontend/public/.keep
普通文件
0
frontend/public/.keep
普通文件
38
frontend/tsconfig.json
普通文件
38
frontend/tsconfig.json
普通文件
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户