初始化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,653 @@
<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>