feat: 完善运维控制台产品化体验

This commit is contained in:
湛兮
2026-06-12 16:36:20 +08:00
parent 0f92eb7b2b
commit 878125fe76
14 changed files with 2957 additions and 129 deletions
+376 -13
View File
@@ -2,13 +2,82 @@
/**
* 项目配置页展示项目仓库、发布环境、Gitea 仓库诊断和真实 Jenkins job 诊断状态。
*/
import { onMounted, ref } from 'vue'
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, ProjectConfig } from '../types/devops'
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()
@@ -80,23 +149,136 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
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>
<ElButton type="primary" plain disabled>新增项目</ElButton>
</div>
<section class="panel">
<div class="panel-header">
<h2>项目清单</h2>
<h1>项目配置</h1>
<p>展示后端返回的项目环境Jenkins JobGitea 仓库和通知通道绑定关系</p>
</div>
<div class="toolbar">
<ElTag type="info" round>后端配置源</ElTag>
<ElButton
size="small"
:icon="Refresh"
type="primary"
plain
:loading="diagnosticsLoading"
@click="refreshProjectDiagnostics"
@@ -104,8 +286,55 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
刷新项目诊断
</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">
<ElTable :data="platform.projects" row-key="key">
<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">
@@ -216,6 +445,23 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
</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 }">
@@ -234,6 +480,30 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
</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>
@@ -241,6 +511,66 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
</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));
@@ -295,6 +625,24 @@ dd {
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;
@@ -340,7 +688,22 @@ dd {
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;