初始化2

This commit is contained in:
2026-04-08 17:10:58 +08:00
commit 4986d90eb9
532 changed files with 112617 additions and 0 deletions

View File

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