feat: Phase 1 — 项目骨架搭建,集成 Gin、SQLite 和设置页面
This commit is contained in:
@@ -25,3 +25,8 @@ go.work.sum
|
||||
# env file
|
||||
.env
|
||||
|
||||
# Project specific
|
||||
pr-helper
|
||||
data/
|
||||
*.db
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
GinMode string
|
||||
DataDir string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
cfg := &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
GinMode: getEnv("GIN_MODE", "debug"),
|
||||
DataDir: getEnv("DATA_DIR", "data"),
|
||||
}
|
||||
// Ensure data directories exist
|
||||
os.MkdirAll(cfg.DataDir, 0o755)
|
||||
os.MkdirAll(filepath.Join(cfg.DataDir, "repos"), 0o755)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (c *Config) DBPath() string {
|
||||
return filepath.Join(c.DataDir, "pr-helper.db")
|
||||
}
|
||||
|
||||
func (c *Config) ReposDir() string {
|
||||
return filepath.Join(c.DataDir, "repos")
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/HoHD/PR-Helper/models"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
func New(dbPath string) (*DB, error) {
|
||||
conn, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db := &DB{conn: conn}
|
||||
if err := db.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.seedDefaults(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (db *DB) Close() error {
|
||||
return db.conn.Close()
|
||||
}
|
||||
|
||||
func (db *DB) Conn() *sql.DB {
|
||||
return db.conn
|
||||
}
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS repositories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
local_path TEXT NOT NULL,
|
||||
size_bytes INTEGER DEFAULT 0,
|
||||
cloned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS analyses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_id INTEGER REFERENCES repositories(id),
|
||||
type TEXT NOT NULL,
|
||||
base_ref TEXT NOT NULL,
|
||||
head_ref TEXT NOT NULL,
|
||||
result TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS review_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
analysis_id INTEGER REFERENCES analyses(id),
|
||||
scope TEXT NOT NULL,
|
||||
scope_key TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.conn.Exec(s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) seedDefaults() error {
|
||||
for key, val := range models.DefaultSettings {
|
||||
_, err := db.conn.Exec(
|
||||
`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, key, val,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("warning: failed to seed setting %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
module github.com/HoHD/PR-Helper
|
||||
|
||||
go 1.22.2
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
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/goccy/go-json v0.10.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // 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
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
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/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
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/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
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/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
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/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=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -0,0 +1,21 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type GenerateHandler struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewGenerateHandler(db *sql.DB) *GenerateHandler {
|
||||
return &GenerateHandler{db: db}
|
||||
}
|
||||
|
||||
// Generate handles POST /api/repos/:id/generate — stub for Phase 4
|
||||
func (h *GenerateHandler) Generate(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "generate not implemented yet — coming in Phase 4"})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/HoHD/PR-Helper/models"
|
||||
)
|
||||
|
||||
type PageHandler struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPageHandler(db *sql.DB) *PageHandler {
|
||||
return &PageHandler{db: db}
|
||||
}
|
||||
|
||||
func (h *PageHandler) Index(c *gin.Context) {
|
||||
repos, _ := h.listRepos()
|
||||
c.HTML(http.StatusOK, "pages/index.html", gin.H{
|
||||
"Repos": repos,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PageHandler) Repo(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "pages/repo.html", gin.H{
|
||||
"ID": c.Param("id"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PageHandler) Generate(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "pages/generate.html", gin.H{
|
||||
"ID": c.Param("id"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PageHandler) Review(c *gin.Context) {
|
||||
topN := "20"
|
||||
h.db.QueryRow(`SELECT value FROM settings WHERE key = 'review.top_n'`).Scan(&topN)
|
||||
c.HTML(http.StatusOK, "pages/review.html", gin.H{
|
||||
"ID": c.Param("id"),
|
||||
"TopN": topN,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PageHandler) Settings(c *gin.Context) {
|
||||
settings := make(map[string]string)
|
||||
for key := range models.DefaultSettings {
|
||||
settings[key] = ""
|
||||
}
|
||||
rows, err := h.db.Query(`SELECT key, value FROM settings`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if rows.Scan(&k, &v) == nil {
|
||||
settings[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
c.HTML(http.StatusOK, "pages/settings.html", gin.H{
|
||||
"Settings": settings,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PageHandler) listRepos() ([]models.Repository, error) {
|
||||
rows, err := h.db.Query(`SELECT id, url, local_path, size_bytes, cloned_at, last_used FROM repositories ORDER BY last_used DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var repos []models.Repository
|
||||
for rows.Next() {
|
||||
var r models.Repository
|
||||
if err := rows.Scan(&r.ID, &r.URL, &r.LocalPath, &r.SizeBytes, &r.ClonedAt, &r.LastUsed); err != nil {
|
||||
continue
|
||||
}
|
||||
repos = append(repos, r)
|
||||
}
|
||||
return repos, nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ReposHandler struct {
|
||||
db *sql.DB
|
||||
reposDir string
|
||||
}
|
||||
|
||||
func NewReposHandler(db *sql.DB, reposDir string) *ReposHandler {
|
||||
return &ReposHandler{db: db, reposDir: reposDir}
|
||||
}
|
||||
|
||||
func (h *ReposHandler) ListRepos(c *gin.Context) {
|
||||
rows, err := h.db.Query(`SELECT id, url, local_path, size_bytes, cloned_at, last_used FROM repositories ORDER BY last_used DESC`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var repos []gin.H
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var url, localPath string
|
||||
var sizeBytes int64
|
||||
var clonedAt, lastUsed string
|
||||
if rows.Scan(&id, &url, &localPath, &sizeBytes, &clonedAt, &lastUsed) == nil {
|
||||
repos = append(repos, gin.H{
|
||||
"id": id, "url": url, "local_path": localPath,
|
||||
"size_bytes": sizeBytes, "cloned_at": clonedAt, "last_used": lastUsed,
|
||||
})
|
||||
}
|
||||
}
|
||||
if repos == nil {
|
||||
repos = []gin.H{}
|
||||
}
|
||||
c.JSON(http.StatusOK, repos)
|
||||
}
|
||||
|
||||
func (h *ReposHandler) DeleteRepo(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var localPath string
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath)
|
||||
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
|
||||
}
|
||||
// Remove from filesystem
|
||||
os.RemoveAll(localPath)
|
||||
// Remove from database
|
||||
h.db.Exec(`DELETE FROM analyses WHERE repo_id = ?`, id)
|
||||
h.db.Exec(`DELETE FROM repositories WHERE id = ?`, id)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *ReposHandler) CleanupRepos(c *gin.Context) {
|
||||
// Get max age from settings
|
||||
var maxAgeDays string
|
||||
h.db.QueryRow(`SELECT value FROM settings WHERE key = 'cache.max_age_days'`).Scan(&maxAgeDays)
|
||||
if maxAgeDays == "" {
|
||||
maxAgeDays = "7"
|
||||
}
|
||||
// Find expired repos
|
||||
rows, err := h.db.Query(`SELECT id, local_path FROM repositories WHERE last_used < datetime('now', '-' || ? || ' days')`, maxAgeDays)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var cleaned []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var localPath string
|
||||
if rows.Scan(&id, &localPath) == nil {
|
||||
os.RemoveAll(localPath)
|
||||
h.db.Exec(`DELETE FROM analyses WHERE repo_id = ?`, id)
|
||||
h.db.Exec(`DELETE FROM repositories WHERE id = ?`, id)
|
||||
cleaned = append(cleaned, id)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"cleaned": cleaned})
|
||||
}
|
||||
|
||||
// CloneRepo handles POST /api/repos — stub for Phase 2
|
||||
func (h *ReposHandler) CloneRepo(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "clone not implemented yet — coming in Phase 2"})
|
||||
}
|
||||
|
||||
// GetGraph handles GET /api/repos/:id/graph — stub for Phase 2
|
||||
func (h *ReposHandler) GetGraph(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "graph not implemented yet — coming in Phase 2"})
|
||||
}
|
||||
|
||||
// GetDiff handles GET /api/repos/:id/diff — stub for Phase 2
|
||||
func (h *ReposHandler) GetDiff(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "diff not implemented yet — coming in Phase 2"})
|
||||
}
|
||||
|
||||
// dirSize returns the total size of a directory in bytes
|
||||
func dirSize(path string) int64 {
|
||||
var size int64
|
||||
filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
size += info.Size()
|
||||
return nil
|
||||
})
|
||||
return size
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ReviewHandler struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewReviewHandler(db *sql.DB) *ReviewHandler {
|
||||
return &ReviewHandler{db: db}
|
||||
}
|
||||
|
||||
// Review handles POST /api/repos/:id/review — stub for Phase 4
|
||||
func (h *ReviewHandler) Review(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "review not implemented yet — coming in Phase 4"})
|
||||
}
|
||||
|
||||
// SaveNotes handles POST /api/repos/:id/review/notes — stub for Phase 5
|
||||
func (h *ReviewHandler) SaveNotes(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "notes not implemented yet — coming in Phase 5"})
|
||||
}
|
||||
|
||||
// GetNotes handles GET /api/repos/:id/review/notes — stub for Phase 5
|
||||
func (h *ReviewHandler) GetNotes(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "notes not implemented yet — coming in Phase 5"})
|
||||
}
|
||||
|
||||
// GeneratePDF handles POST /api/repos/:id/review/pdf — stub for Phase 5
|
||||
func (h *ReviewHandler) GeneratePDF(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "pdf not implemented yet — coming in Phase 5"})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSettingsHandler(db *sql.DB) *SettingsHandler {
|
||||
return &SettingsHandler{db: db}
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
settings := make(map[string]string)
|
||||
rows, err := h.db.Query(`SELECT key, value FROM settings`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if rows.Scan(&k, &v) == nil {
|
||||
settings[k] = v
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
var body map[string]string
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
tx, err := h.db.Begin()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for key, val := range body {
|
||||
_, err := tx.Exec(`INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`, key, val)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/HoHD/PR-Helper/config"
|
||||
"github.com/HoHD/PR-Helper/database"
|
||||
"github.com/HoHD/PR-Helper/handlers"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
db, err := database.New(cfg.DBPath())
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
gin.SetMode(cfg.GinMode)
|
||||
r := gin.Default()
|
||||
|
||||
// Custom template functions
|
||||
funcMap := template.FuncMap{
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"formatSize": func(bytes int64) string {
|
||||
if bytes < 1024 {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
if bytes < 1024*1024 {
|
||||
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
|
||||
}
|
||||
return fmt.Sprintf("%.1f MB", float64(bytes)/1024/1024)
|
||||
},
|
||||
}
|
||||
|
||||
// Load templates: base layout first, then pages and partials
|
||||
tmpl := template.New("").Funcs(funcMap)
|
||||
// Walk all template files and parse them together
|
||||
filepath.Walk("templates", func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".html") {
|
||||
return nil
|
||||
}
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
// Use the relative path as template name (e.g. "layouts/base.html")
|
||||
name := strings.TrimPrefix(path, "templates/")
|
||||
tmpl, _ = tmpl.New(name).Parse(string(data))
|
||||
return nil
|
||||
})
|
||||
r.SetHTMLTemplate(tmpl)
|
||||
|
||||
// Static files
|
||||
r.Static("/static", "./static")
|
||||
|
||||
// Handlers
|
||||
pages := handlers.NewPageHandler(db.Conn())
|
||||
settings := handlers.NewSettingsHandler(db.Conn())
|
||||
repos := handlers.NewReposHandler(db.Conn(), cfg.ReposDir())
|
||||
generate := handlers.NewGenerateHandler(db.Conn())
|
||||
review := handlers.NewReviewHandler(db.Conn())
|
||||
|
||||
// Page routes
|
||||
r.GET("/", pages.Index)
|
||||
r.GET("/repo/:id", pages.Repo)
|
||||
r.GET("/repo/:id/generate", pages.Generate)
|
||||
r.GET("/repo/:id/review", pages.Review)
|
||||
r.GET("/settings", pages.Settings)
|
||||
|
||||
// API routes
|
||||
r.GET("/api/settings", settings.GetSettings)
|
||||
r.PUT("/api/settings", settings.UpdateSettings)
|
||||
r.GET("/api/repos", repos.ListRepos)
|
||||
r.POST("/api/repos", repos.CloneRepo)
|
||||
r.DELETE("/api/repos/:id", repos.DeleteRepo)
|
||||
r.POST("/api/repos/:id/cleanup", repos.CleanupRepos)
|
||||
r.GET("/api/repos/:id/graph", repos.GetGraph)
|
||||
r.GET("/api/repos/:id/diff", repos.GetDiff)
|
||||
r.POST("/api/repos/:id/generate", generate.Generate)
|
||||
r.POST("/api/repos/:id/review", review.Review)
|
||||
r.POST("/api/repos/:id/review/notes", review.SaveNotes)
|
||||
r.GET("/api/repos/:id/review/notes", review.GetNotes)
|
||||
r.POST("/api/repos/:id/review/pdf", review.GeneratePDF)
|
||||
|
||||
log.Printf("PR-Helper starting on :%s", cfg.Port)
|
||||
if err := r.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatalf("failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Analysis struct {
|
||||
ID int64 `json:"id"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Type string `json:"type"` // "pr_description" | "code_review"
|
||||
BaseRef string `json:"base_ref"`
|
||||
HeadRef string `json:"head_ref"`
|
||||
Result string `json:"result"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ReviewNote struct {
|
||||
ID int64 `json:"id"`
|
||||
AnalysisID int64 `json:"analysis_id"`
|
||||
Scope string `json:"scope"` // "overall" | "file" | "suggestion"
|
||||
ScopeKey string `json:"scope_key"` // empty for overall, filename for file, suggestion ID for suggestion
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Repository struct {
|
||||
ID int64 `json:"id"`
|
||||
URL string `json:"url"`
|
||||
LocalPath string `json:"local_path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ClonedAt time.Time `json:"cloned_at"`
|
||||
LastUsed time.Time `json:"last_used"`
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package models
|
||||
|
||||
var DefaultSettings = map[string]string{
|
||||
"llm.endpoint": "https://api.openai.com/v1",
|
||||
"llm.api_key": "",
|
||||
"llm.model": "gpt-4o",
|
||||
"review.top_n": "20",
|
||||
"cache.max_age_days": "7",
|
||||
"cache.max_size_mb": "5000",
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/* PR-Helper Custom Styles */
|
||||
|
||||
/* Prose styling for rendered content */
|
||||
.prose {
|
||||
line-height: 1.7;
|
||||
}
|
||||
.prose h1, .prose h2, .prose h3 {
|
||||
font-weight: 600;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.prose h1 { font-size: 1.5em; }
|
||||
.prose h2 { font-size: 1.25em; }
|
||||
.prose h3 { font-size: 1.1em; }
|
||||
.prose p { margin-bottom: 1em; }
|
||||
.prose ul, .prose ol {
|
||||
margin-left: 1.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.prose li { margin-bottom: 0.25em; }
|
||||
.prose code {
|
||||
background: #f3f4f6;
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.prose pre {
|
||||
background: #1f2937;
|
||||
color: #e5e7eb;
|
||||
padding: 1em;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.prose pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.prose blockquote {
|
||||
border-left: 4px solid #d1d5db;
|
||||
padding-left: 1em;
|
||||
color: #6b7280;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
/* Severity badge colors (used by review) */
|
||||
.border-red-400 { border-color: #f87171; }
|
||||
.border-yellow-400 { border-color: #facc15; }
|
||||
.border-green-400 { border-color: #4ade80; }
|
||||
.bg-red-50 { background-color: #fef2f2; }
|
||||
.bg-yellow-50 { background-color: #fefce8; }
|
||||
.bg-green-50 { background-color: #f0fdf4; }
|
||||
|
||||
/* Loading spinner */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1213
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
|
||||
{{define "head"}}
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PR-Helper</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
</head>
|
||||
{{end}}
|
||||
|
||||
{{define "nav"}}
|
||||
<nav class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-14">
|
||||
<div class="flex items-center space-x-8">
|
||||
<a href="/" class="text-lg font-bold text-indigo-600 hover:text-indigo-700">🔀 PR-Helper</a>
|
||||
<a href="/" class="text-sm text-gray-600 hover:text-gray-900">首页</a>
|
||||
<a href="/settings" class="text-sm text-gray-600 hover:text-gray-900">设置</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
{{define "footer"}}
|
||||
<footer class="bg-white border-t border-gray-200 py-4">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm text-gray-400">
|
||||
PR-Helper — AI-powered PR description & code review
|
||||
</div>
|
||||
</footer>
|
||||
{{end}}
|
||||
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" class="h-full">
|
||||
{{template "head" .}}
|
||||
<body class="h-full bg-gray-50 text-gray-900">
|
||||
<div class="min-h-full flex flex-col">
|
||||
{{template "nav" .}}
|
||||
<main class="flex-1">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold mb-6">生成 PR 描述</h1>
|
||||
<!-- Ref Selection -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">选择范围</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Base (基准)</label>
|
||||
<input type="text" id="base-ref" placeholder="main"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Head (目标)</label>
|
||||
<input type="text" id="head-ref" placeholder="feature/branch"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
<button id="generate-btn" onclick="generatePR()"
|
||||
class="mt-4 bg-green-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-green-700">
|
||||
生成 PR 描述
|
||||
</button>
|
||||
</div>
|
||||
<!-- Output -->
|
||||
<div id="pr-output" class="hidden bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-semibold">PR 描述</h2>
|
||||
<button onclick="copyMarkdown()" class="text-sm text-indigo-600 hover:text-indigo-800">📋 复制 Markdown</button>
|
||||
</div>
|
||||
<div id="pr-title" class="text-xl font-bold mb-2"></div>
|
||||
<div id="pr-type" class="inline-block bg-indigo-100 text-indigo-700 text-xs px-2 py-1 rounded mb-4"></div>
|
||||
<div id="pr-summary" class="text-gray-700 mb-4"></div>
|
||||
<div id="pr-details" class="prose prose-sm max-w-none"></div>
|
||||
<div id="pr-impact" class="mt-4 p-3 bg-yellow-50 rounded text-sm text-yellow-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
</div>
|
||||
<script>
|
||||
async function generatePR() {
|
||||
const base = document.getElementById('base-ref').value.trim();
|
||||
const head = document.getElementById('head-ref').value.trim();
|
||||
if (!base || !head) { alert('请填写 base 和 head'); return; }
|
||||
const btn = document.getElementById('generate-btn');
|
||||
const output = document.getElementById('pr-output');
|
||||
btn.disabled = true; btn.textContent = '生成中...';
|
||||
output.classList.remove('hidden');
|
||||
try {
|
||||
const resp = await fetch('/api/repos/{{.ID}}/generate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({base, head})
|
||||
});
|
||||
if (!resp.ok) { const data = await resp.json(); throw new Error(data.error || '生成失败'); }
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '', eventType = '';
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
|
||||
else if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (eventType === 'title') document.getElementById('pr-title').textContent = data.content;
|
||||
if (eventType === 'type') document.getElementById('pr-type').textContent = data.content;
|
||||
if (eventType === 'summary') document.getElementById('pr-summary').textContent = data.content;
|
||||
if (eventType === 'detail') document.getElementById('pr-details').innerHTML = data.content;
|
||||
if (eventType === 'impact') document.getElementById('pr-impact').textContent = '影响范围: ' + data.content;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('pr-summary').textContent = '错误: ' + err.message;
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '生成 PR 描述';
|
||||
}
|
||||
}
|
||||
function copyMarkdown() {
|
||||
const title = document.getElementById('pr-title').textContent;
|
||||
const type = document.getElementById('pr-type').textContent;
|
||||
const summary = document.getElementById('pr-summary').textContent;
|
||||
const details = document.getElementById('pr-details').innerText;
|
||||
const impact = document.getElementById('pr-impact').textContent;
|
||||
const md = '# ' + title + '\n\n**Type:** ' + type + '\n\n## Summary\n' + summary + '\n\n## Details\n' + details + '\n\n## Impact\n' + impact;
|
||||
navigator.clipboard.writeText(md);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" class="h-full">
|
||||
{{template "head" .}}
|
||||
<body class="h-full bg-gray-50 text-gray-900">
|
||||
<div class="min-h-full flex flex-col">
|
||||
{{template "nav" .}}
|
||||
<main class="flex-1">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Hero -->
|
||||
<div class="text-center mb-10">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-3">PR-Helper</h1>
|
||||
<p class="text-gray-500">输入 Git 仓库 URL,自动生成 PR 描述并进行 AI 代码审查</p>
|
||||
</div>
|
||||
<!-- Clone Form -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<h2 class="text-lg font-semibold mb-4">克隆仓库</h2>
|
||||
<form id="clone-form" class="flex gap-3">
|
||||
<input type="text" id="repo-url" name="url" placeholder="https://github.com/user/repo.git"
|
||||
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" required>
|
||||
<button type="submit" id="clone-btn"
|
||||
class="bg-indigo-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||
克隆
|
||||
</button>
|
||||
</form>
|
||||
<div id="clone-progress" class="hidden mt-4">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-2 border-indigo-600 border-t-transparent"></div>
|
||||
<span id="clone-status" class="text-sm text-gray-600">准备中...</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div id="clone-bar" class="bg-indigo-600 h-2 rounded-full transition-all duration-300" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="clone-error" class="hidden mt-3 text-sm text-red-600"></div>
|
||||
</div>
|
||||
<!-- Cached Repos -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-semibold">已缓存仓库</h2>
|
||||
<button hx-post="/api/repos/cleanup" hx-swap="none"
|
||||
class="text-sm text-gray-500 hover:text-red-600"
|
||||
hx-on::after-request="window.location.reload()">
|
||||
清理过期缓存
|
||||
</button>
|
||||
</div>
|
||||
<div id="repos-list">
|
||||
{{if .Repos}}
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Repos}}
|
||||
<div class="py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<a href="/repo/{{.ID}}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">{{.URL}}</a>
|
||||
<p class="text-xs text-gray-400 mt-1">最后使用: {{.LastUsed}}</p>
|
||||
</div>
|
||||
<button hx-delete="/api/repos/{{.ID}}" hx-confirm="确定删除此仓库缓存?"
|
||||
hx-swap="none" hx-on::after-request="window.location.reload()"
|
||||
class="text-xs text-red-500 hover:text-red-700">删除</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-400 text-center py-6">暂无缓存仓库</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('clone-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const url = document.getElementById('repo-url').value.trim();
|
||||
if (!url) return;
|
||||
const btn = document.getElementById('clone-btn');
|
||||
const progress = document.getElementById('clone-progress');
|
||||
const status = document.getElementById('clone-status');
|
||||
const bar = document.getElementById('clone-bar');
|
||||
const errorEl = document.getElementById('clone-error');
|
||||
btn.disabled = true;
|
||||
progress.classList.remove('hidden');
|
||||
errorEl.classList.add('hidden');
|
||||
bar.style.width = '0%';
|
||||
try {
|
||||
const resp = await fetch('/api/repos', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({url: url})
|
||||
});
|
||||
if (!resp.ok) { const data = await resp.json(); throw new Error(data.error || 'clone failed'); }
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.progress !== undefined) bar.style.width = data.progress + '%';
|
||||
if (data.message) status.textContent = data.message;
|
||||
if (data.done) { window.location.href = '/repo/' + data.repo_id; return; }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" class="h-full">
|
||||
{{template "head" .}}
|
||||
<body class="h-full bg-gray-50 text-gray-900">
|
||||
<div class="min-h-full flex flex-col">
|
||||
{{template "nav" .}}
|
||||
<main class="flex-1">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">仓库详情</h1>
|
||||
<div class="flex gap-3">
|
||||
<a href="/repo/{{.ID}}/generate"
|
||||
class="bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-green-700">
|
||||
生成 PR 描述
|
||||
</a>
|
||||
<a href="/repo/{{.ID}}/review"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700">
|
||||
AI 代码审查
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Repo Info -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<div id="repo-info" class="text-sm text-gray-500">加载中...</div>
|
||||
</div>
|
||||
<!-- Git Graph -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Git Graph</h2>
|
||||
<div id="git-graph" class="overflow-auto" style="min-height: 400px;">
|
||||
<p class="text-sm text-gray-400 text-center py-10">Git Graph 将在 Phase 3 实现</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
</div>
|
||||
<script>
|
||||
fetch('/api/repos').then(r => r.json()).then(repos => {
|
||||
const repo = repos.find(r => r.id == {{.ID}});
|
||||
const el = document.getElementById('repo-info');
|
||||
if (repo) {
|
||||
el.innerHTML = '<p><strong>URL:</strong> ' + repo.url + '</p>' +
|
||||
'<p class="mt-1"><strong>大小:</strong> ' + (repo.size_bytes / 1024 / 1024).toFixed(1) + ' MB</p>' +
|
||||
'<p class="mt-1"><strong>克隆时间:</strong> ' + repo.cloned_at + '</p>';
|
||||
} else {
|
||||
el.innerHTML = '<p class="text-red-500">仓库未找到</p>';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" class="h-full">
|
||||
{{template "head" .}}
|
||||
<body class="h-full bg-gray-50 text-gray-900">
|
||||
<div class="min-h-full flex flex-col">
|
||||
{{template "nav" .}}
|
||||
<main class="flex-1">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold mb-6">AI 代码审查</h1>
|
||||
<!-- Ref Selection + Top-N -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">审查配置</h2>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Base</label>
|
||||
<input type="text" id="base-ref" placeholder="main"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Head</label>
|
||||
<input type="text" id="head-ref" placeholder="feature/branch"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Top-N 文件数</label>
|
||||
<input type="number" id="top-n" value="{{.TopN}}" min="0"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
<p class="text-xs text-gray-400 mt-1">0 = 分析全部文件</p>
|
||||
</div>
|
||||
</div>
|
||||
<button id="review-btn" onclick="startReview()"
|
||||
class="mt-4 bg-blue-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-blue-700">
|
||||
开始审查
|
||||
</button>
|
||||
</div>
|
||||
<!-- Review Output -->
|
||||
<div id="review-output" class="hidden space-y-6">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold mb-3">整体评估</h2>
|
||||
<div id="review-summary" class="prose prose-sm max-w-none text-gray-700"></div>
|
||||
</div>
|
||||
<div id="file-reviews" class="space-y-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
</div>
|
||||
<script>
|
||||
async function startReview() {
|
||||
const base = document.getElementById('base-ref').value.trim();
|
||||
const head = document.getElementById('head-ref').value.trim();
|
||||
const topN = parseInt(document.getElementById('top-n').value) || 0;
|
||||
if (!base || !head) { alert('请填写 base 和 head'); return; }
|
||||
const btn = document.getElementById('review-btn');
|
||||
const output = document.getElementById('review-output');
|
||||
const fileReviews = document.getElementById('file-reviews');
|
||||
btn.disabled = true; btn.textContent = '审查中...';
|
||||
output.classList.remove('hidden');
|
||||
fileReviews.innerHTML = '';
|
||||
document.getElementById('review-summary').innerHTML = '';
|
||||
let currentFileEl = null;
|
||||
try {
|
||||
const resp = await fetch('/api/repos/{{.ID}}/review', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({base, head, top_n: topN})
|
||||
});
|
||||
if (!resp.ok) { const data = await resp.json(); throw new Error(data.error || '审查失败'); }
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '', eventType = '';
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
|
||||
else if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (eventType === 'file_start') {
|
||||
currentFileEl = document.createElement('div');
|
||||
currentFileEl.className = 'bg-white rounded-lg shadow-sm border border-gray-200 p-6';
|
||||
currentFileEl.innerHTML = '<h3 class="font-semibold text-gray-900 mb-3">📄 ' + data.file + '</h3><div class="space-y-2"></div>';
|
||||
fileReviews.appendChild(currentFileEl);
|
||||
}
|
||||
if (eventType === 'suggestion' && currentFileEl) {
|
||||
const colors = {critical: 'red', warning: 'yellow', info: 'green'};
|
||||
const icons = {critical: '🔴', warning: '🟡', info: '🟢'};
|
||||
const c = colors[data.severity] || 'gray';
|
||||
const icon = icons[data.severity] || '⚪';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'border-l-4 border-' + c + '-400 bg-' + c + '-50 px-4 py-2 rounded-r text-sm';
|
||||
div.innerHTML = '<span class="font-medium">' + icon + ' ' + data.severity + '</span><p class="mt-1 text-gray-700">' + data.content + '</p>';
|
||||
currentFileEl.querySelector('.space-y-2').appendChild(div);
|
||||
}
|
||||
if (eventType === 'file_end') currentFileEl = null;
|
||||
if (eventType === 'summary') document.getElementById('review-summary').innerHTML = data.content;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('review-summary').textContent = '错误: ' + err.message;
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '开始审查';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" class="h-full">
|
||||
{{template "head" .}}
|
||||
<body class="h-full bg-gray-50 text-gray-900">
|
||||
<div class="min-h-full flex flex-col">
|
||||
{{template "nav" .}}
|
||||
<main class="flex-1">
|
||||
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold mb-6">设置</h1>
|
||||
<form id="settings-form" class="space-y-6">
|
||||
<!-- LLM Settings -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">LLM 配置</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">API Endpoint</label>
|
||||
<input type="text" name="llm.endpoint" value="{{index .Settings "llm.endpoint"}}"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">API Key</label>
|
||||
<input type="password" name="llm.api_key" value="{{index .Settings "llm.api_key"}}"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="sk-...">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Model</label>
|
||||
<input type="text" name="llm.model" value="{{index .Settings "llm.model"}}"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Review Settings -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">审查配置</h2>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Top-N 文件数</label>
|
||||
<p class="text-xs text-gray-400 mb-2">大 diff 时优先分析变更最大的 N 个文件,设为 0 分析全部</p>
|
||||
<input type="number" name="review.top_n" value="{{index .Settings "review.top_n"}}" min="0"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Cache Settings -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">缓存配置</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">缓存过期天数</label>
|
||||
<input type="number" name="cache.max_age_days" value="{{index .Settings "cache.max_age_days"}}" min="1"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">最大缓存大小 (MB)</label>
|
||||
<input type="number" name="cache.max_size_mb" value="{{index .Settings "cache.max_size_mb"}}" min="100"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Submit -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit"
|
||||
class="bg-indigo-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||
保存设置
|
||||
</button>
|
||||
<span id="save-status" class="text-sm hidden"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('settings-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(this);
|
||||
const data = {};
|
||||
for (const [key, val] of form.entries()) data[key] = val;
|
||||
const status = document.getElementById('save-status');
|
||||
try {
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!resp.ok) throw new Error('保存失败');
|
||||
status.textContent = '✓ 已保存';
|
||||
status.className = 'text-sm text-green-600';
|
||||
} catch (err) {
|
||||
status.textContent = '✗ ' + err.message;
|
||||
status.className = 'text-sm text-red-600';
|
||||
}
|
||||
status.classList.remove('hidden');
|
||||
setTimeout(() => status.classList.add('hidden'), 3000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user