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

702 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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">管理应用的 CI/CD 构建任务触发构建查看构建状态和日志</p>
</div>
<a-space>
<a-button @click="loadBuilds">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button type="primary" :loading="triggering" @click="showTriggerModal = true">
<template #icon><PlayCircleOutlined /></template>
触发构建
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="12" :md="6" v-for="stat in buildStats" :key="stat.key">
<div class="stat-card" :class="stat.color">
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</a-col>
</a-row>
<!-- 筛选栏 -->
<div class="panel mb-4">
<div class="panel-header">
<span class="panel-title">📋 构建记录</span>
<a-space>
<a-select v-model:value="filterStatus" style="width: 120px" placeholder="构建状态" allow-clear @change="loadBuilds">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending">排队中</a-select-option>
<a-select-option value="running">构建中</a-select-option>
<a-select-option value="success">成功</a-select-option>
<a-select-option value="failed">失败</a-select-option>
<a-select-option value="cancelled">已取消</a-select-option>
</a-select>
<a-select v-model:value="filterAppId" style="width: 200px" placeholder="选择应用" allow-clear @change="loadBuilds">
<a-select-option v-for="app in apps" :key="app.productId" :value="app.productId">
{{ app.productName }}
</a-select-option>
</a-select>
<a-input-search v-model:value="searchKeyword" placeholder="构建编号/分支" style="width: 180px" @search="loadBuilds" />
</a-space>
</div>
<!-- 构建列表 -->
<a-spin :spinning="loading">
<div v-if="builds.length > 0" class="build-list">
<div v-for="build in builds" :key="build.id" class="build-item" :class="'status-' + build.status">
<div class="build-status-bar" :class="build.status">
<div class="status-indicator">
<span class="status-dot"></span>
<span class="status-text">{{ statusText(build.status) }}</span>
</div>
<span class="build-time">{{ formatTime(build.createTime) }}</span>
</div>
<div class="build-content">
<div class="build-main">
<div class="build-info">
<div class="build-number">
<span class="ci-badge" :class="build.ciType">{{ ciTypeText(build.ciType) }}</span>
<span class="number-text">#{{ build.buildNumber || build.id }}</span>
</div>
<div class="build-meta">
<span v-if="build.branch" class="meta-item">
<CodeSandboxOutlined /> {{ build.branch }}
</span>
<span v-if="build.commitAuthor" class="meta-item">
<UserOutlined /> {{ build.commitAuthor }}
</span>
<span v-if="build.duration" class="meta-item">
<ClockCircleOutlined /> {{ formatDuration(build.duration) }}
</span>
</div>
<div v-if="build.commitMessage" class="commit-message">{{ build.commitMessage }}</div>
</div>
</div>
<div class="build-actions">
<a-button size="small" type="link" @click="viewLog(build)">查看日志</a-button>
<template v-if="build.status === 'pending' || build.status === 'running'">
<a-popconfirm title="确定要取消此构建?" @confirm="handleCancel(build)">
<a-button danger size="small">取消</a-button>
</a-popconfirm>
</template>
<template v-if="build.status === 'failed'">
<a-button type="primary" size="small" @click="handleRetry(build)">
<template #icon><ReloadOutlined /></template>
重试
</a-button>
</template>
<template v-if="build.artifactUrl">
<a-button size="small" type="link">
<template #icon><DownloadOutlined /></template>
<a :href="build.artifactUrl" target="_blank">下载产物</a>
</a-button>
</template>
</div>
</div>
<div v-if="build.status === 'failed' && build.errorMessage" class="build-error">
<WarningOutlined /> {{ build.errorMessage }}
</div>
</div>
</div>
<a-empty v-else description="暂无构建记录" class="py-8">
<template #image>
<div class="empty-icon">🔨</div>
</template>
</a-empty>
</a-spin>
<!-- 分页 -->
<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"
@change="handlePageChange"
/>
</div>
</div>
<!-- 触发构建弹窗 -->
<a-modal v-model:open="showTriggerModal" title="触发构建" :confirm-loading="triggering" @ok="handleTrigger" @cancel="resetTriggerForm">
<a-form :model="triggerForm" layout="vertical">
<a-form-item label="选择应用" required>
<a-select v-model:value="triggerForm.appId" placeholder="选择要构建的应用" @change="handleAppChange">
<a-select-option v-for="app in apps" :key="app.productId" :value="app.productId">
{{ app.productName }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="选择流水线(可选)">
<a-select v-model:value="triggerForm.pipelineId" placeholder="默认使用第一个可用流水线">
<a-select-option v-for="p in pipelines" :key="p.id" :value="p.id">
{{ p.name }} ({{ p.env }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="分支">
<a-input v-model:value="triggerForm.branch" placeholder="留空则使用默认分支main" />
</a-form-item>
<a-alert v-if="triggerForm.appId" type="info" show-icon>
<template #message>
提示构建任务将在 CI 系统后台执行可随时刷新页面查看构建进度
</template>
</a-alert>
</a-form>
</a-modal>
<!-- 构建日志弹窗 -->
<a-modal v-model:open="showLogModal" title="构建日志" width="800px" :footer="null">
<div class="log-container">
<pre class="log-content">{{ buildLog || '加载中...' }}</pre>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import {
ReloadOutlined,
PlayCircleOutlined,
CodeSandboxOutlined,
UserOutlined,
ClockCircleOutlined,
DownloadOutlined,
WarningOutlined,
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import {
pageBuild,
listBuildByApp,
triggerBuild,
cancelBuild,
retryBuild,
getBuildLog,
getBuildStats,
} from '@/api/app/cicd'
import type { AppBuild } from '@/api/app/cicd'
import { getDeveloperApps } from '@/api/app/appProduct'
import type { AppProduct } from '@/api/app/appProduct/model'
import { listPipelineByApp } from '@/api/app/cicd'
import type { AppPipeline } from '@/api/app/cicd'
definePageMeta({ layout: 'developer' })
useHead({ title: '构建任务 - 开发者中心' })
const userId = import.meta.client ? localStorage.getItem('UserId') : null
// 加载状态
const loading = ref(false)
const triggering = ref(false)
const apps = ref<AppProduct[]>([])
const builds = ref<AppBuild[]>([])
const pipelines = ref<AppPipeline[]>([])
// 筛选
const filterStatus = ref('')
const filterAppId = ref<number | undefined>()
const searchKeyword = ref('')
// 分页
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
// 构建统计
const buildStats = reactive([
{ key: 'total', icon: '📊', label: '总构建', value: 0, color: 'blue' },
{ key: 'running', icon: '⏳', label: '进行中', value: 0, color: 'orange' },
{ key: 'success', icon: '✅', label: '成功', value: 0, color: 'green' },
{ key: 'failed', icon: '❌', label: '失败', value: 0, color: 'red' },
])
// 触发构建弹窗
const showTriggerModal = ref(false)
const triggerForm = reactive({
appId: undefined as number | undefined,
pipelineId: undefined as number | undefined,
branch: '',
})
// 日志弹窗
const showLogModal = ref(false)
const buildLog = ref('')
const currentBuild = ref<AppBuild | null>(null)
// ========== 加载数据 ==========
async function loadApps() {
try {
const res = await getDeveloperApps({ page: 1, limit: 100, userId: userId ? Number(userId) : undefined })
apps.value = (res as any)?.data?.records || res?.list || []
} catch (e) {
console.error('加载应用列表失败:', e)
}
}
async function loadBuilds() {
loading.value = true
try {
const res = await pageBuild({
page: pagination.current,
limit: pagination.pageSize,
appId: filterAppId.value,
status: filterStatus.value || undefined,
})
if (res?.data?.code === 200) {
builds.value = res.data.data.records || []
pagination.total = res.data.data.total || 0
updateStats()
} else {
builds.value = []
pagination.total = 0
}
} catch (e) {
console.error('加载构建记录失败:', e)
builds.value = []
} finally {
loading.value = false
}
}
async function loadPipelines(appId: number) {
try {
const res = await listPipelineByApp(appId)
if (res?.data?.code === 200) {
pipelines.value = res.data.data || []
} else {
pipelines.value = []
}
} catch (e) {
pipelines.value = []
}
}
async function loadStats() {
if (!filterAppId.value) return
try {
const res = await getBuildStats(filterAppId.value)
if (res?.data?.code === 200) {
const stats = res.data.data
buildStats[0].value = stats.total || 0
buildStats[1].value = stats.running || 0
buildStats[2].value = stats.success || 0
buildStats[3].value = stats.failed || 0
}
} catch (e) {
console.error('加载统计数据失败:', e)
}
}
function updateStats() {
buildStats[0].value = builds.value.length
buildStats[1].value = builds.value.filter(b => b.status === 'pending' || b.status === 'running').length
buildStats[2].value = builds.value.filter(b => b.status === 'success').length
buildStats[3].value = builds.value.filter(b => b.status === 'failed').length
}
// ========== 操作 ==========
async function handleTrigger() {
if (!triggerForm.appId) {
message.error('请选择应用')
return
}
triggering.value = true
try {
const res = await triggerBuild(triggerForm.appId, triggerForm.branch || undefined)
if (res?.data?.code === 200) {
message.success('构建已触发!')
showTriggerModal.value = false
resetTriggerForm()
loadBuilds()
} else {
message.error(res?.data?.message || '触发失败')
}
} catch (e: any) {
message.error(e?.message || '触发构建失败')
} finally {
triggering.value = false
}
}
function handleCancel(build: AppBuild) {
cancelBuild(build.id!).then(res => {
if (res?.data?.code === 200) {
message.success('构建已取消')
loadBuilds()
} else {
message.error(res?.data?.message || '取消失败')
}
})
}
function handleRetry(build: AppBuild) {
retryBuild(build.id!).then(res => {
if (res?.data?.code === 200) {
message.success('构建已重试')
loadBuilds()
} else {
message.error(res?.data?.message || '重试失败')
}
})
}
async function viewLog(build: AppBuild) {
currentBuild.value = build
showLogModal.value = true
buildLog.value = ''
try {
const res = await getBuildLog(build.id!)
if (res?.data?.code === 200) {
buildLog.value = res.data.data?.log || '暂无日志'
} else {
buildLog.value = res?.data?.message || '获取日志失败'
}
} catch (e: any) {
buildLog.value = '获取日志失败: ' + (e?.message || '未知错误')
}
}
function handleAppChange(appId: number) {
triggerForm.pipelineId = undefined
triggerForm.branch = ''
if (appId) {
loadPipelines(appId)
}
}
function resetTriggerForm() {
triggerForm.appId = undefined
triggerForm.pipelineId = undefined
triggerForm.branch = ''
pipelines.value = []
}
function handlePageChange(page: number, pageSize: number) {
pagination.current = page
pagination.pageSize = pageSize
loadBuilds()
}
// ========== 辅助函数 ==========
function statusText(status?: string) {
const map: Record<string, string> = {
pending: '排队中',
running: '构建中',
success: '成功',
failed: '失败',
cancelled: '已取消',
}
return map[status || ''] || '未知'
}
function ciTypeText(type?: string) {
const map: Record<string, string> = {
gitea: 'Gitea',
jenkins: 'Jenkins',
github: 'GitHub',
}
return map[type || ''] || type || 'CI'
}
function formatTime(time?: string) {
if (!time) return ''
return new Date(time).toLocaleString('zh-CN')
}
function formatDuration(seconds?: number) {
if (!seconds) return ''
if (seconds < 60) return `${seconds}s`
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
if (mins < 60) return `${mins}m ${secs}s`
const hours = Math.floor(mins / 60)
const mins2 = mins % 60
return `${hours}h ${mins2}m`
}
// 初始化
onMounted(() => {
loadApps()
loadBuilds()
loadStats()
})
</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;
}
.page-desc {
font-size: 13px;
color: #9ca3af;
margin: 4px 0 0;
}
/* 统计卡片 */
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 1px solid transparent;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-1px); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0,0,0,0.85); }
.stat-label { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 2px; }
/* 面板 */
.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);
}
/* 构建列表 */
.build-list {
padding: 0;
}
.build-item {
border-bottom: 1px solid #f0f0f0;
transition: background 0.2s;
}
.build-item:last-child {
border-bottom: none;
}
.build-item:hover {
background: #fafafa;
}
.build-status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 18px;
background: #f9f9f9;
border-bottom: 1px solid #f0f0f0;
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #999;
}
.status-pending .status-dot { background: #faad14; }
.status-running .status-dot { background: #1890ff; animation: pulse 1s infinite; }
.status-success .status-dot { background: #52c41a; }
.status-failed .status-dot { background: #ff4d4f; }
.status-cancelled .status-dot { background: #d9d9d9; }
.status-text {
font-size: 12px;
font-weight: 500;
}
.status-pending .status-text { color: #faad14; }
.status-running .status-text { color: #1890ff; }
.status-success .status-text { color: #52c41a; }
.status-failed .status-text { color: #ff4d4f; }
.status-cancelled .status-text { color: #999; }
.build-time {
font-size: 12px;
color: rgba(0,0,0,0.45);
}
.build-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
gap: 16px;
}
.build-main {
flex: 1;
min-width: 0;
}
.build-number {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.ci-badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
}
.ci-badge.gitea { background: #f0f0f0; color: #333; }
.ci-badge.jenkins { background: #dbeafe; color: #1d4ed8; }
.ci-badge.github { background: #f0f9ff; color: #0366d6; }
.number-text {
font-size: 14px;
font-weight: 600;
color: rgba(0,0,0,0.85);
}
.build-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: rgba(0,0,0,0.45);
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.commit-message {
margin-top: 6px;
font-size: 12px;
color: rgba(0,0,0,0.65);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 500px;
}
.build-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.build-error {
padding: 10px 18px;
background: #fff2f0;
border-top: 1px solid #ffccc7;
font-size: 12px;
color: #ff4d4f;
}
.py-8 { padding: 40px 0; }
.mb-6 { margin-bottom: 24px; }
.mb-4 { margin-bottom: 16px; }
.empty-icon {
font-size: 48px;
margin-bottom: 8px;
}
/* 日志 */
.log-container {
background: #1e1e1e;
border-radius: 8px;
padding: 16px;
max-height: 500px;
overflow: auto;
}
.log-content {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
line-height: 1.6;
color: #d4d4d4;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
.pagination-wrapper {
padding: 16px;
display: flex;
justify-content: flex-end;
border-top: 1px solid #f0f0f0;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>