feat: 完善版本管理功能
后端改进: - 新增版本发布状态管理 (draft/published/deprecated/superseded) - 新增发布/废弃/回滚 API 接口 - 新增增量包生成功能 (仅包含差异文件) - 新增删除版本时的依赖检查 - 数据库字段: publish_status, superseded_by, delta_package_* 前端改进: - 版本列表新增发布状态列和增量包大小列 - 新增发布/废弃/回滚操作按钮 - 新增增量包下载功能 - 更新国际化翻译 修复: - 修复 announcements 页面 typeOptions 未定义问题 - 修复 echarts 模块导入类型问题 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,6 +19,10 @@ const props = defineProps<Omit<DataTableProps<Version>, '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),
|
||||
])
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -45,6 +45,12 @@ const batchDeleteIds = ref<number[]>([])
|
||||
const exportDialogOpen = ref(false)
|
||||
const exportTarget = ref<Version | null>(null)
|
||||
const batchExportDialogOpen = ref(false)
|
||||
const publishDialogOpen = ref(false)
|
||||
const publishTarget = ref<Version | null>(null)
|
||||
const deprecateDialogOpen = ref(false)
|
||||
const deprecateTarget = ref<Version | null>(null)
|
||||
const rollbackDialogOpen = ref(false)
|
||||
const rollbackTarget = ref<Version | null>(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 }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="publishDialogOpen"
|
||||
:confirm-button-text="t('admin.versions.actions.publish')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handlePublish"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('admin.versions.publishTitle') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('admin.versions.publishConfirm', { version: publishTarget?.version }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deprecateDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('admin.versions.actions.deprecate')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleDeprecate"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('admin.versions.deprecateTitle') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('admin.versions.deprecateConfirm', { version: deprecateTarget?.version }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="rollbackDialogOpen"
|
||||
:confirm-button-text="t('admin.versions.actions.rollback')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleRollback"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('admin.versions.rollbackTitle') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('admin.versions.rollbackConfirm', { version: rollbackTarget?.version }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user