From e6e4357a2827c0bf602e77294022b01207470173 Mon Sep 17 00:00:00 2001 From: zhilv Date: Fri, 29 May 2026 14:04:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=84=9A=E6=9C=AC=E5=88=9B=E4=BD=9C+?= =?UTF-8?q?=E5=8F=91=E5=B8=83+=E5=B8=82=E5=9C=BA=E4=BD=93=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据模型新增: title(必填), description(可选), status(draft/published) - 新增 API: POST /scripts/:id/publish, GET /api/market (搜索+分页+runtime过滤) - 前端首页重构: 选语言 → CodeMirror 编辑器(8种语言语法高亮) → 标题/描述 → 草稿/发布 - 新增 /market 页面: 浏览已发布脚本, 搜索+过滤+分页 - 详情页新增: 发布按钮(草稿→市场), title/description 展示 - Shell 类运行时显示 source 命令(继承环境变量) - backend GetSourceCommand 支持 bash/zsh/sh/fish 四种 shell 格式 Co-Authored-By: Claude Opus 4.7 --- backend/cmd/server/main.go | 2 + backend/internal/config/runtime.go | 21 ++ backend/internal/handler/script.go | 112 +++++++-- backend/internal/model/script.go | 18 +- backend/internal/service/script.go | 112 ++++++++- frontend/package-lock.json | 304 +++++++++++++++++++++++++ frontend/package.json | 13 ++ frontend/src/App.tsx | 9 +- frontend/src/components/CodeEditor.tsx | 105 +++++++++ frontend/src/components/ResultCard.tsx | 37 +-- frontend/src/components/ScriptForm.tsx | 76 ------- frontend/src/lib/api.ts | 33 ++- frontend/src/pages/Home.tsx | 157 +++++++++++-- frontend/src/pages/Market.tsx | 118 ++++++++++ frontend/src/pages/ScriptDetail.tsx | 92 +++++--- frontend/src/types.ts | 25 +- 16 files changed, 1051 insertions(+), 183 deletions(-) create mode 100644 frontend/src/components/CodeEditor.tsx delete mode 100644 frontend/src/components/ScriptForm.tsx create mode 100644 frontend/src/pages/Market.tsx diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 1d8cc84..d0a4af7 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -38,7 +38,9 @@ func main() { 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)) } r.GET("/raw/:id", handler.GetRawScript(svc)) diff --git a/backend/internal/config/runtime.go b/backend/internal/config/runtime.go index c45ca46..1bfbde6 100644 --- a/backend/internal/config/runtime.go +++ b/backend/internal/config/runtime.go @@ -35,3 +35,24 @@ func GetRuntime(name string) (RuntimeConfig, bool) { rt, ok := RuntimeMap[name] return rt, ok } + +func IsShellRuntime(name string) bool { + switch name { + case "bash", "zsh", "sh", "fish": + return true + } + return false +} + +func GetSourceCommand(url, runtime string) string { + switch runtime { + case "bash", "zsh": + return "source <(curl " + url + ")" + case "sh": + return ". <(curl " + url + ")" + case "fish": + return "curl " + url + " | source" + default: + return "" + } +} diff --git a/backend/internal/handler/script.go b/backend/internal/handler/script.go index 8b35283..1f8254e 100644 --- a/backend/internal/handler/script.go +++ b/backend/internal/handler/script.go @@ -3,6 +3,7 @@ package handler import ( "errors" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -11,9 +12,12 @@ import ( ) type createRequest struct { - Content string `json:"content" binding:"required,max=16384"` - Runtime string `json:"runtime" binding:"required"` - ExpiresIn string `json:"expires_in" binding:"required,oneof=1h 24h 7d 30d"` + 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"` + ExpiresIn string `json:"expires_in" binding:"required,oneof=1h 24h 7d 30d"` + Publish bool `json:"publish"` } func CreateScript(svc *service.ScriptService) gin.HandlerFunc { @@ -30,13 +34,16 @@ func CreateScript(svc *service.ScriptService) gin.HandlerFunc { } result, err := svc.Create(service.CreateInput{ - Content: req.Content, - Runtime: req.Runtime, - ExpiresIn: req.ExpiresIn, + Title: req.Title, + Description: req.Description, + Content: req.Content, + Runtime: req.Runtime, + ExpiresIn: req.ExpiresIn, + Publish: req.Publish, }, 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"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime"}) return } if err != nil { @@ -45,12 +52,16 @@ func CreateScript(svc *service.ScriptService) gin.HandlerFunc { } c.JSON(http.StatusCreated, gin.H{ - "id": result.Script.ID, - "admin_token": result.AdminToken, - "url": result.RawURL, - "command": result.Command, - "runtime": result.Script.Runtime, - "expires_at": result.Script.ExpiresAt, + "id": result.Script.ID, + "title": result.Script.Title, + "description": result.Script.Description, + "admin_token": result.AdminToken, + "url": result.RawURL, + "command": result.Command, + "source_command": result.SourceCmd, + "runtime": result.Script.Runtime, + "status": result.Script.Status, + "expires_at": result.Script.ExpiresAt, }) } } @@ -71,16 +82,89 @@ func GetScript(svc *service.ScriptService) gin.HandlerFunc { c.JSON(http.StatusOK, gin.H{ "id": script.ID, + "title": script.Title, + "description": script.Description, "runtime": script.Runtime, "content": script.Content, "content_length": len(script.Content), + "status": script.Status, "created_at": script.CreatedAt, "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 query parameter is required"}) + return + } + + script, err := svc.Publish(id, token) + if errors.Is(err, service.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "script not found or invalid token"}) + return + } + if errors.Is(err, service.ErrAlreadyPublished) { + c.JSON(http.StatusBadRequest, gin.H{"error": "script 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 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")) + runtime := c.Query("runtime") + search := c.Query("search") + + result, err := svc.ListMarket(service.MarketQuery{ + Page: page, + PerPage: perPage, + Runtime: runtime, + 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 { + items[i] = gin.H{ + "id": s.ID, + "title": s.Title, + "description": s.Description, + "runtime": s.Runtime, + "published_at": s.PublishedAt, + } + } + + c.JSON(http.StatusOK, gin.H{ + "items": items, + "total": result.Total, + "page": result.Page, + "per_page": result.PerPage, + }) + } +} + func DeleteScript(svc *service.ScriptService) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") @@ -100,4 +184,4 @@ func DeleteScript(svc *service.ScriptService) gin.HandlerFunc { c.Status(http.StatusNoContent) } -} +} \ No newline at end of file diff --git a/backend/internal/model/script.go b/backend/internal/model/script.go index 2127d27..227abc7 100644 --- a/backend/internal/model/script.go +++ b/backend/internal/model/script.go @@ -3,10 +3,14 @@ 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"` -} + 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"` + Runtime string `gorm:"size:16;not null;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"` +} \ No newline at end of file diff --git a/backend/internal/service/script.go b/backend/internal/service/script.go index 93bd274..b75baf7 100644 --- a/backend/internal/service/script.go +++ b/backend/internal/service/script.go @@ -14,6 +14,7 @@ import ( var ( ErrInvalidRuntime = errors.New("invalid runtime") ErrNotFound = errors.New("script not found or expired") + ErrAlreadyPublished = errors.New("script already published") ) type ScriptService struct { @@ -25,15 +26,19 @@ func NewScriptService(db *gorm.DB) *ScriptService { } type CreateInput struct { - Content string - Runtime string - ExpiresIn string + Title string + Description string + Content string + Runtime string + ExpiresIn string + Publish bool } type CreateResult struct { Script model.Script AdminToken string Command string + SourceCmd string RawURL string } @@ -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, - Content: input.Content, - Runtime: input.Runtime, - AdminToken: adminToken, - ExpiresAt: time.Now().Add(d), - CreatedAt: time.Now(), + ID: id, + Title: input.Title, + Description: input.Description, + Content: input.Content, + Runtime: input.Runtime, + AdminToken: adminToken, + Status: status, + ExpiresAt: time.Now().Add(d), + CreatedAt: time.Now(), + PublishedAt: publishedAt, } if err := s.db.Create(&script).Error; err != nil { @@ -81,11 +98,13 @@ func (s *ScriptService) Create(input CreateInput, scheme, host string) (*CreateR rawURL := scheme + "://" + host + "/raw/" + id command := "curl " + rawURL + " | " + input.Runtime + sourceCmd := config.GetSourceCommand(rawURL, input.Runtime) return &CreateResult{ Script: script, AdminToken: adminToken, Command: command, + SourceCmd: sourceCmd, RawURL: rawURL, }, nil } @@ -99,6 +118,79 @@ func (s *ScriptService) GetByID(id string) (*model.Script, error) { return &script, 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 + Runtime string + 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.Runtime != "" && config.IsValidRuntime(q.Runtime) { + query = query.Where("runtime = ?", q.Runtime) + } + 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 { @@ -110,4 +202,4 @@ func (s *ScriptService) Delete(id, token string) error { func (s *ScriptService) CleanupExpired() (int64, error) { result := s.db.Where("expires_at <= ?", time.Now()).Delete(&model.Script{}) return result.RowsAffected, result.Error -} +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9e5abdd..6862343 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index dfcde35..c52b0f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index db93a57..6674452 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ 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' export default function App() { return ( @@ -11,13 +12,17 @@ export default function App() { ScriptForge - 脚本快速转运行链接 +
} /> + } /> } /> } /> @@ -28,4 +33,4 @@ export default function App() { ) -} +} \ No newline at end of file diff --git a/frontend/src/components/CodeEditor.tsx b/frontend/src/components/CodeEditor.tsx new file mode 100644 index 0000000..13a1d16 --- /dev/null +++ b/frontend/src/components/CodeEditor.tsx @@ -0,0 +1,105 @@ +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' + +// Legacy mode imports - use require-style to avoid TS path issues +// @ts-ignore +import { shell } from '@codemirror/legacy-modes/mode/shell' +// @ts-ignore +import { ruby } from '@codemirror/legacy-modes/mode/ruby' + +function getLanguageExtension(runtime: string) { + switch (runtime) { + case 'bash': case 'zsh': case 'sh': case 'fish': + return StreamLanguage.define(shell) + case 'python3': + return python() + case 'node': + 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 + runtime: string +} + +export default function CodeEditor({ value, onChange, runtime }: Props) { + const ref = useRef(null) + const viewRef = useRef(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(runtime), + 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 + } + }, [runtime]) + + 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
+} \ No newline at end of file diff --git a/frontend/src/components/ResultCard.tsx b/frontend/src/components/ResultCard.tsx index f422ccf..19719fb 100644 --- a/frontend/src/components/ResultCard.tsx +++ b/frontend/src/components/ResultCard.tsx @@ -15,7 +15,9 @@ export default function ResultCard({ result, onReset }: Props) {
-

运行链接已生成

+

+ {result.status === 'published' ? '脚本已发布' : '草稿已创建'} +

@@ -34,6 +36,10 @@ export default function ResultCard({ result, onReset }: Props) { )}
+
+ 标题 + {result.title} +
脚本 ID {result.id} @@ -43,19 +49,14 @@ export default function ResultCard({ result, onReset }: Props) { {result.runtime}
- 过期时间 - {new Date(result.expires_at).toLocaleString('zh-CN')} + 状态 + + {result.status === 'published' ? '已发布' : '草稿'} +
- 详情页 - - {detailUrl} - + 过期时间 + {new Date(result.expires_at).toLocaleString('zh-CN')}
管理令牌(请妥善保存,仅此一次)
@@ -80,7 +81,17 @@ export default function ResultCard({ result, onReset }: Props) { > 查看详情 + {result.status === 'published' && ( + + 查看市场 + + )}
) -} +} \ No newline at end of file diff --git a/frontend/src/components/ScriptForm.tsx b/frontend/src/components/ScriptForm.tsx deleted file mode 100644 index 0394cc8..0000000 --- a/frontend/src/components/ScriptForm.tsx +++ /dev/null @@ -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('bash') - const [expiresIn, setExpiresIn] = useState('24h') - - const canSubmit = content.trim().length > 0 && content.length <= 16384 && !loading - - return ( -
-
-
- - -
- -
- - -
-
- -