版本管理全面增强:状态、补丁更新、热更新

1. 自动状态管理
   - 新增版本自动标记状态:最新版本为'有效',旧版本自动标记为'已失效'
   - 前端列表页新增状态列和状态筛选

2. 更新方式升级
   - 更新方式从手动/自动改为:手动更新/热更新/完整更新
   - 热更新:运行时增量替换文件,无需重启
   - 完整更新:下载完整包替换,需重启

3. 补丁更新支持
   - 新增 base_version_id 字段支持增量补丁
   - 创建版本时可选择基础版本,自动识别为补丁模式
   - 补丁模式仅上传变更文件

4. ZIP解析上传(已有功能完善)
   - 上传ZIP自动解析文件列表
   - 支持选择入口文件

5. 国际化完善
   - 中英文新增状态、更新方式等翻译
This commit is contained in:
2026-05-08 16:21:35 +08:00
parent ecf54d3e3f
commit e2ebb6e46e
9 changed files with 218 additions and 43 deletions
+2
View File
@@ -232,12 +232,14 @@ type Version struct {
Description string `gorm:"type:text" json:"description"`
Changelog string `gorm:"type:text" json:"changelog"`
Status string `gorm:"size:20;default:active" json:"status"`
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"`
}
// VersionFile 版本文件模型
+14
View File
@@ -43,6 +43,7 @@ func handleGetAllVersions(c *gin.Context) {
applicationIDFilter := c.Query("application_id")
updateStrategyFilter := c.Query("update_strategy")
updateMethodFilter := c.Query("update_method")
statusFilter := c.Query("status")
searchFilter := c.Query("search")
var total int64
@@ -85,6 +86,9 @@ func handleGetAllVersions(c *gin.Context) {
if updateMethodFilter != "" {
countQuery = countQuery.Where("update_method = ?", updateMethodFilter)
}
if statusFilter != "" {
countQuery = countQuery.Where("status = ?", statusFilter)
}
if searchFilter != "" {
searchLower := strings.ToLower(searchFilter)
countQuery = countQuery.Where("LOWER(version) LIKE ? OR LOWER(description) LIKE ?", "%"+searchLower+"%", "%"+searchLower+"%")
@@ -101,6 +105,9 @@ func handleGetAllVersions(c *gin.Context) {
if updateMethodFilter != "" {
query = query.Where("update_method = ?", updateMethodFilter)
}
if statusFilter != "" {
query = query.Where("status = ?", statusFilter)
}
if searchFilter != "" {
searchLower := strings.ToLower(searchFilter)
query = query.Where("LOWER(version) LIKE ? OR LOWER(description) LIKE ?", "%"+searchLower+"%", "%"+searchLower+"%")
@@ -186,6 +193,7 @@ func handleGetVersionByID(c *gin.Context) {
"min_version": version.MinVersion,
"changelog": version.Changelog,
"status": version.Status,
"base_version_id": version.BaseVersionID,
"files": version.Files,
"created_at": version.CreatedAt,
"updated_at": version.UpdatedAt,
@@ -205,6 +213,7 @@ type CreateVersionRequest struct {
MinVersion string `json:"min_version"`
Description string `json:"description"`
Changelog string `json:"changelog"`
BaseVersionID *uint `json:"base_version_id"`
}
func handleCreateVersionGlobal(c *gin.Context) {
@@ -247,6 +256,7 @@ func handleCreateVersionGlobal(c *gin.Context) {
Description: req.Description,
Changelog: req.Changelog,
Status: "active",
BaseVersionID: req.BaseVersionID,
}
if version.UpdateStrategy == "" {
@@ -261,6 +271,10 @@ func handleCreateVersionGlobal(c *gin.Context) {
return
}
database.DB.Model(&model.Version{}).
Where("application_id = ? AND id != ? AND status = ?", req.ApplicationID, version.ID, "active").
Update("status", "superseded")
userIDPtr := &userID
versionIDPtr := &version.ID
service.CreateLog(service.LogParams{
+48 -18
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Check, CheckCircle, Eye, FileArchive, FilePlus, GitBranch, Loader2, Upload, UploadCloud, X } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
@@ -44,10 +44,12 @@ const formData = ref({
version: '',
description: '',
update_strategy: 'optional' as 'optional' | 'forced',
update_method: 'manual' as 'manual' | 'auto',
update_method: 'manual' as 'manual' | 'hot' | 'full',
status: 'active' as string,
min_version: '',
changelog: '',
entry_file: '',
base_version_id: '',
file: null as File | null,
})
@@ -90,6 +92,8 @@ async function fetchVersion() {
formData.value.description = ver.description || ''
formData.value.update_strategy = ver.update_strategy || 'optional'
formData.value.update_method = ver.update_method || 'manual'
formData.value.status = ver.status || 'active'
formData.value.base_version_id = ver.base_version_id ? String(ver.base_version_id) : ''
formData.value.min_version = ver.min_version || ''
formData.value.changelog = ver.changelog || ''
formData.value.entry_file = ver.entry_file || ''
@@ -177,6 +181,7 @@ async function handleSubmit() {
description: formData.value.description,
update_strategy: formData.value.update_strategy,
update_method: formData.value.update_method,
status: formData.value.status,
min_version: formData.value.min_version,
changelog: formData.value.changelog,
entry_file: formData.value.entry_file,
@@ -430,20 +435,41 @@ onMounted(() => {
/>
</div>
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="space-y-0.5">
<UiLabel class="text-base">
{{ t('admin.versions.createForm.autoUpdate') }}
</UiLabel>
<p class="text-sm text-muted-foreground">
{{ t('admin.versions.createForm.autoUpdateDesc') }}
</p>
</div>
<UiSwitch
:checked="formData.update_method === 'auto'"
:disabled="saving"
@update:checked="formData.update_method = $event ? 'auto' : 'manual'"
/>
<div class="space-y-2">
<UiLabel>{{ t('admin.versions.createForm.updateMethod') }}</UiLabel>
<UiSelect v-model="formData.update_method" :disabled="saving">
<UiSelectTrigger>
<UiSelectValue />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="full">
{{ t('admin.versions.methods.full') }}
</UiSelectItem>
<UiSelectItem value="hot">
{{ t('admin.versions.methods.hot') }}
</UiSelectItem>
<UiSelectItem value="manual">
{{ t('admin.versions.methods.manual') }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-2">
<UiLabel>{{ t('admin.versions.createForm.status') }}</UiLabel>
<UiSelect v-model="formData.status" :disabled="saving">
<UiSelectTrigger>
<UiSelectValue />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="active">
{{ t('admin.versions.status.active') }}
</UiSelectItem>
<UiSelectItem value="superseded">
{{ t('admin.versions.status.superseded') }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-2">
@@ -493,8 +519,12 @@ onMounted(() => {
<span>{{ formData.update_strategy === 'forced' ? t('common.yes') : t('common.no') }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewAutoUpdate') }}</span>
<span>{{ formData.update_method === 'auto' ? t('common.yes') : t('common.no') }}</span>
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewUpdateMethod') }}</span>
<span>{{
formData.update_method === 'full' ? t('admin.versions.methods.full')
: formData.update_method === 'hot' ? t('admin.versions.methods.hot')
: t('admin.versions.methods.manual')
}}</span>
</div>
</div>
@@ -75,15 +75,32 @@ export function getColumns(options: ColumnOptions, t: (key: string) => string):
cell: ({ row }) => {
const methodClasses: Record<string, string> = {
manual: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
auto: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
hot: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
full: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
}
const methodLabels: Record<string, string> = {
manual: t('admin.versions.methods.manual'),
auto: t('admin.versions.methods.auto'),
hot: t('admin.versions.methods.hot'),
full: t('admin.versions.methods.full'),
}
return h(Badge, { class: methodClasses[row.original.update_method] }, () => methodLabels[row.original.update_method])
},
},
{
accessorKey: 'status',
header: () => t('admin.versions.columns.status'),
cell: ({ row }) => {
const statusClasses: Record<string, string> = {
active: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
superseded: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
}
const statusLabels: Record<string, string> = {
active: t('admin.versions.status.active'),
superseded: t('admin.versions.status.superseded'),
}
return h(Badge, { class: statusClasses[row.original.status] }, () => statusLabels[row.original.status])
},
},
{
accessorKey: 'created_at',
header: () => t('admin.versions.columns.createdAt'),
+86 -18
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { CheckCircle, Eye, FileArchive, FilePlus, GitBranch, Loader2, Rocket, Upload, UploadCloud, X } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
@@ -15,6 +15,11 @@ interface Application {
name: string
}
interface VersionOption {
id: number
version: string
}
interface VersionFileInfo {
file_path: string
file_name: string
@@ -37,16 +42,18 @@ const loading = ref(false)
const saving = ref(false)
const uploading = ref(false)
const applications = ref<Application[]>([])
const previousVersions = ref<VersionOption[]>([])
const formData = ref({
application_id: '',
version: '',
description: '',
update_strategy: 'optional' as 'optional' | 'forced',
update_method: 'manual' as 'manual' | 'auto',
update_method: 'manual' as 'manual' | 'hot' | 'full',
min_version: '',
changelog: '',
entry_file: '',
base_version_id: '',
file: null as File | null,
})
@@ -79,6 +86,15 @@ function formatFileSize(size: number): string {
return `${(size / (1024 * 1024)).toFixed(2)} MB`
}
const methodLabel = computed(() => {
const map: Record<string, string> = {
full: t('admin.versions.methods.full'),
hot: t('admin.versions.methods.hot'),
manual: t('admin.versions.methods.manual'),
}
return map[formData.value.update_method] || formData.value.update_method
})
async function fetchApplications() {
loading.value = true
try {
@@ -97,6 +113,21 @@ async function fetchApplications() {
}
}
async function fetchPreviousVersions(appId: string) {
if (!appId)
return
try {
const params = new URLSearchParams()
params.append('application_id', appId)
params.append('page_size', '100')
const data = await api.get<{ versions: VersionOption[] }>(`/dev/versions?${params.toString()}`)
previousVersions.value = data?.versions || []
}
catch (error) {
console.error('获取历史版本失败:', error)
}
}
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement
if (target.files && target.files.length > 0) {
@@ -167,6 +198,7 @@ async function handleSubmit() {
min_version: formData.value.min_version,
changelog: formData.value.changelog,
entry_file: formData.value.entry_file,
base_version_id: formData.value.base_version_id ? Number(formData.value.base_version_id) : null,
file_path: uploadedData.value.file_path,
file_size: uploadedData.value.file_size,
file_hash: uploadedData.value.file_hash,
@@ -186,6 +218,14 @@ async function handleSubmit() {
onMounted(() => {
fetchApplications()
})
watch(() => formData.value.application_id, (newAppId) => {
formData.value.base_version_id = ''
previousVersions.value = []
if (newAppId) {
fetchPreviousVersions(newAppId)
}
})
</script>
<template>
@@ -410,20 +450,48 @@ onMounted(() => {
/>
</div>
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="space-y-0.5">
<UiLabel class="text-base">
{{ t('admin.versions.createForm.autoUpdate') }}
</UiLabel>
<p class="text-sm text-muted-foreground">
{{ t('admin.versions.createForm.autoUpdateDesc') }}
</p>
</div>
<UiSwitch
:checked="formData.update_method === 'auto'"
:disabled="saving"
@update:checked="formData.update_method = $event ? 'auto' : 'manual'"
/>
<div class="space-y-2">
<UiLabel>{{ t('admin.versions.createForm.updateMethod') }}</UiLabel>
<UiSelect v-model="formData.update_method" :disabled="saving">
<UiSelectTrigger>
<UiSelectValue />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="full">
{{ t('admin.versions.methods.full') }}
</UiSelectItem>
<UiSelectItem value="hot">
{{ t('admin.versions.methods.hot') }}
</UiSelectItem>
<UiSelectItem value="manual">
{{ t('admin.versions.methods.manual') }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-2">
<UiLabel>{{ t('admin.versions.createForm.baseVersion') }}</UiLabel>
<UiSelect v-model="formData.base_version_id" :disabled="saving || !formData.application_id">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.versions.createForm.baseVersionPlaceholder')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="">
{{ t('admin.versions.createForm.noBaseVersion') }}
</UiSelectItem>
<UiSelectItem
v-for="ver in previousVersions"
:key="ver.id"
:value="String(ver.id)"
>
v{{ ver.version }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
<p class="text-xs text-muted-foreground">
{{ t('admin.versions.createForm.baseVersionDesc') }}
</p>
</div>
<div class="space-y-2">
@@ -485,8 +553,8 @@ onMounted(() => {
<span>{{ formData.update_strategy === 'forced' ? t('common.yes') : t('common.no') }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewAutoUpdate') }}</span>
<span>{{ formData.update_method === 'auto' ? t('common.yes') : t('common.no') }}</span>
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewUpdateMethod') }}</span>
<span>{{ methodLabel }}</span>
</div>
</div>
@@ -1,7 +1,8 @@
import { z } from 'zod'
export const updateStrategySchema = z.enum(['optional', 'forced'])
export const updateMethodSchema = z.enum(['manual', 'auto'])
export const updateMethodSchema = z.enum(['manual', 'hot', 'full'])
export const versionStatusSchema = z.enum(['active', 'superseded'])
export const versionSchema = z.object({
id: z.number(),
@@ -14,6 +15,8 @@ export const versionSchema = z.object({
force_update: z.boolean(),
update_strategy: updateStrategySchema,
update_method: updateMethodSchema,
status: versionStatusSchema,
base_version_id: z.number().nullable(),
changelog: z.string(),
created_at: z.string(),
updated_at: z.string(),
+17 -2
View File
@@ -31,6 +31,7 @@ const tableRef = ref()
const appFilter = ref<string>('')
const strategyFilter = ref<string>('')
const methodFilter = ref<string>('')
const statusFilter = ref<string>('')
const searchFilter = ref<string>('')
const currentPage = ref(1)
const pageSize = ref(20)
@@ -58,7 +59,13 @@ const strategyOptions = computed(() => [
const methodOptions = computed(() => [
{ label: t('admin.versions.methods.manual'), value: 'manual' },
{ label: t('admin.versions.methods.auto'), value: 'auto' },
{ label: t('admin.versions.methods.hot'), value: 'hot' },
{ label: t('admin.versions.methods.full'), value: 'full' },
])
const statusOptions = computed(() => [
{ label: t('admin.versions.status.active'), value: 'active' },
{ label: t('admin.versions.status.superseded'), value: 'superseded' },
])
const totalSize = computed(() => {
@@ -115,6 +122,9 @@ async function fetchVersions() {
if (methodFilter.value) {
params.append('update_method', methodFilter.value)
}
if (statusFilter.value) {
params.append('status', statusFilter.value)
}
if (searchFilter.value) {
params.append('search', searchFilter.value)
}
@@ -229,7 +239,7 @@ watch([currentPage, pageSize], () => {
fetchVersions()
})
watch([appFilter, strategyFilter, methodFilter, searchFilter], () => {
watch([appFilter, strategyFilter, methodFilter, statusFilter, searchFilter], () => {
currentPage.value = 1
fetchVersions()
})
@@ -340,6 +350,11 @@ watch([appFilter, strategyFilter, methodFilter, searchFilter], () => {
:title="t('admin.versions.method')"
:options="methodOptions"
/>
<SingleFilter
v-model="statusFilter"
:title="t('admin.versions.columns.status')"
:options="statusOptions"
/>
</template>
</DataTable>
</UiCardContent>
+14 -1
View File
@@ -1418,6 +1418,7 @@
"fileSize": "File Size",
"strategy": "Strategy",
"method": "Method",
"status": "Status",
"createdAt": "Created At",
"actions": "Actions"
},
@@ -1427,7 +1428,12 @@
},
"methods": {
"manual": "Manual",
"auto": "Auto"
"hot": "Hot",
"full": "Full"
},
"status": {
"active": "Active",
"superseded": "Superseded"
},
"createForm": {
"title": "Create Version",
@@ -1461,6 +1467,12 @@
"entryFilePlaceholder": "Select entry file (optional)",
"forcedUpdate": "Force Update",
"forcedUpdateDesc": "When enabled, users must update to this version to continue using",
"updateMethod": "Update Method",
"baseVersion": "Base Version",
"baseVersionPlaceholder": "Select base version (patch mode)",
"baseVersionDesc": "Select a base version to only upload changed files for incremental patch update",
"noBaseVersion": "None (Full version)",
"status": "Status",
"autoUpdate": "Auto Update",
"autoUpdateDesc": "When enabled, the application will automatically download and install updates",
"changelog": "Changelog",
@@ -1474,6 +1486,7 @@
"previewFileCount": "File Count",
"previewEntryFile": "Entry File",
"previewForcedUpdate": "Force Update",
"previewUpdateMethod": "Update Method",
"previewAutoUpdate": "Auto Update",
"changelogPreview": "Changelog Preview",
"noChangelog": "No changelog",
+14 -1
View File
@@ -1385,6 +1385,7 @@
"fileSize": "文件大小",
"strategy": "更新策略",
"method": "更新方式",
"status": "状态",
"createdAt": "创建时间",
"actions": "操作"
},
@@ -1394,7 +1395,12 @@
},
"methods": {
"manual": "手动更新",
"auto": "自动更新"
"hot": "更新",
"full": "完整更新"
},
"status": {
"active": "有效",
"superseded": "已失效"
},
"createForm": {
"title": "创建版本",
@@ -1428,6 +1434,12 @@
"entryFilePlaceholder": "选择入口文件(可选)",
"forcedUpdate": "强制更新",
"forcedUpdateDesc": "启用后用户必须更新到此版本才能继续使用",
"updateMethod": "更新方式",
"baseVersion": "基础版本",
"baseVersionPlaceholder": "选择基础版本(补丁模式)",
"baseVersionDesc": "选择基础版本后,只会上传变更的文件,用于增量补丁更新",
"noBaseVersion": "无(完整版本)",
"status": "状态",
"autoUpdate": "自动更新",
"autoUpdateDesc": "启用后应用将自动下载并安装更新",
"changelog": "更新日志",
@@ -1441,6 +1453,7 @@
"previewFileCount": "文件数量",
"previewEntryFile": "入口文件",
"previewForcedUpdate": "强制更新",
"previewUpdateMethod": "更新方式",
"previewAutoUpdate": "自动更新",
"changelogPreview": "更新日志预览",
"noChangelog": "暂无更新日志",