Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5414c9c865 | |||
| 58a80cb196 | |||
| e6e4357a28 | |||
| e3d380f9ab |
@@ -15,9 +15,11 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/assets"
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/auth"
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/handler"
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/middleware"
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/model"
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/seed"
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/service"
|
||||
)
|
||||
|
||||
@@ -26,19 +28,52 @@ func main() {
|
||||
|
||||
db := initDB()
|
||||
syncDB(db)
|
||||
seed.Run(db)
|
||||
|
||||
svc := service.NewScriptService(db)
|
||||
authSvc := auth.NewAuthService(db, getJWTKey())
|
||||
|
||||
go cleanupLoop(svc)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery(), middleware.Logger())
|
||||
|
||||
// Public API
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.Use(middleware.RateLimit(60))
|
||||
api.POST("/scripts", middleware.RateLimit(10), handler.CreateScript(svc))
|
||||
api.GET("/scripts/:id", handler.GetScript(svc))
|
||||
api.POST("/scripts/:id/publish", handler.PublishScript(svc))
|
||||
api.DELETE("/scripts/:id", handler.DeleteScript(svc))
|
||||
api.GET("/market", handler.ListMarket(svc))
|
||||
|
||||
// Auth
|
||||
api.POST("/auth/register", handler.Register(authSvc))
|
||||
api.POST("/auth/login", handler.Login(authSvc))
|
||||
|
||||
// Categories (public read)
|
||||
api.GET("/categories", handler.ListCategories(svc))
|
||||
}
|
||||
|
||||
// Authenticated API
|
||||
authGroup := r.Group("/api")
|
||||
authGroup.Use(middleware.JWTAuth(authSvc))
|
||||
{
|
||||
authGroup.GET("/auth/me", handler.GetMe(authSvc))
|
||||
}
|
||||
|
||||
// Admin API
|
||||
adminGroup := r.Group("/api/admin")
|
||||
adminGroup.Use(middleware.JWTAuth(authSvc), middleware.AdminOnly(authSvc))
|
||||
{
|
||||
adminGroup.GET("/categories", handler.ListCategories(svc))
|
||||
adminGroup.POST("/categories", handler.CreateCategory(svc))
|
||||
adminGroup.PUT("/categories/:id", handler.UpdateCategory(svc))
|
||||
adminGroup.DELETE("/categories/:id", handler.DeleteCategory(svc))
|
||||
adminGroup.POST("/categories/:id/variants", handler.CreateVariant(svc))
|
||||
adminGroup.PUT("/variants/:id", handler.UpdateVariant(svc))
|
||||
adminGroup.DELETE("/variants/:id", handler.DeleteVariant(svc))
|
||||
}
|
||||
|
||||
r.GET("/raw/:id", handler.GetRawScript(svc))
|
||||
@@ -75,7 +110,7 @@ func initDB() *gorm.DB {
|
||||
}
|
||||
|
||||
func syncDB(db *gorm.DB) {
|
||||
if err := db.AutoMigrate(&model.Script{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.User{}, &model.RuntimeCategory{}, &model.RuntimeVariant{}, &model.Script{}); err != nil {
|
||||
log.Fatalf("failed to migrate database: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -91,6 +126,14 @@ func cleanupLoop(svc *service.ScriptService) {
|
||||
}
|
||||
}
|
||||
|
||||
func getJWTKey() string {
|
||||
key := os.Getenv("JWT_KEY")
|
||||
if key == "" {
|
||||
key = "scriptforge-dev-key-change-in-production"
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func getPort() string {
|
||||
if p := os.Getenv("PORT"); p != "" {
|
||||
return p
|
||||
|
||||
@@ -4,6 +4,8 @@ go 1.22
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
golang.org/x/crypto v0.23.0
|
||||
gorm.io/driver/sqlite v1.5.6
|
||||
gorm.io/gorm v1.25.11
|
||||
)
|
||||
@@ -32,7 +34,6 @@ require (
|
||||
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
|
||||
|
||||
@@ -25,6 +25,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
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=
|
||||
|
||||
122
backend/internal/auth/auth.go
Normal file
122
backend/internal/auth/auth.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserExists = errors.New("username already exists")
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
db *gorm.DB
|
||||
jwtKey []byte
|
||||
}
|
||||
|
||||
func NewAuthService(db *gorm.DB, jwtKey string) *AuthService {
|
||||
return &AuthService{db: db, jwtKey: []byte(jwtKey)}
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(username, password string) (*model.User, error) {
|
||||
var existing model.User
|
||||
if err := s.db.Where("username = ?", username).First(&existing).Error; err == nil {
|
||||
return nil, ErrUserExists
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// First user becomes admin
|
||||
var userCount int64
|
||||
s.db.Model(&model.User{}).Count(&userCount)
|
||||
role := "user"
|
||||
if userCount == 0 {
|
||||
role = "admin"
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
Username: username,
|
||||
PasswordHash: string(hash),
|
||||
Role: role,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(username, password string) (*model.User, string, error) {
|
||||
var user model.User
|
||||
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
return nil, "", ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, "", ErrInvalidCredentials
|
||||
}
|
||||
|
||||
token, err := s.GenerateToken(user.ID, user.Username, user.Role)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &user, token, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) GenerateToken(userID uint, username, role string) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(s.jwtKey)
|
||||
}
|
||||
|
||||
func (s *AuthService) ParseToken(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
return s.jwtKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid claims")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) GetUserByID(id uint) (*model.User, error) {
|
||||
var user model.User
|
||||
err := s.db.First(&user, id).Error
|
||||
return &user, err
|
||||
}
|
||||
|
||||
func (s *AuthService) IsAdmin(userID uint) bool {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return user.Role == "admin"
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package config
|
||||
|
||||
type RuntimeConfig struct {
|
||||
Name string
|
||||
Extension string
|
||||
MIMEType string
|
||||
}
|
||||
|
||||
var Runtimes = []RuntimeConfig{
|
||||
{Name: "bash", Extension: ".sh", MIMEType: "text/x-shellscript"},
|
||||
{Name: "zsh", Extension: ".zsh", MIMEType: "text/x-shellscript"},
|
||||
{Name: "sh", Extension: ".sh", MIMEType: "text/x-shellscript"},
|
||||
{Name: "fish", Extension: ".fish", MIMEType: "text/x-shellscript"},
|
||||
{Name: "python3", Extension: ".py", MIMEType: "text/x-python"},
|
||||
{Name: "node", Extension: ".js", MIMEType: "text/javascript"},
|
||||
{Name: "ruby", Extension: ".rb", MIMEType: "text/x-ruby"},
|
||||
{Name: "php", Extension: ".php", MIMEType: "text/x-php"},
|
||||
}
|
||||
|
||||
var RuntimeMap map[string]RuntimeConfig
|
||||
|
||||
func init() {
|
||||
RuntimeMap = make(map[string]RuntimeConfig, len(Runtimes))
|
||||
for _, rt := range Runtimes {
|
||||
RuntimeMap[rt.Name] = rt
|
||||
}
|
||||
}
|
||||
|
||||
func IsValidRuntime(name string) bool {
|
||||
_, ok := RuntimeMap[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func GetRuntime(name string) (RuntimeConfig, bool) {
|
||||
rt, ok := RuntimeMap[name]
|
||||
return rt, ok
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/config"
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/service"
|
||||
)
|
||||
|
||||
@@ -25,12 +24,13 @@ func GetRawScript(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
rt, _ := config.GetRuntime(script.Runtime)
|
||||
mime := rt.MIMEType
|
||||
ext := rt.Extension
|
||||
if mime == "" {
|
||||
mime = "text/plain"
|
||||
ext = ".sh"
|
||||
// Use default variant for MIME/extension
|
||||
variant, verr := svc.GetDefaultVariant(script.CategoryID)
|
||||
mime := "text/plain"
|
||||
ext := ".sh"
|
||||
if verr == nil {
|
||||
mime = variant.MIMEType
|
||||
ext = variant.Extension
|
||||
}
|
||||
|
||||
c.Header("Content-Type", mime+"; charset=utf-8")
|
||||
|
||||
@@ -3,17 +3,118 @@ package handler
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/auth"
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/model"
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/service"
|
||||
)
|
||||
|
||||
func getUserID(c *gin.Context) uint {
|
||||
val, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
return 0
|
||||
}
|
||||
switch v := val.(type) {
|
||||
case uint:
|
||||
return v
|
||||
case float64:
|
||||
return uint(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auth handlers ---
|
||||
|
||||
type registerRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=32"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
func Register(authSvc *auth.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req registerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, err := authSvc.Register(req.Username, req.Password)
|
||||
if errors.Is(err, auth.ErrUserExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "username already exists"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
token, _ := authSvc.GenerateToken(user.ID, user.Username, user.Role)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
"token": token,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Login(authSvc *auth.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req registerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, token, err := authSvc.Login(req.Username, req.Password)
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
"token": token,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetMe(authSvc *auth.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
user, err := authSvc.GetUserByID(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Script handlers ---
|
||||
|
||||
type createRequest struct {
|
||||
Title string `json:"title" binding:"required,max=128"`
|
||||
Description string `json:"description" binding:"max=512"`
|
||||
Content string `json:"content" binding:"required,max=16384"`
|
||||
Runtime string `json:"runtime" binding:"required"`
|
||||
CategoryID uint `json:"category_id" binding:"required"`
|
||||
ExpiresIn string `json:"expires_in" binding:"required,oneof=1h 24h 7d 30d"`
|
||||
Publish bool `json:"publish"`
|
||||
}
|
||||
|
||||
func CreateScript(svc *service.ScriptService) gin.HandlerFunc {
|
||||
@@ -29,14 +130,20 @@ func CreateScript(svc *service.ScriptService) gin.HandlerFunc {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
|
||||
result, err := svc.Create(service.CreateInput{
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Content: req.Content,
|
||||
Runtime: req.Runtime,
|
||||
CategoryID: req.CategoryID,
|
||||
ExpiresIn: req.ExpiresIn,
|
||||
Publish: req.Publish,
|
||||
UserID: userID,
|
||||
}, scheme, c.Request.Host)
|
||||
|
||||
if errors.Is(err, service.ErrInvalidRuntime) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime, supported: bash, zsh, sh, fish, python3, node, ruby, php"})
|
||||
if errors.Is(err, service.ErrInvalidCategory) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid category"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -46,10 +153,12 @@ func CreateScript(svc *service.ScriptService) gin.HandlerFunc {
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": result.Script.ID,
|
||||
"title": result.Script.Title,
|
||||
"description": result.Script.Description,
|
||||
"category_id": result.Script.CategoryID,
|
||||
"admin_token": result.AdminToken,
|
||||
"url": result.RawURL,
|
||||
"command": result.Command,
|
||||
"runtime": result.Script.Runtime,
|
||||
"status": result.Script.Status,
|
||||
"expires_at": result.Script.ExpiresAt,
|
||||
})
|
||||
}
|
||||
@@ -69,35 +178,241 @@ func GetScript(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
cat, _ := svc.GetCategoryByID(script.CategoryID)
|
||||
variants, _ := svc.GetVariants(script.CategoryID)
|
||||
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Request.Host, "localhost") || strings.HasPrefix(c.Request.Host, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
fullURL := scheme + "://" + c.Request.Host + "/raw/" + script.ID
|
||||
|
||||
var commands []gin.H
|
||||
for _, v := range variants {
|
||||
cmd := strings.ReplaceAll(v.CommandTemplate, "{url}", fullURL)
|
||||
entry := gin.H{
|
||||
"variant": v.Name,
|
||||
"label": v.Label,
|
||||
"command": cmd,
|
||||
}
|
||||
if v.SourceTemplate != "" {
|
||||
entry["source_command"] = strings.ReplaceAll(v.SourceTemplate, "{url}", fullURL)
|
||||
}
|
||||
commands = append(commands, entry)
|
||||
}
|
||||
|
||||
categoryName := ""
|
||||
categoryLabel := ""
|
||||
categoryIcon := ""
|
||||
if cat != nil {
|
||||
categoryName = cat.Name
|
||||
categoryLabel = cat.Label
|
||||
categoryIcon = cat.Icon
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": script.ID,
|
||||
"runtime": script.Runtime,
|
||||
"title": script.Title,
|
||||
"description": script.Description,
|
||||
"category_id": script.CategoryID,
|
||||
"category_name": categoryName,
|
||||
"category_label": categoryLabel,
|
||||
"category_icon": categoryIcon,
|
||||
"content": script.Content,
|
||||
"content_length": len(script.Content),
|
||||
"created_at": script.CreatedAt,
|
||||
"status": script.Status,
|
||||
"commands": commands,
|
||||
"expires_at": script.ExpiresAt,
|
||||
"published_at": script.PublishedAt,
|
||||
"expired": false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func PublishScript(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token required"})
|
||||
return
|
||||
}
|
||||
|
||||
script, err := svc.Publish(id, token)
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found or invalid token"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrAlreadyPublished) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "already published"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": script.ID, "status": script.Status, "published_at": script.PublishedAt})
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteScript(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token query parameter is required"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := svc.Delete(id, token); errors.Is(err, service.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "script not found or invalid token"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func ListMarket(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
catID, _ := strconv.Atoi(c.DefaultQuery("category_id", "0"))
|
||||
search := c.Query("search")
|
||||
|
||||
result, err := svc.ListMarket(service.MarketQuery{
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
CategoryID: uint(catID),
|
||||
Search: search,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]gin.H, len(result.Items))
|
||||
for i, s := range result.Items {
|
||||
cat, _ := svc.GetCategoryByID(s.CategoryID)
|
||||
items[i] = gin.H{
|
||||
"id": s.ID,
|
||||
"title": s.Title,
|
||||
"description": s.Description,
|
||||
"category_id": s.CategoryID,
|
||||
"category_name": cat.Name,
|
||||
"category_label": cat.Label,
|
||||
"category_icon": cat.Icon,
|
||||
"published_at": s.PublishedAt,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": result.Total,
|
||||
"page": result.Page,
|
||||
"per_page": result.PerPage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Admin: Runtime management ---
|
||||
|
||||
func ListCategories(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cats, err := svc.ListCategories()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"categories": cats})
|
||||
}
|
||||
}
|
||||
|
||||
func CreateCategory(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var cat model.RuntimeCategory
|
||||
if err := c.ShouldBindJSON(&cat); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.CreateCategory(&cat); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"category": cat})
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateCategory(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var cat model.RuntimeCategory
|
||||
cat.ID = uint(id)
|
||||
if err := c.ShouldBindJSON(&cat); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.UpdateCategory(&cat); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"category": cat})
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteCategory(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if err := svc.DeleteCategory(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func CreateVariant(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var v model.RuntimeVariant
|
||||
if err := c.ShouldBindJSON(&v); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.CreateVariant(&v); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"variant": v})
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateVariant(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var v model.RuntimeVariant
|
||||
v.ID = uint(id)
|
||||
if err := c.ShouldBindJSON(&v); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.UpdateVariant(&v); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"variant": v})
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteVariant(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if err := svc.DeleteVariant(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
54
backend/internal/middleware/auth.go
Normal file
54
backend/internal/middleware/auth.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/auth"
|
||||
)
|
||||
|
||||
func JWTAuth(authSvc *auth.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenStr := ""
|
||||
|
||||
// Check Authorization header
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenStr = strings.TrimPrefix(authHeader, "Bearer ")
|
||||
}
|
||||
|
||||
// Also check query param for convenience
|
||||
if tokenStr == "" {
|
||||
tokenStr = c.Query("token")
|
||||
}
|
||||
|
||||
if tokenStr == "" {
|
||||
c.Next() // No token, proceed as anonymous
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authSvc.ParseToken(tokenStr)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AdminOnly(authSvc *auth.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
76
backend/internal/model/models.go
Normal file
76
backend/internal/model/models.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Username string `gorm:"uniqueIndex;size:32;not null"`
|
||||
PasswordHash string `gorm:"size:128;not null"`
|
||||
Email string `gorm:"size:128"`
|
||||
Role string `gorm:"size:16;not null;default:user"`
|
||||
GiteaID int `gorm:"uniqueIndex"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
type RuntimeCategory struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"uniqueIndex;size:32;not null"`
|
||||
Label string `gorm:"size:64;not null"`
|
||||
Icon string `gorm:"size:8"`
|
||||
SortOrder int `gorm:"default:0"`
|
||||
Variants []RuntimeVariant `gorm:"foreignKey:CategoryID"`
|
||||
}
|
||||
|
||||
type RuntimeVariant struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
CategoryID uint `gorm:"not null;index"`
|
||||
Name string `gorm:"size:32;not null"`
|
||||
Label string `gorm:"size:64;not null"`
|
||||
Extension string `gorm:"size:8;not null"`
|
||||
MIMEType string `gorm:"size:64;not null"`
|
||||
CommandTemplate string `gorm:"size:128;not null"`
|
||||
SourceTemplate string `gorm:"size:128"`
|
||||
IsDefault bool `gorm:"default:false"`
|
||||
SortOrder int `gorm:"default:0"`
|
||||
}
|
||||
|
||||
type Script struct {
|
||||
ID string `gorm:"primaryKey;size:8"`
|
||||
Title string `gorm:"size:128;not null"`
|
||||
Description string `gorm:"size:512"`
|
||||
Content string `gorm:"type:text;not null"`
|
||||
CategoryID uint `gorm:"not null;index"`
|
||||
UserID uint `gorm:"index"`
|
||||
AdminToken string `gorm:"size:64;not null"`
|
||||
Status string `gorm:"size:16;not null;default:draft;index"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null;autoCreateTime"`
|
||||
PublishedAt *time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
// Default seed data
|
||||
var DefaultCategories = []RuntimeCategory{
|
||||
{Name: "shell", Label: "Shell 脚本", Icon: "🐚", SortOrder: 1},
|
||||
{Name: "python", Label: "Python", Icon: "🐍", SortOrder: 2},
|
||||
{Name: "javascript", Label: "JavaScript", Icon: "🟨", SortOrder: 3},
|
||||
{Name: "ruby", Label: "Ruby", Icon: "💎", SortOrder: 4},
|
||||
{Name: "php", Label: "PHP", Icon: "🐘", SortOrder: 5},
|
||||
}
|
||||
|
||||
var DefaultVariants = []RuntimeVariant{
|
||||
// Shell variants
|
||||
{CategoryID: 1, Name: "bash", Label: "Bash", Extension: ".sh", MIMEType: "text/x-shellscript", CommandTemplate: "curl {url} | bash", SourceTemplate: "source <(curl {url})", IsDefault: true, SortOrder: 1},
|
||||
{CategoryID: 1, Name: "zsh", Label: "Zsh", Extension: ".zsh", MIMEType: "text/x-shellscript", CommandTemplate: "curl {url} | zsh", SourceTemplate: "source <(curl {url})", SortOrder: 2},
|
||||
{CategoryID: 1, Name: "sh", Label: "Sh", Extension: ".sh", MIMEType: "text/x-shellscript", CommandTemplate: "curl {url} | sh", SourceTemplate: ". <(curl {url})", SortOrder: 3},
|
||||
{CategoryID: 1, Name: "fish", Label: "Fish", Extension: ".fish", MIMEType: "text/x-shellscript", CommandTemplate: "curl {url} | fish", SourceTemplate: "curl {url} | source", SortOrder: 4},
|
||||
// Python variants
|
||||
{CategoryID: 2, Name: "python3", Label: "Python 3", Extension: ".py", MIMEType: "text/x-python", CommandTemplate: "curl {url} | python3", IsDefault: true, SortOrder: 1},
|
||||
{CategoryID: 2, Name: "python", Label: "Python", Extension: ".py", MIMEType: "text/x-python", CommandTemplate: "curl {url} | python", SortOrder: 2},
|
||||
// JavaScript variants
|
||||
{CategoryID: 3, Name: "node", Label: "Node.js", Extension: ".js", MIMEType: "text/javascript", CommandTemplate: "curl {url} | node", IsDefault: true, SortOrder: 1},
|
||||
{CategoryID: 3, Name: "bun", Label: "Bun", Extension: ".js", MIMEType: "text/javascript", CommandTemplate: "curl {url} | bun", SortOrder: 2},
|
||||
// Ruby variants
|
||||
{CategoryID: 4, Name: "ruby", Label: "Ruby", Extension: ".rb", MIMEType: "text/x-ruby", CommandTemplate: "curl {url} | ruby", IsDefault: true, SortOrder: 1},
|
||||
// PHP variants
|
||||
{CategoryID: 5, Name: "php", Label: "PHP", Extension: ".php", MIMEType: "text/x-php", CommandTemplate: "curl {url} | php", IsDefault: true, SortOrder: 1},
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Script struct {
|
||||
ID string `gorm:"primaryKey;size:8"`
|
||||
Content string `gorm:"type:text;not null"`
|
||||
Runtime string `gorm:"size:16;not null;index"`
|
||||
AdminToken string `gorm:"size:64;not null"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null;autoCreateTime"`
|
||||
}
|
||||
21
backend/internal/seed/seed.go
Normal file
21
backend/internal/seed/seed.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package seed
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/model"
|
||||
)
|
||||
|
||||
func Run(db *gorm.DB) {
|
||||
// Seed categories only if table is empty
|
||||
var count int64
|
||||
db.Model(&model.RuntimeCategory{}).Count(&count)
|
||||
if count == 0 {
|
||||
for i, cat := range model.DefaultCategories {
|
||||
cat.ID = uint(i + 1)
|
||||
db.Create(&cat)
|
||||
}
|
||||
for _, v := range model.DefaultVariants {
|
||||
db.Create(&v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,19 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/config"
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/idgen"
|
||||
"gitea.kmux.cn/zhilv/scriptforge/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidRuntime = errors.New("invalid runtime")
|
||||
ErrNotFound = errors.New("script not found or expired")
|
||||
ErrAlreadyPublished = errors.New("script already published")
|
||||
ErrInvalidCategory = errors.New("invalid category")
|
||||
)
|
||||
|
||||
type ScriptService struct {
|
||||
@@ -25,21 +26,25 @@ func NewScriptService(db *gorm.DB) *ScriptService {
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Content string
|
||||
Runtime string
|
||||
CategoryID uint
|
||||
ExpiresIn string
|
||||
Publish bool
|
||||
UserID uint
|
||||
}
|
||||
|
||||
type CreateResult struct {
|
||||
Script model.Script
|
||||
AdminToken string
|
||||
Command string
|
||||
RawURL string
|
||||
}
|
||||
|
||||
func (s *ScriptService) Create(input CreateInput, scheme, host string) (*CreateResult, error) {
|
||||
if !config.IsValidRuntime(input.Runtime) {
|
||||
return nil, ErrInvalidRuntime
|
||||
var cat model.RuntimeCategory
|
||||
if err := s.db.First(&cat, input.CategoryID).Error; err != nil {
|
||||
return nil, ErrInvalidCategory
|
||||
}
|
||||
|
||||
var d time.Duration
|
||||
@@ -66,13 +71,25 @@ func (s *ScriptService) Create(input CreateInput, scheme, host string) (*CreateR
|
||||
return nil, err
|
||||
}
|
||||
|
||||
status := "draft"
|
||||
var publishedAt *time.Time
|
||||
if input.Publish {
|
||||
status = "published"
|
||||
now := time.Now()
|
||||
publishedAt = &now
|
||||
}
|
||||
|
||||
script := model.Script{
|
||||
ID: id,
|
||||
Title: input.Title,
|
||||
Description: input.Description,
|
||||
Content: input.Content,
|
||||
Runtime: input.Runtime,
|
||||
CategoryID: input.CategoryID,
|
||||
UserID: input.UserID,
|
||||
AdminToken: adminToken,
|
||||
Status: status,
|
||||
ExpiresAt: time.Now().Add(d),
|
||||
CreatedAt: time.Now(),
|
||||
PublishedAt: publishedAt,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&script).Error; err != nil {
|
||||
@@ -80,12 +97,10 @@ func (s *ScriptService) Create(input CreateInput, scheme, host string) (*CreateR
|
||||
}
|
||||
|
||||
rawURL := scheme + "://" + host + "/raw/" + id
|
||||
command := "curl " + rawURL + " | " + input.Runtime
|
||||
|
||||
return &CreateResult{
|
||||
Script: script,
|
||||
AdminToken: adminToken,
|
||||
Command: command,
|
||||
RawURL: rawURL,
|
||||
}, nil
|
||||
}
|
||||
@@ -99,6 +114,99 @@ func (s *ScriptService) GetByID(id string) (*model.Script, error) {
|
||||
return &script, err
|
||||
}
|
||||
|
||||
func (s *ScriptService) GetCategoryByID(id uint) (*model.RuntimeCategory, error) {
|
||||
var cat model.RuntimeCategory
|
||||
err := s.db.Preload("Variants").First(&cat, id).Error
|
||||
return &cat, err
|
||||
}
|
||||
|
||||
func (s *ScriptService) GetVariants(categoryID uint) ([]model.RuntimeVariant, error) {
|
||||
var variants []model.RuntimeVariant
|
||||
err := s.db.Where("category_id = ?", categoryID).Order("sort_order ASC").Find(&variants).Error
|
||||
return variants, err
|
||||
}
|
||||
|
||||
func (s *ScriptService) GetDefaultVariant(categoryID uint) (*model.RuntimeVariant, error) {
|
||||
var v model.RuntimeVariant
|
||||
err := s.db.Where("category_id = ? AND is_default = true", categoryID).First(&v).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
err = s.db.Where("category_id = ?", categoryID).Order("sort_order ASC").First(&v).Error
|
||||
}
|
||||
return &v, err
|
||||
}
|
||||
|
||||
func (s *ScriptService) Publish(id, token string) (*model.Script, error) {
|
||||
var script model.Script
|
||||
err := s.db.Where("id = ? AND admin_token = ? AND expires_at > ?", id, token, time.Now()).First(&script).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if script.Status == "published" {
|
||||
return nil, ErrAlreadyPublished
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
script.Status = "published"
|
||||
script.PublishedAt = &now
|
||||
if err := s.db.Save(&script).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &script, nil
|
||||
}
|
||||
|
||||
type MarketQuery struct {
|
||||
Page int
|
||||
PerPage int
|
||||
CategoryID uint
|
||||
Search string
|
||||
}
|
||||
|
||||
type MarketResult struct {
|
||||
Items []model.Script
|
||||
Total int64
|
||||
Page int
|
||||
PerPage int
|
||||
}
|
||||
|
||||
func (s *ScriptService) ListMarket(q MarketQuery) (*MarketResult, error) {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PerPage < 1 || q.PerPage > 50 {
|
||||
q.PerPage = 20
|
||||
}
|
||||
|
||||
query := s.db.Model(&model.Script{}).
|
||||
Where("status = ? AND expires_at > ?", "published", time.Now())
|
||||
|
||||
if q.CategoryID > 0 {
|
||||
query = query.Where("category_id = ?", q.CategoryID)
|
||||
}
|
||||
if q.Search != "" {
|
||||
search := "%" + q.Search + "%"
|
||||
query = query.Where("title LIKE ? OR description LIKE ?", search, search)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var scripts []model.Script
|
||||
err := query.Order("published_at DESC").
|
||||
Offset((q.Page - 1) * q.PerPage).
|
||||
Limit(q.PerPage).
|
||||
Find(&scripts).Error
|
||||
|
||||
return &MarketResult{
|
||||
Items: scripts,
|
||||
Total: total,
|
||||
Page: q.Page,
|
||||
PerPage: q.PerPage,
|
||||
}, err
|
||||
}
|
||||
|
||||
func (s *ScriptService) Delete(id, token string) error {
|
||||
result := s.db.Where("id = ? AND admin_token = ?", id, token).Delete(&model.Script{})
|
||||
if result.RowsAffected == 0 {
|
||||
@@ -111,3 +219,38 @@ func (s *ScriptService) CleanupExpired() (int64, error) {
|
||||
result := s.db.Where("expires_at <= ?", time.Now()).Delete(&model.Script{})
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
func (s *ScriptService) ListCategories() ([]model.RuntimeCategory, error) {
|
||||
var cats []model.RuntimeCategory
|
||||
err := s.db.Preload("Variants").Order("sort_order ASC").Find(&cats).Error
|
||||
return cats, err
|
||||
}
|
||||
|
||||
func (s *ScriptService) CreateCategory(cat *model.RuntimeCategory) error {
|
||||
return s.db.Create(cat).Error
|
||||
}
|
||||
|
||||
func (s *ScriptService) UpdateCategory(cat *model.RuntimeCategory) error {
|
||||
return s.db.Save(cat).Error
|
||||
}
|
||||
|
||||
func (s *ScriptService) DeleteCategory(id uint) error {
|
||||
s.db.Where("category_id = ?", id).Delete(&model.RuntimeVariant{})
|
||||
return s.db.Delete(&model.RuntimeCategory{}, id).Error
|
||||
}
|
||||
|
||||
func (s *ScriptService) CreateVariant(v *model.RuntimeVariant) error {
|
||||
return s.db.Create(v).Error
|
||||
}
|
||||
|
||||
func (s *ScriptService) UpdateVariant(v *model.RuntimeVariant) error {
|
||||
return s.db.Save(v).Error
|
||||
}
|
||||
|
||||
func (s *ScriptService) DeleteVariant(id uint) error {
|
||||
return s.db.Delete(&model.RuntimeVariant{}, id).Error
|
||||
}
|
||||
|
||||
func FormatCommand(template, url string) string {
|
||||
return fmt.Sprintf(template, url)
|
||||
}
|
||||
304
frontend/package-lock.json
generated
304
frontend/package-lock.json
generated
@@ -8,6 +8,19 @@
|
||||
"name": "scriptforge-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.2",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-php": "^6.0.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@codemirror/lint": "^6.9.6",
|
||||
"@codemirror/search": "^6.7.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
@@ -318,6 +331,179 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
|
||||
"integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.3",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.10.3.tgz",
|
||||
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.11",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
||||
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-php": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
|
||||
"integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/php": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-python": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
|
||||
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.3.2",
|
||||
"@codemirror/language": "^6.8.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/python": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.3.tgz",
|
||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/legacy-modes": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz",
|
||||
"integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.6",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.6.tgz",
|
||||
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.7.0.tgz",
|
||||
"integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz",
|
||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/theme-one-dark": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
|
||||
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.0",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.43.0.tgz",
|
||||
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -759,6 +945,91 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.2.tgz",
|
||||
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/@lezer/css/-/css-1.3.3.tgz",
|
||||
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.13",
|
||||
"resolved": "https://registry.npmmirror.com/@lezer/html/-/html-1.3.13.tgz",
|
||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmmirror.com/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/php": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/@lezer/php/-/php-1.0.5.tgz",
|
||||
"integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/python": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmmirror.com/@lezer/python/-/python-1.1.19.tgz",
|
||||
"integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -1471,6 +1742,21 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz",
|
||||
@@ -1488,6 +1774,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -2434,6 +2726,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz",
|
||||
@@ -2711,6 +3009,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.2",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-php": "^6.0.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@codemirror/lint": "^6.9.6",
|
||||
"@codemirror/search": "^6.7.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
|
||||
@@ -2,8 +2,15 @@ import { Routes, Route, Link } from 'react-router-dom'
|
||||
import Home from './pages/Home'
|
||||
import ScriptDetail from './pages/ScriptDetail'
|
||||
import DeleteScript from './pages/DeleteScript'
|
||||
import Market from './pages/Market'
|
||||
import Login from './pages/Login'
|
||||
import Register from './pages/Register'
|
||||
import Admin from './pages/Admin'
|
||||
import { isLoggedIn } from './lib/api'
|
||||
|
||||
export default function App() {
|
||||
const loggedIn = isLoggedIn()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="border-b border-gray-800">
|
||||
@@ -11,15 +18,32 @@ export default function App() {
|
||||
<Link to="/" className="text-xl font-bold tracking-tight hover:text-blue-400 transition-colors">
|
||||
<span className="text-blue-500">⚡</span> ScriptForge
|
||||
</Link>
|
||||
<span className="text-sm text-gray-500">脚本快速转运行链接</span>
|
||||
<nav className="flex gap-4 text-sm">
|
||||
<Link to="/" className="text-gray-400 hover:text-blue-400 transition-colors">市场</Link>
|
||||
<Link to="/create" className="text-gray-400 hover:text-blue-400 transition-colors">创建</Link>
|
||||
{loggedIn ? (
|
||||
<>
|
||||
<Link to="/admin" className="text-gray-400 hover:text-blue-400 transition-colors">管理</Link>
|
||||
<button onClick={() => { localStorage.removeItem('token'); window.location.reload() }} className="text-gray-500 hover:text-red-400 transition-colors cursor-pointer bg-transparent border-none text-sm">
|
||||
退出
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="text-gray-400 hover:text-blue-400 transition-colors">登录</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 max-w-4xl mx-auto px-4 py-8 w-full">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/" element={<Market />} />
|
||||
<Route path="/create" element={<Home />} />
|
||||
<Route path="/s/:id" element={<ScriptDetail />} />
|
||||
<Route path="/s/:id/delete" element={<DeleteScript />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
|
||||
104
frontend/src/components/CodeEditor.tsx
Normal file
104
frontend/src/components/CodeEditor.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine } from '@codemirror/view'
|
||||
import { defaultKeymap, indentWithTab, history, historyKeymap } from '@codemirror/commands'
|
||||
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldGutter, indentOnInput, StreamLanguage } from '@codemirror/language'
|
||||
import { python } from '@codemirror/lang-python'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { php } from '@codemirror/lang-php'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'
|
||||
import { lintKeymap } from '@codemirror/lint'
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
|
||||
|
||||
// @ts-ignore
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell'
|
||||
// @ts-ignore
|
||||
import { ruby } from '@codemirror/legacy-modes/mode/ruby'
|
||||
|
||||
function getLanguageExtension(categoryName: string) {
|
||||
switch (categoryName) {
|
||||
case 'shell':
|
||||
return StreamLanguage.define(shell)
|
||||
case 'python':
|
||||
return python()
|
||||
case 'javascript':
|
||||
return javascript()
|
||||
case 'ruby':
|
||||
return StreamLanguage.define(ruby)
|
||||
case 'php':
|
||||
return php()
|
||||
default:
|
||||
return StreamLanguage.define(shell)
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
categoryName: string
|
||||
}
|
||||
|
||||
export default function CodeEditor({ value, onChange, categoryName }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
highlightActiveLine(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
highlightSelectionMatches(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...lintKeymap,
|
||||
indentWithTab,
|
||||
]),
|
||||
getLanguageExtension(categoryName),
|
||||
oneDark,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onChange(update.state.doc.toString())
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': { fontSize: '14px', height: '100%' },
|
||||
'.cm-scroller': { overflow: 'auto' },
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const view = new EditorView({ state, parent: ref.current })
|
||||
viewRef.current = view
|
||||
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
}, [categoryName])
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
const currentDoc = view.state.doc.toString()
|
||||
if (currentDoc !== value) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: currentDoc.length, insert: value },
|
||||
})
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return <div ref={ref} className="h-full" />
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
command: string
|
||||
label?: string
|
||||
variant?: 'primary' | 'secondary'
|
||||
}
|
||||
|
||||
export default function CommandCard({ command }: Props) {
|
||||
export default function CommandCard({ command, label = '运行命令', variant = 'primary' }: Props) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = () => {
|
||||
@@ -14,18 +16,38 @@ export default function CommandCard({ command }: Props) {
|
||||
})
|
||||
}
|
||||
|
||||
const isPrimary = variant === 'primary'
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-blue-500/30 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-blue-500/10 border-b border-blue-500/20">
|
||||
<span className="text-xs text-blue-400 font-medium">运行命令</span>
|
||||
<div className={`rounded-lg overflow-hidden border ${
|
||||
isPrimary
|
||||
? 'bg-gray-900 border-blue-500/30'
|
||||
: 'bg-gray-900/50 border-gray-700'
|
||||
}`}>
|
||||
<div className={`flex items-center justify-between px-4 py-2 border-b ${
|
||||
isPrimary
|
||||
? 'bg-blue-500/10 border-blue-500/20'
|
||||
: 'bg-gray-800/50 border-gray-700'
|
||||
}`}>
|
||||
<span className={`text-xs font-medium ${
|
||||
isPrimary ? 'text-blue-400' : 'text-gray-400'
|
||||
}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 flex items-center gap-3">
|
||||
<code className="flex-1 text-sm font-mono text-green-400 break-all select-all">
|
||||
<code className={`flex-1 text-sm font-mono break-all select-all ${
|
||||
isPrimary ? 'text-green-400' : 'text-gray-300'
|
||||
}`}>
|
||||
{command}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-xs font-medium transition-colors"
|
||||
className={`shrink-0 px-3 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
isPrimary
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CreateScriptResponse } from '../types'
|
||||
import CommandCard from './CommandCard'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface Props {
|
||||
result: CreateScriptResponse
|
||||
@@ -7,41 +7,40 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function ResultCard({ result, onReset }: Props) {
|
||||
const detailUrl = `${window.location.origin}/s/${result.id}`
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">✅</div>
|
||||
<h2 className="text-xl font-bold">运行链接已生成</h2>
|
||||
<h2 className="text-xl font-bold">
|
||||
{result.status === 'published' ? '脚本已发布' : '草稿已创建'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<CommandCard command={result.command} />
|
||||
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4 space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">标题</span>
|
||||
<span>{result.title}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">脚本 ID</span>
|
||||
<span className="font-mono text-blue-400">{result.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">运行环境</span>
|
||||
<span>{result.runtime}</span>
|
||||
<span className="text-gray-400">运行链接</span>
|
||||
<a href={result.url} target="_blank" rel="noreferrer" className="font-mono text-blue-400 hover:underline break-all text-right max-w-[70%]">
|
||||
{result.url}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">状态</span>
|
||||
<span className={result.status === 'published' ? 'text-green-400' : 'text-gray-400'}>
|
||||
{result.status === 'published' ? '已发布' : '草稿'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">过期时间</span>
|
||||
<span>{new Date(result.expires_at).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">详情页</span>
|
||||
<a
|
||||
href={detailUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-400 hover:underline font-mono text-xs"
|
||||
>
|
||||
{detailUrl}
|
||||
</a>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-gray-700">
|
||||
<div className="text-xs text-gray-500 mb-1">管理令牌(请妥善保存,仅此一次)</div>
|
||||
<div className="font-mono text-xs bg-gray-900 px-3 py-2 rounded break-all select-all">
|
||||
@@ -57,14 +56,12 @@ export default function ResultCard({ result, onReset }: Props) {
|
||||
>
|
||||
创建另一个
|
||||
</button>
|
||||
<a
|
||||
href={detailUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
<Link
|
||||
to={`/s/${result.id}`}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
查看详情
|
||||
</a>
|
||||
查看详情和命令
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { RUNTIME_OPTIONS, EXPIRES_OPTIONS, RuntimeOption, ExpiresIn } from '../types'
|
||||
|
||||
interface Props {
|
||||
onSubmit: (content: string, runtime: RuntimeOption, expiresIn: ExpiresIn) => void
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export default function ScriptForm({ onSubmit, loading }: Props) {
|
||||
const [content, setContent] = useState('')
|
||||
const [runtime, setRuntime] = useState<RuntimeOption>('bash')
|
||||
const [expiresIn, setExpiresIn] = useState<ExpiresIn>('24h')
|
||||
|
||||
const canSubmit = content.trim().length > 0 && content.length <= 16384 && !loading
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">运行环境</label>
|
||||
<select
|
||||
value={runtime}
|
||||
onChange={(e) => setRuntime(e.target.value as RuntimeOption)}
|
||||
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{RUNTIME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">过期时间</label>
|
||||
<select
|
||||
value={expiresIn}
|
||||
onChange={(e) => setExpiresIn(e.target.value as ExpiresIn)}
|
||||
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{EXPIRES_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="在此粘贴你的脚本..."
|
||||
rows={12}
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-sm font-mono focus:outline-none focus:border-blue-500 resize-y"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">
|
||||
{content.length.toLocaleString()} / 16,384 字符
|
||||
{content.length > 16384 && (
|
||||
<span className="text-red-400 ml-1">超出限制!</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => onSubmit(content, runtime, expiresIn)}
|
||||
disabled={!canSubmit}
|
||||
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{loading ? '生成中...' : '生成运行链接'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
interface Props {
|
||||
content: string
|
||||
runtime: string
|
||||
categoryName: string
|
||||
}
|
||||
|
||||
export default function ScriptViewer({ content, runtime }: Props) {
|
||||
const extMap: Record<string, string> = {
|
||||
shell: 'sh',
|
||||
python: 'py',
|
||||
javascript: 'js',
|
||||
ruby: 'rb',
|
||||
php: 'php',
|
||||
}
|
||||
|
||||
export default function ScriptViewer({ content, categoryName }: Props) {
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-gray-800/50 border-b border-gray-700">
|
||||
<span className="text-xs text-gray-400 font-mono">script.{runtime === 'node' ? 'js' : runtime === 'python3' ? 'py' : runtime}</span>
|
||||
<span className="text-xs text-gray-400 font-mono">script.{extMap[categoryName] || categoryName}</span>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(content)}
|
||||
className="text-xs text-gray-500 hover:text-blue-400 transition-colors"
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { CreateScriptResponse, ScriptDetail, RuntimeOption, ExpiresIn } from '../types'
|
||||
|
||||
const BASE = ''
|
||||
import { CreateScriptResponse, ScriptDetail, ExpiresIn, MarketResponse, RuntimeCategory, RuntimeVariant, AuthResponse } from '../types'
|
||||
|
||||
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(BASE + url, {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
@@ -14,13 +12,21 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem('token') || ''
|
||||
}
|
||||
|
||||
export async function createScript(params: {
|
||||
title: string
|
||||
description?: string
|
||||
content: string
|
||||
runtime: RuntimeOption
|
||||
category_id: number
|
||||
expires_in: ExpiresIn
|
||||
publish: boolean
|
||||
}): Promise<CreateScriptResponse> {
|
||||
return request('/api/scripts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getToken()}` },
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
@@ -29,16 +35,119 @@ export async function getScript(id: string): Promise<ScriptDetail> {
|
||||
return request(`/api/scripts/${id}`)
|
||||
}
|
||||
|
||||
export async function deleteScript(id: string, token: string): Promise<void> {
|
||||
await fetch(`${BASE}/api/scripts/${id}?token=${encodeURIComponent(token)}`, {
|
||||
method: 'DELETE',
|
||||
}).then((res) => {
|
||||
if (!res.ok && res.status !== 204) {
|
||||
throw new Error('delete failed')
|
||||
}
|
||||
export async function publishScript(id: string, token: string): Promise<{ id: string; status: string }> {
|
||||
return request(`/api/scripts/${id}/publish?token=${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function getCommandUrl(id: string): string {
|
||||
return `${window.location.origin}/raw/${id}`
|
||||
export async function deleteScript(id: string, token: string): Promise<void> {
|
||||
await fetch(`/api/scripts/${id}?token=${encodeURIComponent(token)}`, {
|
||||
method: 'DELETE',
|
||||
}).then((res) => {
|
||||
if (!res.ok && res.status !== 204) throw new Error('delete failed')
|
||||
})
|
||||
}
|
||||
|
||||
export async function listMarket(params: {
|
||||
page?: number
|
||||
per_page?: number
|
||||
category_id?: number
|
||||
search?: string
|
||||
}): Promise<MarketResponse> {
|
||||
const query = new URLSearchParams()
|
||||
if (params.page) query.set('page', String(params.page))
|
||||
if (params.per_page) query.set('per_page', String(params.per_page))
|
||||
if (params.category_id) query.set('category_id', String(params.category_id))
|
||||
if (params.search) query.set('search', params.search)
|
||||
return request(`/api/market?${query.toString()}`)
|
||||
}
|
||||
|
||||
export async function listCategories(): Promise<RuntimeCategory[]> {
|
||||
const res = await request<{ categories: RuntimeCategory[] }>('/api/categories')
|
||||
return res.categories || []
|
||||
}
|
||||
|
||||
export async function register(username: string, password: string): Promise<AuthResponse> {
|
||||
const res = await request<AuthResponse>('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
localStorage.setItem('token', res.token)
|
||||
return res
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<AuthResponse> {
|
||||
const res = await request<AuthResponse>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
localStorage.setItem('token', res.token)
|
||||
return res
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<{ id: number; username: string; role: string }> {
|
||||
return request('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${getToken()}` },
|
||||
})
|
||||
}
|
||||
|
||||
export function isLoggedIn(): boolean {
|
||||
return !!localStorage.getItem('token')
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
// --- Admin API ---
|
||||
|
||||
export async function adminCreateCategory(cat: Partial<RuntimeCategory>): Promise<RuntimeCategory> {
|
||||
const res = await request<{ category: RuntimeCategory }>('/api/admin/categories', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getToken()}` },
|
||||
body: JSON.stringify(cat),
|
||||
})
|
||||
return res.category
|
||||
}
|
||||
|
||||
export async function adminUpdateCategory(id: number, cat: Partial<RuntimeCategory>): Promise<RuntimeCategory> {
|
||||
const res = await request<{ category: RuntimeCategory }>(`/api/admin/categories/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getToken()}` },
|
||||
body: JSON.stringify(cat),
|
||||
})
|
||||
return res.category
|
||||
}
|
||||
|
||||
export async function adminDeleteCategory(id: number): Promise<void> {
|
||||
await request(`/api/admin/categories/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${getToken()}` },
|
||||
})
|
||||
}
|
||||
|
||||
export async function adminCreateVariant(categoryId: number, variant: Partial<RuntimeVariant>): Promise<RuntimeVariant> {
|
||||
const res = await request<{ variant: RuntimeVariant }>(`/api/admin/categories/${categoryId}/variants`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getToken()}` },
|
||||
body: JSON.stringify(variant),
|
||||
})
|
||||
return res.variant
|
||||
}
|
||||
|
||||
export async function adminUpdateVariant(id: number, variant: Partial<RuntimeVariant>): Promise<RuntimeVariant> {
|
||||
const res = await request<{ variant: RuntimeVariant }>(`/api/admin/variants/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getToken()}` },
|
||||
body: JSON.stringify(variant),
|
||||
})
|
||||
return res.variant
|
||||
}
|
||||
|
||||
export async function adminDeleteVariant(id: number): Promise<void> {
|
||||
await request(`/api/admin/variants/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${getToken()}` },
|
||||
})
|
||||
}
|
||||
247
frontend/src/pages/Admin.tsx
Normal file
247
frontend/src/pages/Admin.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { listCategories, getMe, logout, adminCreateCategory, adminUpdateCategory, adminDeleteCategory, adminCreateVariant, adminUpdateVariant, adminDeleteVariant } from '../lib/api'
|
||||
import { RuntimeCategory, RuntimeVariant } from '../types'
|
||||
|
||||
export default function Admin() {
|
||||
const [categories, setCategories] = useState<RuntimeCategory[]>([])
|
||||
const [user, setUser] = useState<{ id: number; username: string; role: string } | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Category form
|
||||
const [editingCat, setEditingCat] = useState<RuntimeCategory | null>(null)
|
||||
const [catName, setCatName] = useState('')
|
||||
const [catLabel, setCatLabel] = useState('')
|
||||
const [catIcon, setCatIcon] = useState('')
|
||||
const [catOrder, setCatOrder] = useState(0)
|
||||
|
||||
// Variant form
|
||||
const [selectedCatId, setSelectedCatId] = useState<number>(0)
|
||||
const [editingVariant, setEditingVariant] = useState<RuntimeVariant | null>(null)
|
||||
const [vName, setVName] = useState('')
|
||||
const [vLabel, setVLabel] = useState('')
|
||||
const [vExt, setVExt] = useState('')
|
||||
const [vMime, setVMime] = useState('')
|
||||
const [vCmd, setVCmd] = useState('')
|
||||
const [vSrcCmd, setVSrcCmd] = useState('')
|
||||
const [vDefault, setVDefault] = useState(false)
|
||||
const [vOrder, setVOrder] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getMe().catch(() => null),
|
||||
listCategories().catch(() => [] as RuntimeCategory[]),
|
||||
]).then(([u, cats]) => {
|
||||
setUser(u)
|
||||
setCategories(cats)
|
||||
setLoading(false)
|
||||
if (!u || u.role !== 'admin') setError('无权访问管理后台')
|
||||
})
|
||||
}, [])
|
||||
|
||||
const refresh = () => listCategories().then(setCategories)
|
||||
|
||||
const resetCatForm = () => {
|
||||
setEditingCat(null)
|
||||
setCatName('')
|
||||
setCatLabel('')
|
||||
setCatIcon('')
|
||||
setCatOrder(0)
|
||||
}
|
||||
|
||||
const editCat = (cat: RuntimeCategory) => {
|
||||
setEditingCat(cat)
|
||||
setCatName(cat.Name)
|
||||
setCatLabel(cat.Label)
|
||||
setCatIcon(cat.Icon)
|
||||
setCatOrder(cat.SortOrder)
|
||||
}
|
||||
|
||||
const saveCategory = async () => {
|
||||
try {
|
||||
if (editingCat) {
|
||||
await adminUpdateCategory(editingCat.ID, { Name: catName, Label: catLabel, Icon: catIcon, SortOrder: catOrder })
|
||||
} else {
|
||||
await adminCreateCategory({ Name: catName, Label: catLabel, Icon: catIcon, SortOrder: catOrder })
|
||||
}
|
||||
resetCatForm()
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
alert('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCategory = async (id: number) => {
|
||||
if (!confirm('确定删除此分类?相关变体也会被删除。')) return
|
||||
try {
|
||||
await adminDeleteCategory(id)
|
||||
await refresh()
|
||||
} catch { alert('删除失败') }
|
||||
}
|
||||
|
||||
const resetVForm = () => {
|
||||
setEditingVariant(null)
|
||||
setVName('')
|
||||
setVLabel('')
|
||||
setVExt('')
|
||||
setVMime('')
|
||||
setVCmd('')
|
||||
setVSrcCmd('')
|
||||
setVDefault(false)
|
||||
setVOrder(0)
|
||||
}
|
||||
|
||||
const editVariant = (v: RuntimeVariant) => {
|
||||
setEditingVariant(v)
|
||||
setSelectedCatId(v.CategoryID)
|
||||
setVName(v.Name)
|
||||
setVLabel(v.Label)
|
||||
setVExt(v.Extension)
|
||||
setVMime(v.MIMEType)
|
||||
setVCmd(v.CommandTemplate)
|
||||
setVSrcCmd(v.SourceTemplate)
|
||||
setVDefault(v.IsDefault)
|
||||
setVOrder(v.SortOrder)
|
||||
}
|
||||
|
||||
const saveVariant = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
Name: vName,
|
||||
Label: vLabel,
|
||||
Extension: vExt,
|
||||
MIMEType: vMime,
|
||||
CommandTemplate: vCmd,
|
||||
SourceTemplate: vSrcCmd,
|
||||
IsDefault: vDefault,
|
||||
SortOrder: vOrder,
|
||||
}
|
||||
if (editingVariant) {
|
||||
await adminUpdateVariant(editingVariant.ID, payload)
|
||||
} else {
|
||||
await adminCreateVariant(selectedCatId, payload)
|
||||
}
|
||||
resetVForm()
|
||||
await refresh()
|
||||
} catch { alert('操作失败') }
|
||||
}
|
||||
|
||||
const deleteVariant = async (id: number) => {
|
||||
if (!confirm('确定删除此变体?')) return
|
||||
try {
|
||||
await adminDeleteVariant(id)
|
||||
await refresh()
|
||||
} catch { alert('删除失败') }
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-center py-20 text-gray-500 animate-pulse">加载中...</div>
|
||||
|
||||
if (error || !user || user.role !== 'admin') {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<div className="text-6xl mb-4">🔒</div>
|
||||
<h2 className="text-xl font-bold mb-2">无权访问</h2>
|
||||
<p className="text-gray-400 mb-4">{error || '请使用管理员账号登录'}</p>
|
||||
<a href="/login" className="text-blue-400 hover:underline">登录</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedCat = categories.find(c => c.ID === selectedCatId)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">管理后台</h1>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-gray-400">{user.username}</span>
|
||||
<button onClick={() => { logout(); window.location.href = '/' }} className="text-gray-500 hover:text-red-400 transition-colors">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Form */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium mb-3">{editingCat ? '编辑分类' : '新增分类'}</h2>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<input value={catName} onChange={e => setCatName(e.target.value)} placeholder="名称 (shell)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
|
||||
<input value={catLabel} onChange={e => setCatLabel(e.target.value)} placeholder="标签 (Shell)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
|
||||
<input value={catIcon} onChange={e => setCatIcon(e.target.value)} placeholder="图标 (🐚)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
|
||||
<input value={catOrder} onChange={e => setCatOrder(Number(e.target.value))} placeholder="排序" type="number" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button onClick={saveCategory} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-xs">{editingCat ? '更新' : '创建'}</button>
|
||||
{editingCat && <button onClick={resetCatForm} className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs">取消</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories List */}
|
||||
{categories.map(cat => (
|
||||
<div key={cat.ID} className="bg-gray-800/30 border border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{cat.Icon}</span>
|
||||
<span className="font-medium">{cat.Label}</span>
|
||||
<span className="text-xs text-gray-500 font-mono">({cat.Name})</span>
|
||||
<span className="text-xs text-gray-600">排序: {cat.SortOrder}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => editCat(cat)} className="px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded text-xs">编辑</button>
|
||||
<button onClick={() => deleteCategory(cat.ID)} className="px-2 py-1 bg-red-700 hover:bg-red-600 rounded text-xs">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Variants */}
|
||||
<div className="ml-4 space-y-2">
|
||||
{cat.Variants?.map(v => (
|
||||
<div key={v.ID} className="flex items-center justify-between bg-gray-900/50 rounded px-3 py-2">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="font-mono text-blue-400">{v.Name}</span>
|
||||
<span className="text-gray-400">{v.Label}</span>
|
||||
<span className="text-gray-600">.{v.Extension}</span>
|
||||
{v.IsDefault && <span className="text-green-500">默认</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => { setSelectedCatId(cat.ID); editVariant(v) }} className="px-2 py-0.5 bg-gray-700 hover:bg-gray-600 rounded text-xs">编辑</button>
|
||||
<button onClick={() => deleteVariant(v.ID)} className="px-2 py-0.5 bg-red-700 hover:bg-red-600 rounded text-xs">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => { resetVForm(); setSelectedCatId(cat.ID) }} className="text-xs text-blue-400 hover:underline">+ 添加变体</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Variant Form */}
|
||||
{(selectedCatId > 0 || editingVariant) && (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium mb-3">
|
||||
{editingVariant ? '编辑变体' : `为 ${selectedCat?.Label || selectedCatId} 添加变体`}
|
||||
</h2>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<input value={vName} onChange={e => setVName(e.target.value)} placeholder="名称 (bash)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
|
||||
<input value={vLabel} onChange={e => setVLabel(e.target.value)} placeholder="标签 (Bash)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
|
||||
<input value={vExt} onChange={e => setVExt(e.target.value)} placeholder="扩展名 (.sh)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
|
||||
<input value={vMime} onChange={e => setVMime(e.target.value)} placeholder="MIME" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 mt-3">
|
||||
<input value={vCmd} onChange={e => setVCmd(e.target.value)} placeholder="命令模板 (curl {url} | bash)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
|
||||
<input value={vSrcCmd} onChange={e => setVSrcCmd(e.target.value)} placeholder="source模板 (可选)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
|
||||
</div>
|
||||
<div className="flex items-center gap-6 mt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="number" value={vOrder} onChange={e => setVOrder(Number(e.target.value))} placeholder="排序" className="w-20 px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<input type="checkbox" checked={vDefault} onChange={e => setVDefault(e.target.checked)} className="w-3 h-3" />
|
||||
默认变体
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button onClick={saveVariant} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-xs">{editingVariant ? '更新' : '创建'}</button>
|
||||
<button onClick={resetVForm} className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +1,169 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import ScriptForm from '../components/ScriptForm'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import CodeEditor from '../components/CodeEditor'
|
||||
import ResultCard from '../components/ResultCard'
|
||||
import { createScript } from '../lib/api'
|
||||
import { CreateScriptResponse, RuntimeOption, ExpiresIn } from '../types'
|
||||
import { createScript, listCategories } from '../lib/api'
|
||||
import { CreateScriptResponse, ExpiresIn, EXPIRES_OPTIONS, RuntimeCategory } from '../types'
|
||||
|
||||
export default function Home() {
|
||||
const [categories, setCategories] = useState<RuntimeCategory[]>([])
|
||||
const [categoryId, setCategoryId] = useState<number>(0)
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [expiresIn, setExpiresIn] = useState<ExpiresIn>('24h')
|
||||
const [publish, setPublish] = useState(false)
|
||||
const [result, setResult] = useState<CreateScriptResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = useCallback(async (content: string, runtime: RuntimeOption, expiresIn: ExpiresIn) => {
|
||||
useEffect(() => {
|
||||
listCategories().then(setCategories).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const selectedCategory = categories.find(c => c.ID === categoryId)
|
||||
const categoryName = selectedCategory?.Name || 'shell'
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!title.trim() || !content.trim() || !categoryId) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await createScript({ content, runtime, expires_in: expiresIn })
|
||||
const res = await createScript({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
content,
|
||||
category_id: categoryId,
|
||||
expires_in: expiresIn,
|
||||
publish,
|
||||
})
|
||||
setResult(res)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '创建失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [title, description, content, categoryId, expiresIn, publish])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setResult(null)
|
||||
setError(null)
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setContent('')
|
||||
}, [])
|
||||
|
||||
if (result) {
|
||||
return <ResultCard result={result} onReset={handleReset} />
|
||||
}
|
||||
|
||||
const canSubmit = title.trim().length > 0 && content.trim().length > 0 && content.length <= 16384 && categoryId > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!result ? (
|
||||
<>
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-3xl font-bold mb-2">粘贴脚本,生成运行链接</h1>
|
||||
<p className="text-gray-400">
|
||||
选择运行环境,立即生成可分享的 <code className="text-blue-400">curl | bash</code> 命令
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-2">
|
||||
<h1 className="text-2xl font-bold mb-1">创建脚本</h1>
|
||||
<p className="text-gray-400 text-sm">编写脚本,生成可分享的运行命令</p>
|
||||
</div>
|
||||
<ScriptForm onSubmit={handleSubmit} loading={loading} />
|
||||
|
||||
{/* Category + Expires */}
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="block text-xs text-gray-500 mb-1">运行环境 <span className="text-red-400">*</span></label>
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value={0}>选择分类</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.ID} value={cat.ID}>{cat.Icon} {cat.Label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="block text-xs text-gray-500 mb-1">过期时间</label>
|
||||
<select
|
||||
value={expiresIn}
|
||||
onChange={(e) => setExpiresIn(e.target.value as ExpiresIn)}
|
||||
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{EXPIRES_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title + Description */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">标题 <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="给你的脚本起个名字"
|
||||
maxLength={128}
|
||||
className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">描述(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="简述脚本用途"
|
||||
maxLength={512}
|
||||
className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Editor */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
脚本内容 <span className="text-red-400">*</span>
|
||||
<span className="ml-2">{content.length.toLocaleString()} / 16,384</span>
|
||||
{content.length > 16384 && <span className="text-red-400 ml-1">超出限制!</span>}
|
||||
</label>
|
||||
<div className="border border-gray-700 rounded-lg overflow-hidden" style={{ height: '320px' }}>
|
||||
<CodeEditor value={content} onChange={setContent} categoryName={categoryName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Publish toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={publish}
|
||||
onChange={(e) => setPublish(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-gray-800 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm">发布到脚本市场</span>
|
||||
</label>
|
||||
{!publish && (
|
||||
<span className="text-xs text-gray-500">(仅生成链接,不上架市场)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||
<div className="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<ResultCard result={result} onReset={handleReset} />
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || loading}
|
||||
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{loading ? '创建中...' : (publish ? '创建并发布' : '创建草稿')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
frontend/src/pages/Login.tsx
Normal file
59
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { login } from '../lib/api'
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!username.trim() || !password) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await login(username.trim(), password)
|
||||
navigate('/')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-sm mx-auto py-12">
|
||||
<h1 className="text-xl font-bold mb-6 text-center">登录</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="用户名"
|
||||
className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="密码"
|
||||
className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username.trim() || !password}
|
||||
className="w-full px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-gray-500 mt-4">
|
||||
没有账号? <Link to="/register" className="text-blue-400 hover:underline">注册</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
128
frontend/src/pages/Market.tsx
Normal file
128
frontend/src/pages/Market.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { listMarket, listCategories } from '../lib/api'
|
||||
import { MarketItem, RuntimeCategory } from '../types'
|
||||
|
||||
export default function Market() {
|
||||
const [items, setItems] = useState<MarketItem[]>([])
|
||||
const [categories, setCategories] = useState<RuntimeCategory[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [categoryId, setCategoryId] = useState(0)
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
listCategories().then(setCategories).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const fetchMarket = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await listMarket({
|
||||
page,
|
||||
per_page: 20,
|
||||
category_id: categoryId || undefined,
|
||||
search: search || undefined,
|
||||
})
|
||||
setItems(res.items)
|
||||
setTotal(res.total)
|
||||
} catch {
|
||||
setItems([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, categoryId, search])
|
||||
|
||||
useEffect(() => { fetchMarket() }, [fetchMarket])
|
||||
|
||||
const totalPages = Math.ceil(total / 20)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-1">脚本市场</h1>
|
||||
<p className="text-gray-400 text-sm">浏览已发布的脚本,一键获取运行命令</p>
|
||||
</div>
|
||||
|
||||
{/* Search + Filter */}
|
||||
<div className="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
placeholder="搜索脚本..."
|
||||
className="flex-1 px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(e) => { setCategoryId(Number(e.target.value)); setPage(1) }}
|
||||
className="px-3 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value={0}>全部</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.ID} value={cat.ID}>{cat.Icon} {cat.Label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{loading ? (
|
||||
<div className="text-center py-10 text-gray-500 animate-pulse">加载中...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-10">
|
||||
<p className="text-gray-500">暂无脚本</p>
|
||||
<Link to="/create" className="text-blue-400 hover:underline text-sm mt-2 block">创建一个</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={`/s/${item.id}`}
|
||||
className="bg-gray-800/60 border border-gray-700 rounded-lg p-4 hover:border-blue-500/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-sm group-hover:text-blue-400 transition-colors">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 bg-blue-600/20 text-blue-400 text-xs rounded border border-blue-600/30">
|
||||
{item.category_icon} {item.category_label}
|
||||
</span>
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="text-gray-400 text-xs line-clamp-2">{item.description}</p>
|
||||
)}
|
||||
<div className="mt-2 text-xs text-gray-500 font-mono">
|
||||
{item.id}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-900 disabled:text-gray-600 rounded text-sm"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="text-sm text-gray-400 py-1">
|
||||
{page} / {totalPages} ({total} 个脚本)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-900 disabled:text-gray-600 rounded text-sm"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
frontend/src/pages/Register.tsx
Normal file
59
frontend/src/pages/Register.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { register } from '../lib/api'
|
||||
|
||||
export default function Register() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!username.trim() || password.length < 6) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await register(username.trim(), password)
|
||||
navigate('/')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '注册失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-sm mx-auto py-12">
|
||||
<h1 className="text-xl font-bold mb-6 text-center">注册</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="用户名(3-32位)"
|
||||
className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="密码(至少6位)"
|
||||
className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || username.trim().length < 3 || password.length < 6}
|
||||
className="w-full px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-gray-500 mt-4">
|
||||
已有账号? <Link to="/login" className="text-blue-400 hover:underline">登录</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import ScriptViewer from '../components/ScriptViewer'
|
||||
import CommandCard from '../components/CommandCard'
|
||||
import { getScript } from '../lib/api'
|
||||
import { getScript, publishScript } from '../lib/api'
|
||||
import { ScriptDetail as ScriptDetailType } from '../types'
|
||||
|
||||
export default function ScriptDetail() {
|
||||
@@ -10,6 +10,8 @@ export default function ScriptDetail() {
|
||||
const [script, setScript] = useState<ScriptDetailType | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [publishing, setPublishing] = useState(false)
|
||||
const [adminToken, setAdminToken] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
@@ -20,11 +22,7 @@ export default function ScriptDetail() {
|
||||
}, [id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-20">
|
||||
<div className="animate-pulse text-gray-500">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
return <div className="flex justify-center py-20"><div className="animate-pulse text-gray-500">加载中...</div></div>
|
||||
}
|
||||
|
||||
if (error || !script) {
|
||||
@@ -33,44 +31,103 @@ export default function ScriptDetail() {
|
||||
<div className="text-6xl mb-4">😕</div>
|
||||
<h2 className="text-xl font-bold mb-2">脚本不存在或已过期</h2>
|
||||
<p className="text-gray-400 mb-6">{error}</p>
|
||||
<Link to="/" className="text-blue-400 hover:underline">
|
||||
创建新脚本
|
||||
</Link>
|
||||
<Link to="/create" className="text-blue-400 hover:underline">创建新脚本</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const command = `curl ${window.location.origin}/raw/${script.id} | ${script.runtime}`
|
||||
const handlePublish = async () => {
|
||||
if (!adminToken.trim()) return
|
||||
setPublishing(true)
|
||||
try {
|
||||
await publishScript(script.id, adminToken.trim())
|
||||
const updated = await getScript(script.id)
|
||||
setScript(updated)
|
||||
setAdminToken('')
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : '发布失败')
|
||||
} finally {
|
||||
setPublishing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<Link to="/" className="text-sm text-gray-500 hover:text-blue-400 transition-colors">
|
||||
← 返回首页
|
||||
</Link>
|
||||
<Link to="/" className="text-sm text-gray-500 hover:text-blue-400 transition-colors">← 市场</Link>
|
||||
<Link to="/create" className="text-sm text-gray-500 hover:text-blue-400 transition-colors ml-4">创建</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-bold">{script.title}</h1>
|
||||
{script.description && <p className="text-gray-400 text-sm mt-1">{script.description}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<h1 className="text-2xl font-bold font-mono">{script.id}</h1>
|
||||
<span className="font-mono text-blue-400 text-sm">{script.id}</span>
|
||||
<span className="px-2 py-0.5 bg-blue-600/20 text-blue-400 text-xs rounded-full border border-blue-600/30">
|
||||
{script.runtime}
|
||||
{script.category_icon} {script.category_label}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
script.status === 'published'
|
||||
? 'bg-green-600/20 text-green-400 border border-green-600/30'
|
||||
: 'bg-gray-600/20 text-gray-400 border border-gray-600/30'
|
||||
}`}>
|
||||
{script.status === 'published' ? '已发布' : '草稿'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(script.expires_at).toLocaleString('zh-CN')} 过期
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ScriptViewer content={script.content} runtime={script.runtime} />
|
||||
<ScriptViewer content={script.content} categoryName={script.category_name} />
|
||||
|
||||
<div className="mt-8">
|
||||
<CommandCard command={command} />
|
||||
{/* Commands */}
|
||||
<div className="mt-8 space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">运行命令</h3>
|
||||
{script.commands.map((cmd, i) => (
|
||||
<div key={cmd.variant}>
|
||||
<CommandCard
|
||||
command={cmd.command}
|
||||
label={cmd.label}
|
||||
variant={i === 0 ? 'primary' : 'secondary'}
|
||||
/>
|
||||
{cmd.source_command && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-gray-500 mb-1">继承环境变量(source 方式执行)</p>
|
||||
<CommandCard command={cmd.source_command} label={`${cmd.label} (source)`} variant="secondary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<Link
|
||||
to={`/s/${script.id}/delete`}
|
||||
className="text-xs text-gray-600 hover:text-red-400 transition-colors"
|
||||
{/* Publish section for drafts */}
|
||||
{script.status === 'draft' && (
|
||||
<div className="mt-8 bg-gray-800/50 border border-gray-700 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium mb-2">发布到脚本市场</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">输入创建时返回的管理令牌,将草稿发布到市场</p>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={adminToken}
|
||||
onChange={(e) => setAdminToken(e.target.value)}
|
||||
placeholder="管理令牌"
|
||||
className="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-xs font-mono focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={publishing || !adminToken.trim()}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-700 disabled:text-gray-500 rounded-lg text-xs font-medium transition-colors"
|
||||
>
|
||||
{publishing ? '发布中...' : '发布'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<Link to={`/s/${script.id}/delete`} className="text-xs text-gray-600 hover:text-red-400 transition-colors">
|
||||
删除此脚本
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,86 @@
|
||||
export interface CreateScriptResponse {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
category_id: number
|
||||
admin_token: string
|
||||
url: string
|
||||
command: string
|
||||
runtime: string
|
||||
status: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface CommandVariant {
|
||||
variant: string
|
||||
label: string
|
||||
command: string
|
||||
source_command?: string
|
||||
}
|
||||
|
||||
export interface ScriptDetail {
|
||||
id: string
|
||||
runtime: string
|
||||
title: string
|
||||
description: string
|
||||
category_id: number
|
||||
category_name: string
|
||||
category_label: string
|
||||
category_icon: string
|
||||
content: string
|
||||
content_length: number
|
||||
created_at: string
|
||||
status: string
|
||||
commands: CommandVariant[]
|
||||
expires_at: string
|
||||
published_at: string | null
|
||||
expired: boolean
|
||||
}
|
||||
|
||||
export type RuntimeOption = 'bash' | 'zsh' | 'sh' | 'fish' | 'python3' | 'node' | 'ruby' | 'php'
|
||||
export type ExpiresIn = '1h' | '24h' | '7d' | '30d'
|
||||
|
||||
export const RUNTIME_OPTIONS: { value: RuntimeOption; label: string }[] = [
|
||||
{ value: 'bash', label: 'Bash' },
|
||||
{ value: 'zsh', label: 'Zsh' },
|
||||
{ value: 'sh', label: 'Sh' },
|
||||
{ value: 'fish', label: 'Fish' },
|
||||
{ value: 'python3', label: 'Python 3' },
|
||||
{ value: 'node', label: 'Node.js' },
|
||||
{ value: 'ruby', label: 'Ruby' },
|
||||
{ value: 'php', label: 'PHP' },
|
||||
]
|
||||
export interface RuntimeCategory {
|
||||
ID: number
|
||||
Name: string
|
||||
Label: string
|
||||
Icon: string
|
||||
SortOrder: number
|
||||
Variants: RuntimeVariant[]
|
||||
}
|
||||
|
||||
export interface RuntimeVariant {
|
||||
ID: number
|
||||
CategoryID: number
|
||||
Name: string
|
||||
Label: string
|
||||
Extension: string
|
||||
MIMEType: string
|
||||
CommandTemplate: string
|
||||
SourceTemplate: string
|
||||
IsDefault: boolean
|
||||
SortOrder: number
|
||||
}
|
||||
|
||||
export interface MarketItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
category_id: number
|
||||
category_name: string
|
||||
category_label: string
|
||||
category_icon: string
|
||||
published_at: string | null
|
||||
}
|
||||
|
||||
export interface MarketResponse {
|
||||
items: MarketItem[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export const EXPIRES_OPTIONS: { value: ExpiresIn; label: string }[] = [
|
||||
{ value: '1h', label: '1 小时' },
|
||||
|
||||
Reference in New Issue
Block a user