diff --git a/openflare_server/controller/config_version.go b/openflare_server/controller/config_version.go index c8f5e53d..e2acd99c 100644 --- a/openflare_server/controller/config_version.go +++ b/openflare_server/controller/config_version.go @@ -189,3 +189,42 @@ func ActivateConfigVersion(c *gin.Context) { "data": version, }) } + +type CleanupConfigVersionRequest struct { + KeepCount int `json:"keep_count" binding:"required,min=3"` +} + +// CleanupConfigVersions godoc +// @Summary Cleanup old config versions +// @Tags ConfigVersions +// @Produce json +// @Security BearerAuth +// @Param request body CleanupConfigVersionRequest true "Cleanup request" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Router /api/config-versions/cleanup [post] +func CleanupConfigVersions(c *gin.Context) { + var req CleanupConfigVersionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + + deletedCount, err := service.CleanupConfigVersions(req.KeepCount) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "清理成功", + "data": map[string]interface{}{"deleted_count": deletedCount}, + }) +} diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go index 25e4a09e..498ac108 100644 --- a/openflare_server/router/api-router.go +++ b/openflare_server/router/api-router.go @@ -149,6 +149,7 @@ func SetApiRouter(router *gin.Engine) { configVersionRoute.GET("/:id", controller.GetConfigVersion) configVersionRoute.POST("/publish", controller.PublishConfigVersion) configVersionRoute.POST("/:id/activate", controller.ActivateConfigVersion) + configVersionRoute.POST("/cleanup", controller.CleanupConfigVersions) } dashboardRoute := apiRouter.Group("/dashboard") dashboardRoute.Use(middleware.AdminAuth()) diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index de23e5cb..fb6e4bd6 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -406,6 +406,34 @@ func ActivateConfigVersion(id uint) (*model.ConfigVersion, error) { return version, nil } +func CleanupConfigVersions(keepCount int) (int64, error) { + if keepCount < 3 { + keepCount = 3 + } + var versions []model.ConfigVersion + if err := model.DB.Select("id", "is_active").Order("id desc").Find(&versions).Error; err != nil { + return 0, err + } + if len(versions) <= keepCount { + return 0, nil + } + var deleteIDs []uint + for i, v := range versions { + if i < keepCount { + continue + } + if v.IsActive { + continue + } + deleteIDs = append(deleteIDs, v.ID) + } + if len(deleteIDs) == 0 { + return 0, nil + } + result := model.DB.Where("id IN ?", deleteIDs).Delete(&model.ConfigVersion{}) + return result.RowsAffected, result.Error +} + func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) { routes, err := model.GetEnabledProxyRoutes() if err != nil { diff --git a/openflare_server/web/features/config-versions/api/config-versions.ts b/openflare_server/web/features/config-versions/api/config-versions.ts index cf9259a8..374bb301 100644 --- a/openflare_server/web/features/config-versions/api/config-versions.ts +++ b/openflare_server/web/features/config-versions/api/config-versions.ts @@ -38,3 +38,10 @@ export function activateConfigVersion(id: number) { method: 'POST', }); } + +export function cleanupConfigVersions(payload: { keep_count: number }) { + return apiRequest<{ deleted_count: number }>('/config-versions/cleanup', { + method: 'POST', + body: JSON.stringify(payload), + }); +} diff --git a/openflare_server/web/features/config-versions/components/cleanup-config-versions-modal.tsx b/openflare_server/web/features/config-versions/components/cleanup-config-versions-modal.tsx new file mode 100644 index 00000000..a2198198 --- /dev/null +++ b/openflare_server/web/features/config-versions/components/cleanup-config-versions-modal.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState } from 'react'; + +import { InlineMessage } from '@/components/feedback/inline-message'; +import { AppModal } from '@/components/ui/app-modal'; +import { + PrimaryButton, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; + +export function CleanupConfigVersionsModal({ + isOpen, + onClose, + onConfirm, + isPending, +}: { + isOpen: boolean; + onClose: () => void; + onConfirm: (keepCount: number) => void; + isPending: boolean; +}) { + const [keepCount, setKeepCount] = useState(10); + const [error, setError] = useState(null); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (keepCount < 3) { + setError('最少保留 3 个历史快照'); + return; + } + setError(null); + onConfirm(keepCount); + }; + + return ( + + + 取消 + + + {isPending ? '清理中...' : '确认清理'} + + + } + > +
+ {error && } +
+ + setKeepCount(parseInt(e.target.value, 10) || 3)} + className="w-full rounded-lg border border-[var(--border-default)] bg-[var(--surface-default)] px-3 py-2 text-sm text-[var(--foreground-primary)] focus:border-[var(--brand-primary)] focus:ring-1 focus:ring-[var(--brand-primary)] focus:outline-none" + disabled={isPending} + /> +

+ 默认为 10 个,最少需保留 3 个。 +

+
+ +
+ ); +} diff --git a/openflare_server/web/features/config-versions/components/config-versions-page.tsx b/openflare_server/web/features/config-versions/components/config-versions-page.tsx index ca733d5a..7fb6a89e 100644 --- a/openflare_server/web/features/config-versions/components/config-versions-page.tsx +++ b/openflare_server/web/features/config-versions/components/config-versions-page.tsx @@ -17,8 +17,10 @@ import { getConfigVersionPreview, getConfigVersions, publishConfigVersion, + cleanupConfigVersions, } from '@/features/config-versions/api/config-versions'; import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal'; +import { CleanupConfigVersionsModal } from '@/features/config-versions/components/cleanup-config-versions-modal'; import type { ConfigOptionDiffItem, ConfigDiffResult, @@ -404,6 +406,7 @@ export function ConfigVersionsPage() { diff: ConfigDiffResult; } | null>(null); const [isPreviewLoading, setIsPreviewLoading] = useState(false); + const [isCleanupModalOpen, setIsCleanupModalOpen] = useState(false); const versionsQuery = useQuery({ queryKey: versionsQueryKey, @@ -462,6 +465,21 @@ export function ConfigVersionsPage() { }, }); + const cleanupMutation = useMutation({ + mutationFn: cleanupConfigVersions, + onSuccess: async (data) => { + setFeedback({ + tone: 'success', + message: `成功清理 ${data.deleted_count} 个历史快照`, + }); + setIsCleanupModalOpen(false); + await queryClient.invalidateQueries({ queryKey: versionsQueryKey }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + const handleOpenPublishPreview = async () => { setFeedback(null); setIsPreviewLoading(true); @@ -535,16 +553,24 @@ export function ConfigVersionsPage() { title="历史版本" description="发布成功后会立即刷新列表,不再需要手动刷新页面。" action={ - - void queryClient.invalidateQueries({ - queryKey: versionsQueryKey, - }) - } - > - 刷新列表 - +
+ setIsCleanupModalOpen(true)} + > + 清理旧版本 + + + void queryClient.invalidateQueries({ + queryKey: versionsQueryKey, + }) + } + > + 刷新列表 + +
} > {versionsQuery.isLoading ? ( @@ -630,6 +656,13 @@ export function ConfigVersionsPage() { version={selectedVersion} onClose={() => setSelectedVersionId(null)} /> + + setIsCleanupModalOpen(false)} + onConfirm={(keepCount) => cleanupMutation.mutate({ keep_count: keepCount })} + isPending={cleanupMutation.isPending} + /> ); }