674 lines
18 KiB
Vue
674 lines
18 KiB
Vue
<template>
|
||
<div class="dev-page">
|
||
<!-- 页面头部 -->
|
||
<div class="page-header">
|
||
<div>
|
||
<h2 class="page-title">🏷️ 版本管理</h2>
|
||
<p class="page-desc">管理应用版本发布、回滚和更新日志</p>
|
||
</div>
|
||
<a-button type="primary" @click="showVersionModal = true">
|
||
<template #icon><PlusOutlined /></template>
|
||
新增版本
|
||
</a-button>
|
||
</div>
|
||
|
||
<!-- 选择应用 -->
|
||
<div class="panel mb-4">
|
||
<div class="panel-header">
|
||
<span class="panel-title">📦 选择应用</span>
|
||
</div>
|
||
<div class="p-4">
|
||
<div v-if="loading.apps" class="loading-state">
|
||
<a-spin size="small" />
|
||
<span class="ml-2">正在加载应用列表...</span>
|
||
</div>
|
||
<a-select
|
||
v-model:value="selectedAppId"
|
||
style="width: 300px"
|
||
placeholder="选择要管理的应用"
|
||
:loading="loading.apps"
|
||
:disabled="loading.apps"
|
||
@change="handleAppChange"
|
||
>
|
||
<a-select-option v-for="app in apps" :key="app.productId" :value="app.productId">
|
||
<div class="select-app-option">
|
||
<img v-if="app.icon" :src="app.icon" class="select-app-icon" />
|
||
<span v-else class="select-app-icon-placeholder">{{ (app.productName || 'A').charAt(0) }}</span>
|
||
<span>{{ app.productName }}</span>
|
||
</div>
|
||
</a-select-option>
|
||
</a-select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 版本列表 -->
|
||
<div class="panel" v-if="selectedAppId">
|
||
<div class="panel-header">
|
||
<span class="panel-title">📋 版本历史</span>
|
||
<a-space>
|
||
<a-radio-group v-model:value="versionFilter" size="small" @change="loadVersions">
|
||
<a-radio-button value="">全部</a-radio-button>
|
||
<a-radio-button value="1">已发布</a-radio-button>
|
||
<a-radio-button value="0">构建中</a-radio-button>
|
||
</a-radio-group>
|
||
</a-space>
|
||
</div>
|
||
|
||
<!-- 加载状态 -->
|
||
<div v-if="loading.versions" class="loading-state">
|
||
<div class="loading-dots">
|
||
<span class="loading-dot"></span>
|
||
<span class="loading-dot"></span>
|
||
<span class="loading-dot"></span>
|
||
</div>
|
||
<div class="loading-text">正在加载版本记录...</div>
|
||
</div>
|
||
|
||
<!-- 版本时间线 -->
|
||
<a-timeline v-else-if="versions.length > 0" class="version-timeline">
|
||
<a-timeline-item
|
||
v-for="version in versions"
|
||
:key="version.id"
|
||
:color="versionColor(version.status)"
|
||
>
|
||
<div class="version-card" :class="{ 'is-current': version.isCurrent }">
|
||
<!-- 版本头部 -->
|
||
<div class="version-header">
|
||
<div class="version-info">
|
||
<span class="version-number">v{{ version.versionNo }}</span>
|
||
<a-tag v-if="version.versionName">{{ version.versionName }}</a-tag>
|
||
<a-tag :color="statusTagColor(version.status)">{{ statusText(version.status) }}</a-tag>
|
||
<a-tag v-if="version.isCurrent" color="blue">当前版本</a-tag>
|
||
</div>
|
||
<div class="version-actions">
|
||
<!-- 构建中 → 发布 -->
|
||
<a-button
|
||
v-if="version.status === 0"
|
||
type="primary"
|
||
size="small"
|
||
@click="handlePublish(version)"
|
||
>
|
||
发布
|
||
</a-button>
|
||
<!-- 已发布 → 回滚 -->
|
||
<a-popconfirm
|
||
v-if="version.status === 1 && version.isCurrent"
|
||
title="回滚操作不可撤销,确定要回滚此版本吗?"
|
||
@confirm="handleRollback(version)"
|
||
>
|
||
<a-button
|
||
danger
|
||
size="small"
|
||
:loading="rollbackLoading && rollbackTarget?.id === version.id"
|
||
>
|
||
回滚
|
||
</a-button>
|
||
</a-popconfirm>
|
||
<!-- 非 0/1 状态可删除 -->
|
||
<a-popconfirm
|
||
v-if="version.status !== 1 && version.status !== 0"
|
||
title="确定要删除此版本记录吗?"
|
||
@confirm="handleDelete(version)"
|
||
>
|
||
<a-button danger size="small">删除</a-button>
|
||
</a-popconfirm>
|
||
<!-- 任何状态都可删除非当前版本 -->
|
||
<a-popconfirm
|
||
v-if="version.status === 1 && !version.isCurrent"
|
||
title="确定要删除此版本记录吗?"
|
||
@confirm="handleDelete(version)"
|
||
>
|
||
<a-button size="small">删除</a-button>
|
||
</a-popconfirm>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 版本详情 -->
|
||
<div class="version-body">
|
||
<div v-if="version.changelog" class="version-desc">{{ version.changelog }}</div>
|
||
<div v-if="version.remark" class="version-desc">{{ version.remark }}</div>
|
||
<div v-if="!version.changelog && !version.remark" class="version-desc text-gray-400">暂无更新说明</div>
|
||
|
||
<!-- 版本元信息 -->
|
||
<div class="version-meta">
|
||
<span v-if="version.publishBy">发布者ID:{{ version.publishBy }}</span>
|
||
<span v-if="version.publishTime">发布时间:{{ version.publishTime }}</span>
|
||
<span v-if="version.packageSize">包大小:{{ formatFileSize(version.packageSize) }}</span>
|
||
<span v-if="version.env">环境:{{ envText(version.env) }}</span>
|
||
<span v-if="version.packageUrl">
|
||
<a :href="version.packageUrl" target="_blank" rel="noopener noreferrer">下载安装包</a>
|
||
</span>
|
||
<span>创建时间:{{ version.createTime }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a-timeline-item>
|
||
</a-timeline>
|
||
|
||
<!-- 空状态 -->
|
||
<div v-else class="empty-state">
|
||
<div class="empty-icon">📋</div>
|
||
<div class="empty-title">暂无版本记录</div>
|
||
<div class="empty-desc">新增版本后,版本记录将在此处显示</div>
|
||
<a-button type="primary" class="mt-4" @click="showVersionModal = true">新增版本</a-button>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<div v-if="pagination.total > 0" class="pagination-wrapper">
|
||
<a-pagination
|
||
v-model:current="pagination.current"
|
||
v-model:pageSize="pagination.pageSize"
|
||
:total="pagination.total"
|
||
:show-size-changer="true"
|
||
:show-quick-jumper="true"
|
||
size="small"
|
||
@change="handlePageChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 未选择应用提示 -->
|
||
<div v-else class="panel">
|
||
<div class="empty-state">
|
||
<a-empty description="请先选择要管理的应用">
|
||
<template #image>
|
||
<div class="empty-icon">📦</div>
|
||
</template>
|
||
</a-empty>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新增版本弹窗 -->
|
||
<a-modal
|
||
v-model:open="showVersionModal"
|
||
title="新增版本"
|
||
width="600px"
|
||
:confirm-loading="submitLoading"
|
||
@ok="handleSubmitVersion"
|
||
@cancel="resetVersionForm"
|
||
>
|
||
<a-form :model="versionForm" layout="vertical">
|
||
<a-form-item label="版本号" required>
|
||
<a-input v-model:value="versionForm.versionNo" placeholder="如:1.2.0" />
|
||
</a-form-item>
|
||
|
||
<a-form-item label="版本名称">
|
||
<a-input v-model:value="versionForm.versionName" placeholder="如:新春特惠版" />
|
||
</a-form-item>
|
||
|
||
<a-form-item label="环境" required>
|
||
<a-radio-group v-model:value="versionForm.env">
|
||
<a-radio value="development">开发</a-radio>
|
||
<a-radio value="staging">测试</a-radio>
|
||
<a-radio value="production">生产</a-radio>
|
||
</a-radio-group>
|
||
</a-form-item>
|
||
|
||
<a-form-item label="安装包地址">
|
||
<a-input v-model:value="versionForm.packageUrl" placeholder="安装包下载地址(可选)" />
|
||
</a-form-item>
|
||
|
||
<a-form-item label="更新说明">
|
||
<a-textarea v-model:value="versionForm.changelog" :rows="4" placeholder="描述此版本的主要更新内容" />
|
||
</a-form-item>
|
||
|
||
<a-form-item label="备注">
|
||
<a-input v-model:value="versionForm.remark" placeholder="备注信息(可选)" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||
import { message } from 'ant-design-vue'
|
||
import { getDeveloperApps } from '@/api/app/appProduct'
|
||
import type { AppProduct } from '@/api/app/appProduct/model'
|
||
import {
|
||
pageAppVersion,
|
||
addAppVersion,
|
||
publishAppVersion,
|
||
rollbackAppVersion,
|
||
removeAppVersion,
|
||
} from '@/api/app/appVersion'
|
||
import type { AppVersion, AppVersionParam } from '@/api/app/appVersion/model'
|
||
|
||
definePageMeta({ layout: 'developer' })
|
||
useHead({ title: '版本管理 - 开发者中心' })
|
||
|
||
const userId = import.meta.client ? localStorage.getItem('UserId') : null
|
||
|
||
// 状态
|
||
const loading = ref({
|
||
apps: true,
|
||
versions: false,
|
||
})
|
||
const apps = ref<AppProduct[]>([])
|
||
const selectedAppId = ref<number | undefined>()
|
||
const versionFilter = ref<string>('')
|
||
const versions = ref<AppVersion[]>([])
|
||
const rollbackLoading = ref(false)
|
||
const rollbackTarget = ref<AppVersion | null>(null)
|
||
|
||
// 分页
|
||
const pagination = reactive({
|
||
current: 1,
|
||
pageSize: 10,
|
||
total: 0,
|
||
})
|
||
|
||
// 弹窗
|
||
const showVersionModal = ref(false)
|
||
const submitLoading = ref(false)
|
||
|
||
// 表单
|
||
const versionForm = reactive({
|
||
appId: undefined as number | undefined,
|
||
versionNo: '',
|
||
versionName: '',
|
||
env: 'production' as string,
|
||
changelog: '',
|
||
remark: '',
|
||
packageUrl: '',
|
||
})
|
||
|
||
// ========== 辅助函数 ==========
|
||
|
||
function versionColor(status?: number) {
|
||
const map: Record<number, string> = {
|
||
0: 'blue', // 构建中
|
||
1: 'green', // 已发布
|
||
2: 'orange', // 已回滚
|
||
3: 'red', // 构建失败
|
||
}
|
||
return (status !== undefined) ? (map[status] || 'gray') : 'gray'
|
||
}
|
||
|
||
function statusText(status?: number) {
|
||
const map: Record<number, string> = {
|
||
0: '构建中',
|
||
1: '已发布',
|
||
2: '已回滚',
|
||
3: '构建失败',
|
||
}
|
||
return (status !== undefined) ? (map[status] || '未知') : '未知'
|
||
}
|
||
|
||
function statusTagColor(status?: number) {
|
||
const map: Record<number, string> = {
|
||
0: 'processing',
|
||
1: 'success',
|
||
2: 'warning',
|
||
3: 'error',
|
||
}
|
||
return (status !== undefined) ? (map[status] || 'default') : 'default'
|
||
}
|
||
|
||
function envText(env?: string) {
|
||
const map: Record<string, string> = {
|
||
development: '开发',
|
||
staging: '测试',
|
||
production: '生产',
|
||
}
|
||
return map[env || ''] || env || ''
|
||
}
|
||
|
||
function formatFileSize(bytes?: number): string {
|
||
if (!bytes) return '0 B'
|
||
const k = 1024
|
||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||
}
|
||
|
||
// ========== 加载数据 ==========
|
||
|
||
async function loadApps() {
|
||
loading.value.apps = true
|
||
try {
|
||
const queryUserId = userId ? Number(userId) : undefined
|
||
const res = await getDeveloperApps({
|
||
page: 1,
|
||
limit: 100,
|
||
userId: queryUserId,
|
||
})
|
||
// getDeveloperApps 返回的可能是 res.data.data.records 格式
|
||
if (Array.isArray(res)) {
|
||
apps.value = res
|
||
} else if (res && (res as any).data && Array.isArray((res as any).data?.records)) {
|
||
apps.value = (res as any).data.records
|
||
} else {
|
||
apps.value = []
|
||
}
|
||
|
||
if (apps.value.length > 0 && !selectedAppId.value) {
|
||
selectedAppId.value = apps.value[0].productId
|
||
await loadVersions()
|
||
}
|
||
} catch (e) {
|
||
console.error('加载应用列表失败:', e)
|
||
message.error('加载应用列表失败')
|
||
} finally {
|
||
loading.value.apps = false
|
||
}
|
||
}
|
||
|
||
async function loadVersions() {
|
||
if (!selectedAppId.value) return
|
||
loading.value.versions = true
|
||
try {
|
||
const params: AppVersionParam = {
|
||
appId: selectedAppId.value,
|
||
page: pagination.current,
|
||
limit: pagination.pageSize,
|
||
}
|
||
if (versionFilter.value !== '') {
|
||
params.status = Number(versionFilter.value)
|
||
}
|
||
const res = await pageAppVersion(params)
|
||
versions.value = res?.list || []
|
||
pagination.total = res?.count || 0
|
||
} catch (error: any) {
|
||
console.error('加载版本列表失败:', error)
|
||
message.error(error?.message || '加载版本列表失败')
|
||
} finally {
|
||
loading.value.versions = false
|
||
}
|
||
}
|
||
|
||
// ========== 事件处理 ==========
|
||
|
||
function handleAppChange(value: number) {
|
||
selectedAppId.value = value
|
||
pagination.current = 1
|
||
loadVersions()
|
||
}
|
||
|
||
function handlePageChange(page: number, pageSize: number) {
|
||
pagination.current = page
|
||
pagination.pageSize = pageSize
|
||
loadVersions()
|
||
}
|
||
|
||
async function handlePublish(version: AppVersion) {
|
||
try {
|
||
await publishAppVersion(version.id!)
|
||
message.success('版本发布成功')
|
||
await loadVersions()
|
||
} catch (error: any) {
|
||
message.error(error?.message || '版本发布失败,请稍后重试')
|
||
}
|
||
}
|
||
|
||
async function handleRollback(version: AppVersion) {
|
||
rollbackTarget.value = version
|
||
rollbackLoading.value = true
|
||
try {
|
||
await rollbackAppVersion(version.id!)
|
||
message.success('版本回滚成功')
|
||
rollbackTarget.value = null
|
||
await loadVersions()
|
||
} catch (error: any) {
|
||
message.error(error?.message || '版本回滚失败,请稍后重试')
|
||
} finally {
|
||
rollbackLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleDelete(version: AppVersion) {
|
||
try {
|
||
await removeAppVersion(version.id!)
|
||
message.success('版本删除成功')
|
||
await loadVersions()
|
||
} catch (error: any) {
|
||
message.error(error?.message || '删除失败,请稍后重试')
|
||
}
|
||
}
|
||
|
||
async function handleSubmitVersion() {
|
||
if (!versionForm.versionNo) {
|
||
message.error('请填写版本号')
|
||
return
|
||
}
|
||
submitLoading.value = true
|
||
try {
|
||
await addAppVersion({
|
||
appId: selectedAppId.value,
|
||
versionNo: versionForm.versionNo,
|
||
versionName: versionForm.versionName || undefined,
|
||
env: versionForm.env,
|
||
changelog: versionForm.changelog || undefined,
|
||
remark: versionForm.remark || undefined,
|
||
packageUrl: versionForm.packageUrl || undefined,
|
||
})
|
||
message.success('版本创建成功(构建中),创建后可点击"发布"按钮正式发布')
|
||
showVersionModal.value = false
|
||
resetVersionForm()
|
||
await loadVersions()
|
||
} catch (error: any) {
|
||
message.error(error?.message || '版本创建失败,请稍后重试')
|
||
} finally {
|
||
submitLoading.value = false
|
||
}
|
||
}
|
||
|
||
function resetVersionForm() {
|
||
versionForm.appId = undefined
|
||
versionForm.versionNo = ''
|
||
versionForm.versionName = ''
|
||
versionForm.env = 'production'
|
||
versionForm.changelog = ''
|
||
versionForm.remark = ''
|
||
versionForm.packageUrl = ''
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadApps()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.dev-page {
|
||
min-height: 100%;
|
||
padding: 20px 24px 28px;
|
||
}
|
||
|
||
.page-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #1f2937;
|
||
margin: 0;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.page-desc {
|
||
font-size: 13px;
|
||
color: #9ca3af;
|
||
margin: 2px 0 0;
|
||
}
|
||
|
||
/* 面板 */
|
||
.panel {
|
||
background: #fff;
|
||
border: 1px solid #f0f0f0;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 14px 18px;
|
||
border-bottom: 1px solid #f5f5f5;
|
||
}
|
||
|
||
.panel-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: rgba(0, 0, 0, 0.85);
|
||
}
|
||
|
||
/* 选择应用 */
|
||
.select-app-option {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.select-app-icon {
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 4px;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.select-app-icon-placeholder {
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 4px;
|
||
background: #4e6ef2;
|
||
color: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 版本时间线 */
|
||
.version-timeline {
|
||
padding: 20px;
|
||
}
|
||
|
||
.version-card {
|
||
background: #fafafa;
|
||
border: 1px solid #f0f0f0;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.version-card.is-current {
|
||
background: #f0f9ff;
|
||
border-color: #91caff;
|
||
}
|
||
|
||
.version-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.version-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.version-number {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: rgba(0, 0, 0, 0.85);
|
||
}
|
||
|
||
.version-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.version-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.version-desc {
|
||
font-size: 14px;
|
||
color: rgba(0, 0, 0, 0.65);
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
/* 版本元信息 */
|
||
.version-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
font-size: 12px;
|
||
color: rgba(0, 0, 0, 0.45);
|
||
padding-top: 12px;
|
||
border-top: 1px dashed #e8e8e8;
|
||
}
|
||
|
||
/* 分页 */
|
||
.pagination-wrapper {
|
||
padding: 16px;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
/* 空状态 */
|
||
.empty-state {
|
||
padding: 60px 20px;
|
||
text-align: center;
|
||
}
|
||
|
||
.empty-icon {
|
||
font-size: 48px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.mb-4 { margin-bottom: 16px; }
|
||
|
||
/* 加载状态 */
|
||
.loading-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 80px 20px;
|
||
text-align: center;
|
||
}
|
||
|
||
.loading-dots {
|
||
display: flex;
|
||
gap: 6px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.loading-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
background: #4f46e5;
|
||
animation: pulse 1.4s ease-in-out infinite;
|
||
}
|
||
|
||
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
|
||
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||
50% { opacity: 1; transform: scale(1); }
|
||
}
|
||
|
||
.loading-text {
|
||
font-size: 14px;
|
||
color: rgba(0, 0, 0, 0.45);
|
||
}
|
||
|
||
.ml-2 { margin-left: 8px; }
|
||
.mt-4 { margin-top: 16px; }
|
||
</style>
|