From 6c60b767dfbe628f4c436615ea2fe22ce537cdac Mon Sep 17 00:00:00 2001 From: wonder Date: Sat, 20 Jun 2026 22:26:46 +0800 Subject: [PATCH] refactor: replace chromedp with browser native print for PDF export Remove chromedp dependency, use window.print() instead. Docker image no longer needs Chromium (~200MB smaller). - Delete services/pdf.go and templates/reports/review.html - Remove PDF API route POST /api/repos/:id/review/pdf - Add @media print CSS to review page - Remove chromium from Dockerfile --- CLAUDE.md | 9 +- Dockerfile | 3 +- go.mod | 8 -- go.sum | 20 --- handlers/review.go | 82 ------------- main.go | 2 +- services/pdf.go | 222 ---------------------------------- static/js/note-editor.js | 48 +------- templates/pages/review.html | 78 ++++++++++++ templates/reports/review.html | 126 ------------------- 10 files changed, 88 insertions(+), 510 deletions(-) delete mode 100644 services/pdf.go delete mode 100644 templates/reports/review.html diff --git a/CLAUDE.md b/CLAUDE.md index 921ae17..920487b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,10 +8,10 @@ PR-Helper is a self-hosted web service that auto-generates PR descriptions from ## Tech Stack -- **Backend**: Go + Gin + SQLite (go-sqlite3) + go-git + chromedp (PDF generation) +- **Backend**: Go + Gin + SQLite (go-sqlite3) + go-git - **Frontend**: Go html/template + HTMX + D3.js + diff2html + Tailwind CSS - **LLM**: OpenAI-compatible API (SSE streaming) -- **Deploy**: Docker (includes Chromium for PDF) +- **Deploy**: Docker ## Build & Run @@ -35,18 +35,17 @@ services/ → Business logic (git ops, LLM calls, PDF gen, cache management) models/ → Data models (repository, settings, analysis) database/ → SQLite init and migrations config/ → Configuration loading -templates/ → Go HTML templates (layouts/, pages/, partials/, reports/) +templates/ → Go HTML templates (layouts/, pages/, partials/) static/ → CSS (Tailwind output), JS (graph, diff-viewer, sse), vendor libs ``` -**Data flow**: Browser ↔ Gin handlers → services (git/llm/pdf) → SQLite + filesystem (`data/`) +**Data flow**: Browser ↔ Gin handlers → services (git/llm) → SQLite + filesystem (`data/`) **Key service layer responsibilities**: - `services/git.go` — clone, diff, graph data extraction via go-git - `services/llm.go` — OpenAI-compatible API calls with SSE streaming - `services/generate.go` — PR description generation (commits + diff → LLM → structured output) - `services/review.go` — AI code review (per-file analysis + summary, Top-N strategy for large diffs) -- `services/pdf.go` — chromedp HTML→PDF conversion - `services/cache.go` — repository cache lifecycle (clone, expiry cleanup) ## Key Patterns diff --git a/Dockerfile b/Dockerfile index e389ba7..33f525b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,7 @@ RUN CGO_ENABLED=1 go build -o pr-helper . # Runtime stage FROM alpine:3.20 -RUN apk add --no-cache ca-certificates chromium -ENV CHROME_BIN=/usr/bin/chromium-browser +RUN apk add --no-cache ca-certificates WORKDIR /app COPY --from=builder /app/pr-helper . COPY --from=builder /app/templates ./templates diff --git a/go.mod b/go.mod index 29a15a6..baf7952 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,6 @@ module github.com/HoHD/PR-Helper go 1.25.0 require ( - github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb - github.com/chromedp/chromedp v0.11.2 github.com/gin-contrib/sessions v1.1.0 github.com/gin-gonic/gin v1.12.0 github.com/go-git/go-git/v5 v5.19.1 @@ -20,7 +18,6 @@ require ( github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect - github.com/chromedp/sysutil v1.1.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect @@ -32,9 +29,6 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect - github.com/gobwas/httphead v0.1.0 // indirect - github.com/gobwas/pool v0.2.1 // indirect - github.com/gobwas/ws v1.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect @@ -42,12 +36,10 @@ require ( github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/go.sum b/go.sum index 1f39b69..6eafadc 100644 --- a/go.sum +++ b/go.sum @@ -15,12 +15,6 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb h1:noKVm2SsG4v0Yd0lHNtFYc9EUxIVvrr4kJ6hM8wvIYU= -github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM= -github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0= -github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8= -github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= -github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= @@ -60,12 +54,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= -github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= -github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= -github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= -github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= -github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -85,8 +73,6 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= @@ -100,12 +86,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= -github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= @@ -117,8 +99,6 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= -github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= diff --git a/handlers/review.go b/handlers/review.go index 9a1fe2b..7aad2c0 100644 --- a/handlers/review.go +++ b/handlers/review.go @@ -309,85 +309,3 @@ func (h *ReviewHandler) GetReview(c *gin.Context) { "result": reviewResult, }) } - -// GeneratePDF handles POST /api/repos/:id/review/pdf — generate and download a PDF report. -func (h *ReviewHandler) GeneratePDF(c *gin.Context) { - user := GetCurrentUser(c) - if user == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"}) - return - } - - id := c.Param("id") - - // Get repo info (scoped to user) - var repoURL string - err := h.db.QueryRow(`SELECT url FROM repositories WHERE id = ? AND user_id = ?`, id, user.ID).Scan(&repoURL) - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Parse request - var req struct { - AnalysisID int64 `json:"analysis_id" binding:"required"` - Base string `json:"base"` - Head string `json:"head"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "analysis_id is required"}) - return - } - - // Verify analysis belongs to user - var analysisResult, baseRef, headRef string - var createdAt string - err = h.db.QueryRow(`SELECT result, base_ref, head_ref, created_at FROM analyses WHERE id = ? AND user_id = ?`, req.AnalysisID, user.ID).Scan(&analysisResult, &baseRef, &headRef, &createdAt) - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Load all notes for this analysis - notes, err := services.GetNotes(h.db, req.AnalysisID, "") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Build report data - reportData := services.ReportData{ - RepoURL: repoURL, - BaseRef: baseRef, - HeadRef: headRef, - ReviewedAt: createdAt, - AnalysisID: req.AnalysisID, - Result: analysisResult, - Notes: notes, - } - - // Generate PDF - pdfBytes, err := services.GeneratePDFReport(reportData) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "PDF generation failed: " + err.Error()}) - return - } - - // Return PDF as download - dateStr := createdAt - if len(dateStr) > 10 { - dateStr = dateStr[:10] - } - filename := fmt.Sprintf("pr-helper-review-%s.pdf", dateStr) - c.Header("Content-Type", "application/pdf") - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) - c.Data(http.StatusOK, "application/pdf", pdfBytes) -} diff --git a/main.go b/main.go index 1d33145..ddd7562 100644 --- a/main.go +++ b/main.go @@ -124,7 +124,7 @@ func main() { r.GET("/api/repos/:id/review/analyses/:aid", authMw, review.GetReview) r.POST("/api/repos/:id/review/notes", authMw, review.SaveNotes) r.GET("/api/repos/:id/review/notes", authMw, review.GetNotes) - r.POST("/api/repos/:id/review/pdf", authMw, review.GeneratePDF) + // Graceful shutdown with signal handling srv := &http.Server{ diff --git a/services/pdf.go b/services/pdf.go deleted file mode 100644 index a0a062c..0000000 --- a/services/pdf.go +++ /dev/null @@ -1,222 +0,0 @@ -package services - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "html/template" - "os" - "time" - - "github.com/HoHD/PR-Helper/models" - "github.com/chromedp/chromedp" - "github.com/chromedp/cdproto/page" -) - -// ReportData holds all data needed to render the PDF report template. -type ReportData struct { - RepoURL string - BaseRef string - HeadRef string - ReviewedAt string - AnalysisID int64 - Result string // raw JSON from analysis - Notes []models.ReviewNote -} - -// ParsedReview holds the structured review data for the template. -type ParsedReview struct { - Score int - Overall string - Findings string - Recommendations string - FileReviews []FileReviewForReport -} - -// FileReviewForReport is a file review entry formatted for the report template. -type FileReviewForReport struct { - FileName string - ChangeLines int - Suggestions []SuggestionForReport - Notes []string -} - -// SuggestionForReport is a single suggestion formatted for the report template. -type SuggestionForReport struct { - Severity string - SeverityCN string - Description string - Suggestion string - CodeExample string - Notes []string -} - -// parseReportData converts raw analysis JSON + notes into template-ready structures. -func parseReportData(data ReportData) ParsedReview { - result := ParsedReview{} - - // Index notes by scope:scopeKey - noteMap := make(map[string][]string) - for _, n := range data.Notes { - key := n.Scope + ":" + n.ScopeKey - noteMap[key] = append(noteMap[key], n.Content) - } - - // Try to parse structured analysis result (new ReviewResult format) - var reviewResult ReviewResult - if err := json.Unmarshal([]byte(data.Result), &reviewResult); err == nil && len(reviewResult.FileReviews) > 0 { - // New format: { file_reviews: [...], summary: {...}, top_n: N } - result.Score = reviewResult.Summary.Score - result.Overall = reviewResult.Summary.Overall - result.Findings = reviewResult.Summary.Findings - result.Recommendations = reviewResult.Summary.Recommendations - - for _, fr := range reviewResult.FileReviews { - fileReport := FileReviewForReport{ - FileName: fr.FileName, - ChangeLines: fr.ChangeLines, - } - // Collect file-level notes - fileReport.Notes = noteMap["file:"+fr.FileName] - - for _, s := range fr.Suggestions { - sug := SuggestionForReport{ - Severity: s.Severity, - SeverityCN: severityCN(s.Severity), - Description: s.Description, - Suggestion: s.Suggestion, - CodeExample: s.CodeExample, - } - fileReport.Suggestions = append(fileReport.Suggestions, sug) - } - result.FileReviews = append(result.FileReviews, fileReport) - } - return result - } - - // Fallback: try legacy flat format { score, overall, findings, recommendations } - var analysisMap map[string]interface{} - if err := json.Unmarshal([]byte(data.Result), &analysisMap); err == nil { - if score, ok := analysisMap["score"].(float64); ok { - result.Score = int(score) - } - if overall, ok := analysisMap["overall"].(string); ok { - result.Overall = overall - } - if findings, ok := analysisMap["findings"].(string); ok { - result.Findings = findings - } - if recs, ok := analysisMap["recommendations"].(string); ok { - result.Recommendations = recs - } - } - - return result -} - -// severityCN returns the Chinese label for a severity level. -func severityCN(severity string) string { - switch severity { - case "critical": - return "严重" - case "warning": - return "建议" - case "info": - return "提示" - default: - return "提示" - } -} - -// GeneratePDFReport generates a PDF from the review report data using chromedp. -func GeneratePDFReport(data ReportData) ([]byte, error) { - review := parseReportData(data) - - // Collect overall notes - var overallNotes []string - for _, n := range data.Notes { - if n.Scope == "overall" { - overallNotes = append(overallNotes, n.Content) - } - } - - // Build template data - tmplData := struct { - RepoURL string - BaseRef string - HeadRef string - ReviewedAt string - Score int - Overall string - Findings string - Recommendations string - FileReviews []FileReviewForReport - OverallNotes []string - Result string - }{ - RepoURL: data.RepoURL, - BaseRef: data.BaseRef, - HeadRef: data.HeadRef, - ReviewedAt: data.ReviewedAt, - Score: review.Score, - Overall: review.Overall, - Findings: review.Findings, - Recommendations: review.Recommendations, - FileReviews: review.FileReviews, - OverallNotes: overallNotes, - Result: data.Result, - } - - // Render HTML from template - tmpl, err := template.ParseFiles("templates/reports/review.html") - if err != nil { - return nil, fmt.Errorf("parse template: %w", err) - } - - var htmlBuf bytes.Buffer - if err := tmpl.Execute(&htmlBuf, tmplData); err != nil { - return nil, fmt.Errorf("execute template: %w", err) - } - - // Write HTML to temp file for chromedp - tmpFile, err := os.CreateTemp("", "pr-helper-report-*.html") - if err != nil { - return nil, fmt.Errorf("create temp file: %w", err) - } - defer os.Remove(tmpFile.Name()) - - if _, err := tmpFile.Write(htmlBuf.Bytes()); err != nil { - tmpFile.Close() - return nil, fmt.Errorf("write temp file: %w", err) - } - tmpFile.Close() - - // Use chromedp to convert HTML to PDF - ctx, cancel := chromedp.NewContext(context.Background()) - defer cancel() - - ctx, cancel = context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - var pdfBytes []byte - fileURL := "file://" + tmpFile.Name() - - err = chromedp.Run(ctx, - chromedp.Navigate(fileURL), - chromedp.WaitReady("body"), - chromedp.ActionFunc(func(ctx context.Context) error { - var err error - pdfBytes, _, err = page.PrintToPDF(). - WithDisplayHeaderFooter(false). - WithPrintBackground(true). - Do(ctx) - return err - }), - ) - if err != nil { - return nil, fmt.Errorf("chromedp: %w", err) - } - - return pdfBytes, nil -} diff --git a/static/js/note-editor.js b/static/js/note-editor.js index b72efad..f7c7797 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -149,55 +149,15 @@ const NoteEditor = { }, /** - * Trigger PDF export and download. + * Trigger PDF export via browser's native print dialog. + * Users can select "Save as PDF" in the print dialog. */ - async exportPDF() { + exportPDF() { if (!this.analysisId) { showToast('请先完成审查再导出 PDF', 'warning'); return; } - - const btn = document.getElementById('btn-export-pdf'); - if (btn) { - btn.disabled = true; - btn.textContent = '⏳ 生成中...'; - } - - try { - const resp = await fetch(`/api/repos/${this.repoId}/review/pdf`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'same-origin', - body: JSON.stringify({ - analysis_id: this.analysisId, - base: document.getElementById('base-ref')?.value || '', - head: document.getElementById('head-ref')?.value || '', - }), - }); - - if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.error || 'PDF generation failed'); - } - - // Download the PDF - const blob = await resp.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `pr-helper-review-${new Date().toISOString().slice(0, 10)}.pdf`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } catch (err) { - showToast('PDF 导出失败: ' + err.message, 'error'); - } finally { - if (btn) { - btn.disabled = false; - btn.textContent = '📄 导出 PDF 报告'; - } - } + window.print(); }, // ── Internal helpers ────────────────────────────────────────── diff --git a/templates/pages/review.html b/templates/pages/review.html index 3165518..96e012f 100644 --- a/templates/pages/review.html +++ b/templates/pages/review.html @@ -10,6 +10,61 @@ + {{template "nav" .}} @@ -71,6 +126,16 @@