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:
2026-06-01 20:31:52 +08:00
parent c3e44c74da
commit c755587a4d
13 changed files with 774 additions and 92 deletions
@@ -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' },
+1 -1
View File
@@ -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(),
})
+130
View File
@@ -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>
+24 -2
View File
@@ -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",
+24 -2
View File
@@ -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": "创建版本",
+70 -5
View File
@@ -518,9 +518,23 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/agent/cloud-data/': RouteRecordInfo<
'/agent/cloud-data/',
'/agent/cloud-data',
'/agent/cloud-variables/': RouteRecordInfo<
'/agent/cloud-variables/',
'/agent/cloud-variables',
Record<never, never>,
Record<never, never>,
| never
>,
'/agent/cloud-variables/[id]/records': RouteRecordInfo<
'/agent/cloud-variables/[id]/records',
'/agent/cloud-variables/:id/records',
{ id: ParamValue<true> },
{ id: ParamValue<false> },
| never
>,
'/agent/devices/': RouteRecordInfo<
'/agent/devices/',
'/agent/devices',
Record<never, never>,
Record<never, never>,
| never
@@ -532,6 +546,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/agent/sessions/': RouteRecordInfo<
'/agent/sessions/',
'/agent/sessions',
Record<never, never>,
Record<never, never>,
| never
>,
'/agent/users/': RouteRecordInfo<
'/agent/users/',
'/agent/users',
@@ -539,6 +560,20 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/agent/users/[id]': RouteRecordInfo<
'/agent/users/[id]',
'/agent/users/:id',
{ id: ParamValue<true> },
{ id: ParamValue<false> },
| never
>,
'/agent/users/create': RouteRecordInfo<
'/agent/users/create',
'/agent/users/create',
Record<never, never>,
Record<never, never>,
| 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'