feat: Phase 5 — 审查备注编辑器与 PDF 导出
This commit is contained in:
@@ -713,13 +713,13 @@ volumes:
|
||||
|
||||
## 10. 开发阶段
|
||||
|
||||
> **当前进度**:Phase 1-4 已完成,Phase 5-6 待开发
|
||||
> **当前进度**:Phase 1-5 已完成,Phase 6 待完善
|
||||
>
|
||||
> - ✅ Phase 1(基础骨架):Go 项目、Gin 路由、SQLite、设置页面
|
||||
> - ✅ Phase 2(Git 核心):clone、refs、diff、graph 数据、缓存管理
|
||||
> - ✅ Phase 3(前端交互):Git Graph、Diff 查看器、文件树、SSE 流式展示、行内建议
|
||||
> - ✅ Phase 4(LLM 集成):OpenAI 兼容 API、PR 描述生成、AI 代码审查、Top-N 策略
|
||||
> - ⏳ Phase 5(审查编辑与导出):待开发
|
||||
> - ✅ Phase 5(审查编辑与导出):备注编辑器、备注 CRUD API、PDF 报告生成
|
||||
> - ⚠️ Phase 6(完善与部署):Docker 配置已完成,其他待完善
|
||||
|
||||
### Phase 1:基础骨架 ✅ 已完成
|
||||
@@ -751,12 +751,12 @@ volumes:
|
||||
- [x] AI 代码审查(分文件处理 + 汇总)(services/review.go)
|
||||
- [x] 大 diff 处理(Top-N 策略)
|
||||
|
||||
### Phase 5:审查编辑与导出 ⏳ 待开发
|
||||
- [ ] 备注编辑器组件(Markdown 支持,三级作用域)
|
||||
- [ ] 备注 CRUD API(保存/读取)
|
||||
- [ ] PDF 报告 HTML 模板(打印样式)
|
||||
- [ ] chromedp HTML→PDF 生成服务
|
||||
- [ ] PDF 下载接口
|
||||
### Phase 5:审查编辑与导出 ✅ 已完成
|
||||
- [x] 备注编辑器组件(Markdown 支持,三级作用域)
|
||||
- [x] 备注 CRUD API(保存/读取)
|
||||
- [x] PDF 报告 HTML 模板(打印样式)
|
||||
- [x] chromedp HTML→PDF 生成服务
|
||||
- [x] PDF 下载接口
|
||||
|
||||
### Phase 6:完善与部署 ⚠️ 部分完成
|
||||
- [ ] 错误处理和用户提示
|
||||
|
||||
@@ -3,6 +3,8 @@ 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-gonic/gin v1.10.0
|
||||
github.com/go-git/go-git/v5 v5.19.1
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
@@ -15,6 +17,7 @@ require (
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
@@ -27,13 +30,18 @@ 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.20.0 // 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.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // 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
|
||||
|
||||
@@ -13,6 +13,12 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
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.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
@@ -52,6 +58,12 @@ 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.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
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.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
@@ -61,6 +73,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
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=
|
||||
@@ -76,8 +90,12 @@ 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=
|
||||
@@ -89,6 +107,8 @@ 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.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
|
||||
+130
-7
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/HoHD/PR-Helper/models"
|
||||
"github.com/HoHD/PR-Helper/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -96,21 +97,143 @@ func (h *ReviewHandler) Review(c *gin.Context) {
|
||||
"top_n": topN,
|
||||
}
|
||||
resultJSON, _ := json.Marshal(resultData)
|
||||
h.db.Exec(`INSERT INTO analyses (repo_id, type, base_ref, head_ref, result) VALUES (?, 'code_review', ?, ?, ?)`,
|
||||
result, err := h.db.Exec(`INSERT INTO analyses (repo_id, type, base_ref, head_ref, result) VALUES (?, 'code_review', ?, ?, ?)`,
|
||||
id, req.Base, req.Head, string(resultJSON))
|
||||
if err == nil {
|
||||
analysisID, _ := result.LastInsertId()
|
||||
sendEvent("analysis_saved", map[string]interface{}{
|
||||
"analysis_id": analysisID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SaveNotes handles POST /api/repos/:id/review/notes — stub for Phase 5
|
||||
// SaveNotes handles POST /api/repos/:id/review/notes — upsert a review note.
|
||||
func (h *ReviewHandler) SaveNotes(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "notes not implemented yet — coming in Phase 5"})
|
||||
var req struct {
|
||||
AnalysisID int64 `json:"analysis_id" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
ScopeKey string `json:"scope_key"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "analysis_id and scope are required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate scope
|
||||
switch req.Scope {
|
||||
case "overall", "file", "suggestion":
|
||||
// valid
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be overall, file, or suggestion"})
|
||||
return
|
||||
}
|
||||
|
||||
note, err := services.SaveNote(h.db, req.AnalysisID, req.Scope, req.ScopeKey, req.Content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, note)
|
||||
}
|
||||
|
||||
// GetNotes handles GET /api/repos/:id/review/notes — stub for Phase 5
|
||||
// GetNotes handles GET /api/repos/:id/review/notes — list review notes for an analysis.
|
||||
func (h *ReviewHandler) GetNotes(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "notes not implemented yet — coming in Phase 5"})
|
||||
analysisIDStr := c.Query("analysis_id")
|
||||
if analysisIDStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "analysis_id query parameter is required"})
|
||||
return
|
||||
}
|
||||
|
||||
analysisID, err := strconv.ParseInt(analysisIDStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid analysis_id"})
|
||||
return
|
||||
}
|
||||
|
||||
scope := c.Query("scope") // optional filter
|
||||
|
||||
notes, err := services.GetNotes(h.db, analysisID, scope)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if notes == nil {
|
||||
notes = []models.ReviewNote{}
|
||||
}
|
||||
c.JSON(http.StatusOK, notes)
|
||||
}
|
||||
|
||||
// GeneratePDF handles POST /api/repos/:id/review/pdf — stub for Phase 5
|
||||
// GeneratePDF handles POST /api/repos/:id/review/pdf — generate and download a PDF report.
|
||||
func (h *ReviewHandler) GeneratePDF(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "pdf not implemented yet — coming in Phase 5"})
|
||||
id := c.Param("id")
|
||||
|
||||
// Get repo info
|
||||
var repoURL string
|
||||
err := h.db.QueryRow(`SELECT url FROM repositories WHERE id = ?`, 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
|
||||
}
|
||||
|
||||
// Load the analysis result
|
||||
var analysisResult, baseRef, headRef string
|
||||
var createdAt string
|
||||
err = h.db.QueryRow(`SELECT result, base_ref, head_ref, created_at FROM analyses WHERE id = ?`, req.AnalysisID).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
|
||||
filename := fmt.Sprintf("pr-helper-review-%s.pdf", createdAt[:10])
|
||||
c.Header("Content-Type", "application/pdf")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/HoHD/PR-Helper/models"
|
||||
)
|
||||
|
||||
// SaveNote upserts a review note (insert or update if exists for the same analysis_id + scope + scope_key).
|
||||
func SaveNote(db *sql.DB, analysisID int64, scope, scopeKey, content string) (*models.ReviewNote, error) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Try to find existing note
|
||||
var existingID int64
|
||||
err := db.QueryRow(`SELECT id FROM review_notes WHERE analysis_id = ? AND scope = ? AND scope_key = ?`,
|
||||
analysisID, scope, scopeKey).Scan(&existingID)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// Insert new
|
||||
result, err := db.Exec(`INSERT INTO review_notes (analysis_id, scope, scope_key, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
analysisID, scope, scopeKey, content, now, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert note: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
return &models.ReviewNote{
|
||||
ID: id,
|
||||
AnalysisID: analysisID,
|
||||
Scope: scope,
|
||||
ScopeKey: scopeKey,
|
||||
Content: content,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query existing note: %w", err)
|
||||
}
|
||||
|
||||
// Update existing
|
||||
_, err = db.Exec(`UPDATE review_notes SET content = ?, updated_at = ? WHERE id = ?`, content, now, existingID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update note: %w", err)
|
||||
}
|
||||
|
||||
return &models.ReviewNote{
|
||||
ID: existingID,
|
||||
AnalysisID: analysisID,
|
||||
Scope: scope,
|
||||
ScopeKey: scopeKey,
|
||||
Content: content,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetNotes returns all review notes for a given analysis, optionally filtered by scope.
|
||||
func GetNotes(db *sql.DB, analysisID int64, scope string) ([]models.ReviewNote, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if scope != "" {
|
||||
rows, err = db.Query(`SELECT id, analysis_id, scope, scope_key, content, created_at, updated_at FROM review_notes WHERE analysis_id = ? AND scope = ? ORDER BY id`,
|
||||
analysisID, scope)
|
||||
} else {
|
||||
rows, err = db.Query(`SELECT id, analysis_id, scope, scope_key, content, created_at, updated_at FROM review_notes WHERE analysis_id = ? ORDER BY id`,
|
||||
analysisID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query notes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []models.ReviewNote
|
||||
for rows.Next() {
|
||||
var n models.ReviewNote
|
||||
if err := rows.Scan(&n.ID, &n.AnalysisID, &n.Scope, &n.ScopeKey, &n.Content, &n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
notes = append(notes, n)
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
// DeleteNote deletes a specific review note by ID.
|
||||
func DeleteNote(db *sql.DB, noteID int64) error {
|
||||
_, err := db.Exec(`DELETE FROM review_notes WHERE id = ?`, noteID)
|
||||
return err
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
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
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// Note Editor: manages review notes with auto-save and three-level scoping
|
||||
// Scopes: overall (page-level), file (per-file), suggestion (per-suggestion)
|
||||
const NoteEditor = {
|
||||
repoId: null,
|
||||
analysisId: null,
|
||||
_saveTimers: {}, // debounce timers by key
|
||||
_notes: {}, // loaded notes keyed by "scope:scopeKey"
|
||||
|
||||
/**
|
||||
* Initialize the note editor for a review page.
|
||||
* @param {number} repoId - Repository ID
|
||||
* @param {number} analysisId - Analysis ID (from the most recent review)
|
||||
*/
|
||||
init(repoId, analysisId) {
|
||||
this.repoId = repoId;
|
||||
this.analysisId = analysisId;
|
||||
this._notes = {};
|
||||
this._saveTimers = {};
|
||||
|
||||
// Bind existing note editor placeholders (from diff-viewer suggestion cards)
|
||||
this._bindPlaceholders();
|
||||
|
||||
// Watch for new placeholders added dynamically (from SSE streaming)
|
||||
this._observeNewPlaceholders();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load all notes for the current analysis and populate editors.
|
||||
*/
|
||||
async loadNotes() {
|
||||
if (!this.analysisId) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/repos/${this.repoId}/review/notes?analysis_id=${this.analysisId}`);
|
||||
if (!resp.ok) return;
|
||||
const notes = await resp.json();
|
||||
|
||||
// Index notes
|
||||
notes.forEach(n => {
|
||||
const key = n.scope + ':' + (n.scope_key || '');
|
||||
this._notes[key] = n;
|
||||
});
|
||||
|
||||
// Populate all existing editors
|
||||
this._populateAllEditors();
|
||||
} catch (err) {
|
||||
console.error('Failed to load notes:', err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save a note (upsert). Debounced to avoid rapid-fire requests.
|
||||
*/
|
||||
saveNote(scope, scopeKey, content) {
|
||||
const key = scope + ':' + (scopeKey || '');
|
||||
|
||||
// Clear existing timer for this key
|
||||
if (this._saveTimers[key]) {
|
||||
clearTimeout(this._saveTimers[key]);
|
||||
}
|
||||
|
||||
// Debounce: wait 800ms after last keystroke
|
||||
this._saveTimers[key] = setTimeout(async () => {
|
||||
delete this._saveTimers[key];
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/repos/${this.repoId}/review/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
analysis_id: this.analysisId,
|
||||
scope: scope,
|
||||
scope_key: scopeKey || '',
|
||||
content: content,
|
||||
}),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const note = await resp.json();
|
||||
this._notes[key] = note;
|
||||
|
||||
// Update save indicator
|
||||
const indicator = document.querySelector(`[data-note-key="${key}"] .note-save-indicator`);
|
||||
if (indicator) {
|
||||
indicator.textContent = '已保存';
|
||||
indicator.classList.remove('text-gray-400');
|
||||
indicator.classList.add('text-green-500');
|
||||
setTimeout(() => {
|
||||
indicator.textContent = '';
|
||||
indicator.classList.remove('text-green-500');
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save note:', err);
|
||||
}
|
||||
}, 800);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a note editor HTML for a given scope.
|
||||
* @param {string} scope - 'overall', 'file', or 'suggestion'
|
||||
* @param {string} scopeKey - empty for overall, filename for file, suggestionId for suggestion
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
createEditorHTML(scope, scopeKey) {
|
||||
const key = scope + ':' + (scopeKey || '');
|
||||
const existing = this._notes[key];
|
||||
const content = existing ? existing.content : '';
|
||||
const placeholder = this._getPlaceholder(scope);
|
||||
|
||||
return `
|
||||
<div class="note-editor mt-2" data-note-key="${key}" data-scope="${scope}" data-scope-key="${scopeKey || ''}">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-gray-400">💬 备注 (Markdown)</span>
|
||||
<span class="note-save-indicator text-xs"></span>
|
||||
</div>
|
||||
<textarea
|
||||
class="note-textarea"
|
||||
placeholder="${placeholder}"
|
||||
rows="2"
|
||||
oninput="NoteEditor.onInput(this, '${scope}', '${scopeKey || ''}')"
|
||||
>${this._escapeHtml(content)}</textarea>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Render a standalone "overall" note editor section (for the review page).
|
||||
*/
|
||||
renderOverallEditor() {
|
||||
if (!this.analysisId) return '';
|
||||
return `
|
||||
<div id="overall-note-section" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-3">📝 整体备注</h2>
|
||||
${this.createEditorHTML('overall', '')}
|
||||
</div>`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Render a "PDF Export" button.
|
||||
*/
|
||||
renderPDFButton() {
|
||||
return `
|
||||
<button onclick="NoteEditor.exportPDF()" id="btn-export-pdf"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 text-sm">
|
||||
📄 导出 PDF 报告
|
||||
</button>`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger PDF export and download.
|
||||
*/
|
||||
async exportPDF() {
|
||||
if (!this.analysisId) {
|
||||
alert('请先完成审查再导出 PDF');
|
||||
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' },
|
||||
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) {
|
||||
alert('PDF 导出失败: ' + err.message);
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📄 导出 PDF 报告';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────
|
||||
|
||||
_getPlaceholder(scope) {
|
||||
switch (scope) {
|
||||
case 'overall': return '添加整体备注...';
|
||||
case 'file': return '添加文件级备注...';
|
||||
case 'suggestion': return '添加备注...';
|
||||
default: return '添加备注...';
|
||||
}
|
||||
},
|
||||
|
||||
_escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
onInput(textarea, scope, scopeKey) {
|
||||
// Update save indicator to "saving..."
|
||||
const key = scope + ':' + (scopeKey || '');
|
||||
const indicator = textarea.closest('.note-editor')?.querySelector('.note-save-indicator');
|
||||
if (indicator) {
|
||||
indicator.textContent = '保存中...';
|
||||
indicator.classList.remove('text-green-500');
|
||||
indicator.classList.add('text-gray-400');
|
||||
}
|
||||
|
||||
this.saveNote(scope, scopeKey, textarea.value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Bind click/input handlers to existing note-editor-placeholder divs.
|
||||
*/
|
||||
_bindPlaceholders() {
|
||||
document.querySelectorAll('.note-editor-placeholder').forEach(placeholder => {
|
||||
if (placeholder.dataset.initialized) return;
|
||||
placeholder.dataset.initialized = 'true';
|
||||
|
||||
const scope = placeholder.dataset.scope || 'suggestion';
|
||||
const scopeKey = placeholder.dataset.scopeKey || '';
|
||||
|
||||
placeholder.innerHTML = this.createEditorHTML(scope, scopeKey);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Populate all note editors with loaded content.
|
||||
*/
|
||||
_populateAllEditors() {
|
||||
document.querySelectorAll('.note-editor').forEach(editor => {
|
||||
const key = editor.dataset.noteKey;
|
||||
const note = this._notes[key];
|
||||
if (note && note.content) {
|
||||
const textarea = editor.querySelector('.note-textarea');
|
||||
if (textarea && !textarea.value) {
|
||||
textarea.value = note.content;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Observe DOM for new note-editor-placeholder elements (from SSE streaming).
|
||||
*/
|
||||
_observeNewPlaceholders() {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
let hasNew = false;
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType !== 1) continue;
|
||||
if (node.classList?.contains('note-editor-placeholder') ||
|
||||
node.querySelector?.('.note-editor-placeholder')) {
|
||||
hasNew = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasNew) break;
|
||||
}
|
||||
if (hasNew) {
|
||||
this._bindPlaceholders();
|
||||
this._populateAllEditors();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the analysis ID (called after a review completes and analysis is saved).
|
||||
*/
|
||||
setAnalysisId(id) {
|
||||
this.analysisId = id;
|
||||
},
|
||||
};
|
||||
|
||||
window.NoteEditor = NoteEditor;
|
||||
@@ -8,6 +8,7 @@
|
||||
<script src="/static/js/sse.js"></script>
|
||||
<script src="/static/js/diff-viewer.js"></script>
|
||||
<script src="/static/js/review-inline.js"></script>
|
||||
<script src="/static/js/note-editor.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
{{template "nav" .}}
|
||||
@@ -58,6 +59,27 @@
|
||||
<div id="summary-content" class="text-gray-700 prose text-sm"></div>
|
||||
</div>
|
||||
|
||||
<!-- Overall Note Editor -->
|
||||
<div id="overall-note-section" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-3">📝 整体备注</h2>
|
||||
<div class="note-editor" data-note-key="overall:" data-scope="overall" data-scope-key="">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-gray-400">💬 备注 (Markdown)</span>
|
||||
<span class="note-save-indicator text-xs"></span>
|
||||
</div>
|
||||
<textarea class="note-textarea" placeholder="添加整体备注..." rows="2"
|
||||
oninput="NoteEditor.onInput(this, 'overall', '')"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<div id="review-actions" class="flex items-center gap-3 mb-6 hidden">
|
||||
<button onclick="NoteEditor.exportPDF()" id="btn-export-pdf"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 text-sm">
|
||||
📄 导出 PDF 报告
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline Diff + Suggestions View -->
|
||||
<div id="diff-review-container" class="mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
@@ -84,6 +106,7 @@
|
||||
let diffVisible = true;
|
||||
let allSuggestions = [];
|
||||
let fileReviews = {};
|
||||
let currentAnalysisId = null;
|
||||
|
||||
// Load refs on page load
|
||||
async function loadRefs() {
|
||||
@@ -148,8 +171,10 @@
|
||||
// Reset state
|
||||
allSuggestions = [];
|
||||
fileReviews = {};
|
||||
currentAnalysisId = null;
|
||||
document.getElementById('summary-content').innerHTML = '';
|
||||
document.getElementById('file-reviews').innerHTML = '';
|
||||
document.getElementById('review-actions').classList.add('hidden');
|
||||
|
||||
// Progress tracking
|
||||
let totalFiles = 0;
|
||||
@@ -214,6 +239,7 @@
|
||||
setTimeout(() => {
|
||||
progress.classList.add('hidden');
|
||||
results.classList.remove('hidden');
|
||||
document.getElementById('review-actions').classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始审查';
|
||||
}, 500);
|
||||
@@ -225,6 +251,12 @@
|
||||
|
||||
currentSSE = null;
|
||||
},
|
||||
analysis_saved(data) {
|
||||
currentAnalysisId = data.analysis_id;
|
||||
// Initialize note editor with the analysis ID
|
||||
NoteEditor.init(repoId, currentAnalysisId);
|
||||
NoteEditor.loadNotes();
|
||||
},
|
||||
error(data) {
|
||||
alert('审查失败: ' + (data.message || '未知错误'));
|
||||
progress.classList.add('hidden');
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{{define "note-editor"}}
|
||||
<div class="note-editor mt-2" data-note-key="{{.Scope}}:{{.ScopeKey}}" data-scope="{{.Scope}}" data-scope-key="{{.ScopeKey}}">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-gray-400">💬 备注 (Markdown)</span>
|
||||
<span class="note-save-indicator text-xs"></span>
|
||||
</div>
|
||||
<textarea
|
||||
class="note-textarea"
|
||||
placeholder="{{if eq .Scope "overall"}}添加整体备注...{{else if eq .Scope "file"}}添加文件级备注...{{else}}添加备注...{{end}}"
|
||||
rows="2"
|
||||
oninput="NoteEditor.onInput(this, '{{.Scope}}', '{{.ScopeKey}}')"
|
||||
>{{.Content}}</textarea>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,126 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user