From c755587a4d634dd19830a9009c11032f49d95a54 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 1 Jun 2026 20:31:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端改进: - 新增版本发布状态管理 (draft/published/deprecated/superseded) - 新增发布/废弃/回滚 API 接口 - 新增增量包生成功能 (仅包含差异文件) - 新增删除版本时的依赖检查 - 数据库字段: publish_status, superseded_by, delta_package_* 前端改进: - 版本列表新增发布状态列和增量包大小列 - 新增发布/废弃/回滚操作按钮 - 新增增量包下载功能 - 更新国际化翻译 修复: - 修复 announcements 页面 typeOptions 未定义问题 - 修复 echarts 模块导入类型问题 Co-Authored-By: Claude Opus 4.7 --- backend/internal/model/models.go | 15 +- backend/internal/router/admin/applications.go | 31 +- backend/internal/router/admin/package_util.go | 145 +++++++++- backend/internal/router/admin/versions.go | 273 ++++++++++++++++-- .../src/pages/admin/announcements/index.vue | 2 +- frontend/src/pages/admin/index.vue | 2 +- .../admin/versions/components/columns.ts | 120 ++++++-- .../admin/versions/components/data-table.vue | 8 + .../src/pages/admin/versions/data/schema.ts | 13 +- frontend/src/pages/admin/versions/index.vue | 130 +++++++++ frontend/src/plugins/i18n/en.json | 26 +- frontend/src/plugins/i18n/zh.json | 26 +- frontend/src/types/route-map.d.ts | 75 ++++- 13 files changed, 774 insertions(+), 92 deletions(-) diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index ee2de94..588bc7f 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -234,17 +234,24 @@ type Version struct { FullPackagePath string `gorm:"size:255" json:"full_package_path"` FullPackageSize int64 `json:"full_package_size"` FullPackageHash string `gorm:"size:64" json:"full_package_hash"` + DeltaPackagePath string `gorm:"size:255" json:"delta_package_path"` + DeltaPackageSize int64 `json:"delta_package_size"` + DeltaPackageHash string `gorm:"size:64" json:"delta_package_hash"` Description string `gorm:"type:text" json:"description"` Changelog string `gorm:"type:text" json:"changelog"` - Status string `gorm:"size:20;default:active" json:"status"` + PublishStatus string `gorm:"size:20;default:draft;index" json:"publish_status"` // draft, published, deprecated, superseded + SupersededBy *uint `json:"superseded_by"` + PublishedAt *time.Time `json:"published_at"` + DeprecatedAt *time.Time `json:"deprecated_at"` BaseVersionID *uint `json:"base_version_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` - Files []VersionFile `gorm:"foreignKey:VersionID" json:"files,omitempty"` - BaseVersion *Version `gorm:"foreignKey:BaseVersionID" json:"base_version,omitempty"` + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` + Files []VersionFile `gorm:"foreignKey:VersionID" json:"files,omitempty"` + BaseVersion *Version `gorm:"foreignKey:BaseVersionID" json:"base_version,omitempty"` + Superseder *Version `gorm:"foreignKey:SupersededBy" json:"superseder,omitempty"` } // VersionFile 版本文件模型 diff --git a/backend/internal/router/admin/applications.go b/backend/internal/router/admin/applications.go index 010467a..595ba1e 100644 --- a/backend/internal/router/admin/applications.go +++ b/backend/internal/router/admin/applications.go @@ -903,7 +903,7 @@ func handleCreateVersion(c *gin.Context) { Version: req.Version, Description: req.Description, FilePath: filePath, - Status: "active", + PublishStatus: "draft", FileSize: fileSize, } @@ -952,7 +952,7 @@ func handleUpdateVersion(c *gin.Context) { Version string `json:"version"` Description string `json:"description"` FilePath string `json:"file_path"` - Status string `json:"status"` + PublishStatus string `json:"publish_status"` } if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, 400, "参数错误") @@ -968,7 +968,7 @@ func handleUpdateVersion(c *gin.Context) { version.Version = req.Version version.Description = req.Description version.FilePath = req.FilePath - version.Status = req.Status + version.PublishStatus = req.PublishStatus if err := database.DB.Save(&version).Error; err != nil { response.Error(c, 500, "更新版本失败") @@ -997,31 +997,6 @@ func handleDeleteVersion(c *gin.Context) { response.Success(c, nil) } -func handlePublishVersion(c *gin.Context) { - userID := c.GetUint("user_id") - appID := c.Param("id") - versionID := c.Param("versionId") - - var app model.Application - if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { - response.Error(c, 404, "应用不存在") - return - } - - var version model.Version - if err := database.DB.Where("id = ? AND application_id = ?", versionID, appID).First(&version).Error; err != nil { - response.Error(c, 404, "版本不存在") - return - } - - version.Status = "published" - if err := database.DB.Save(&version).Error; err != nil { - response.Error(c, 500, "发布版本失败") - return - } - - response.Success(c, version) -} func handleManualDeduct(c *gin.Context) { userID := c.GetUint("user_id") diff --git a/backend/internal/router/admin/package_util.go b/backend/internal/router/admin/package_util.go index f59773f..955f028 100644 --- a/backend/internal/router/admin/package_util.go +++ b/backend/internal/router/admin/package_util.go @@ -2,6 +2,8 @@ package admin import ( "archive/zip" + "crypto/sha256" + "encoding/hex" "fmt" "io" "log" @@ -61,6 +63,147 @@ func MergePatchWithFullPackage(fullPackagePath, patchPath string, appID uint) (s return "/" + filepath.ToSlash(newFilePath), fileInfo.Size(), hash, nil } +// GenerateDeltaPackage generates a delta package containing only changed/new files +func GenerateDeltaPackage(basePackagePath, newPackagePath string, appID uint) (string, int64, string, error) { + basePackageFile := filepath.Join(".", basePackagePath) + newPackageFile := filepath.Join(".", newPackagePath) + + // 获取基础包的文件哈希映射 + baseFiles, err := getZipFileHashes(basePackageFile) + if err != nil { + return "", 0, "", fmt.Errorf("read base package failed: %v", err) + } + + // 获取新包的文件哈希映射 + newFiles, err := getZipFileHashes(newPackageFile) + if err != nil { + return "", 0, "", fmt.Errorf("read new package failed: %v", err) + } + + // 找出差异文件(新增或修改) + var deltaFiles []string + for filePath, hash := range newFiles { + if baseHash, exists := baseFiles[filePath]; !exists || baseHash != hash { + deltaFiles = append(deltaFiles, filePath) + } + } + + if len(deltaFiles) == 0 { + log.Printf("[INFO] No delta files found, packages are identical") + return "", 0, "", nil + } + + log.Printf("[INFO] Found %d delta files", len(deltaFiles)) + + // 创建增量包 + timestamp := time.Now().Unix() + deltaFileName := fmt.Sprintf("delta_%d_%d.zip", appID, timestamp) + deltaFilePath := filepath.Join("uploads", "versions", deltaFileName) + + if err := os.MkdirAll(filepath.Dir(deltaFilePath), 0755); err != nil { + return "", 0, "", fmt.Errorf("create dir failed: %v", err) + } + + // 从新包中提取差异文件并打包 + if err := createDeltaZip(newPackageFile, deltaFilePath, deltaFiles); err != nil { + return "", 0, "", fmt.Errorf("create delta package failed: %v", err) + } + + fileInfo, err := os.Stat(deltaFilePath) + if err != nil { + return "", 0, "", fmt.Errorf("stat delta file failed: %v", err) + } + + hash, err := calculateFileHash(deltaFilePath) + if err != nil { + return "", 0, "", fmt.Errorf("calculate hash failed: %v", err) + } + + log.Printf("[INFO] Created delta package: %s, size: %d", deltaFilePath, fileInfo.Size()) + + return "/" + filepath.ToSlash(deltaFilePath), fileInfo.Size(), hash, nil +} + +// getZipFileHashes returns a map of file paths to their SHA256 hashes +func getZipFileHashes(zipPath string) (map[string]string, error) { + r, err := zip.OpenReader(zipPath) + if err != nil { + return nil, err + } + defer r.Close() + + fileHashes := make(map[string]string) + for _, f := range r.File { + if f.FileInfo().IsDir() { + continue + } + + rc, err := f.Open() + if err != nil { + continue + } + + hash := sha256.New() + io.Copy(hash, rc) + rc.Close() + + fileHashes[f.Name] = hex.EncodeToString(hash.Sum(nil)) + } + + return fileHashes, nil +} + +// createDeltaZip creates a zip file containing only the specified files from the source zip +func createDeltaZip(sourceZipPath, deltaZipPath string, deltaFiles []string) error { + r, err := zip.OpenReader(sourceZipPath) + if err != nil { + return err + } + defer r.Close() + + deltaFileSet := make(map[string]bool) + for _, f := range deltaFiles { + deltaFileSet[f] = true + } + + zipFile, err := os.Create(deltaZipPath) + if err != nil { + return err + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + for _, f := range r.File { + if !deltaFileSet[f.Name] { + continue + } + + if f.FileInfo().IsDir() { + continue + } + + writer, err := zipWriter.Create(f.Name) + if err != nil { + return err + } + + rc, err := f.Open() + if err != nil { + return err + } + + _, err = io.Copy(writer, rc) + rc.Close() + if err != nil { + return err + } + } + + return nil +} + func extractZip(zipPath, destDir string) error { r, err := zip.OpenReader(zipPath) if err != nil { @@ -140,4 +283,4 @@ func createZipFromDir(srcDir, zipPath string) error { _, err = io.Copy(writer, file) return err }) -} +} \ No newline at end of file diff --git a/backend/internal/router/admin/versions.go b/backend/internal/router/admin/versions.go index 7aa9e98..1474192 100644 --- a/backend/internal/router/admin/versions.go +++ b/backend/internal/router/admin/versions.go @@ -31,6 +31,9 @@ func SetupVersionRoutes(r *gin.RouterGroup) { versions.PUT("/:id", handleUpdateVersionGlobal) versions.DELETE("/batch", handleBatchDeleteVersions) versions.POST("/upload-zip", handleUploadVersionZip) + versions.POST("/:id/publish", handlePublishVersion) + versions.POST("/:id/deprecate", handleDeprecateVersion) + versions.POST("/:id/rollback", handleRollbackVersion) } } @@ -178,6 +181,7 @@ func handleGetAllVersions(c *gin.Context) { type VersionWithAppName struct { model.Version ApplicationName string `json:"application_name"` + Status string `json:"status"` } result := make([]VersionWithAppName, len(versions)) @@ -185,25 +189,16 @@ func handleGetAllVersions(c *gin.Context) { appName := appNameMap[v.ApplicationID] log.Printf("[DEBUG] Mapping version %d: ApplicationID=%d -> AppName=%s\n", i, v.ApplicationID, appName) - isSuperseded := false - if forcedIDs, ok := appForcedVersionIDs[v.ApplicationID]; ok { - for _, forcedID := range forcedIDs { - if forcedID > v.ID { - isSuperseded = true - break - } - } - } - - if isSuperseded { - v.Status = "superseded" - } else { - v.Status = "active" + // 使用数据库中的 publish_status 作为状态 + status := v.PublishStatus + if status == "" { + status = "draft" } result[i] = VersionWithAppName{ Version: v, ApplicationName: appName, + Status: status, } } @@ -238,15 +233,6 @@ func handleGetVersionByID(c *gin.Context) { return } - computedStatus := "active" - var forcedCount int64 - database.DB.Model(&model.Version{}). - Where("application_id = ? AND id > ? AND update_strategy = ?", version.ApplicationID, version.ID, "forced"). - Count(&forcedCount) - if forcedCount > 0 { - computedStatus = "superseded" - } - response.Success(c, gin.H{ "id": version.ID, "application_id": version.ApplicationID, @@ -261,7 +247,11 @@ func handleGetVersionByID(c *gin.Context) { "update_method": version.UpdateMethod, "custom_download_url": version.CustomDownloadURL, "changelog": version.Changelog, - "status": computedStatus, + "status": version.PublishStatus, + "publish_status": version.PublishStatus, + "superseded_by": version.SupersededBy, + "published_at": version.PublishedAt, + "deprecated_at": version.DeprecatedAt, "base_version_id": version.BaseVersionID, "files": version.Files, "created_at": version.CreatedAt, @@ -323,7 +313,7 @@ func handleCreateVersionGlobal(c *gin.Context) { CustomDownloadURL: req.CustomDownloadURL, Description: req.Description, Changelog: req.Changelog, - Status: "active", + PublishStatus: "draft", } if version.UpdateStrategy == "" { @@ -342,6 +332,32 @@ func handleCreateVersionGlobal(c *gin.Context) { version.FullPackagePath = version.FilePath version.FullPackageSize = version.FileSize version.FullPackageHash = version.FileHash + + // 尝试生成增量包(与前一个版本对比) + var prevVersion model.Version + if err := database.DB.Where("application_id = ? AND update_type = ?", + req.ApplicationID, "full"). + Order("id DESC").First(&prevVersion).Error; err == nil { + // 有前一个版本,生成增量包 + prevFullPackagePath := prevVersion.FullPackagePath + if prevFullPackagePath == "" { + prevFullPackagePath = prevVersion.FilePath + } + + deltaPath, deltaSize, deltaHash, err := GenerateDeltaPackage( + prevFullPackagePath, + version.FilePath, + req.ApplicationID, + ) + if err != nil { + log.Printf("[WARN] Failed to generate delta package: %v\n", err) + } else if deltaPath != "" { + version.DeltaPackagePath = deltaPath + version.DeltaPackageSize = deltaSize + version.DeltaPackageHash = deltaHash + log.Printf("[INFO] Delta package created: %s, size: %d\n", deltaPath, deltaSize) + } + } } else if version.UpdateType == "patch" { // 补丁包:需要找到基础全量包并合并 var lastFullVersion model.Version @@ -571,6 +587,21 @@ func handleBatchDeleteVersions(c *gin.Context) { } } + // 检查是否有其他补丁包依赖这些版本 + var dependentPatches []model.Version + if err := database.DB.Where("base_version_id IN ?", req.IDs).Find(&dependentPatches).Error; err != nil { + response.Error(c, 500, "检查依赖关系失败") + return + } + if len(dependentPatches) > 0 { + dependentIDs := make([]string, len(dependentPatches)) + for i, p := range dependentPatches { + dependentIDs[i] = fmt.Sprintf("v%d.%s", p.ID, p.Version) + } + response.Error(c, 400, fmt.Sprintf("以下补丁包依赖所选版本,无法删除: %s", strings.Join(dependentIDs, ", "))) + return + } + if err := database.DB.Where("id IN ? AND application_id IN ?", req.IDs, appIDs).Delete(&model.Version{}).Error; err != nil { response.Error(c, 500, "批量删除版本失败") return @@ -765,3 +796,195 @@ func getFileType(filename string) string { return "resource" } } + +func handlePublishVersion(c *gin.Context) { + userID := c.GetUint("user_id") + versionID := c.Param("id") + + var userApps []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&userApps).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + appIDs := make([]uint, len(userApps)) + for i, app := range userApps { + appIDs[i] = app.ID + } + + var version model.Version + if err := database.DB.Where("id = ? AND application_id IN ?", versionID, appIDs).First(&version).Error; err != nil { + response.Error(c, 404, "版本不存在") + return + } + + if version.PublishStatus != "draft" && version.PublishStatus != "deprecated" { + response.Error(c, 400, "只能发布草稿或已废弃的版本") + return + } + + now := time.Now() + version.PublishStatus = "published" + version.PublishedAt = &now + + if err := database.DB.Save(&version).Error; err != nil { + response.Error(c, 500, "发布版本失败") + return + } + + // 如果是强制更新,标记旧版本为 superseded + if version.UpdateStrategy == "forced" { + database.DB.Model(&model.Version{}). + Where("application_id = ? AND id < ? AND publish_status = ?", version.ApplicationID, version.ID, "published"). + Updates(map[string]interface{}{ + "publish_status": "superseded", + "superseded_by": version.ID, + "deprecated_at": now, + }) + } + + userIDPtr := &userID + versionIDPtr := &version.ID + service.CreateLog(service.LogParams{ + UserID: userIDPtr, + LogType: "version", + Action: "publish", + Resource: "version", + ResourceID: versionIDPtr, + Details: fmt.Sprintf("发布版本: %s (应用ID: %d)", version.Version, version.ApplicationID), + }) + + response.Success(c, gin.H{ + "id": version.ID, + "publish_status": version.PublishStatus, + "published_at": version.PublishedAt, + }) +} + +func handleDeprecateVersion(c *gin.Context) { + userID := c.GetUint("user_id") + versionID := c.Param("id") + + var userApps []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&userApps).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + appIDs := make([]uint, len(userApps)) + for i, app := range userApps { + appIDs[i] = app.ID + } + + var version model.Version + if err := database.DB.Where("id = ? AND application_id IN ?", versionID, appIDs).First(&version).Error; err != nil { + response.Error(c, 404, "版本不存在") + return + } + + if version.PublishStatus != "published" { + response.Error(c, 400, "只能废弃已发布的版本") + return + } + + now := time.Now() + version.PublishStatus = "deprecated" + version.DeprecatedAt = &now + + if err := database.DB.Save(&version).Error; err != nil { + response.Error(c, 500, "废弃版本失败") + return + } + + // 如果有被该版本取代的版本,恢复它们为 published + database.DB.Model(&model.Version{}). + Where("superseded_by = ?", version.ID). + Updates(map[string]interface{}{ + "publish_status": "published", + "superseded_by": nil, + "deprecated_at": nil, + }) + + userIDPtr := &userID + versionIDPtr := &version.ID + service.CreateLog(service.LogParams{ + UserID: userIDPtr, + LogType: "version", + Action: "deprecate", + Resource: "version", + ResourceID: versionIDPtr, + Details: fmt.Sprintf("废弃版本: %s (应用ID: %d)", version.Version, version.ApplicationID), + }) + + response.Success(c, gin.H{ + "id": version.ID, + "publish_status": version.PublishStatus, + "deprecated_at": version.DeprecatedAt, + }) +} + +func handleRollbackVersion(c *gin.Context) { + userID := c.GetUint("user_id") + versionID := c.Param("id") + + var userApps []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&userApps).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + appIDs := make([]uint, len(userApps)) + for i, app := range userApps { + appIDs[i] = app.ID + } + + var version model.Version + if err := database.DB.Where("id = ? AND application_id IN ?", versionID, appIDs).First(&version).Error; err != nil { + response.Error(c, 404, "版本不存在") + return + } + + // 只能回滚 superseded 状态的版本 + if version.PublishStatus != "superseded" { + response.Error(c, 400, "只能回滚已被取代的版本") + return + } + + now := time.Now() + version.PublishStatus = "published" + version.SupersededBy = nil + version.DeprecatedAt = nil + + if err := database.DB.Save(&version).Error; err != nil { + response.Error(c, 500, "回滚版本失败") + return + } + + // 找到当前发布的版本,将其改为 superseded(如果有的话) + var currentPublished model.Version + err := database.DB.Where("application_id = ? AND publish_status = ? AND id != ?", + version.ApplicationID, "published", version.ID). + Order("id DESC").First(¤tPublished).Error + if err == nil { + currentPublished.PublishStatus = "superseded" + currentPublished.SupersededBy = &version.ID + currentPublished.DeprecatedAt = &now + database.DB.Save(¤tPublished) + } + + userIDPtr := &userID + versionIDPtr := &version.ID + service.CreateLog(service.LogParams{ + UserID: userIDPtr, + LogType: "version", + Action: "rollback", + Resource: "version", + ResourceID: versionIDPtr, + Details: fmt.Sprintf("回滚版本: %s (应用ID: %d)", version.Version, version.ApplicationID), + }) + + response.Success(c, gin.H{ + "id": version.ID, + "publish_status": version.PublishStatus, + }) +} diff --git a/frontend/src/pages/admin/announcements/index.vue b/frontend/src/pages/admin/announcements/index.vue index ed07b5b..961edf8 100644 --- a/frontend/src/pages/admin/announcements/index.vue +++ b/frontend/src/pages/admin/announcements/index.vue @@ -51,7 +51,7 @@ const applicationOptions = computed(() => { })) }) -const methodOptions = computed(() => [ +const typeOptions = computed(() => [ { label: t('admin.announcements.types.info'), value: 'info' }, { label: t('admin.announcements.types.warning'), value: 'warning' }, { label: t('admin.announcements.types.error'), value: 'error' }, diff --git a/frontend/src/pages/admin/index.vue b/frontend/src/pages/admin/index.vue index 3dc6dfd..744f641 100644 --- a/frontend/src/pages/admin/index.vue +++ b/frontend/src/pages/admin/index.vue @@ -243,7 +243,7 @@ async function initMapChart() { fetch('/world.json'), ]) - const echarts = echartsModule.default || echartsModule + const echarts = (echartsModule as any).default || echartsModule if (!mapChart.value) { console.warn('Map chart container was unmounted during initialization') diff --git a/frontend/src/pages/admin/versions/components/columns.ts b/frontend/src/pages/admin/versions/components/columns.ts index 8a48d2a..d3e46da 100644 --- a/frontend/src/pages/admin/versions/components/columns.ts +++ b/frontend/src/pages/admin/versions/components/columns.ts @@ -1,4 +1,4 @@ -import { Download, Edit, Trash2 } from 'lucide-vue-next' +import { Download, Edit, Trash2, Upload, Archive, ArrowDownToLine } from 'lucide-vue-next' import type { ColumnDef } from '@tanstack/vue-table' import { h } from 'vue' @@ -12,6 +12,10 @@ interface ColumnOptions { onDownload: (row: Version) => void onEdit: (row: Version) => void onDelete: (row: Version) => void + onPublish?: (row: Version) => void + onDeprecate?: (row: Version) => void + onRollback?: (row: Version) => void + onDownloadDelta?: (row: Version) => void } function formatFileSize(bytes: number): string { @@ -85,34 +89,37 @@ export function getColumns(options: ColumnOptions, t: (key: string) => string): }, }, { - accessorKey: 'update_method', - header: () => t('admin.versions.columns.method'), - cell: ({ row }) => { - const methodClasses: Record = { - auto: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', - manual: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', - } - const methodLabels: Record = { - auto: t('admin.versions.methods.auto'), - manual: t('admin.versions.methods.manual'), - } - return h(Badge, { class: methodClasses[row.original.update_method] || methodClasses.manual }, () => methodLabels[row.original.update_method] || row.original.update_method) - }, - }, - { - accessorKey: 'status', + accessorKey: 'publish_status', header: () => t('admin.versions.columns.status'), cell: ({ row }) => { - const status = row.original.status + const status = row.original.publish_status || row.original.status || 'draft' const statusClasses: Record = { - active: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + draft: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', + published: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + deprecated: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', superseded: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', } const statusLabels: Record = { - active: t('admin.versions.status.active'), + draft: t('admin.versions.status.draft'), + published: t('admin.versions.status.published'), + deprecated: t('admin.versions.status.deprecated'), superseded: t('admin.versions.status.superseded'), + active: t('admin.versions.status.published'), } - return h(Badge, { class: statusClasses[status] || statusClasses.superseded }, () => statusLabels[status] || status) + return h(Badge, { class: statusClasses[status] || statusClasses.draft }, () => statusLabels[status] || status) + }, + }, + { + accessorKey: 'delta_package', + header: () => t('admin.versions.columns.deltaPackage'), + cell: ({ row }) => { + if (row.original.delta_package_path && row.original.delta_package_size) { + return h('div', { class: 'flex items-center gap-1 text-xs text-muted-foreground' }, [ + h(Archive, { class: 'h-3 w-3' }), + h('span', {}, formatFileSize(row.original.delta_package_size)), + ]) + } + return h('span', { class: 'text-muted-foreground' }, '-') }, }, { @@ -127,33 +134,104 @@ export function getColumns(options: ColumnOptions, t: (key: string) => string): header: () => t('admin.versions.columns.actions'), cell: ({ row }) => { const buttons = [] + + // 下载按钮 if (row.original.file_path) { buttons.push( h(Button, { variant: 'ghost', size: 'sm', + title: t('admin.versions.actions.download'), onClick: () => options.onDownload(row.original), }, () => [ h(Download, { class: 'h-4 w-4' }), ]), ) } + + // 增量包下载 + if (row.original.delta_package_path && options.onDownloadDelta) { + buttons.push( + h(Button, { + variant: 'ghost', + size: 'sm', + title: t('admin.versions.actions.downloadDelta'), + onClick: () => options.onDownloadDelta?.(row.original), + }, () => [ + h(ArrowDownToLine, { class: 'h-4 w-4 text-orange-500' }), + ]), + ) + } + + // 编辑按钮 buttons.push( h(Button, { variant: 'ghost', size: 'sm', + title: t('admin.versions.actions.edit'), onClick: () => options.onEdit(row.original), }, () => [ h(Edit, { class: 'h-4 w-4' }), ]), + ) + + // 发布状态操作按钮 + const publishStatus = row.original.publish_status || row.original.status || 'draft' + + if (publishStatus === 'draft' && options.onPublish) { + buttons.push( + h(Button, { + variant: 'ghost', + size: 'sm', + title: t('admin.versions.actions.publish'), + class: 'text-green-600 hover:text-green-700', + onClick: () => options.onPublish?.(row.original), + }, () => [ + h(Upload, { class: 'h-4 w-4' }), + ]), + ) + } + + if (publishStatus === 'published' && options.onDeprecate) { + buttons.push( + h(Button, { + variant: 'ghost', + size: 'sm', + title: t('admin.versions.actions.deprecate'), + class: 'text-orange-600 hover:text-orange-700', + onClick: () => options.onDeprecate?.(row.original), + }, () => [ + h(Archive, { class: 'h-4 w-4' }), + ]), + ) + } + + if (publishStatus === 'superseded' && options.onRollback) { + buttons.push( + h(Button, { + variant: 'ghost', + size: 'sm', + title: t('admin.versions.actions.rollback'), + class: 'text-blue-600 hover:text-blue-700', + onClick: () => options.onRollback?.(row.original), + }, () => [ + h(ArrowDownToLine, { class: 'h-4 w-4' }), + ]), + ) + } + + // 删除按钮 + buttons.push( h(Button, { variant: 'ghost', size: 'sm', + title: t('admin.versions.actions.delete'), onClick: () => options.onDelete(row.original), }, () => [ h(Trash2, { class: 'h-4 w-4 text-destructive' }), ]), ) + return h('div', { class: 'flex items-center justify-end gap-1' }, buttons) }, }, diff --git a/frontend/src/pages/admin/versions/components/data-table.vue b/frontend/src/pages/admin/versions/components/data-table.vue index cfed32c..8e84eb4 100644 --- a/frontend/src/pages/admin/versions/components/data-table.vue +++ b/frontend/src/pages/admin/versions/components/data-table.vue @@ -19,6 +19,10 @@ const props = defineProps, 'columns'> & { onDownload: (row: Version) => void onEdit: (row: Version) => void onDelete: (row: Version) => void + onPublish?: (row: Version) => void + onDeprecate?: (row: Version) => void + onRollback?: (row: Version) => void + onDownloadDelta?: (row: Version) => void searchFilter?: string }>() @@ -38,6 +42,10 @@ const columns = computed(() => [ onDownload: props.onDownload, onEdit: props.onEdit, onDelete: props.onDelete, + onPublish: props.onPublish, + onDeprecate: props.onDeprecate, + onRollback: props.onRollback, + onDownloadDelta: props.onDownloadDelta, }, t), ]) diff --git a/frontend/src/pages/admin/versions/data/schema.ts b/frontend/src/pages/admin/versions/data/schema.ts index 3a0b8de..ae003b3 100644 --- a/frontend/src/pages/admin/versions/data/schema.ts +++ b/frontend/src/pages/admin/versions/data/schema.ts @@ -3,7 +3,7 @@ import { z } from 'zod' export const updateStrategySchema = z.enum(['optional', 'forced']) export const updateTypeSchema = z.enum(['full', 'patch']) export const updateMethodSchema = z.enum(['auto', 'manual']) -export const versionStatusSchema = z.enum(['active', 'superseded']) +export const publishStatusSchema = z.enum(['draft', 'published', 'deprecated', 'superseded']) export const versionSchema = z.object({ id: z.number(), @@ -13,11 +13,20 @@ export const versionSchema = z.object({ description: z.string(), file_path: z.string(), file_size: z.number(), + file_hash: z.string().optional(), update_strategy: updateStrategySchema, update_type: updateTypeSchema, update_method: updateMethodSchema, - status: versionStatusSchema, + status: z.string(), // 兼容旧字段 + publish_status: publishStatusSchema, + superseded_by: z.number().nullable().optional(), + published_at: z.string().nullable().optional(), + deprecated_at: z.string().nullable().optional(), changelog: z.string(), + delta_package_path: z.string().optional(), + delta_package_size: z.number().optional(), + full_package_path: z.string().optional(), + full_package_size: z.number().optional(), created_at: z.string(), updated_at: z.string(), }) diff --git a/frontend/src/pages/admin/versions/index.vue b/frontend/src/pages/admin/versions/index.vue index 33f668f..acd799c 100644 --- a/frontend/src/pages/admin/versions/index.vue +++ b/frontend/src/pages/admin/versions/index.vue @@ -45,6 +45,12 @@ const batchDeleteIds = ref([]) const exportDialogOpen = ref(false) const exportTarget = ref(null) const batchExportDialogOpen = ref(false) +const publishDialogOpen = ref(false) +const publishTarget = ref(null) +const deprecateDialogOpen = ref(false) +const deprecateTarget = ref(null) +const rollbackDialogOpen = ref(false) +const rollbackTarget = ref(null) const applicationOptions = computed(() => { return applications.value.map(app => ({ @@ -226,6 +232,83 @@ async function handleBatchDelete() { } } +function downloadDelta(version: Version) { + if (!version.delta_package_path) + return + const token = localStorage.getItem('token') + const downloadUrl = `${version.delta_package_path}?token=${token}` + window.open(downloadUrl, '_blank') +} + +function confirmPublish(version: Version) { + publishTarget.value = version + publishDialogOpen.value = true +} + +async function handlePublish() { + if (!publishTarget.value) + return + + try { + await api.post(`/dev/versions/${publishTarget.value.id}/publish`) + toast.success(t('admin.versions.publishSuccess')) + fetchVersions() + } + catch (error: any) { + console.error('发布版本失败:', error) + toast.error(error.message || t('common.error')) + } + finally { + publishTarget.value = null + } +} + +function confirmDeprecate(version: Version) { + deprecateTarget.value = version + deprecateDialogOpen.value = true +} + +async function handleDeprecate() { + if (!deprecateTarget.value) + return + + try { + await api.post(`/dev/versions/${deprecateTarget.value.id}/deprecate`) + toast.success(t('admin.versions.deprecateSuccess')) + fetchVersions() + } + catch (error: any) { + console.error('废弃版本失败:', error) + toast.error(error.message || t('common.error')) + } + finally { + deprecateTarget.value = null + } +} + +function confirmRollback(version: Version) { + rollbackTarget.value = version + rollbackDialogOpen.value = true +} + +async function handleRollback() { + if (!rollbackTarget.value) + return + + try { + await api.post(`/dev/versions/${rollbackTarget.value.id}/rollback`) + toast.success(t('admin.versions.rollbackSuccess')) + fetchVersions() + } + catch (error: any) { + console.error('回滚版本失败:', error) + toast.error(error.message || t('common.error')) + } + finally { + rollbackTarget.value = null + } +} + onMounted(() => { fetchApplications() fetchVersions() @@ -327,6 +410,10 @@ watch([appFilter, strategyFilter, typeFilter, methodFilter, searchFilter], () => :on-download="downloadVersion" :on-edit="goToEdit" :on-delete="confirmDelete" + :on-publish="confirmPublish" + :on-deprecate="confirmDeprecate" + :on-rollback="confirmRollback" + :on-download-delta="downloadDelta" :search-filter="searchFilter" @refresh="fetchVersions" @update:search-filter="searchFilter = $event" @@ -390,5 +477,48 @@ watch([appFilter, strategyFilter, typeFilter, methodFilter, searchFilter], () => {{ t('admin.versions.batchDeleteConfirm', { count: batchDeleteIds.length }) }} + + + + + + + + + + + + + + + diff --git a/frontend/src/plugins/i18n/en.json b/frontend/src/plugins/i18n/en.json index 735584e..766f992 100644 --- a/frontend/src/plugins/i18n/en.json +++ b/frontend/src/plugins/i18n/en.json @@ -1415,6 +1415,15 @@ "batchExportBtn": "Batch Export", "batchDeleteConfirm": "Are you sure you want to delete {count} selected versions? This action cannot be undone.", "batchDeleteSuccess": "Batch delete successful", + "publishTitle": "Publish Version", + "publishConfirm": "Are you sure you want to publish version \"v{version}\"? This will make it available to users.", + "publishSuccess": "Version published successfully", + "deprecateTitle": "Deprecate Version", + "deprecateConfirm": "Are you sure you want to deprecate version \"v{version}\"? This will stop distributing it to users.", + "deprecateSuccess": "Version deprecated successfully", + "rollbackTitle": "Rollback Version", + "rollbackConfirm": "Are you sure you want to rollback version \"v{version}\"? This will restore it to published status.", + "rollbackSuccess": "Version rolled back successfully", "select": "Select", "columns": { "version": "Version", @@ -1425,9 +1434,19 @@ "type": "Type", "method": "Method", "status": "Status", + "deltaPackage": "Delta", "createdAt": "Created At", "actions": "Actions" }, + "actions": { + "download": "Download", + "downloadDelta": "Download Delta", + "edit": "Edit", + "delete": "Delete", + "publish": "Publish", + "deprecate": "Deprecate", + "rollback": "Rollback" + }, "strategies": { "optional": "Optional", "forced": "Forced" @@ -1441,8 +1460,11 @@ "manual": "Manual Update" }, "status": { - "active": "Active", - "superseded": "Superseded" + "draft": "Draft", + "published": "Published", + "deprecated": "Deprecated", + "superseded": "Superseded", + "active": "Active" }, "createForm": { "title": "Create Version", diff --git a/frontend/src/plugins/i18n/zh.json b/frontend/src/plugins/i18n/zh.json index 394eebc..ef9b64a 100644 --- a/frontend/src/plugins/i18n/zh.json +++ b/frontend/src/plugins/i18n/zh.json @@ -1393,6 +1393,15 @@ "batchExportBtn": "批量导出", "batchDeleteConfirm": "确定要删除选中的 {count} 个版本吗?此操作不可撤销。", "batchDeleteSuccess": "批量删除成功", + "publishTitle": "发布版本", + "publishConfirm": "确定要发布版本「v{version}」吗?发布后将分发给用户。", + "publishSuccess": "版本发布成功", + "deprecateTitle": "废弃版本", + "deprecateConfirm": "确定要废弃版本「v{version}」吗?废弃后将停止分发给用户。", + "deprecateSuccess": "版本废弃成功", + "rollbackTitle": "回滚版本", + "rollbackConfirm": "确定要回滚版本「v{version}」吗?回滚后将恢复为已发布状态。", + "rollbackSuccess": "版本回滚成功", "select": "选择", "columns": { "version": "版本号", @@ -1403,9 +1412,19 @@ "type": "更新类型", "method": "更新方式", "status": "状态", + "deltaPackage": "增量包", "createdAt": "创建时间", "actions": "操作" }, + "actions": { + "download": "下载", + "downloadDelta": "下载增量包", + "edit": "编辑", + "delete": "删除", + "publish": "发布", + "deprecate": "废弃", + "rollback": "回滚" + }, "strategies": { "optional": "可选更新", "forced": "强制更新" @@ -1419,8 +1438,11 @@ "manual": "手动更新" }, "status": { - "active": "有效", - "superseded": "已失效" + "draft": "草稿", + "published": "已发布", + "deprecated": "已废弃", + "superseded": "已失效", + "active": "有效" }, "createForm": { "title": "创建版本", diff --git a/frontend/src/types/route-map.d.ts b/frontend/src/types/route-map.d.ts index ae0419e..1661a5a 100644 --- a/frontend/src/types/route-map.d.ts +++ b/frontend/src/types/route-map.d.ts @@ -518,9 +518,23 @@ declare module 'vue-router/auto-routes' { Record, | never >, - '/agent/cloud-data/': RouteRecordInfo< - '/agent/cloud-data/', - '/agent/cloud-data', + '/agent/cloud-variables/': RouteRecordInfo< + '/agent/cloud-variables/', + '/agent/cloud-variables', + Record, + Record, + | never + >, + '/agent/cloud-variables/[id]/records': RouteRecordInfo< + '/agent/cloud-variables/[id]/records', + '/agent/cloud-variables/:id/records', + { id: ParamValue }, + { id: ParamValue }, + | never + >, + '/agent/devices/': RouteRecordInfo< + '/agent/devices/', + '/agent/devices', Record, Record, | never @@ -532,6 +546,13 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/agent/sessions/': RouteRecordInfo< + '/agent/sessions/', + '/agent/sessions', + Record, + Record, + | never + >, '/agent/users/': RouteRecordInfo< '/agent/users/', '/agent/users', @@ -539,6 +560,20 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/agent/users/[id]': RouteRecordInfo< + '/agent/users/[id]', + '/agent/users/:id', + { id: ParamValue }, + { id: ParamValue }, + | never + >, + '/agent/users/create': RouteRecordInfo< + '/agent/users/create', + '/agent/users/create', + Record, + Record, + | never + >, '/auth/login': RouteRecordInfo< '/auth/login', '/auth/login', @@ -1058,9 +1093,21 @@ declare module 'vue-router/auto-routes' { views: | never } - 'src/pages/agent/cloud-data/index.vue': { + 'src/pages/agent/cloud-variables/index.vue': { routes: - | '/agent/cloud-data/' + | '/agent/cloud-variables/' + views: + | never + } + 'src/pages/agent/cloud-variables/[id]/records.vue': { + routes: + | '/agent/cloud-variables/[id]/records' + views: + | never + } + 'src/pages/agent/devices/index.vue': { + routes: + | '/agent/devices/' views: | never } @@ -1070,12 +1117,30 @@ declare module 'vue-router/auto-routes' { views: | never } + 'src/pages/agent/sessions/index.vue': { + routes: + | '/agent/sessions/' + views: + | never + } 'src/pages/agent/users/index.vue': { routes: | '/agent/users/' views: | never } + 'src/pages/agent/users/[id].vue': { + routes: + | '/agent/users/[id]' + views: + | never + } + 'src/pages/agent/users/create.vue': { + routes: + | '/agent/users/create' + views: + | never + } 'src/pages/auth/login.vue': { routes: | '/auth/login'