Files
devops-platform-web/src/views/ProjectsView.vue
T
2026-06-12 16:36:20 +08:00

713 lines
20 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.
<script setup lang="ts">
/**
* 项目配置页展示项目仓库、发布环境、Gitea 仓库诊断和真实 Jenkins job 诊断状态。
*/
import { Promotion, Refresh, Search } from '@element-plus/icons-vue'
import { computed, onMounted, ref } from 'vue'
import StatusPill from '../components/StatusPill.vue'
import { usePlatformStore } from '../stores/platform'
import type { EnvironmentStatus, HealthState, ProjectConfig, ProjectPermissionLevel } from '../types/devops'
const platform = usePlatformStore()
const diagnosticsLoading = ref(false)
const searchKeyword = ref('')
const readinessFilter = ref<ProjectReadinessState | ''>('')
const permissionFilter = ref<ProjectPermissionLevel | ''>('')
type ProjectReadinessState = 'ready' | 'attention' | 'blocked' | 'readonly'
const readinessOptions: Array<{ label: string; value: ProjectReadinessState }> = [
{ label: '可发布', value: 'ready' },
{ label: '需关注', value: 'attention' },
{ label: '有阻断', value: 'blocked' },
{ label: '只读', value: 'readonly' },
]
const permissionOptions: Array<{ label: string; value: ProjectPermissionLevel }> = [
{ label: '构建权限', value: 'build' },
{ label: '只读权限', value: 'read' },
]
const filteredProjects = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return platform.projects.filter((project) => {
if (readinessFilter.value && projectReadinessState(project) !== readinessFilter.value) {
return false
}
if (permissionFilter.value && platform.projectPermissionLevel(project.key) !== permissionFilter.value) {
return false
}
if (!keyword) {
return true
}
return [
project.name,
project.key,
project.serviceType,
project.repository,
project.defaultBranch,
project.tagPolicy,
project.permissionLevelName,
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.some((value) => value.toLowerCase().includes(keyword))
})
})
const projectOverview = computed(() => {
const ready = platform.projects.filter((project) => projectReadinessState(project) === 'ready').length
const attention = platform.projects.filter((project) => projectReadinessState(project) === 'attention').length
const blocked = platform.projects.filter((project) => projectReadinessState(project) === 'blocked').length
const readOnly = platform.projects.filter((project) => projectReadinessState(project) === 'readonly').length
return [
{ key: 'total', label: '纳管项目', value: platform.projects.length, tone: 'neutral' },
{ key: 'ready', label: '可发布', value: ready, tone: 'success' },
{ key: 'attention', label: '需关注', value: attention, tone: attention > 0 ? 'warning' : 'neutral' },
{ key: 'blocked', label: '有阻断', value: blocked, tone: blocked > 0 ? 'danger' : 'neutral' },
{ key: 'readonly', label: '只读项目', value: readOnly, tone: readOnly > 0 ? 'muted' : 'neutral' },
]
})
const hasActiveFilters = computed(() => Boolean(
searchKeyword.value.trim() ||
readinessFilter.value ||
permissionFilter.value,
))
onMounted(() => {
void refreshProjectDiagnostics()
})
async function refreshProjectDiagnostics() {
diagnosticsLoading.value = true
try {
await Promise.all([
platform.loadProjectJenkinsDiagnostics(),
platform.loadProjectGiteaDiagnostics(),
])
} finally {
diagnosticsLoading.value = false
}
}
function lastBuildText(env: EnvironmentStatus): string {
if (!env.jenkinsLastBuild?.number) {
return '-'
}
if (env.jenkinsLastBuild.building) {
return `#${env.jenkinsLastBuild.number} 运行中`
}
return `#${env.jenkinsLastBuild.number} ${env.jenkinsLastBuild.result || 'UNKNOWN'}`
}
function commitText(project: ProjectConfig): string {
const commit = project.giteaLatestCommit
if (!commit?.sha) {
return '-'
}
const title = commit.message.split('\n')[0] || '无提交说明'
return `${commit.sha.slice(0, 8)} · ${title}`
}
function branchTagText(project: ProjectConfig): string {
if (project.giteaBranchCount === undefined && project.giteaTagCount === undefined) {
return '尚未诊断'
}
const branchCount = project.giteaBranchCount ?? 0
const tagCount = project.giteaTagCount ?? 0
return `${branchCount} branches · ${tagCount} tags`
}
function webhookCountText(project: ProjectConfig): string {
if (
project.giteaWebhookHookCount === undefined &&
project.giteaWebhookActivePushHookCount === undefined
) {
return '尚未诊断'
}
return `${project.giteaWebhookActivePushHookCount ?? 0}/${project.giteaWebhookHookCount ?? 0} 个 active push hook`
}
function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): string {
const events = hook.events.length > 0 ? hook.events.join(', ') : '无事件'
const branch = hook.branchFilter ? ` · ${hook.branchFilter}` : ''
const matched = hook.matchesExpectedRelay ? ' · relay 匹配' : ''
const active = hook.active ? '启用' : '停用'
return `#${hook.id} ${hook.type} · ${active} · ${events}${branch}${matched}`
}
function projectReadinessState(project: ProjectConfig): ProjectReadinessState {
if (projectHasBlockingIssue(project)) {
return 'blocked'
}
if (projectHasWarningIssue(project)) {
return 'attention'
}
if (!platform.canBuildProject(project.key)) {
return 'readonly'
}
return 'ready'
}
function projectReadinessLabel(project: ProjectConfig): string {
const state = projectReadinessState(project)
if (state === 'ready') {
return '可发布'
}
if (state === 'blocked') {
return '有阻断'
}
if (state === 'readonly') {
return '只读'
}
return '需关注'
}
function projectReadinessTagType(
project: ProjectConfig,
): 'success' | 'warning' | 'danger' | 'info' {
const state = projectReadinessState(project)
if (state === 'ready') {
return 'success'
}
if (state === 'blocked') {
return 'danger'
}
return state === 'attention' ? 'warning' : 'info'
}
function projectReadinessSummary(project: ProjectConfig): string {
const issues = projectIssueSummaries(project)
if (issues.length > 0) {
return issues[0]
}
if (!platform.canBuildProject(project.key)) {
return '当前账号只有只读权限'
}
return 'Gitea、Jenkins、Webhook 和通知链路均未发现异常'
}
function projectIssueSummaries(project: ProjectConfig): string[] {
const issues: string[] = []
appendHealthIssue(issues, 'Gitea 仓库', project.giteaStatus, project.giteaMessage)
appendHealthIssue(issues, 'Webhook / Relay', project.giteaWebhookStatus, project.giteaWebhookMessage)
for (const env of project.environments) {
appendHealthIssue(issues, `${env.label} Jenkins`, env.jenkinsStatus, env.jenkinsMessage)
}
appendHealthIssue(issues, '通知链路', project.integrations.wecom)
return issues
}
function appendHealthIssue(
issues: string[],
label: string,
state: HealthState,
message?: string,
) {
if (state === 'healthy') {
return
}
const stateText = state === 'warning' ? '待配置' : state === 'offline' ? '离线' : '异常'
issues.push(`${label}${message ? `${message}` : `${stateText}`}`)
}
function projectHasBlockingIssue(project: ProjectConfig): boolean {
return projectHealthStates(project).some((state) => state === 'error' || state === 'offline')
}
function projectHasWarningIssue(project: ProjectConfig): boolean {
return projectHealthStates(project).some((state) => state === 'warning')
}
function projectHealthStates(project: ProjectConfig): HealthState[] {
return [
project.giteaStatus,
project.giteaWebhookStatus,
project.integrations.wecom,
...project.environments.map((env) => env.jenkinsStatus),
]
}
function resetProjectFilters() {
searchKeyword.value = ''
readinessFilter.value = ''
permissionFilter.value = ''
}
</script>
<template>
<div class="page-shell">
<div class="page-header">
<div>
<h1>项目配置</h1>
<p>展示后端返回的项目环境Jenkins JobGitea 仓库和通知通道绑定关系</p>
</div>
<div class="toolbar">
<ElTag type="info" round>后端配置源</ElTag>
<ElButton
:icon="Refresh"
type="primary"
plain
:loading="diagnosticsLoading"
@click="refreshProjectDiagnostics"
>
刷新项目诊断
</ElButton>
</div>
</div>
<section class="project-overview-grid">
<article
v-for="item in projectOverview"
:key="item.key"
class="project-overview-card"
:class="`tone-${item.tone}`"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</section>
<section class="panel">
<div class="panel-header">
<h2>项目清单</h2>
<span>{{ filteredProjects.length }} / {{ platform.projects.length }} 个项目</span>
</div>
<div class="panel-body">
<div class="project-filter-bar">
<ElInput
v-model="searchKeyword"
clearable
:prefix-icon="Search"
placeholder="搜索项目 / 仓库 / 默认分支"
class="project-search-input"
/>
<ElSelect v-model="readinessFilter" clearable placeholder="发布态">
<ElOption
v-for="option in readinessOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElSelect v-model="permissionFilter" clearable placeholder="权限">
<ElOption
v-for="option in permissionOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElButton :disabled="!hasActiveFilters" @click="resetProjectFilters">
重置筛选
</ElButton>
</div>
<ElTable :data="filteredProjects" row-key="key" empty-text="没有符合条件的项目">
<ElTableColumn type="expand">
<template #default="{ row }">
<div class="env-config">
<div class="project-diagnostic-card">
<div class="env-config-head">
<strong>Gitea 仓库诊断</strong>
<StatusPill :state="row.giteaStatus" />
</div>
<dl>
<dt>仓库状态</dt>
<dd>
<StatusPill :state="row.giteaStatus" />
<span class="diagnostic-message">{{ row.giteaMessage }}</span>
</dd>
<dt>公开地址</dt>
<dd>
<a
v-if="row.giteaWebUrl"
:href="row.giteaWebUrl"
target="_blank"
rel="noreferrer"
>
{{ row.giteaWebUrl }}
</a>
<span v-else class="mono">{{ row.repository }}</span>
</dd>
<dt>默认分支</dt>
<dd>{{ row.giteaDefaultBranch || row.defaultBranch }}</dd>
<dt>分支 / Tag</dt>
<dd>{{ branchTagText(row) }}</dd>
<dt>默认分支 commit</dt>
<dd class="mono">{{ commitText(row) }}</dd>
<dt>Webhook / Relay</dt>
<dd>
<StatusPill :state="row.giteaWebhookStatus" />
<span class="diagnostic-message">{{ row.giteaWebhookMessage }}</span>
</dd>
<dt>Relay 目标</dt>
<dd class="mono">{{ row.giteaWebhookExpectedRelayTarget || '-' }}</dd>
<dt>Hook 数量</dt>
<dd>{{ webhookCountText(row) }}</dd>
<dt>Hook 明细</dt>
<dd>
<div v-if="row.giteaWebhookHooks.length > 0" class="webhook-hook-list">
<div
v-for="hook in row.giteaWebhookHooks"
:key="hook.id"
class="webhook-hook-item"
>
<span>{{ webhookHookText(hook) }}</span>
<small class="mono">{{ hook.target || '-' }}</small>
</div>
</div>
<span v-else>-</span>
</dd>
</dl>
</div>
<div v-for="env in row.environments" :key="env.name" class="env-config-card">
<div class="env-config-head">
<strong>{{ env.label }}</strong>
<StatusPill :state="env.status" />
</div>
<dl>
<dt>分支策略</dt>
<dd>{{ env.branchPolicy }}</dd>
<dt>Jenkins Job</dt>
<dd class="mono">{{ env.jenkinsJob }}</dd>
<dt>Job 状态</dt>
<dd>
<StatusPill :state="env.jenkinsStatus" />
<span class="diagnostic-message">{{ env.jenkinsMessage }}</span>
</dd>
<dt>最近构建</dt>
<dd>
<a
v-if="env.jenkinsLastBuild?.url"
:href="env.jenkinsLastBuild.url"
target="_blank"
rel="noreferrer"
>
{{ lastBuildText(env) }}
</a>
<span v-else>{{ lastBuildText(env) }}</span>
</dd>
<dt>通知通道</dt>
<dd>{{ env.wecomRobot }}</dd>
<dt>最近发布</dt>
<dd>
<div v-if="env.lastRunId !== '-'" class="latest-release">
<div class="latest-release-head">
<StatusPill :state="env.status" />
<RouterLink
class="run-link mono"
:to="{ name: 'runs', query: { runId: env.lastRunId } }"
>
{{ env.lastRunId }}
</RouterLink>
</div>
<small>
{{ env.version }} · {{ env.lastActor }} · {{ env.lastReleasedAt }}
</small>
</div>
<span v-else>暂无真实发布记录</span>
</dd>
</dl>
</div>
</div>
</template>
</ElTableColumn>
<ElTableColumn prop="name" label="项目" min-width="150" />
<ElTableColumn label="发布态" min-width="190">
<template #default="{ row }">
<div class="readiness-cell">
<ElTag :type="projectReadinessTagType(row)" effect="light" round>
{{ projectReadinessLabel(row) }}
</ElTag>
<small>{{ projectReadinessSummary(row) }}</small>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="权限" width="120">
<template #default="{ row }">
<ElTag :type="platform.canBuildProject(row.key) ? 'success' : 'info'" effect="plain" round>
{{ platform.canBuildProject(row.key) ? '构建' : '只读' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="serviceType" label="类型" min-width="130" />
<ElTableColumn prop="repository" label="Gitea 仓库" min-width="320">
<template #default="{ row }">
<span class="mono">{{ row.repository }}</span>
</template>
</ElTableColumn>
<ElTableColumn prop="defaultBranch" label="默认分支" width="120" />
<ElTableColumn prop="tagPolicy" label="Tag 策略" min-width="220" />
<ElTableColumn label="集成状态" width="280">
<template #default="{ row }">
<div class="status-tags">
<StatusPill :state="row.integrations.jenkins" text="Jenkins" />
<StatusPill :state="row.integrations.gitea" text="Gitea" />
<StatusPill :state="row.integrations.wecom" text="通知" />
<StatusPill :state="row.integrations.llm" text="LLM" />
</div>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="170" fixed="right">
<template #default="{ row }">
<div class="project-actions">
<RouterLink
v-slot="{ navigate }"
custom
:to="{ name: 'releases', query: { projectKey: row.key } }"
>
<ElButton
:icon="Promotion"
link
type="primary"
:disabled="!platform.canBuildProject(row.key)"
@click="navigate"
>
发布
</ElButton>
</RouterLink>
<RouterLink :to="{ name: 'runs', query: { projectKey: row.key } }">
<ElButton link type="primary">运行记录</ElButton>
</RouterLink>
</div>
</template>
</ElTableColumn>
</ElTable>
</div>
</section>
</div>
</template>
<style scoped>
.project-overview-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.project-overview-card {
min-width: 0;
padding: 12px 14px;
border: 1px solid #d8e2ef;
border-radius: 8px;
background: #fff;
}
.project-overview-card span {
display: block;
color: #64748b;
font-size: 12px;
}
.project-overview-card strong {
display: block;
margin-top: 6px;
color: #0f172a;
font-size: 22px;
line-height: 1;
}
.project-overview-card.tone-success {
border-color: #bbf7d0;
background: #f0fdf4;
}
.project-overview-card.tone-warning {
border-color: #fde68a;
background: #fffbeb;
}
.project-overview-card.tone-danger {
border-color: #fecaca;
background: #fef2f2;
}
.project-overview-card.tone-muted {
border-color: #e2e8f0;
background: #f8fafc;
}
.project-filter-bar {
display: grid;
grid-template-columns: minmax(260px, 1.4fr) 150px 150px max-content;
gap: 10px;
margin-bottom: 12px;
align-items: center;
}
.project-search-input {
min-width: 0;
}
.env-config {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 14px 46px;
background: #f8fafc;
}
.env-config-card {
padding: 14px;
border: 1px solid #e1e8f2;
border-radius: 8px;
background: #ffffff;
}
.project-diagnostic-card {
grid-column: 1 / -1;
padding: 14px;
border: 1px solid #d7e5ff;
border-radius: 8px;
background: #ffffff;
}
.env-config-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
dl {
display: grid;
grid-template-columns: 100px minmax(0, 1fr);
gap: 8px 12px;
margin: 0;
color: #596578;
font-size: 13px;
}
dt {
color: #7b8798;
}
dd {
margin: 0;
word-break: break-word;
}
.status-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.readiness-cell {
display: grid;
gap: 6px;
min-width: 0;
}
.readiness-cell small {
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.project-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.diagnostic-message {
margin-left: 8px;
color: #596578;
font-size: 12px;
}
.latest-release {
display: grid;
gap: 6px;
}
.latest-release-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.latest-release small {
color: #7b8798;
}
.webhook-hook-list {
display: grid;
gap: 6px;
}
.webhook-hook-item {
display: grid;
gap: 3px;
}
.webhook-hook-item small {
color: #7b8798;
}
.run-link {
color: #2563eb;
text-decoration: none;
}
.run-link:hover {
text-decoration: underline;
}
@media (max-width: 1080px) {
.project-overview-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.project-filter-bar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.project-overview-grid,
.project-filter-bar {
grid-template-columns: 1fr;
}
.env-config {
grid-template-columns: 1fr;
padding: 12px;
}
}
</style>