初始版本

This commit is contained in:
2026-04-23 16:30:57 +08:00
commit 0d0683a6e6
538 changed files with 113042 additions and 0 deletions

View File

@@ -0,0 +1,673 @@
<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>