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

654 lines
17 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">配置 CI/CD 流水线对接 Gitea CI / Jenkins / GitHub Actions</p>
</div>
<a-space>
<a-button @click="loadPipelines">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button type="primary" @click="showPipelineModal = true">
<template #icon><PlusOutlined /></template>
新建流水线
</a-button>
</a-space>
</div>
<!-- 选择应用 -->
<div class="panel mb-4">
<div class="panel-header">
<span class="panel-title">📦 选择应用</span>
</div>
<div class="p-4">
<a-select
v-model:value="selectedAppId"
style="width: 300px"
placeholder="选择要管理流水线的应用"
:loading="loadingApps"
allow-clear
@change="handleAppChange"
>
<a-select-option v-for="app in apps" :key="app.productId" :value="app.productId">
{{ app.productName }}
</a-select-option>
</a-select>
</div>
</div>
<!-- 流水线列表 -->
<div v-if="selectedAppId" class="panel">
<div class="panel-header">
<span class="panel-title"> 流水线列表</span>
<a-tag v-if="pipelines.length > 0">{{ pipelines.length }} 条流水线</a-tag>
</div>
<a-spin :spinning="loading">
<div v-if="pipelines.length > 0" class="pipeline-list">
<div v-for="pipeline in pipelines" :key="pipeline.id" class="pipeline-item">
<div class="pipeline-header">
<div class="pipeline-info">
<span class="pipeline-name">{{ pipeline.name }}</span>
<a-tag :color="pipeline.enabled ? 'green' : 'default'" size="small">
{{ pipeline.enabled ? '启用' : '禁用' }}
</a-tag>
<a-tag size="small">{{ ciTypeText(pipeline.ciType) }}</a-tag>
<a-tag v-if="pipeline.autoDeploy" color="blue" size="small">自动部署</a-tag>
</div>
<div class="pipeline-actions">
<a-switch
:checked="pipeline.enabled"
size="small"
@change="(checked: boolean) => handleToggle(pipeline, checked)"
/>
<a-button type="link" size="small" @click="handleEdit(pipeline)">编辑</a-button>
<a-popconfirm title="确定要删除此流水线?" @confirm="handleDelete(pipeline)">
<a-button danger type="link" size="small">删除</a-button>
</a-popconfirm>
</div>
</div>
<div class="pipeline-body">
<div class="pipeline-desc">{{ pipeline.description || '暂无描述' }}</div>
<div class="pipeline-meta">
<span v-if="pipeline.repoFullName" class="meta-item">
<GithubOutlined /> {{ pipeline.repoFullName }}
</span>
<span v-if="pipeline.workflowFile" class="meta-item">
<FileOutlined /> {{ pipeline.workflowFile }}
</span>
<span class="meta-item">
<BranchesOutlined /> {{ pipeline.defaultBranch || 'main' }}
</span>
<span class="meta-item">
<NodeIndexOutlined /> {{ stagesText(pipeline.stages) }}
</span>
</div>
<div class="pipeline-stats">
<span class="stat-item success">
<CheckCircleOutlined /> {{ pipeline.successCount || 0 }} 成功
</span>
<span class="stat-item failed">
<CloseCircleOutlined /> {{ pipeline.failureCount || 0 }} 失败
</span>
<span v-if="pipeline.lastBuildTime" class="stat-item">
<ClockCircleOutlined /> {{ formatTime(pipeline.lastBuildTime) }}
</span>
</div>
</div>
<div v-if="pipeline.lastBuildStatus" class="pipeline-last-build" :class="pipeline.lastBuildStatus">
最近构建: <span>{{ statusText(pipeline.lastBuildStatus) }}</span>
</div>
</div>
</div>
<a-empty v-else description="暂无流水线配置" class="py-8">
<template #image>
<div class="empty-icon"></div>
</template>
<a-button type="primary" @click="showPipelineModal = true">创建第一条流水线</a-button>
</a-empty>
</a-spin>
</div>
<!-- 未选择应用提示 -->
<div v-else class="panel">
<div class="empty-state">
<div class="empty-icon">📦</div>
<div class="empty-title">请先选择应用</div>
<div class="empty-desc">选择应用后可以查看和管理该应用的 CI/CD 流水线</div>
</div>
</div>
<!-- 新建/编辑流水线弹窗 -->
<a-modal
v-model:open="showPipelineModal"
:title="editingPipeline ? '编辑流水线' : '新建流水线'"
width="600px"
:confirm-loading="submitting"
@ok="handleSubmit"
@cancel="resetForm"
>
<a-form :model="form" layout="vertical">
<a-form-item label="流水线名称" required>
<a-input v-model:value="form.name" placeholder="如:生产环境构建" />
</a-form-item>
<a-form-item label="描述">
<a-textarea v-model:value="form.description" :rows="2" placeholder="描述此流水线的用途" />
</a-form-item>
<a-form-item label="CI 系统" required>
<a-radio-group v-model:value="form.ciType">
<a-radio value="gitea">Gitea CI</a-radio>
<a-radio value="jenkins">Jenkins</a-radio>
<a-radio value="github">GitHub Actions</a-radio>
</a-radio-group>
</a-form-item>
<template v-if="form.ciType === 'gitea'">
<a-form-item label="仓库全称" required>
<a-input v-model:value="form.repoFullName" placeholder="如gxwebsoft/my-app" />
<div class="form-hint">Gitea 上的仓库完整名称用户名/仓库名</div>
</a-form-item>
<a-form-item label="工作流文件">
<a-input v-model:value="form.workflowFile" placeholder="build.yml" />
<div class="form-hint">.gitea/workflows/ 目录下的工作流文件名</div>
</a-form-item>
</template>
<template v-if="form.ciType === 'jenkins'">
<a-form-item label="Jenkins Job 名称" required>
<a-input v-model:value="form.repoFullName" placeholder="如my-app-build" />
</a-form-item>
</template>
<template v-if="form.ciType === 'github'">
<a-form-item label="仓库全称" required>
<a-input v-model:value="form.repoFullName" placeholder="如owner/repo" />
</a-form-item>
<a-form-item label="工作流文件">
<a-input v-model:value="form.workflowFile" placeholder=".github/workflows/build.yml" />
</a-form-item>
</template>
<a-form-item label="环境" required>
<a-radio-group v-model:value="form.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="form.defaultBranch" placeholder="main" />
</a-form-item>
<a-form-item label="流水线阶段">
<a-checkbox-group v-model:value="form.selectedStages">
<a-checkbox value="build">构建</a-checkbox>
<a-checkbox value="test">测试</a-checkbox>
<a-checkbox value="deploy">部署</a-checkbox>
</a-checkbox-group>
</a-form-item>
<a-form-item label="超时时间(秒)">
<a-input-number v-model:value="form.timeout" :min="60" :max="7200" style="width: 200px" />
</a-form-item>
<a-form-item>
<a-checkbox v-model:checked="form.autoDeploy">自动部署</a-checkbox>
<div class="form-hint">构建成功后自动触发部署流程</div>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import {
ReloadOutlined,
PlusOutlined,
GithubOutlined,
FileOutlined,
BranchesOutlined,
NodeIndexOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ClockCircleOutlined,
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import {
pagePipeline,
listPipelineByApp,
createPipeline,
updatePipeline,
deletePipeline,
togglePipeline,
} from '@/api/app/cicd'
import type { AppPipeline } from '@/api/app/cicd'
import { getDeveloperApps } from '@/api/app/appProduct'
import type { AppProduct } from '@/api/app/appProduct/model'
definePageMeta({ layout: 'developer' })
useHead({ title: '流水线管理 - 开发者中心' })
const userId = import.meta.client ? localStorage.getItem('UserId') : null
// 状态
const loading = ref(false)
const loadingApps = ref(false)
const submitting = ref(false)
const apps = ref<AppProduct[]>([])
const pipelines = ref<AppPipeline[]>([])
const selectedAppId = ref<number | undefined>()
// 弹窗
const showPipelineModal = ref(false)
const editingPipeline = ref<AppPipeline | null>(null)
// 表单
const form = reactive({
name: '',
description: '',
ciType: 'gitea' as 'gitea' | 'jenkins' | 'github',
repoFullName: '',
workflowFile: '',
env: 'production' as 'development' | 'staging' | 'production',
defaultBranch: 'main',
stages: '',
selectedStages: ['build', 'test', 'deploy'] as string[],
timeout: 3600,
autoDeploy: false,
})
// ========== 加载数据 ==========
async function loadApps() {
loadingApps.value = true
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)
} finally {
loadingApps.value = false
}
}
async function loadPipelines() {
if (!selectedAppId.value) {
pipelines.value = []
return
}
loading.value = true
try {
const res = await listPipelineByApp(selectedAppId.value)
if (res?.data?.code === 200) {
pipelines.value = res.data.data || []
} else {
pipelines.value = []
}
} catch (e) {
console.error('加载流水线列表失败:', e)
pipelines.value = []
} finally {
loading.value = false
}
}
function handleAppChange(appId: number | undefined) {
selectedAppId.value = appId
if (appId) {
loadPipelines()
} else {
pipelines.value = []
}
}
// ========== 操作 ==========
function handleEdit(pipeline: AppPipeline) {
editingPipeline.value = pipeline
form.name = pipeline.name || ''
form.description = pipeline.description || ''
form.ciType = (pipeline.ciType as any) || 'gitea'
form.repoFullName = pipeline.repoFullName || ''
form.workflowFile = pipeline.workflowFile || ''
form.env = (pipeline.env as any) || 'production'
form.defaultBranch = pipeline.defaultBranch || 'main'
form.selectedStages = pipeline.stages ? pipeline.stages.split(',') : ['build', 'test', 'deploy']
form.timeout = pipeline.timeout || 3600
form.autoDeploy = pipeline.autoDeploy || false
showPipelineModal.value = true
}
async function handleToggle(pipeline: AppPipeline, enabled: boolean) {
try {
const res = await togglePipeline(pipeline.id!, enabled)
if (res?.data?.code === 200) {
message.success(enabled ? '流水线已启用' : '流水线已禁用')
loadPipelines()
} else {
message.error(res?.data?.message || '操作失败')
}
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
async function handleDelete(pipeline: AppPipeline) {
try {
const res = await deletePipeline(pipeline.id!)
if (res?.data?.code === 200) {
message.success('流水线已删除')
loadPipelines()
} else {
message.error(res?.data?.message || '删除失败')
}
} catch (e: any) {
message.error(e?.message || '删除失败')
}
}
async function handleSubmit() {
if (!form.name.trim()) {
message.error('请填写流水线名称')
return
}
if (!selectedAppId.value) {
message.error('请先选择应用')
return
}
submitting.value = true
try {
const data: Partial<AppPipeline> = {
appId: selectedAppId.value,
name: form.name,
description: form.description,
ciType: form.ciType,
repoFullName: form.repoFullName,
workflowFile: form.workflowFile,
env: form.env,
defaultBranch: form.defaultBranch,
stages: form.selectedStages.join(','),
timeout: form.timeout,
autoDeploy: form.autoDeploy,
}
let res
if (editingPipeline.value) {
data.id = editingPipeline.value.id
res = await updatePipeline(data)
} else {
res = await createPipeline(data)
}
if (res?.data?.code === 200) {
message.success(editingPipeline.value ? '流水线已更新' : '流水线已创建')
showPipelineModal.value = false
resetForm()
loadPipelines()
} else {
message.error(res?.data?.message || '操作失败')
}
} catch (e: any) {
message.error(e?.message || '操作失败')
} finally {
submitting.value = false
}
}
function resetForm() {
form.name = ''
form.description = ''
form.ciType = 'gitea'
form.repoFullName = ''
form.workflowFile = ''
form.env = 'production'
form.defaultBranch = 'main'
form.selectedStages = ['build', 'test', 'deploy']
form.timeout = 3600
form.autoDeploy = false
editingPipeline.value = null
}
// ========== 辅助函数 ==========
function ciTypeText(type?: string) {
const map: Record<string, string> = {
gitea: 'Gitea CI',
jenkins: 'Jenkins',
github: 'GitHub Actions',
}
return map[type || ''] || type || 'CI'
}
function stagesText(stages?: string) {
if (!stages) return '未知'
const map: Record<string, string> = {
build: '构建',
test: '测试',
deploy: '部署',
}
return stages.split(',').map(s => map[s] || s).join(' → ')
}
function statusText(status?: string) {
const map: Record<string, string> = {
pending: '排队中',
running: '构建中',
success: '成功',
failed: '失败',
cancelled: '已取消',
}
return map[status || ''] || status || '未知'
}
function formatTime(time?: string) {
if (!time) return ''
return new Date(time).toLocaleString('zh-CN')
}
// 初始化
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;
}
.page-desc {
font-size: 13px;
color: #9ca3af;
margin: 4px 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);
}
/* 流水线列表 */
.pipeline-list {
padding: 0;
}
.pipeline-item {
border-bottom: 1px solid #f0f0f0;
transition: background 0.2s;
}
.pipeline-item:last-child {
border-bottom: none;
}
.pipeline-item:hover {
background: #fafafa;
}
.pipeline-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
}
.pipeline-info {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pipeline-name {
font-size: 15px;
font-weight: 600;
color: rgba(0,0,0,0.85);
}
.pipeline-actions {
display: flex;
align-items: center;
gap: 8px;
}
.pipeline-body {
padding: 14px 18px;
}
.pipeline-desc {
font-size: 13px;
color: rgba(0,0,0,0.65);
margin-bottom: 10px;
}
.pipeline-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 12px;
color: rgba(0,0,0,0.45);
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.pipeline-stats {
display: flex;
gap: 16px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #f0f0f0;
}
.stat-item {
font-size: 12px;
color: rgba(0,0,0,0.45);
display: flex;
align-items: center;
gap: 4px;
}
.stat-item.success { color: #52c41a; }
.stat-item.failed { color: #ff4d4f; }
.pipeline-last-build {
padding: 8px 18px;
background: #f9f9f9;
font-size: 12px;
color: rgba(0,0,0,0.45);
}
.pipeline-last-build span {
font-weight: 500;
}
.pipeline-last-build.success span { color: #52c41a; }
.pipeline-last-build.failed span { color: #ff4d4f; }
.pipeline-last-build.running span { color: #1890ff; }
.pipeline-last-build.pending span { color: #faad14; }
/* 空状态 */
.empty-state {
padding: 60px 20px;
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 8px;
}
.empty-title {
font-size: 16px;
font-weight: 500;
color: rgba(0,0,0,0.85);
margin-bottom: 4px;
}
.empty-desc {
font-size: 14px;
color: rgba(0,0,0,0.45);
}
.py-8 { padding: 40px 0; }
.mb-4 { margin-bottom: 16px; }
.form-hint {
font-size: 12px;
color: rgba(0,0,0,0.38);
margin-top: 4px;
}
</style>