Files
jczxw-pc/app/pages/developer/versions.vue
2026-04-23 16:30:57 +08:00

674 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>