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
This commit is contained in:
2026-06-20 22:26:46 +08:00
parent 37aa9c0a33
commit 6c60b767df
10 changed files with 88 additions and 510 deletions
+4 -5
View File
@@ -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
+1 -2
View File
@@ -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
-8
View File
@@ -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
-20
View File
@@ -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=
-82
View File
@@ -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)
}
+1 -1
View File
@@ -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{
-222
View File
@@ -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
}
+4 -44
View File
@@ -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 ──────────────────────────────────────────
+78
View File
@@ -10,6 +10,61 @@
<script src="/static/js/diff-viewer.js"></script>
<script src="/static/js/review-inline.js"></script>
<script src="/static/js/note-editor.js"></script>
<style>
@media print {
/* Hide non-content elements */
nav, footer, #review-actions, #btn-review, #btn-toggle-diff,
.note-save-indicator, select, input, #progress,
#diff-review-container, #history-select,
label[for="base-ref"], label[for="head-ref"],
label[for="top-n"], label[for="concurrency"] { display: none !important; }
/* Show results and print header even if hidden */
#results { display: block !important; }
.print-header { display: block !important; }
/* Page setup */
body { background: white !important; padding: 0 !important; font-size: 12px; }
main { max-width: 100% !important; padding: 16px !important; }
/* Cards without shadow */
.bg-white { box-shadow: none !important; border: 1px solid #e5e7eb; }
/* Avoid page breaks inside cards */
.bg-white.rounded-lg { break-inside: avoid; }
/* File review cards */
#file-reviews > div { break-inside: avoid; margin-bottom: 12px; }
/* Note editors: show content as plain text */
.note-textarea {
border: none !important;
padding: 0 !important;
resize: none !important;
height: auto !important;
overflow: visible !important;
white-space: pre-wrap;
background: #f0f9ff !important;
padding: 8px !important;
border-left: 3px solid #3b82f6 !important;
}
/* Suggestion cards: ensure colors print */
.suggestion-card { break-inside: avoid; }
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
/* Score badge */
.rounded-full { border: 1px solid currentColor; }
/* Branch info as text */
#base-ref, #head-ref, #top-n, #concurrency {
display: inline !important;
border: none !important;
padding: 0 !important;
font-weight: 600;
}
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
{{template "nav" .}}
@@ -71,6 +126,16 @@
<!-- Results -->
<div id="results" class="hidden">
<!-- Print-only header -->
<div class="print-header hidden mb-6">
<h1 style="font-size:20px;font-weight:700;color:#111827;margin-bottom:8px;">PR-Helper 审查报告</h1>
<p style="color:#6b7280;font-size:12px;">
📦 仓库: {{.RepoURL}}
&nbsp;&nbsp;🌿 分支: <span class="print-base-ref"></span> → <span class="print-head-ref"></span>
&nbsp;&nbsp;📅 <span class="print-date"></span>
</p>
<hr style="margin-top:12px;border:none;border-top:1px solid #e5e7eb;">
</div>
<!-- Summary -->
<div id="summary" class="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">审查总结</h2>
@@ -357,6 +422,7 @@
document.getElementById('review-actions').classList.remove('hidden');
btn.disabled = false;
btn.textContent = '开始审查';
updatePrintHeader();
}, 500);
// Render inline suggestions if diff is available
@@ -466,6 +532,17 @@
return Markdown.render(text);
}
// Update print-only header with current branch info
function updatePrintHeader() {
const baseEl = document.getElementById('base-ref');
const headEl = document.getElementById('head-ref');
const baseText = baseEl?.options[baseEl.selectedIndex]?.text || baseEl?.value || '';
const headText = headEl?.options[headEl.selectedIndex]?.text || headEl?.value || '';
document.querySelectorAll('.print-base-ref').forEach(el => el.textContent = baseText);
document.querySelectorAll('.print-head-ref').forEach(el => el.textContent = headText);
document.querySelectorAll('.print-date').forEach(el => el.textContent = new Date().toLocaleString('zh-CN'));
}
// ── History loading ────────────────────────────────────────
async function loadHistoryList() {
@@ -539,6 +616,7 @@
// Show results
document.getElementById('results').classList.remove('hidden');
document.getElementById('review-actions').classList.remove('hidden');
updatePrintHeader();
// Load diff and render inline
const base = data.base_ref;
-126
View File
@@ -1,126 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>PR-Helper 审查报告</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans SC", sans-serif;
font-size: 13px;
line-height: 1.6;
color: #1f2937;
padding: 40px;
max-width: 900px;
margin: 0 auto;
}
h1 { font-size: 22px; margin-bottom: 8px; color: #111827; }
h2 { font-size: 16px; margin: 24px 0 12px; color: #111827; border-bottom: 2px solid #e5e7eb; padding-bottom: 6px; }
h3 { font-size: 14px; margin: 16px 0 8px; color: #374151; }
p { margin-bottom: 8px; }
.meta { color: #6b7280; font-size: 12px; margin-bottom: 20px; }
.meta span { display: inline-block; margin-right: 16px; }
.score { font-size: 28px; font-weight: 700; color: #2563eb; }
.score-label { font-size: 14px; color: #6b7280; margin-left: 4px; }
.section { margin-bottom: 24px; padding: 16px; border: 1px solid #e5e7eb; border-radius: 8px; }
.severity { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; margin-right: 6px; }
.severity-critical { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.severity-warning { background: #fefce8; color: #854d0e; border: 1px solid #fef08a; }
.severity-info { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
.file-section { margin-bottom: 16px; padding: 12px; border: 1px solid #e5e7eb; border-radius: 6px; }
.file-header { font-weight: 600; font-family: monospace; font-size: 13px; margin-bottom: 8px; color: #111827; }
.suggestion { margin: 8px 0; padding: 8px 12px; border-radius: 6px; font-size: 12px; }
.suggestion-critical { border-left: 3px solid #ef4444; background: #fef2f2; }
.suggestion-warning { border-left: 3px solid #f59e0b; background: #fefce8; }
.suggestion-info { border-left: 3px solid #22c55e; background: #f0fdf4; }
.note { margin-top: 8px; padding: 8px 12px; background: #f0f9ff; border-left: 3px solid #3b82f6; font-size: 12px; border-radius: 4px; }
.note-label { font-size: 11px; color: #6b7280; margin-bottom: 4px; }
.overall-note { padding: 12px 16px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; margin-top: 12px; }
pre { background: #f8fafc; border: 1px solid #e2e8f0; padding: 10px; border-radius: 6px; font-family: monospace; font-size: 12px; overflow-x: auto; margin: 8px 0; white-space: pre-wrap; word-wrap: break-word; }
code { font-family: monospace; background: #f1f5f9; padding: 1px 4px; border-radius: 3px; font-size: 12px; }
pre code { background: none; padding: 0; }
.footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #e5e7eb; text-align: center; color: #9ca3af; font-size: 11px; }
@media print {
body { padding: 20px; }
.section { break-inside: avoid; }
.file-section { break-inside: avoid; }
}
</style>
</head>
<body>
<h1>PR-Helper 审查报告</h1>
<div class="meta">
<span>📦 仓库: {{.RepoURL}}</span>
<span>🌿 分支: {{.BaseRef}} → {{.HeadRef}}</span>
<span>📅 审查时间: {{.ReviewedAt}}</span>
</div>
<!-- Overall Assessment -->
<div class="section">
<h2>整体评估</h2>
{{if .Score}}
<div style="margin-bottom: 12px;">
<span class="score">{{.Score}}</span><span class="score-label">/10</span>
</div>
{{end}}
{{if .Overall}}
<p><strong>总体评价:</strong> {{.Overall}}</p>
{{end}}
{{if .Findings}}
<p><strong>主要发现:</strong></p>
<p>{{.Findings}}</p>
{{end}}
{{if .Recommendations}}
<p><strong>改进建议:</strong></p>
<p>{{.Recommendations}}</p>
{{end}}
{{if .OverallNotes}}
<div class="overall-note">
<div class="note-label">💬 用户备注:</div>
{{range .OverallNotes}}
<p>{{.}}</p>
{{end}}
</div>
{{end}}
</div>
<!-- File Reviews -->
{{if .FileReviews}}
<h2>逐文件审查</h2>
{{range .FileReviews}}
<div class="file-section">
<div class="file-header">📄 {{.FileName}}
{{if .ChangeLines}}<span style="font-weight:normal;color:#6b7280;font-size:12px;">({{.ChangeLines}} 行变更)</span>{{end}}
</div>
{{range .Suggestions}}
<div class="suggestion suggestion-{{.Severity}}">
<span class="severity severity-{{.Severity}}">{{.SeverityCN}}</span>
<strong>{{.Description}}</strong>
{{if .Suggestion}}<p style="margin-top:4px;">建议: {{.Suggestion}}</p>{{end}}
{{if .CodeExample}}<pre><code>{{.CodeExample}}</code></pre>{{end}}
</div>
{{end}}
{{range .Notes}}
<div class="note">
<div class="note-label">💬 备注:</div>
<p>{{.}}</p>
</div>
{{end}}
</div>
{{end}}
{{end}}
<!-- Raw result fallback if no structured data -->
{{if and (not .FileReviews) .Result}}
<div class="section">
<h2>审查详情</h2>
<pre>{{.Result}}</pre>
</div>
{{end}}
<div class="footer">
PR-Helper — AI 代码审查报告 · {{.ReviewedAt}}
</div>
</body>
</html>