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
+1 -1
View File
@@ -29,5 +29,5 @@
- BPMN 组件只展示流程图、节点状态和节点详情,不推进状态机。 - BPMN 组件只展示流程图、节点状态和节点详情,不推进状态机。
- `step.bpmnNodeId` 必须与 BPMN XML 节点 id 一致。 - `step.bpmnNodeId` 必须与 BPMN XML 节点 id 一致。
- Agent 只能作为全局右侧抽屉工具存在,不作为独立侧边栏路由;能力只允许 DevOps 场景:发布风险摘要、失败诊断、Runbook 问答、发布说明草稿。 - Agent 只能作为全局右侧抽屉工具存在,不作为独立侧边栏路由;能力只允许 DevOps 场景:发布风险摘要、失败诊断、Runbook 问答、发布说明草稿和事故复盘草稿
- 禁止在前端新增泛聊天入口。 - 禁止在前端新增泛聊天入口。
+8 -4
View File
@@ -6,11 +6,14 @@ Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于
- 登录、总览、项目配置、成员权限、发布中心、运行记录、系统配置,以及全局右侧 Agent 抽屉。 - 登录、总览、项目配置、成员权限、发布中心、运行记录、系统配置,以及全局右侧 Agent 抽屉。
- 所有项目、发布、refs、流程和集成状态均调用 `devops-platform-api`,前端不再提供本地 mock 数据降级。 - 所有项目、发布、refs、流程和集成状态均调用 `devops-platform-api`,前端不再提供本地 mock 数据降级。
- 成员权限页调用后端 `/members``/messages`,维护普通成员账号、项目权限、密码重置和平台消息 - 总览页提供值班焦点、优先 Run 队列、项目发布健康、项目环境摘要和跨页快捷入口,用于快速定位失败发布、项目阻断、配置缺口和通知 outbox 风险
- 发布中心包含项目、环境、ref/tag、备注、生产确认提示、触发、取消、重试和 Jenkins 状态同步入口 - 成员权限页调用后端 `/members``/messages`,展示账号/构建权限/安全状态概览,支持角色、启停、安全状态和关键词筛选,并维护普通成员账号、项目权限、密码重置和平台消息
- 运行记录展示 Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要 - 项目配置提供项目可发布性概览、项目/仓库搜索、发布态/权限筛选、Gitea/Jenkins/Webhook 诊断展开和发布/运行记录上下文跳转
- 发布中心包含项目、环境、后端返回的 ref/tag、备注、发布前检查、生产发布回滚/窗口/通知确认、触发、取消、重试和 Jenkins 状态同步入口。
- 运行记录展示状态概览、项目/环境/状态/关键词筛选、Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要,并提供超级管理员导入 Jenkins 直接构建记录的入口。
- 系统配置页展示集成配置、Agent 密钥状态、通知 outbox 和审计日志概览,支持通知投递与审计记录筛选,用于定位配置缺失、通知失败和敏感操作。
- BPMN 状态区使用后端流程 XML 和 run steps 作为状态来源,左侧展示节点输出或 Jenkins 构建日志,右侧粘性展示进度统计、节点时间线和详情面板。 - BPMN 状态区使用后端流程 XML 和 run steps 作为状态来源,左侧展示节点输出或 Jenkins 构建日志,右侧粘性展示进度统计、节点时间线和详情面板。
- Agent 作为全局工具从顶部打开,只包含发布风险摘要、失败诊断、Runbook 问答、发布说明草稿,并通过后端 Agent API 创建调用记录。 - Agent 作为全局工具从顶部打开,只包含发布风险摘要、失败诊断、Runbook 问答、发布说明草稿和事故复盘草稿,并通过后端 Agent API 创建调用记录。
## 技术栈 ## 技术栈
@@ -65,5 +68,6 @@ pnpm build
- 失败诊断 - 失败诊断
- Runbook 问答 - Runbook 问答
- 发布说明草稿 - 发布说明草稿
- 事故复盘草稿
模型调用、脱敏、审计、白名单知识库和密钥管理由后端代理实现。 模型调用、脱敏、审计、白名单知识库和密钥管理由后端代理实现。
+8 -6
View File
@@ -16,13 +16,13 @@
## 页面结构 ## 页面结构
- `/login`:真实平台账号登录入口,支持 token 恢复和首次改密。 - `/login`:真实平台账号登录入口,支持 token 恢复和首次改密。
- `/overview`:总览,展示服务状态、最近发布和项目环境。 - `/overview`:总览,展示值班焦点、优先 Run 队列、项目发布健康、服务状态和项目环境快捷入口
- `/projects`:项目配置,展示 Gitea 仓库、Jenkins Job、通知通道环境策略。 - `/projects`:项目配置,展示项目可发布性概览、发布态/权限筛选、Gitea 仓库、Jenkins Job、通知通道环境策略和上下文跳转
- `/members`:成员权限,仅超级管理员可见,维护普通成员、项目权限和密码重置消息。 - `/members`:成员权限,仅超级管理员可见,展示账号/构建权限/安全状态概览,支持成员筛选,并维护普通成员、项目权限和密码重置消息。
- `/releases`:发布中心,提供发布参数、二次确认、触发、取消、重试和 BPMN 流程状态。 - `/releases`:发布中心,提供发布参数、后端 ref/tag 选择、发布前检查、生产发布确认、触发、取消、重试和 BPMN 流程状态。
- `/runs`:运行记录,展示 run 列表和流程轨迹 - `/runs`:运行记录,展示 run 状态概览、筛选列表、执行详情、流程轨迹,并支持超级管理员导入 Jenkins 直接构建记录
- 顶部 Agent 按钮:打开全局右侧抽屉,只保留 DevOps 场景能力;`/agent` 仅重定向到总览,不作为独立侧边栏入口。 - 顶部 Agent 按钮:打开全局右侧抽屉,只保留 DevOps 场景能力;`/agent` 仅重定向到总览,不作为独立侧边栏入口。
- `/settings`:系统配置,展示 API、BPMN、Agent、通知边界,并维护后端 Agent 模型配置。 - `/settings`:系统配置,展示集成配置、Agent、通知 outbox 和审计日志概览,支持通知/审计筛选,并维护后端 Agent 模型配置。
## 数据约定 ## 数据约定
@@ -36,6 +36,8 @@
- `devopsApi` 所有项目、发布、refs、流程、成员、消息和 Agent 调用均走后端接口。 - `devopsApi` 所有项目、发布、refs、流程、成员、消息和 Agent 调用均走后端接口。
- 发布中心触发按钮接入后端发布单创建接口。 - 发布中心触发按钮接入后端发布单创建接口。
- 运行记录接入后端 run/step 查询接口。 - 运行记录接入后端 run/step 查询接口。
- Jenkins 直接构建不由前端直连 Jenkins;运行记录页调用后端 `/deploy-runs/import-jenkins` 扫描已纳管 job 并导入 run。
- BPMN XML 由后端流程定义接口返回。 - BPMN XML 由后端流程定义接口返回。
- Agent 面板调用后端 Agent proxy,前端只传 DevOps 场景参数。 - Agent 面板调用后端 Agent proxy,前端只传 DevOps 场景参数。
- Agent 配置页调用后端 `/agent/config`,前端不持久化 key、baseURL 或 model。 - Agent 配置页调用后端 `/agent/config`,前端不持久化 key、baseURL 或 model。
- Agent 能力限定为发布风险摘要、失败诊断、Runbook 问答、发布说明草稿和事故复盘草稿。
+45 -5
View File
@@ -55,11 +55,27 @@ const failedRunOptions = computed(() => {
return platform.failedRuns.filter((run) => !selectedProject.value || run.projectKey === selectedProject.value) return platform.failedRuns.filter((run) => !selectedProject.value || run.projectKey === selectedProject.value)
}) })
const requiresRunSelection = computed(() =>
selectedMode.value === 'diagnosis' || selectedMode.value === 'incidentReview',
)
const sendDisabled = computed(() => {
if (sending.value || !prompt.value.trim()) {
return true
}
return requiresRunSelection.value && !selectedRun.value
})
const purpose = computed<AgentPurpose>(() => { const purpose = computed<AgentPurpose>(() => {
if (selectedMode.value === 'diagnosis') { if (selectedMode.value === 'diagnosis') {
return 'failure-diagnosis' return 'failure-diagnosis'
} }
if (selectedMode.value === 'incidentReview') {
return 'incident-review'
}
if (selectedMode.value === 'runbook') { if (selectedMode.value === 'runbook') {
return 'runbook-qa' return 'runbook-qa'
} }
@@ -82,18 +98,26 @@ watch(
) )
watch(failedRunOptions, (runs) => { watch(failedRunOptions, (runs) => {
if (selectedMode.value === 'diagnosis') { if (requiresRunSelection.value) {
selectedRun.value = runs[0]?.id ?? '' selectedRun.value = runs[0]?.id ?? ''
} }
}) })
watch(selectedMode, (mode) => { watch(selectedMode, (mode) => {
if (mode === 'diagnosis') { if (mode === 'diagnosis' || mode === 'incidentReview') {
selectedRun.value = failedRunOptions.value[0]?.id ?? '' selectedRun.value = failedRunOptions.value[0]?.id ?? ''
}
if (mode === 'diagnosis') {
prompt.value = '请根据最近失败 run 的 Jenkins 日志摘要和流程节点给出排查步骤。' prompt.value = '请根据最近失败 run 的 Jenkins 日志摘要和流程节点给出排查步骤。'
return return
} }
if (mode === 'incidentReview') {
prompt.value = '请基于这个失败 run 生成事故复盘草稿,包含时间线、影响、根因假设和预防项。'
return
}
if (mode === 'runbook') { if (mode === 'runbook') {
prompt.value = '生产发布失败后,应该先检查哪些平台信息?' prompt.value = '生产发布失败后,应该先检查哪些平台信息?'
return return
@@ -147,7 +171,7 @@ async function sendPrompt() {
const invocation = await devopsApi.createAgentInvocation({ const invocation = await devopsApi.createAgentInvocation({
type: purpose.value, type: purpose.value,
projectKey: selectedProject.value || undefined, projectKey: selectedProject.value || undefined,
runId: selectedMode.value === 'diagnosis' ? selectedRun.value || undefined : undefined, runId: requiresRunSelection.value ? selectedRun.value || undefined : undefined,
promptSummary: text, promptSummary: text,
}) })
@@ -240,6 +264,7 @@ function formatTime(value: string): string {
{ label: '诊断', value: 'diagnosis' }, { label: '诊断', value: 'diagnosis' },
{ label: 'Runbook', value: 'runbook' }, { label: 'Runbook', value: 'runbook' },
{ label: '发布说明', value: 'releaseNotes' }, { label: '发布说明', value: 'releaseNotes' },
{ label: '复盘', value: 'incidentReview' },
]" ]"
/> />
@@ -253,7 +278,7 @@ function formatTime(value: string): string {
</ElSelect> </ElSelect>
<ElSelect <ElSelect
v-if="selectedMode === 'diagnosis'" v-if="requiresRunSelection"
v-model="selectedRun" v-model="selectedRun"
filterable filterable
placeholder="选择失败 Run" placeholder="选择失败 Run"
@@ -265,6 +290,9 @@ function formatTime(value: string): string {
:value="run.id" :value="run.id"
/> />
</ElSelect> </ElSelect>
<div v-if="requiresRunSelection && !selectedRun" class="agent-context-hint">
当前项目没有可用于分析的失败 run
</div>
</div> </div>
<div class="message-list"> <div class="message-list">
@@ -291,7 +319,13 @@ function formatTime(value: string): string {
resize="none" resize="none"
@keydown="handleComposerKeydown" @keydown="handleComposerKeydown"
/> />
<ElButton type="primary" :icon="Promotion" :loading="sending" @click="sendPrompt"> <ElButton
type="primary"
:icon="Promotion"
:loading="sending"
:disabled="sendDisabled"
@click="sendPrompt"
>
发送 发送
</ElButton> </ElButton>
</footer> </footer>
@@ -351,6 +385,12 @@ function formatTime(value: string): string {
width: 100%; width: 100%;
} }
.agent-context-hint {
color: #8a5a00;
font-size: 12px;
line-height: 1.5;
}
.message-list { .message-list {
display: flex; display: flex;
flex: 1; flex: 1;
+87
View File
@@ -116,6 +116,27 @@ async function logout() {
</ElDropdown> </ElDropdown>
</div> </div>
</ElHeader> </ElHeader>
<nav class="mobile-nav" aria-label="移动端模块导航">
<RouterLink
v-for="item in menus"
:key="`mobile-${item.path}`"
v-slot="{ navigate }"
custom
:to="item.path"
>
<button
class="mobile-nav-item"
:class="{ active: activeMenu === item.path }"
type="button"
@click="navigate"
>
<ElIcon>
<component :is="item.icon" />
</ElIcon>
<span>{{ item.label }}</span>
</button>
</RouterLink>
</nav>
<ElMain class="main-area" v-loading="platform.loading"> <ElMain class="main-area" v-loading="platform.loading">
<RouterView /> <RouterView />
</ElMain> </ElMain>
@@ -189,6 +210,51 @@ async function logout() {
background: #ffffff; background: #ffffff;
} }
.mobile-nav {
display: none;
gap: 8px;
max-width: 100%;
padding: 10px 12px;
overflow-x: auto;
border-bottom: 1px solid #e0e6ef;
background: #ffffff;
scrollbar-width: none;
}
.mobile-nav::-webkit-scrollbar {
display: none;
}
.mobile-nav-item {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 88px;
height: 38px;
padding: 0 12px;
border: 1px solid #d8e2ef;
border-radius: 8px;
background: #f8fafc;
color: #475467;
font-size: 13px;
white-space: nowrap;
cursor: pointer;
}
.mobile-nav-item.active {
border-color: #7aa7f7;
background: #edf4ff;
color: #1d5fd0;
font-weight: 600;
}
.mobile-nav-item .el-icon {
flex: 0 0 auto;
font-size: 15px;
}
.env-strip { .env-strip {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -207,6 +273,10 @@ async function logout() {
display: none; display: none;
} }
.mobile-nav {
display: flex;
}
.topbar { .topbar {
flex-wrap: wrap; flex-wrap: wrap;
height: auto; height: auto;
@@ -222,4 +292,21 @@ async function logout() {
width: 100%; width: 100%;
} }
} }
@media (max-width: 520px) {
.topbar > .toolbar {
width: 100%;
justify-content: space-between;
}
.topbar .env-strip {
order: 2;
width: 100%;
margin-top: 8px;
}
.mobile-nav-item {
min-width: 82px;
}
}
</style> </style>
+61 -2
View File
@@ -21,6 +21,7 @@ import type {
IntegrationConfigStatus, IntegrationConfigStatus,
IntegrationVariableHelp, IntegrationVariableHelp,
IntegrationStatus, IntegrationStatus,
JenkinsImportSummary,
JenkinsBuildLog, JenkinsBuildLog,
JenkinsSyncSummary, JenkinsSyncSummary,
LoginPayload, LoginPayload,
@@ -107,7 +108,7 @@ interface BackendDeployRun {
environment?: EnvironmentName environment?: EnvironmentName
ref?: string ref?: string
status?: BackendRunStatus status?: BackendRunStatus
trigger?: 'manual' | 'gitea_webhook' | 'retry' trigger?: 'manual' | 'gitea_webhook' | 'retry' | 'jenkins_external'
operator?: string operator?: string
jenkinsQueueId?: string | number jenkinsQueueId?: string | number
jenkinsBuildNumber?: number jenkinsBuildNumber?: number
@@ -129,6 +130,26 @@ interface BackendJenkinsSyncSummary {
}> }>
} }
interface BackendJenkinsImportSummary {
checkedAt: string
totalJobs: number
totalBuilds: number
importedRuns: BackendDeployRun[]
syncedRuns: BackendDeployRun[]
skippedBuilds: Array<{
projectKey: string
environment: EnvironmentName
buildNumber: number
reason: 'already_imported' | 'linked_platform_run' | 'parameter_mismatch'
}>
failedBuilds: Array<{
projectKey: string
environment: EnvironmentName
buildNumber?: number
message: string
}>
}
interface BackendProcessDefinition { interface BackendProcessDefinition {
key: 'release' key: 'release'
version: string version: string
@@ -205,6 +226,13 @@ const agentCards: AgentCard[] = [
inputScope: ['commit 列表', 'PR 信息', 'tag'], inputScope: ['commit 列表', 'PR 信息', 'tag'],
outputPreview: ['变更摘要', '验证范围', '回滚提示'], outputPreview: ['变更摘要', '验证范围', '回滚提示'],
}, },
{
mode: 'incidentReview',
title: '事故复盘草稿',
description: '基于失败 run、流程耗时、Jenkins 摘要和 Runbook 生成复盘草稿。',
inputScope: ['失败 run', 'BPMN 节点', '脱敏日志片段', 'Runbook'],
outputPreview: ['时间线', '根因假设', '修复和预防项'],
},
] ]
const settings: SystemSetting[] = [ const settings: SystemSetting[] = [
@@ -229,7 +257,7 @@ const settings: SystemSetting[] = [
name: 'Agent 边界', name: 'Agent 边界',
status: 'warning', status: 'warning',
owner: '后端 Agent proxy', owner: '后端 Agent proxy',
contract: '只支持发布风险、失败诊断、Runbook、发布说明类用途。', contract: '只支持发布风险、失败诊断、Runbook、发布说明和事故复盘五类用途。',
boundary: '无泛聊天入口;模型 key/baseURL/model 仅后端保存。', boundary: '无泛聊天入口;模型 key/baseURL/model 仅后端保存。',
}, },
{ {
@@ -439,6 +467,14 @@ export const devopsApi = {
return toJenkinsSyncSummary(data, projectList) return toJenkinsSyncSummary(data, projectList)
}, },
async importJenkinsBuilds(projectList: ProjectConfig[] = []): Promise<JenkinsImportSummary> {
const { data } = await http.post<BackendJenkinsImportSummary>(
'/deploy-runs/import-jenkins',
)
lastDataSource = 'backend'
return toJenkinsImportSummary(data, projectList)
},
async getJenkinsLog(runId: string, start = 0): Promise<JenkinsBuildLog> { async getJenkinsLog(runId: string, start = 0): Promise<JenkinsBuildLog> {
const { data } = await http.get<JenkinsBuildLog>(`/deploy-runs/${runId}/jenkins-log`, { const { data } = await http.get<JenkinsBuildLog>(`/deploy-runs/${runId}/jenkins-log`, {
params: { start }, params: { start },
@@ -1055,6 +1091,25 @@ function toJenkinsSyncSummary(
} }
} }
function toJenkinsImportSummary(
summary: BackendJenkinsImportSummary,
projectList: ProjectConfig[],
): JenkinsImportSummary {
return {
checkedAt: summary.checkedAt,
totalJobs: Number.isFinite(summary.totalJobs) ? summary.totalJobs : 0,
totalBuilds: Number.isFinite(summary.totalBuilds) ? summary.totalBuilds : 0,
importedRuns: (Array.isArray(summary.importedRuns) ? summary.importedRuns : []).map((run) =>
toReleaseRun(run, projectList),
),
syncedRuns: (Array.isArray(summary.syncedRuns) ? summary.syncedRuns : []).map((run) =>
toReleaseRun(run, projectList),
),
skippedBuilds: Array.isArray(summary.skippedBuilds) ? summary.skippedBuilds : [],
failedBuilds: Array.isArray(summary.failedBuilds) ? summary.failedBuilds : [],
}
}
function sanitizeAuditLogEntry(record: AuditLogEntry): AuditLogEntry { function sanitizeAuditLogEntry(record: AuditLogEntry): AuditLogEntry {
return { return {
id: safeText(record.id, 'audit-unknown'), id: safeText(record.id, 'audit-unknown'),
@@ -1216,6 +1271,10 @@ function triggerLabel(trigger: BackendDeployRun['trigger']): string {
return '失败后重试' return '失败后重试'
} }
if (trigger === 'jenkins_external') {
return 'Jenkins 直构导入'
}
return '人工触发' return '人工触发'
} }
+11
View File
@@ -16,6 +16,7 @@ import type {
ProjectJenkinsDiagnosticsSummary, ProjectJenkinsDiagnosticsSummary,
ProjectPermissionLevel, ProjectPermissionLevel,
ProjectRefs, ProjectRefs,
JenkinsImportSummary,
JenkinsSyncSummary, JenkinsSyncSummary,
NotificationOutboxMessage, NotificationOutboxMessage,
ReleaseRun, ReleaseRun,
@@ -140,6 +141,15 @@ export const usePlatformStore = defineStore('platform', () => {
return result return result
} }
async function importJenkinsBuilds(): Promise<JenkinsImportSummary> {
const result = await devopsApi.importJenkinsBuilds(projects.value)
result.importedRuns.forEach(upsertRun)
result.syncedRuns.forEach(upsertRun)
applyProjectRunSummaries()
dataSource.value = devopsApi.getLastDataSource()
return result
}
/** /**
* 发布单状态被其他请求推进时,只刷新 run 列表并同步项目环境摘要。 * 发布单状态被其他请求推进时,只刷新 run 列表并同步项目环境摘要。
*/ */
@@ -360,6 +370,7 @@ export const usePlatformStore = defineStore('platform', () => {
retryDeployRun, retryDeployRun,
syncJenkinsRun, syncJenkinsRun,
syncJenkinsRuns, syncJenkinsRuns,
importJenkinsBuilds,
refreshRuns, refreshRuns,
loadProjectRefs, loadProjectRefs,
loadProjectJenkinsDiagnostics, loadProjectJenkinsDiagnostics,
+30 -1
View File
@@ -22,7 +22,12 @@ export type StepStatus =
| 'skipped' | 'skipped'
| 'canceled' | 'canceled'
export type AgentMode = 'risk' | 'diagnosis' | 'runbook' | 'releaseNotes' export type AgentMode =
| 'risk'
| 'diagnosis'
| 'runbook'
| 'releaseNotes'
| 'incidentReview'
export type AgentPurpose = export type AgentPurpose =
| 'release-risk' | 'release-risk'
@@ -402,6 +407,30 @@ export interface JenkinsSyncSummary {
failedRuns: JenkinsSyncError[] failedRuns: JenkinsSyncError[]
} }
export interface JenkinsImportSkippedBuild {
projectKey: string
environment: EnvironmentName
buildNumber: number
reason: 'already_imported' | 'linked_platform_run' | 'parameter_mismatch'
}
export interface JenkinsImportFailedBuild {
projectKey: string
environment: EnvironmentName
buildNumber?: number
message: string
}
export interface JenkinsImportSummary {
checkedAt: string
totalJobs: number
totalBuilds: number
importedRuns: ReleaseRun[]
syncedRuns: ReleaseRun[]
skippedBuilds: JenkinsImportSkippedBuild[]
failedBuilds: JenkinsImportFailedBuild[]
}
export interface AuditLogEntry { export interface AuditLogEntry {
id: string id: string
action: string action: string
+334 -29
View File
@@ -9,6 +9,7 @@ import {
Lock, Lock,
Plus, Plus,
Refresh, Refresh,
Search,
User, User,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
@@ -17,8 +18,11 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { devopsApi } from '../services/devopsApi' import { devopsApi } from '../services/devopsApi'
import { usePlatformStore } from '../stores/platform' import { usePlatformStore } from '../stores/platform'
import type { import type {
AuthRole,
AuthStatus,
CreateMemberPayload, CreateMemberPayload,
MemberSummary, MemberSummary,
PlatformMessageState,
PlatformMessageSummary, PlatformMessageSummary,
ProjectConfig, ProjectConfig,
ProjectPermissionLevel, ProjectPermissionLevel,
@@ -30,6 +34,7 @@ type PermissionProjectGroup = {
label: string label: string
children: ProjectConfig[] children: ProjectConfig[]
} }
type MemberSecurityFilter = 'normal' | 'mustChangePassword' | 'passwordResetRequest'
const route = useRoute() const route = useRoute()
const platform = usePlatformStore() const platform = usePlatformStore()
@@ -39,6 +44,10 @@ const editingMember = ref<MemberSummary | null>(null)
const members = ref<MemberSummary[]>([]) const members = ref<MemberSummary[]>([])
const messages = ref<PlatformMessageSummary[]>([]) const messages = ref<PlatformMessageSummary[]>([])
const accountKeyword = ref('') const accountKeyword = ref('')
const roleFilter = ref<AuthRole | ''>('')
const statusFilter = ref<AuthStatus | ''>('')
const securityFilter = ref<MemberSecurityFilter | ''>('')
const messageStatusFilter = ref<PlatformMessageState | ''>('')
const createForm = reactive({ const createForm = reactive({
account: '', account: '',
displayName: '', displayName: '',
@@ -51,19 +60,54 @@ const permissionOptions: Array<{ label: string; value: ProjectPermissionLevel }>
{ label: '只读', value: 'read' }, { label: '只读', value: 'read' },
{ label: '构建', value: 'build' }, { label: '构建', value: 'build' },
] ]
const roleOptions: Array<{ label: string; value: AuthRole }> = [
{ label: '超级管理员', value: 'super_admin' },
{ label: '普通成员', value: 'member' },
]
const statusOptions: Array<{ label: string; value: AuthStatus }> = [
{ label: '启用', value: 'active' },
{ label: '禁用', value: 'disabled' },
]
const securityOptions: Array<{ label: string; value: MemberSecurityFilter }> = [
{ label: '正常', value: 'normal' },
{ label: '待改密', value: 'mustChangePassword' },
{ label: '请求重置', value: 'passwordResetRequest' },
]
const messageStatusOptions: Array<{ label: string; value: PlatformMessageState }> = [
{ label: '待处理', value: 'pending' },
{ label: '已处理', value: 'handled' },
]
const filteredMembers = computed(() => { const filteredMembers = computed(() => {
const keyword = accountKeyword.value.trim().toLowerCase() const keyword = accountKeyword.value.trim().toLowerCase()
if (!keyword) {
return members.value
}
return members.value.filter((member) => { return members.value.filter((member) => {
return ( if (roleFilter.value && member.role !== roleFilter.value) {
member.account.toLowerCase().includes(keyword) || return false
member.displayName.toLowerCase().includes(keyword) }
)
if (statusFilter.value && member.status !== statusFilter.value) {
return false
}
if (securityFilter.value && !memberMatchesSecurityFilter(member, securityFilter.value)) {
return false
}
if (!keyword) {
return true
}
return [
member.account,
member.displayName,
member.roleName,
member.statusName,
...member.projectPermissions.map((permission) => permission.projectName),
...member.projectPermissions.map((permission) => permission.levelName),
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.some((value) => value.toLowerCase().includes(keyword))
}) })
}) })
@@ -71,6 +115,56 @@ const pendingMessages = computed(() =>
messages.value.filter((message) => message.status === 'pending'), messages.value.filter((message) => message.status === 'pending'),
) )
const filteredMessages = computed(() => {
if (!messageStatusFilter.value) {
return messages.value
}
return messages.value.filter((message) => message.status === messageStatusFilter.value)
})
const memberOverview = computed(() => {
const activeCount = members.value.filter((member) => member.status === 'active').length
const buildAccountCount = members.value.filter(memberHasBuildPermission).length
const mustChangePasswordCount = members.value.filter((member) => member.mustChangePassword).length
const resetRequestCount = members.value.filter((member) => member.hasPendingPasswordResetRequest).length
return [
{ key: 'total', label: '平台账号', value: members.value.length, tone: 'neutral' },
{ key: 'active', label: '启用账号', value: activeCount, tone: 'success' },
{ key: 'build', label: '构建账号', value: buildAccountCount, tone: 'active' },
{
key: 'must-change',
label: '待改密',
value: mustChangePasswordCount,
tone: mustChangePasswordCount > 0 ? 'warning' : 'neutral',
},
{
key: 'reset',
label: '请求重置',
value: resetRequestCount,
tone: resetRequestCount > 0 ? 'danger' : 'neutral',
},
{
key: 'messages',
label: '待处理消息',
value: pendingMessages.value.length,
tone: pendingMessages.value.length > 0 ? 'warning' : 'neutral',
},
]
})
const hasActiveMemberFilters = computed(() =>
Boolean(
accountKeyword.value.trim() ||
roleFilter.value ||
statusFilter.value ||
securityFilter.value,
),
)
const hasActiveMessageFilters = computed(() => Boolean(messageStatusFilter.value))
const permissionProjectTree = computed<PermissionProjectGroup[]>(() => { const permissionProjectTree = computed<PermissionProjectGroup[]>(() => {
const groupByOwner = new Map<string, ProjectConfig[]>() const groupByOwner = new Map<string, ProjectConfig[]>()
@@ -280,6 +374,60 @@ function canMutate(member: MemberSummary) {
return member.role !== 'super_admin' return member.role !== 'super_admin'
} }
function memberMatchesSecurityFilter(member: MemberSummary, filter: MemberSecurityFilter) {
if (filter === 'mustChangePassword') {
return member.mustChangePassword
}
if (filter === 'passwordResetRequest') {
return member.hasPendingPasswordResetRequest
}
return !member.mustChangePassword && !member.hasPendingPasswordResetRequest
}
function memberHasBuildPermission(member: MemberSummary) {
return member.role === 'super_admin' ||
member.projectPermissions.some((permission) => permission.level === 'build')
}
function memberBuildPermissionCount(member: MemberSummary) {
if (member.role === 'super_admin') {
return platform.projects.length
}
return member.projectPermissions.filter((permission) => permission.level === 'build').length
}
function memberReadPermissionCount(member: MemberSummary) {
return Math.max(member.projectPermissions.length - memberBuildPermissionCount(member), 0)
}
function memberPermissionSummary(member: MemberSummary) {
if (member.role === 'super_admin') {
return '超级管理员拥有全部项目构建权限'
}
const buildCount = memberBuildPermissionCount(member)
if (buildCount === 0) {
return `${member.projectPermissions.length} 个项目只读`
}
return `${buildCount} 个构建 / ${memberReadPermissionCount(member)} 个只读`
}
function resetMemberFilters() {
accountKeyword.value = ''
roleFilter.value = ''
statusFilter.value = ''
securityFilter.value = ''
}
function resetMessageFilters() {
messageStatusFilter.value = ''
}
function setAllProjectBuildPermissions() { function setAllProjectBuildPermissions() {
setAllProjectPermissions(currentPermissionMap(), 'build') setAllProjectPermissions(currentPermissionMap(), 'build')
} }
@@ -377,12 +525,6 @@ function formatDateTime(value?: string) {
<p>维护平台账号项目只读/构建权限和密码重置消息</p> <p>维护平台账号项目只读/构建权限和密码重置消息</p>
</div> </div>
<div class="toolbar"> <div class="toolbar">
<ElInput
v-model="accountKeyword"
class="member-search"
clearable
placeholder="搜索账号或名称"
/>
<ElButton :icon="Refresh" @click="loadData">刷新</ElButton> <ElButton :icon="Refresh" @click="loadData">刷新</ElButton>
<ElButton type="primary" :icon="Plus" @click="openCreateDialog"> <ElButton type="primary" :icon="Plus" @click="openCreateDialog">
新增成员 新增成员
@@ -390,15 +532,40 @@ function formatDateTime(value?: string) {
</div> </div>
</div> </div>
<section class="member-overview-grid">
<article
v-for="item in memberOverview"
:key="item.key"
class="member-overview-card"
:class="`tone-${item.tone}`"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</section>
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>密码消息</h2> <h2>密码消息</h2>
<span>{{ pendingMessages.length }} 条待处理</span> <span>{{ filteredMessages.length }} / {{ messages.length }} {{ pendingMessages.length }} 条待处理</span>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div v-if="messages.length" class="message-list"> <div class="message-filter-bar">
<ElSelect v-model="messageStatusFilter" clearable placeholder="消息状态">
<ElOption
v-for="option in messageStatusOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElButton :disabled="!hasActiveMessageFilters" @click="resetMessageFilters">
重置筛选
</ElButton>
</div>
<div v-if="filteredMessages.length" class="message-list">
<article <article
v-for="message in messages" v-for="message in filteredMessages"
:key="message.id" :key="message.id"
class="message-row" class="message-row"
:class="{ handled: message.status === 'handled' }" :class="{ handled: message.status === 'handled' }"
@@ -422,7 +589,11 @@ function formatDateTime(value?: string) {
<ElTag v-else type="success" round>已处理</ElTag> <ElTag v-else type="success" round>已处理</ElTag>
</article> </article>
</div> </div>
<ElEmpty v-else description="暂无平台消息" :image-size="72" /> <ElEmpty
v-else
:description="messages.length ? '暂无符合条件的平台消息' : '暂无平台消息'"
:image-size="72"
/>
</div> </div>
</section> </section>
@@ -432,7 +603,43 @@ function formatDateTime(value?: string) {
<span>{{ filteredMembers.length }} / {{ members.length }}</span> <span>{{ filteredMembers.length }} / {{ members.length }}</span>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<ElTable :data="filteredMembers" row-key="id" border> <div class="member-filter-bar">
<ElInput
v-model="accountKeyword"
clearable
:prefix-icon="Search"
placeholder="搜索账号 / 名称 / 项目权限"
class="member-search"
/>
<ElSelect v-model="roleFilter" clearable placeholder="角色">
<ElOption
v-for="option in roleOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElSelect v-model="statusFilter" clearable placeholder="账号状态">
<ElOption
v-for="option in statusOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElSelect v-model="securityFilter" clearable placeholder="安全状态">
<ElOption
v-for="option in securityOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElButton :disabled="!hasActiveMemberFilters" @click="resetMemberFilters">
重置筛选
</ElButton>
</div>
<ElTable :data="filteredMembers" row-key="id" border empty-text="没有符合条件的成员">
<ElTableColumn label="成员" min-width="180"> <ElTableColumn label="成员" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
<div class="member-cell"> <div class="member-cell">
@@ -460,15 +667,18 @@ function formatDateTime(value?: string) {
</ElTableColumn> </ElTableColumn>
<ElTableColumn label="权限" min-width="340"> <ElTableColumn label="权限" min-width="340">
<template #default="{ row }"> <template #default="{ row }">
<div class="permission-tags"> <div class="permission-cell">
<ElTag <strong>{{ memberPermissionSummary(row) }}</strong>
v-for="permission in row.projectPermissions" <div class="permission-tags">
:key="permission.projectKey" <ElTag
:type="permission.level === 'build' ? 'success' : 'info'" v-for="permission in row.projectPermissions"
effect="plain" :key="permission.projectKey"
> :type="permission.level === 'build' ? 'success' : 'info'"
{{ permission.projectName }}{{ permission.levelName }} effect="plain"
</ElTag> >
{{ permission.projectName }}{{ permission.levelName }}
</ElTag>
</div>
</div> </div>
</template> </template>
</ElTableColumn> </ElTableColumn>
@@ -642,8 +852,75 @@ function formatDateTime(value?: string) {
</template> </template>
<style scoped> <style scoped>
.member-overview-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.member-overview-card {
min-width: 0;
padding: 12px 14px;
border: 1px solid #d8e2ef;
border-radius: 8px;
background: #fff;
}
.member-overview-card span {
display: block;
color: #64748b;
font-size: 12px;
}
.member-overview-card strong {
display: block;
margin-top: 6px;
color: #0f172a;
font-size: 22px;
line-height: 1;
}
.member-overview-card.tone-active {
border-color: #bfdbfe;
background: #eff6ff;
}
.member-overview-card.tone-success {
border-color: #bbf7d0;
background: #f0fdf4;
}
.member-overview-card.tone-warning {
border-color: #fde68a;
background: #fffbeb;
}
.member-overview-card.tone-danger {
border-color: #fecaca;
background: #fef2f2;
}
.message-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
.message-filter-bar .el-select {
width: 160px;
}
.member-filter-bar {
display: grid;
grid-template-columns: minmax(260px, 1.4fr) 150px 150px 150px max-content;
gap: 10px;
margin-bottom: 12px;
align-items: center;
}
.member-search { .member-search {
width: 220px; min-width: 0;
} }
.message-list { .message-list {
@@ -715,6 +992,18 @@ function formatDateTime(value?: string) {
gap: 6px; gap: 6px;
} }
.permission-cell {
display: grid;
gap: 8px;
min-width: 0;
}
.permission-cell > strong {
color: #172033;
font-size: 13px;
font-weight: 600;
}
:deep(.member-permission-dialog .el-dialog__body) { :deep(.member-permission-dialog .el-dialog__body) {
padding-top: 8px; padding-top: 8px;
padding-bottom: 8px; padding-bottom: 8px;
@@ -866,12 +1155,28 @@ function formatDateTime(value?: string) {
letter-spacing: 0; letter-spacing: 0;
} }
@media (max-width: 1180px) {
.member-overview-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.member-filter-bar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) { @media (max-width: 760px) {
.member-overview-grid,
.member-filter-bar,
.member-form-grid { .member-form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
}
.member-form-grid {
gap: 0; gap: 0;
} }
.message-filter-bar .el-select,
.member-search, .member-search,
.permission-radio-group { .permission-radio-group {
width: 100%; width: 100%;
+607 -36
View File
@@ -1,15 +1,317 @@
<script setup lang="ts"> <script setup lang="ts">
import { CircleCheck, Cpu, Failed, Finished } from '@element-plus/icons-vue' import {
Cpu,
Failed,
Finished,
Promotion,
Refresh,
Tickets,
} from '@element-plus/icons-vue'
import { computed } from 'vue' import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import MetricTile from '../components/MetricTile.vue' import MetricTile from '../components/MetricTile.vue'
import StatusPill from '../components/StatusPill.vue' import StatusPill from '../components/StatusPill.vue'
import { usePlatformStore } from '../stores/platform' import { usePlatformStore } from '../stores/platform'
import type { HealthState, ProjectConfig, ReleaseRun } from '../types/devops'
const platform = usePlatformStore() const platform = usePlatformStore()
const successCount = computed(() => platform.runs.filter((run) => run.status === 'success').length) const successCount = computed(() => platform.runs.filter((run) => run.status === 'success').length)
const failedCount = computed(() => platform.failedRuns.length) const failedCount = computed(() => platform.failedRuns.length)
const runningCount = computed(() => platform.runningRuns.length) const runningCount = computed(() => platform.runningRuns.length)
const readyProjectCount = computed(
() => platform.projects.filter((project) => projectReadinessState(project) === 'ready').length,
)
const blockedProjects = computed(
() => platform.projects.filter((project) => projectReadinessState(project) === 'blocked'),
)
const attentionProjects = computed(
() => platform.projects.filter((project) => projectReadinessState(project) === 'attention'),
)
const notificationAttentionCount = computed(
() =>
platform.notificationOutbox.filter(
(item) => item.status === 'pending' || item.status === 'failed' || item.status === 'dead',
).length,
)
const integrationAttentionCount = computed(
() => platform.integrationConfig.integrations.filter((item) => item.status !== 'configured').length,
)
type ProjectReadinessState = 'ready' | 'attention' | 'blocked' | 'readonly'
type FocusTone = 'success' | 'warning' | 'danger' | 'active' | 'neutral'
type FocusItem = {
key: string
label: string
value: string | number
detail: string
tone: FocusTone
action: string
to: RouteLocationRaw
}
const focusItems = computed<FocusItem[]>(() => {
const items: FocusItem[] = []
if (failedCount.value > 0) {
items.push({
key: 'failed-runs',
label: '失败待诊断',
value: failedCount.value,
detail: '优先查看失败节点、日志摘要和复盘材料。',
tone: 'danger',
action: '查看失败 Run',
to: { name: 'runs' },
})
}
if (runningCount.value > 0) {
items.push({
key: 'running-runs',
label: '执行中发布',
value: runningCount.value,
detail: '关注 Jenkins queue/build 状态和流程节点推进。',
tone: 'active',
action: '查看进度',
to: { name: 'runs' },
})
}
if (blockedProjects.value.length > 0) {
items.push({
key: 'blocked-projects',
label: '项目阻断',
value: blockedProjects.value.length,
detail: 'Gitea、Webhook、Jenkins 或通知链路存在阻断。',
tone: 'danger',
action: '查看项目',
to: { name: 'projects' },
})
}
if (attentionProjects.value.length > 0) {
items.push({
key: 'attention-projects',
label: '配置关注',
value: attentionProjects.value.length,
detail: '存在待配置项,生产发布前需要复核。',
tone: 'warning',
action: '定位配置',
to: { name: 'projects' },
})
}
if (notificationAttentionCount.value > 0) {
items.push({
key: 'notification-outbox',
label: '通知待排查',
value: notificationAttentionCount.value,
detail: '通知 outbox 存在待投递、失败或终止记录。',
tone: 'warning',
action: '查看 outbox',
to: { name: 'settings' },
})
}
if (integrationAttentionCount.value > 0) {
items.push({
key: 'integration-config',
label: '集成缺口',
value: integrationAttentionCount.value,
detail: '系统配置中存在未配置或部分配置的集成项。',
tone: 'warning',
action: '查看系统配置',
to: { name: 'settings' },
})
}
if (items.length > 0) {
return items
}
return [
{
key: 'stable',
label: '当前稳定',
value: 'OK',
detail: '未发现失败 run、项目阻断或待处理通知。',
tone: 'success',
action: '查看项目',
to: { name: 'projects' },
},
]
})
const priorityRuns = computed(() => {
return [...platform.runs]
.sort((left, right) => {
const priorityDiff = runPriority(left) - runPriority(right)
if (priorityDiff !== 0) {
return priorityDiff
}
return dateValue(right.startedAt) - dateValue(left.startedAt)
})
.slice(0, 5)
})
const projectAttentionQueue = computed(() => {
return [...blockedProjects.value, ...attentionProjects.value]
.filter((project, index, list) => list.findIndex((item) => item.key === project.key) === index)
.slice(0, 5)
})
function runPriority(run: ReleaseRun): number {
const priority: Record<ReleaseRun['status'], number> = {
failed: 0,
running: 1,
queued: 2,
canceled: 3,
success: 4,
idle: 5,
}
return priority[run.status] ?? 6
}
function dateValue(value?: string): number {
if (!value || value === '-') {
return 0
}
const date = new Date(value)
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
}
function formatRunTime(value?: string): string {
if (!value || value === '-') {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
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 '发布链路可用'
}
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),
]
}
</script> </script>
<template> <template>
@@ -17,62 +319,128 @@ const runningCount = computed(() => platform.runningRuns.length)
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>运维总览</h1> <h1>运维总览</h1>
<p>集中查看纳管项目环境最近发布Jenkins/Gitea/通知平台/LLM 当前状态</p> <p>集中查看纳管项目环境待处理发布Jenkins/Gitea/通知平台/LLM 当前状态</p>
</div> </div>
<ElButton type="primary" plain @click="platform.loadAll">刷新数据</ElButton> <ElButton :icon="Refresh" type="primary" plain :loading="platform.loading" @click="platform.loadAll">
刷新数据
</ElButton>
</div> </div>
<div class="status-grid"> <div class="status-grid">
<MetricTile label="纳管项目" :value="platform.projects.length" caption="含 DevOps 平台自管理项目" :icon="Cpu" /> <MetricTile label="纳管项目" :value="platform.projects.length" caption="含 DevOps 平台自管理项目" :icon="Cpu" />
<MetricTile label="可发布项目" :value="readyProjectCount" caption="无阻断且当前账号可构建" :icon="Promotion" tone="success" />
<MetricTile label="运行中发布" :value="runningCount" caption="当前 Jenkins 或健康检查处理中" :icon="Finished" tone="warning" /> <MetricTile label="运行中发布" :value="runningCount" caption="当前 Jenkins 或健康检查处理中" :icon="Finished" tone="warning" />
<MetricTile label="成功记录" :value="successCount" caption="最近发布成功数" :icon="CircleCheck" tone="success" />
<MetricTile label="失败提醒" :value="failedCount" caption="需要诊断或重试的 run" :icon="Failed" tone="danger" /> <MetricTile label="失败提醒" :value="failedCount" caption="需要诊断或重试的 run" :icon="Failed" tone="danger" />
</div> </div>
<div class="section-grid"> <div class="section-grid">
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>集成状态</h2> <h2>值班焦点</h2>
<span>来自后端 health</span> <span>{{ focusItems.length }} </span>
</div> </div>
<div class="panel-body compact-list"> <div class="panel-body focus-list">
<div v-for="item in platform.integrations" :key="item.key" class="list-row"> <div
<span> v-for="item in focusItems"
<strong>{{ item.name }}</strong> :key="item.key"
<small>{{ item.summary }}</small> class="focus-row"
</span> :class="`tone-${item.tone}`"
<span class="integration-tail"> >
<StatusPill :state="item.state" /> <div class="focus-main">
<small>{{ item.latencyMs ? `${item.latencyMs}ms` : item.checkedAt }}</small> <span>{{ item.label }}</span>
</span> <strong>{{ item.value }}</strong>
<small>{{ item.detail }}</small>
</div>
<RouterLink v-slot="{ navigate }" custom :to="item.to">
<ElButton link type="primary" @click="navigate">{{ item.action }}</ElButton>
</RouterLink>
</div> </div>
</div> </div>
</section> </section>
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>最近发布</h2> <h2>优先 Run 队列</h2>
<span> startedAt 倒序</span> <span>{{ priorityRuns.length }} / {{ platform.runs.length }} </span>
</div> </div>
<div class="panel-body compact-list"> <div class="panel-body compact-list">
<div v-for="run in platform.runs" :key="run.id" class="list-row"> <div v-for="run in priorityRuns" :key="run.id" class="list-row run-row">
<span> <span>
<strong>{{ run.projectName }} · {{ run.ref }}</strong> <strong>{{ run.projectName }} · {{ run.ref }}</strong>
<small>{{ run.environment }} · {{ run.actor }} · {{ run.duration }}</small> <small>{{ run.environment }} · {{ run.actor }} · {{ formatRunTime(run.startedAt) }} · {{ run.duration }}</small>
</span> </span>
<StatusPill :state="run.status" /> <div class="row-actions">
<StatusPill :state="run.status" />
<RouterLink :to="{ name: 'runs', query: { runId: run.id } }">
<ElButton :icon="Tickets" link type="primary">详情</ElButton>
</RouterLink>
</div>
</div> </div>
<ElEmpty v-if="priorityRuns.length === 0" description="暂无运行记录" />
</div> </div>
</section> </section>
</div> </div>
<section class="panel">
<div class="panel-header">
<h2>项目发布健康</h2>
<span>{{ readyProjectCount }} / {{ platform.projects.length }} 个项目可发布</span>
</div>
<div class="panel-body project-attention-grid">
<article class="health-summary-card">
<span>成功记录</span>
<strong>{{ successCount }}</strong>
<small>最近发布成功数</small>
</article>
<article class="health-summary-card warning">
<span>需关注</span>
<strong>{{ attentionProjects.length }}</strong>
<small>存在待配置项</small>
</article>
<article class="health-summary-card danger">
<span>有阻断</span>
<strong>{{ blockedProjects.length }}</strong>
<small>发布前需要处理</small>
</article>
<div class="attention-list">
<div v-if="projectAttentionQueue.length === 0" class="attention-empty">
<strong>项目链路当前稳定</strong>
<span>纳管项目未发现阻断或关注项</span>
</div>
<div
v-for="project in projectAttentionQueue"
:key="project.key"
class="attention-row"
>
<div>
<strong>{{ project.name }}</strong>
<small>{{ projectReadinessSummary(project) }}</small>
</div>
<div class="row-actions">
<ElTag :type="projectReadinessTagType(project)" effect="light" round>
{{ projectReadinessLabel(project) }}
</ElTag>
<RouterLink :to="{ name: 'projects' }">
<ElButton link type="primary">查看</ElButton>
</RouterLink>
</div>
</div>
</div>
</div>
</section>
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>项目环境</h2> <h2>项目环境</h2>
<span>测试/生产状态和集成摘要</span> <span>测试/生产状态和快捷入口</span>
</div> </div>
<div class="project-grid panel-body"> <div class="project-grid panel-body">
<article v-for="project in platform.projects" :key="project.key" class="project-card"> <article
v-for="project in platform.projects"
:key="project.key"
class="project-card"
:class="`readiness-${projectReadinessState(project)}`"
>
<div class="project-head"> <div class="project-head">
<div> <div>
<strong>{{ project.name }}</strong> <strong>{{ project.name }}</strong>
@@ -80,6 +448,12 @@ const runningCount = computed(() => platform.runningRuns.length)
</div> </div>
<ElTag type="info" round>{{ project.defaultBranch }}</ElTag> <ElTag type="info" round>{{ project.defaultBranch }}</ElTag>
</div> </div>
<div class="project-readiness">
<ElTag :type="projectReadinessTagType(project)" effect="light" round>
{{ projectReadinessLabel(project) }}
</ElTag>
<small>{{ projectReadinessSummary(project) }}</small>
</div>
<div class="compact-list"> <div class="compact-list">
<div v-for="env in project.environments" :key="env.name" class="env-row"> <div v-for="env in project.environments" :key="env.name" class="env-row">
<span> <span>
@@ -89,6 +463,26 @@ const runningCount = computed(() => platform.runningRuns.length)
<StatusPill :state="env.status" /> <StatusPill :state="env.status" />
</div> </div>
</div> </div>
<div class="project-card-actions">
<RouterLink
v-slot="{ navigate }"
custom
:to="{ name: 'releases', query: { projectKey: project.key } }"
>
<ElButton
:icon="Promotion"
link
type="primary"
:disabled="!platform.canBuildProject(project.key)"
@click="navigate"
>
发布
</ElButton>
</RouterLink>
<RouterLink :to="{ name: 'runs', query: { projectKey: project.key } }">
<ElButton :icon="Tickets" link type="primary">运行记录</ElButton>
</RouterLink>
</div>
</article> </article>
</div> </div>
</section> </section>
@@ -96,26 +490,170 @@ const runningCount = computed(() => platform.runningRuns.length)
</template> </template>
<style scoped> <style scoped>
.integration-tail { .focus-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; gap: 10px;
gap: 5px;
} }
.project-grid { .focus-row {
display: grid; display: flex;
grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: center;
gap: 14px; justify-content: space-between;
} gap: 12px;
min-height: 86px;
.project-card {
padding: 14px; padding: 14px;
border: 1px solid #e3e8f2; border: 1px solid #e3e8f2;
border-radius: 8px; border-radius: 8px;
background: #fbfcff; background: #fbfcff;
} }
.focus-row.tone-danger {
border-color: #fecaca;
background: #fff7f7;
}
.focus-row.tone-warning {
border-color: #fde68a;
background: #fffbeb;
}
.focus-row.tone-active {
border-color: #bfdbfe;
background: #eff6ff;
}
.focus-row.tone-success {
border-color: #bbf7d0;
background: #f0fdf4;
}
.focus-main {
min-width: 0;
}
.focus-main span,
.health-summary-card span {
display: block;
color: #64748b;
font-size: 12px;
}
.focus-main strong,
.health-summary-card strong {
display: block;
margin-top: 5px;
color: #0f172a;
font-size: 24px;
line-height: 1;
}
.focus-main small,
.health-summary-card small {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.row-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.run-row {
min-height: 68px;
}
.project-attention-grid {
display: grid;
grid-template-columns: repeat(3, minmax(140px, 0.35fr)) minmax(320px, 1fr);
gap: 12px;
}
.health-summary-card {
min-width: 0;
padding: 14px;
border: 1px solid #d8e2ef;
border-radius: 8px;
background: #ffffff;
}
.health-summary-card.warning {
border-color: #fde68a;
background: #fffbeb;
}
.health-summary-card.danger {
border-color: #fecaca;
background: #fef2f2;
}
.attention-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.attention-row,
.attention-empty {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 68px;
padding: 12px;
border: 1px solid #e3e8f2;
border-radius: 8px;
background: #fbfcff;
}
.attention-row strong,
.attention-empty strong {
display: block;
color: #172033;
font-size: 14px;
}
.attention-row small,
.attention-empty span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.project-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.project-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border: 1px solid #e3e8f2;
border-radius: 8px;
background: #fbfcff;
}
.project-card.readiness-blocked {
border-color: #fecaca;
background: #fffafa;
}
.project-card.readiness-attention {
border-color: #fde68a;
background: #fffdf5;
}
.project-head, .project-head,
.env-row { .env-row {
display: flex; display: flex;
@@ -125,7 +663,7 @@ const runningCount = computed(() => platform.runningRuns.length)
} }
.project-head { .project-head {
margin-bottom: 12px; margin-bottom: 0;
} }
.project-head strong, .project-head strong,
@@ -149,15 +687,48 @@ const runningCount = computed(() => platform.runningRuns.length)
background: #ffffff; background: #ffffff;
} }
.project-readiness {
display: grid;
gap: 6px;
min-height: 54px;
}
.project-readiness small {
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.project-card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
padding-top: 2px;
}
@media (max-width: 1180px) { @media (max-width: 1180px) {
.project-grid { .project-grid,
.project-attention-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.project-grid { .project-grid,
.project-attention-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.focus-row,
.attention-row,
.attention-empty {
align-items: flex-start;
flex-direction: column;
}
.row-actions {
justify-content: flex-start;
}
} }
</style> </style>
+376 -13
View File
@@ -2,13 +2,82 @@
/** /**
* 项目配置页展示项目仓库、发布环境、Gitea 仓库诊断和真实 Jenkins job 诊断状态。 * 项目配置页展示项目仓库、发布环境、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 StatusPill from '../components/StatusPill.vue'
import { usePlatformStore } from '../stores/platform' 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 platform = usePlatformStore()
const diagnosticsLoading = ref(false) 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(() => { onMounted(() => {
void refreshProjectDiagnostics() void refreshProjectDiagnostics()
@@ -80,23 +149,136 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
return `#${hook.id} ${hook.type} · ${active} · ${events}${branch}${matched}` 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> </script>
<template> <template>
<div class="page-shell"> <div class="page-shell">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>项目配置</h1> <h1>项目配置</h1>
<p>展示后端返回的项目环境Jenkins JobGitea 仓库和通知通道绑定关系</p> <p>展示后端返回的项目环境Jenkins JobGitea 仓库和通知通道绑定关系</p>
</div> </div>
<ElButton type="primary" plain disabled>新增项目</ElButton> <div class="toolbar">
</div> <ElTag type="info" round>后端配置源</ElTag>
<section class="panel">
<div class="panel-header">
<h2>项目清单</h2>
<ElButton <ElButton
size="small" :icon="Refresh"
type="primary"
plain plain
:loading="diagnosticsLoading" :loading="diagnosticsLoading"
@click="refreshProjectDiagnostics" @click="refreshProjectDiagnostics"
@@ -104,8 +286,55 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
刷新项目诊断 刷新项目诊断
</ElButton> </ElButton>
</div> </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="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"> <ElTableColumn type="expand">
<template #default="{ row }"> <template #default="{ row }">
<div class="env-config"> <div class="env-config">
@@ -216,6 +445,23 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn prop="name" label="项目" min-width="150" /> <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="serviceType" label="类型" min-width="130" />
<ElTableColumn prop="repository" label="Gitea 仓库" min-width="320"> <ElTableColumn prop="repository" label="Gitea 仓库" min-width="320">
<template #default="{ row }"> <template #default="{ row }">
@@ -234,6 +480,30 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
</div> </div>
</template> </template>
</ElTableColumn> </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> </ElTable>
</div> </div>
</section> </section>
@@ -241,6 +511,66 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
</template> </template>
<style scoped> <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 { .env-config {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -295,6 +625,24 @@ dd {
gap: 6px; 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 { .diagnostic-message {
margin-left: 8px; margin-left: 8px;
color: #596578; color: #596578;
@@ -340,7 +688,22 @@ dd {
text-decoration: underline; 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) { @media (max-width: 760px) {
.project-overview-grid,
.project-filter-bar {
grid-template-columns: 1fr;
}
.env-config { .env-config {
grid-template-columns: 1fr; grid-template-columns: 1fr;
padding: 12px; padding: 12px;
+477 -9
View File
@@ -5,21 +5,31 @@
import { CircleClose, Promotion, RefreshRight, VideoPause } from '@element-plus/icons-vue' import { CircleClose, Promotion, RefreshRight, VideoPause } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import BpmnStatusViewer from '../components/BpmnStatusViewer.vue' import BpmnStatusViewer from '../components/BpmnStatusViewer.vue'
import RunExecutionDetails from '../components/RunExecutionDetails.vue' import RunExecutionDetails from '../components/RunExecutionDetails.vue'
import StatusPill from '../components/StatusPill.vue' import StatusPill from '../components/StatusPill.vue'
import { isDevopsApiError } from '../services/http' import { isDevopsApiError } from '../services/http'
import { usePlatformStore } from '../stores/platform' import { usePlatformStore } from '../stores/platform'
import type { EnvironmentName, ProjectRefItem, ReleaseRun, ReleaseStatus } from '../types/devops' import { useSessionStore } from '../stores/session'
import type { EnvironmentName, HealthState, ProjectRefItem, ReleaseRun, ReleaseStatus } from '../types/devops'
const platform = usePlatformStore() const platform = usePlatformStore()
const session = useSessionStore()
const route = useRoute()
const form = reactive({ const form = reactive({
projectKey: '', projectKey: queryProjectKey(),
environment: 'test' as EnvironmentName, environment: 'test' as EnvironmentName,
ref: '', ref: '',
note: '发布验证', note: '发布验证',
}) })
const productionConfirmation = reactive({
reviewed: false,
rollbackPlan: '',
releaseWindow: '',
notifyTarget: '',
})
const localStatus = ref<ReleaseStatus>('idle') const localStatus = ref<ReleaseStatus>('idle')
const activeRun = ref<ReleaseRun | null>(null) const activeRun = ref<ReleaseRun | null>(null)
@@ -57,6 +67,10 @@ const selectedEnvironment = computed(() => {
return selectedProject.value?.environments.find((environment) => environment.name === form.environment) return selectedProject.value?.environments.find((environment) => environment.name === form.environment)
}) })
const notificationIntegration = computed(() => {
return platform.integrations.find((integration) => integration.key === 'notification')
})
const useBranchRefs = computed(() => { const useBranchRefs = computed(() => {
return selectedEnvironment.value?.branchPolicy === 'master' return selectedEnvironment.value?.branchPolicy === 'master'
}) })
@@ -71,6 +85,8 @@ const refOptions = computed(() => {
return refs?.branches.map((branch) => branch.name) ?? [] return refs?.branches.map((branch) => branch.name) ?? []
}) })
const hasRefOptions = computed(() => refOptions.value.length > 0)
const refSourceText = computed(() => { const refSourceText = computed(() => {
const source = currentRefs.value?.source const source = currentRefs.value?.source
@@ -98,6 +114,10 @@ const selectedRefDetail = computed<ProjectRefItem | undefined>(() => {
return candidates.find((item) => item.name === form.ref) return candidates.find((item) => item.name === form.ref)
}) })
const selectedRefExists = computed(() => {
return Boolean(form.ref && refOptions.value.includes(form.ref))
})
const selectedCommitMessage = computed(() => { const selectedCommitMessage = computed(() => {
return selectedRefDetail.value?.commit?.message?.split('\n')[0] || '' return selectedRefDetail.value?.commit?.message?.split('\n')[0] || ''
}) })
@@ -135,12 +155,182 @@ const selectedPullRequestMeta = computed(() => {
return parts.join(' · ') return parts.join(' · ')
}) })
type PreflightState = Extract<HealthState, 'healthy' | 'warning' | 'error'>
type PreflightItem = {
key: string
title: string
description: string
state: PreflightState
required: boolean
}
const productionConfirmationReady = computed(() => {
if (form.environment !== 'production') {
return true
}
return (
productionConfirmation.reviewed &&
Boolean(productionConfirmation.rollbackPlan.trim()) &&
Boolean(productionConfirmation.releaseWindow.trim()) &&
Boolean(productionConfirmation.notifyTarget.trim())
)
})
const triggerDisabledReason = computed(() => {
if (!form.projectKey) {
return '请选择项目'
}
if (!canBuildSelectedProject.value) {
return '当前账号只有只读权限,发布操作已禁用'
}
if (!hasRefOptions.value) {
return '后端未返回可发布的 ref/tag'
}
if (!selectedRefExists.value) {
return '请选择后端返回的 ref/tag'
}
if (!productionConfirmationReady.value) {
return '请补全生产发布确认'
}
return ''
})
const canTrigger = computed(() => { const canTrigger = computed(() => {
if (!canBuildSelectedProject.value || !form.projectKey || !form.environment || !form.ref) { if (!canBuildSelectedProject.value || !form.projectKey || !form.environment || !form.ref) {
return false return false
} }
return true return selectedRefExists.value && productionConfirmationReady.value
})
const preflightItems = computed<PreflightItem[]>(() => {
const refs = currentRefs.value
const refKindText = form.environment === 'production' && !useBranchRefs.value ? 'tag' : 'branch'
const jenkinsState = selectedEnvironment.value?.jenkinsStatus ?? 'warning'
const giteaState = selectedProject.value?.giteaStatus ?? 'warning'
const webhookState = selectedProject.value?.giteaWebhookStatus ?? 'warning'
const notificationState = notificationIntegration.value?.state ?? 'warning'
return [
{
key: 'permission',
title: '操作权限',
description: canBuildSelectedProject.value
? `${session.displayName || session.operator || '当前账号'}${form.projectKey || '-'} 具备构建权限`
: '当前账号不能触发该项目发布',
state: canBuildSelectedProject.value ? 'healthy' : 'error',
required: true,
},
{
key: 'ref',
title: '发布对象',
description: selectedRefExists.value
? `${form.ref} 来自 ${refSourceText.value},类型为 ${refKindText}`
: hasRefOptions.value
? '请选择后端返回的 ref/tag'
: '后端暂未返回可发布 ref/tag',
state: selectedRefExists.value
? refs?.source === 'gitea'
? 'healthy'
: 'warning'
: 'error',
required: true,
},
{
key: 'commit',
title: '提交摘要',
description: selectedCommitSha.value
? `${shortSha(selectedCommitSha.value)} · ${selectedCommitMessage.value || '无提交说明'}`
: '后端未返回 commit 摘要,建议先检查 Gitea refs 接口',
state: selectedCommitSha.value ? 'healthy' : 'warning',
required: false,
},
{
key: 'jenkins',
title: 'Jenkins Job',
description: selectedEnvironment.value?.jenkinsJob
? `${selectedEnvironment.value.jenkinsJob}${selectedEnvironment.value.jenkinsMessage ? ` · ${selectedEnvironment.value.jenkinsMessage}` : ''}`
: '尚未加载当前环境 Jenkins Job',
state: normalizePreflightState(jenkinsState),
required: false,
},
{
key: 'gitea',
title: 'Gitea 仓库',
description: selectedProject.value?.giteaMessage
? selectedProject.value.giteaMessage
: selectedProject.value?.giteaBranchCount !== undefined || selectedProject.value?.giteaTagCount !== undefined
? `branch ${selectedProject.value.giteaBranchCount ?? '-'} / tag ${selectedProject.value.giteaTagCount ?? '-'}`
: '等待仓库诊断结果',
state: normalizePreflightState(giteaState),
required: false,
},
{
key: 'webhook',
title: 'Webhook / Relay',
description: selectedProject.value?.giteaWebhookMessage || '等待 webhook 诊断结果',
state: normalizePreflightState(webhookState),
required: false,
},
{
key: 'notification',
title: '通知链路',
description:
notificationIntegration.value?.summary ||
'通知未配置时不会阻断发布,但会进入 outbox 供后续排查',
state: normalizePreflightState(notificationState),
required: false,
},
{
key: 'production',
title: '生产确认',
description:
form.environment === 'production'
? productionConfirmationReady.value
? '已补齐回滚方案、业务窗口、通知对象和核对勾选'
: '生产发布必须补齐回滚、窗口、通知对象和核对勾选'
: '测试环境不需要生产确认',
state:
form.environment === 'production' && !productionConfirmationReady.value
? 'error'
: 'healthy',
required: form.environment === 'production',
},
]
})
const preflightOverallState = computed<PreflightState>(() => {
if (preflightItems.value.some((item) => item.state === 'error')) {
return 'error'
}
if (preflightItems.value.some((item) => item.state === 'warning')) {
return 'warning'
}
return 'healthy'
})
const preflightSummary = computed(() => {
const blockers = preflightItems.value.filter((item) => item.state === 'error').length
const warnings = preflightItems.value.filter((item) => item.state === 'warning').length
if (blockers > 0) {
return `${blockers} 项阻断,需处理后才能触发发布`
}
if (warnings > 0) {
return `${warnings} 项提醒,发布前建议复核`
}
return '关键检查均已通过'
}) })
const shownRun = computed(() => { const shownRun = computed(() => {
@@ -173,7 +363,6 @@ async function triggerRelease() {
projectKey: form.projectKey, projectKey: form.projectKey,
environment: form.environment, environment: form.environment,
ref: form.ref, ref: form.ref,
operator: 'ops-admin',
remark: form.note, remark: form.note,
idempotencyKey: `${form.projectKey}:${form.environment}:${form.ref}:${Date.now()}`, idempotencyKey: `${form.projectKey}:${form.environment}:${form.ref}:${Date.now()}`,
productionConfirmation: productionConfirmation:
@@ -181,7 +370,7 @@ async function triggerRelease() {
? { ? {
confirmed: true, confirmed: true,
confirmedAt: new Date().toISOString(), confirmedAt: new Date().toISOString(),
summary: '已确认 ref、回滚方案、通知对象和业务窗口', summary: buildProductionConfirmationSummary(),
} }
: undefined, : undefined,
}) })
@@ -212,6 +401,44 @@ function formatCommitTime(value?: string): string {
}).format(new Date(value)) }).format(new Date(value))
} }
function normalizePreflightState(state: HealthState): PreflightState {
if (state === 'healthy') {
return 'healthy'
}
if (state === 'error' || state === 'offline') {
return 'error'
}
return 'warning'
}
function preflightTagType(state: PreflightState): 'success' | 'warning' | 'danger' {
if (state === 'healthy') {
return 'success'
}
return state === 'warning' ? 'warning' : 'danger'
}
function preflightStateText(state: PreflightState): string {
if (state === 'healthy') {
return '通过'
}
return state === 'warning' ? '提醒' : '阻断'
}
function buildProductionConfirmationSummary(): string {
return [
`操作者=${session.operator || session.displayName || 'unknown'}`,
`ref=${form.ref}`,
`回滚方案=${productionConfirmation.rollbackPlan.trim()}`,
`业务窗口=${productionConfirmation.releaseWindow.trim()}`,
`通知对象=${productionConfirmation.notifyTarget.trim()}`,
].join('')
}
async function cancelRelease() { async function cancelRelease() {
if (!shownRun.value) { if (!shownRun.value) {
return return
@@ -296,6 +523,10 @@ function useFailedRun() {
activeRun.value = failed activeRun.value = failed
} }
function queryProjectKey(): string {
return typeof route.query.projectKey === 'string' ? route.query.projectKey : ''
}
async function handleReleaseActionError( async function handleReleaseActionError(
error: unknown, error: unknown,
fallbackMessage: string, fallbackMessage: string,
@@ -343,11 +574,32 @@ watch(
{ immediate: true }, { immediate: true },
) )
watch(
() => route.query.projectKey,
() => {
const projectKey = queryProjectKey()
if (projectKey && projectKey !== form.projectKey) {
form.projectKey = projectKey
form.ref = ''
activeRun.value = null
localStatus.value = 'idle'
}
},
)
watch(refOptions, (options) => { watch(refOptions, (options) => {
if (options.length > 0 && !options.includes(form.ref)) { if (options.length > 0 && !options.includes(form.ref)) {
form.ref = options[0] form.ref = options[0]
} }
}) })
watch(
() => [form.projectKey, form.environment, form.ref],
() => {
productionConfirmation.reviewed = false
},
)
</script> </script>
<template> <template>
@@ -395,9 +647,10 @@ watch(refOptions, (options) => {
<ElSelect <ElSelect
v-model="form.ref" v-model="form.ref"
filterable filterable
allow-create
default-first-option default-first-option
:disabled="!hasRefOptions"
:loading="refsLoading" :loading="refsLoading"
no-data-text="后端未返回可选 ref/tag"
> >
<ElOption v-for="item in refOptions" :key="item" :label="item" :value="item" /> <ElOption v-for="item in refOptions" :key="item" :label="item" :value="item" />
</ElSelect> </ElSelect>
@@ -405,6 +658,9 @@ watch(refOptions, (options) => {
{{ form.environment === 'production' ? selectedProject?.tagPolicy : selectedProject?.defaultBranch }} {{ form.environment === 'production' ? selectedProject?.tagPolicy : selectedProject?.defaultBranch }}
· {{ refSourceText }} · {{ refSourceText }}
</div> </div>
<div v-if="form.ref && !selectedRefExists" class="field-warning">
当前值不在后端返回列表中请重新选择
</div>
<div v-if="selectedCommitSha" class="commit-preview"> <div v-if="selectedCommitSha" class="commit-preview">
<div class="commit-main"> <div class="commit-main">
<ElTag size="small" effect="plain" class="mono"> <ElTag size="small" effect="plain" class="mono">
@@ -435,13 +691,77 @@ watch(refOptions, (options) => {
<ElInput v-model="form.note" type="textarea" :rows="2" maxlength="120" show-word-limit /> <ElInput v-model="form.note" type="textarea" :rows="2" maxlength="120" show-word-limit />
</ElFormItem> </ElFormItem>
</div> </div>
<div v-if="form.environment === 'production'" class="production-confirmation-card">
<div class="production-confirmation-head">
<div>
<strong>生产发布确认</strong>
<span>提交前必须明确回滚窗口和通知边界</span>
</div>
<StatusPill :state="productionConfirmationReady ? 'healthy' : 'warning'" />
</div>
<div class="production-confirmation-grid">
<ElFormItem label="回滚方案">
<ElInput
v-model="productionConfirmation.rollbackPlan"
maxlength="90"
show-word-limit
placeholder="例如:回滚到上一个生产 tag,并复用 prod job"
/>
</ElFormItem>
<ElFormItem label="业务窗口">
<ElInput
v-model="productionConfirmation.releaseWindow"
maxlength="60"
show-word-limit
placeholder="例如:低峰期 22:00-23:00"
/>
</ElFormItem>
<ElFormItem label="通知对象" class="notify-target-field">
<ElInput
v-model="productionConfirmation.notifyTarget"
maxlength="80"
show-word-limit
placeholder="例如:项目负责人、运维值班群、业务验收人"
/>
</ElFormItem>
</div>
<ElCheckbox v-model="productionConfirmation.reviewed">
我已核对目标 tag/commitJenkins Job回滚方案通知对象和业务窗口
</ElCheckbox>
</div>
<ElAlert <ElAlert
v-if="form.environment === 'production'" v-if="form.environment === 'production'"
class="danger-note" class="danger-note"
title="生产发布将提交确认信息,请确认 tag、回滚方案、通知对象和业务窗口。" title="生产发布会写入确认摘要和审计日志;确认信息不完整时不能提交。"
type="warning" type="warning"
:closable="false" :closable="false"
/> />
<div class="release-preflight-card">
<div class="release-preflight-head">
<div>
<strong>发布前检查</strong>
<span>{{ preflightSummary }}</span>
</div>
<StatusPill :state="preflightOverallState" />
</div>
<div class="preflight-grid">
<div
v-for="item in preflightItems"
:key="item.key"
class="preflight-item"
:class="`is-${item.state}`"
>
<div class="preflight-item-head">
<strong>{{ item.title }}</strong>
<ElTag :type="preflightTagType(item.state)" effect="light" round size="small">
{{ preflightStateText(item.state) }}
</ElTag>
</div>
<p>{{ item.description }}</p>
<small>{{ item.required ? '提交前必须通过' : '建议发布前复核' }}</small>
</div>
</div>
</div>
<div class="action-row"> <div class="action-row">
<ElButton <ElButton
type="primary" type="primary"
@@ -458,8 +778,8 @@ watch(refOptions, (options) => {
<ElButton :icon="CircleClose" :disabled="!canRetryShownRun || submitting" @click="retryRelease"> <ElButton :icon="CircleClose" :disabled="!canRetryShownRun || submitting" @click="retryRelease">
重试 重试
</ElButton> </ElButton>
<span v-if="form.projectKey && !canBuildSelectedProject" class="permission-action-hint"> <span v-if="triggerDisabledReason" class="permission-action-hint">
当前账号只有只读权限发布操作已禁用 {{ triggerDisabledReason }}
</span> </span>
</div> </div>
</ElForm> </ElForm>
@@ -601,6 +921,146 @@ watch(refOptions, (options) => {
font-size: 12px; font-size: 12px;
} }
.field-warning {
margin-top: 6px;
color: #b45309;
font-size: 12px;
line-height: 1.5;
}
.production-confirmation-card {
margin-top: 6px;
padding: 14px;
border: 1px solid #f5d0a6;
border-radius: 8px;
background: #fffaf2;
}
.production-confirmation-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.production-confirmation-head strong {
display: block;
color: #7c2d12;
font-size: 14px;
}
.production-confirmation-head span {
display: block;
margin-top: 4px;
color: #9a5a26;
font-size: 12px;
}
.production-confirmation-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 14px;
}
.notify-target-field {
grid-column: 1 / -1;
}
.danger-note {
margin-top: 12px;
}
.release-preflight-card {
margin-top: 12px;
padding: 14px;
border: 1px solid #d8e2ef;
border-radius: 8px;
background: #f8fafc;
}
.release-preflight-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.release-preflight-head strong {
display: block;
color: #0f172a;
font-size: 14px;
}
.release-preflight-head span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.preflight-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.preflight-item {
min-width: 0;
padding: 10px;
border: 1px solid #e2e8f0;
border-left-width: 3px;
border-radius: 6px;
background: #fff;
}
.preflight-item.is-healthy {
border-left-color: #16a34a;
}
.preflight-item.is-warning {
border-left-color: #d97706;
}
.preflight-item.is-error {
border-left-color: #dc2626;
}
.preflight-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.preflight-item-head strong {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-size: 13px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.preflight-item p {
display: -webkit-box;
min-height: 36px;
margin: 8px 0 6px;
overflow: hidden;
color: #475569;
font-size: 12px;
line-height: 1.5;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.preflight-item small {
color: #94a3b8;
font-size: 11px;
}
.commit-preview { .commit-preview {
display: grid; display: grid;
gap: 4px; gap: 4px;
@@ -677,6 +1137,14 @@ watch(refOptions, (options) => {
.form-grid { .form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.production-confirmation-grid {
grid-template-columns: 1fr;
}
.preflight-grid {
grid-template-columns: 1fr;
}
} }
@media (max-width: 1180px) { @media (max-width: 1180px) {
+580 -13
View File
@@ -2,7 +2,7 @@
/** /**
* 运行记录页展示历史发布单、Jenkins 同步入口和后端步骤流转图。 * 运行记录页展示历史发布单、Jenkins 同步入口和后端步骤流转图。
*/ */
import { CircleClose, RefreshRight } from '@element-plus/icons-vue' import { CircleClose, Download, RefreshRight, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
@@ -12,16 +12,101 @@ import StatusPill from '../components/StatusPill.vue'
import { isDevopsApiError } from '../services/http' import { isDevopsApiError } from '../services/http'
import { usePlatformStore } from '../stores/platform' import { usePlatformStore } from '../stores/platform'
import { useSessionStore } from '../stores/session' import { useSessionStore } from '../stores/session'
import type { ReleaseRun } from '../types/devops' import type {
EnvironmentName,
JenkinsImportSummary,
ReleaseRun,
ReleaseStatus,
} from '../types/devops'
const platform = usePlatformStore() const platform = usePlatformStore()
const session = useSessionStore() const session = useSessionStore()
const route = useRoute() const route = useRoute()
const selectedRunId = ref(platform.runs[0]?.id || '') const selectedRunId = ref(platform.runs[0]?.id || '')
const syncingAll = ref(false) const syncingAll = ref(false)
const importingJenkins = ref(false)
const lastJenkinsImportSummary = ref<JenkinsImportSummary | null>(null)
const filterProjectKey = ref(queryProjectKey())
const filterEnvironment = ref<EnvironmentName | ''>('')
const filterStatus = ref<ReleaseStatus | ''>('')
const searchKeyword = ref('')
const statusOptions: Array<{ label: string; value: ReleaseStatus }> = [
{ label: '排队中', value: 'queued' },
{ label: '运行中', value: 'running' },
{ label: '成功', value: 'success' },
{ label: '失败', value: 'failed' },
{ label: '已取消', value: 'canceled' },
]
const environmentOptions: Array<{ label: string; value: EnvironmentName }> = [
{ label: '测试环境', value: 'test' },
{ label: '生产环境', value: 'production' },
]
const runOverview = computed(() => {
const total = platform.runs.length
const running = platform.runs.filter((run) => run.status === 'queued' || run.status === 'running').length
const failed = platform.runs.filter((run) => run.status === 'failed').length
const success = platform.runs.filter((run) => run.status === 'success').length
const canceled = platform.runs.filter((run) => run.status === 'canceled').length
return [
{ key: 'total', label: '全部 run', value: total, tone: 'neutral' },
{ key: 'running', label: '执行中', value: running, tone: running > 0 ? 'active' : 'neutral' },
{ key: 'failed', label: '失败', value: failed, tone: failed > 0 ? 'danger' : 'neutral' },
{ key: 'success', label: '成功', value: success, tone: 'success' },
{ key: 'canceled', label: '取消', value: canceled, tone: 'neutral' },
]
})
const filteredRuns = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return platform.runs.filter((run) => {
if (filterProjectKey.value && run.projectKey !== filterProjectKey.value) {
return false
}
if (filterEnvironment.value && run.environment !== filterEnvironment.value) {
return false
}
if (filterStatus.value && run.status !== filterStatus.value) {
return false
}
if (!keyword) {
return true
}
return [
run.id,
run.projectName,
run.projectKey,
run.ref,
run.actor,
run.note,
run.commitSummary,
run.jenkinsBuildNumber ? `#${run.jenkinsBuildNumber}` : '',
run.jenkinsQueueId ?? '',
]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(keyword))
})
})
const hasActiveFilters = computed(() => {
return Boolean(
filterProjectKey.value ||
filterEnvironment.value ||
filterStatus.value ||
searchKeyword.value.trim(),
)
})
const selectedRun = computed<ReleaseRun | undefined>(() => { const selectedRun = computed<ReleaseRun | undefined>(() => {
return platform.runs.find((run) => run.id === selectedRunId.value) || platform.runs[0] return filteredRuns.value.find((run) => run.id === selectedRunId.value)
}) })
const canSyncSelectedRun = computed(() => { const canSyncSelectedRun = computed(() => {
@@ -36,6 +121,33 @@ const canSyncAnyRun = computed(
() => session.role === 'super_admin' && platform.runs.some((run) => canSyncRun(run)), () => session.role === 'super_admin' && platform.runs.some((run) => canSyncRun(run)),
) )
const canImportJenkinsBuilds = computed(() => session.role === 'super_admin')
const jenkinsImportStats = computed(() => {
const summary = lastJenkinsImportSummary.value
if (!summary) {
return []
}
return [
{ key: 'jobs', label: '扫描 Job', value: summary.totalJobs, tone: 'neutral' },
{ key: 'builds', label: '扫描构建', value: summary.totalBuilds, tone: 'neutral' },
{ key: 'imported', label: '新导入', value: summary.importedRuns.length, tone: 'active' },
{ key: 'synced', label: '已同步', value: summary.syncedRuns.length, tone: 'success' },
{ key: 'skipped', label: '跳过', value: summary.skippedBuilds.length, tone: 'neutral' },
{ key: 'failed', label: '失败', value: summary.failedBuilds.length, tone: summary.failedBuilds.length > 0 ? 'danger' : 'neutral' },
]
})
const jenkinsImportFailedPreview = computed(() => {
return lastJenkinsImportSummary.value?.failedBuilds.slice(0, 3) ?? []
})
const jenkinsImportSkippedPreview = computed(() => {
return lastJenkinsImportSummary.value?.skippedBuilds.slice(0, 3) ?? []
})
watch( watch(
() => [route.query.runId, platform.runs.map((run) => run.id).join('|')], () => [route.query.runId, platform.runs.map((run) => run.id).join('|')],
() => { () => {
@@ -53,6 +165,34 @@ watch(
{ immediate: true }, { immediate: true },
) )
watch(
() => route.query.projectKey,
() => {
const projectKey = queryProjectKey()
if (projectKey) {
filterProjectKey.value = projectKey
}
},
{ immediate: true },
)
watch(
() => filteredRuns.value.map((run) => run.id).join('|'),
() => {
if (filteredRuns.value.length === 0) {
selectedRunId.value = ''
return
}
if (filteredRuns.value.some((run) => run.id === selectedRunId.value)) {
return
}
selectedRunId.value = filteredRuns.value[0].id
},
)
function canSyncRun(run?: ReleaseRun): boolean { function canSyncRun(run?: ReleaseRun): boolean {
return ( return (
platform.canBuildProject(run?.projectKey) && platform.canBuildProject(run?.projectKey) &&
@@ -119,6 +259,38 @@ async function syncAllJenkinsRuns() {
} }
} }
async function importJenkinsBuilds() {
importingJenkins.value = true
try {
const result = await platform.importJenkinsBuilds()
lastJenkinsImportSummary.value = result
const importedCount = result.importedRuns.length
const failedCount = result.failedBuilds.length
const firstImportedRun = result.importedRuns[0] ?? result.syncedRuns[0]
if (firstImportedRun) {
selectedRunId.value = firstImportedRun.id
}
if (failedCount > 0) {
ElMessage.warning(
`已导入 ${importedCount} 个 Jenkins 直构,${failedCount} 个构建导入失败`,
)
return
}
ElMessage.success(
importedCount > 0
? `已导入 ${importedCount} 个 Jenkins 直构`
: '没有发现新的 Jenkins 直构',
)
} catch (error) {
await handleRunActionError(error, 'Jenkins 直构导入失败')
} finally {
importingJenkins.value = false
}
}
async function handleRunActionError(error: unknown, fallbackMessage: string) { async function handleRunActionError(error: unknown, fallbackMessage: string) {
if (isDevopsApiError(error) && error.code === 'CONFLICT') { if (isDevopsApiError(error) && error.code === 'CONFLICT') {
await platform.refreshRuns() await platform.refreshRuns()
@@ -134,6 +306,60 @@ async function handleRunActionError(error: unknown, fallbackMessage: string) {
ElMessage.error(error instanceof Error ? error.message : fallbackMessage) ElMessage.error(error instanceof Error ? error.message : fallbackMessage)
} }
function resetFilters() {
filterProjectKey.value = ''
filterEnvironment.value = ''
filterStatus.value = ''
searchKeyword.value = ''
}
function queryProjectKey(): string {
return typeof route.query.projectKey === 'string' ? route.query.projectKey : ''
}
function runRowClassName({ row }: { row: ReleaseRun }) {
return row.id === selectedRunId.value ? 'selected-run-row' : ''
}
function formatRunTime(value?: string): string {
if (!value || value === '-') {
return '-'
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value))
}
function formatCheckedAt(value: string): string {
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(value))
}
function skippedReasonLabel(reason: string): string {
if (reason === 'already_imported') {
return '已导入'
}
if (reason === 'linked_platform_run') {
return '已关联平台 Run'
}
if (reason === 'parameter_mismatch') {
return '参数不匹配'
}
return reason
}
</script> </script>
<template> <template>
@@ -143,26 +369,134 @@ async function handleRunActionError(error: unknown, fallbackMessage: string) {
<h1>运行记录</h1> <h1>运行记录</h1>
<p>查看发布 runJenkins 构建链接耗时失败节点和流程轨迹</p> <p>查看发布 runJenkins 构建链接耗时失败节点和流程轨迹</p>
</div> </div>
<ElButton <div class="page-actions">
:icon="RefreshRight" <ElButton
:loading="syncingAll" :icon="Download"
:disabled="!canSyncAnyRun" :loading="importingJenkins"
@click="syncAllJenkinsRuns" :disabled="!canImportJenkinsBuilds"
> @click="importJenkinsBuilds"
同步全部 Jenkins >
</ElButton> 导入 Jenkins 直构
</ElButton>
<ElButton
:icon="RefreshRight"
:loading="syncingAll"
:disabled="!canSyncAnyRun"
@click="syncAllJenkinsRuns"
>
同步全部 Jenkins
</ElButton>
</div>
</div> </div>
<section class="run-overview-grid">
<article
v-for="item in runOverview"
:key="item.key"
class="run-overview-card"
:class="`tone-${item.tone}`"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</section>
<section class="import-summary-band" v-if="lastJenkinsImportSummary">
<div class="import-summary-main">
<div>
<h2>Jenkins 直构导入结果</h2>
<p>
{{ formatCheckedAt(lastJenkinsImportSummary.checkedAt) }} 完成扫描新导入的 run 会进入下方列表
已存在的 Jenkins build 会复用原 run 并尝试同步状态
</p>
</div>
<div class="import-summary-stats">
<span
v-for="item in jenkinsImportStats"
:key="item.key"
class="import-summary-stat"
:class="`tone-${item.tone}`"
>
<small>{{ item.label }}</small>
<strong>{{ item.value }}</strong>
</span>
</div>
</div>
<div
class="import-summary-details"
v-if="jenkinsImportFailedPreview.length > 0 || jenkinsImportSkippedPreview.length > 0"
>
<div v-if="jenkinsImportFailedPreview.length > 0">
<strong>失败构建</strong>
<span
v-for="item in jenkinsImportFailedPreview"
:key="`${item.projectKey}:${item.environment}:${item.buildNumber ?? 'job'}`"
>
{{ item.projectKey }} / {{ item.environment }}
<template v-if="item.buildNumber"> #{{ item.buildNumber }}</template>
{{ item.message }}
</span>
</div>
<div v-if="jenkinsImportSkippedPreview.length > 0">
<strong>跳过构建</strong>
<span
v-for="item in jenkinsImportSkippedPreview"
:key="`${item.projectKey}:${item.environment}:${item.buildNumber}`"
>
{{ item.projectKey }} / {{ item.environment }} #{{ item.buildNumber }}{{ skippedReasonLabel(item.reason) }}
</span>
</div>
</div>
</section>
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>Run 列表</h2> <h2>Run 列表</h2>
<span>点击行切换下方流程图</span> <span>{{ filteredRuns.length }} / {{ platform.runs.length }} 点击行切换下方流程图</span>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="run-filter-bar">
<ElSelect v-model="filterProjectKey" clearable filterable placeholder="项目">
<ElOption
v-for="project in platform.projects"
:key="project.key"
:label="project.name"
:value="project.key"
/>
</ElSelect>
<ElSelect v-model="filterEnvironment" clearable placeholder="环境">
<ElOption
v-for="environment in environmentOptions"
:key="environment.value"
:label="environment.label"
:value="environment.value"
/>
</ElSelect>
<ElSelect v-model="filterStatus" clearable placeholder="状态">
<ElOption
v-for="status in statusOptions"
:key="status.value"
:label="status.label"
:value="status.value"
/>
</ElSelect>
<ElInput
v-model="searchKeyword"
clearable
:prefix-icon="Search"
placeholder="搜索 Run ID / ref / 操作者 / Jenkins 编号"
class="run-search-input"
/>
<ElButton :disabled="!hasActiveFilters" @click="resetFilters">
重置筛选
</ElButton>
</div>
<ElTable <ElTable
:data="platform.runs" :data="filteredRuns"
row-key="id" row-key="id"
highlight-current-row highlight-current-row
:row-class-name="runRowClassName"
empty-text="没有符合条件的运行记录"
@row-click="(row: ReleaseRun) => (selectedRunId = row.id)" @row-click="(row: ReleaseRun) => (selectedRunId = row.id)"
> >
<ElTableColumn prop="id" label="Run ID" width="110" /> <ElTableColumn prop="id" label="Run ID" width="110" />
@@ -178,6 +512,11 @@ async function handleRunActionError(error: unknown, fallbackMessage: string) {
<StatusPill :state="row.status" /> <StatusPill :state="row.status" />
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn label="开始时间" width="135">
<template #default="{ row }">
{{ formatRunTime(row.startedAt) }}
</template>
</ElTableColumn>
<ElTableColumn prop="actor" label="操作者" width="130" /> <ElTableColumn prop="actor" label="操作者" width="130" />
<ElTableColumn prop="duration" label="耗时" width="100" /> <ElTableColumn prop="duration" label="耗时" width="100" />
<ElTableColumn prop="note" label="备注" min-width="220" /> <ElTableColumn prop="note" label="备注" min-width="220" />
@@ -232,12 +571,240 @@ async function handleRunActionError(error: unknown, fallbackMessage: string) {
/> />
</div> </div>
</section> </section>
<section class="panel" v-else-if="platform.runs.length > 0">
<div class="panel-header">
<h2>执行详情</h2>
</div>
<div class="panel-body run-empty-state">
<strong>当前筛选没有可查看的 Run</strong>
<span>调整项目环境状态或搜索条件后再查看对应的执行详情与流程轨迹</span>
</div>
</section>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.run-overview-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.run-overview-card {
min-width: 0;
padding: 12px 14px;
border: 1px solid #d8e2ef;
border-radius: 8px;
background: #fff;
}
.run-overview-card span {
display: block;
color: #64748b;
font-size: 12px;
}
.run-overview-card strong {
display: block;
margin-top: 6px;
color: #0f172a;
font-size: 22px;
line-height: 1;
}
.run-overview-card.tone-active {
border-color: #bfdbfe;
background: #eff6ff;
}
.run-overview-card.tone-success {
border-color: #bbf7d0;
background: #f0fdf4;
}
.run-overview-card.tone-danger {
border-color: #fecaca;
background: #fef2f2;
}
.import-summary-band {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
border: 1px solid #c7d7ee;
border-radius: 8px;
background: #f8fbff;
}
.import-summary-main {
display: grid;
grid-template-columns: minmax(240px, 1fr) minmax(420px, 1.2fr);
gap: 18px;
align-items: center;
}
.import-summary-main h2 {
margin: 0;
color: #172033;
font-size: 16px;
}
.import-summary-main p {
margin: 6px 0 0;
color: #5f6f89;
font-size: 13px;
line-height: 1.6;
}
.import-summary-stats {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
.import-summary-stat {
min-width: 0;
padding: 10px;
border: 1px solid #d8e2ef;
border-radius: 8px;
background: #ffffff;
}
.import-summary-stat small {
display: block;
color: #64748b;
font-size: 12px;
}
.import-summary-stat strong {
display: block;
margin-top: 4px;
color: #172033;
font-size: 20px;
line-height: 1;
}
.import-summary-stat.tone-active {
border-color: #bfdbfe;
background: #eff6ff;
}
.import-summary-stat.tone-success {
border-color: #bbf7d0;
background: #f0fdf4;
}
.import-summary-stat.tone-danger {
border-color: #fecaca;
background: #fef2f2;
}
.import-summary-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.import-summary-details div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
padding-top: 12px;
border-top: 1px solid #dbe6f4;
color: #52627a;
font-size: 12px;
line-height: 1.5;
}
.import-summary-details strong {
color: #172033;
font-size: 13px;
}
.run-filter-bar {
display: grid;
grid-template-columns: minmax(150px, 1fr) 140px 140px minmax(260px, 1.4fr) max-content;
gap: 10px;
margin-bottom: 12px;
align-items: center;
}
.run-search-input {
min-width: 0;
}
:deep(.selected-run-row td) {
background: #eff6ff;
}
.permission-action-hint { .permission-action-hint {
color: #8a5a00; color: #8a5a00;
font-size: 12px; font-size: 12px;
} }
.run-empty-state {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 112px;
justify-content: center;
color: #64748b;
}
.run-empty-state strong {
color: #172033;
font-size: 15px;
}
.run-empty-state span {
font-size: 13px;
line-height: 1.6;
}
@media (max-width: 1080px) {
.run-overview-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.import-summary-main,
.import-summary-details {
grid-template-columns: 1fr;
}
.import-summary-stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.run-filter-bar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.page-actions {
width: 100%;
justify-content: stretch;
}
.page-actions .el-button {
flex: 1 1 0;
min-width: 0;
}
.run-overview-grid,
.import-summary-stats,
.run-filter-bar {
grid-template-columns: 1fr;
}
}
</style> </style>
+332 -10
View File
@@ -2,9 +2,9 @@
/** /**
* 系统配置页展示集成变量说明、配置边界和审计日志脱敏 JSON。 * 系统配置页展示集成变量说明、配置边界和审计日志脱敏 JSON。
*/ */
import { Connection, Refresh } from '@element-plus/icons-vue' import { Connection, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import AuditJsonCell from '../components/AuditJsonCell.vue' import AuditJsonCell from '../components/AuditJsonCell.vue'
import StatusPill from '../components/StatusPill.vue' import StatusPill from '../components/StatusPill.vue'
import { devopsApi } from '../services/devopsApi' import { devopsApi } from '../services/devopsApi'
@@ -16,6 +16,7 @@ import type {
IntegrationConfigItem, IntegrationConfigItem,
IntegrationConfigState, IntegrationConfigState,
NotificationOutboxMessage, NotificationOutboxMessage,
NotificationOutboxStatus,
} from '../types/devops' } from '../types/devops'
const platform = usePlatformStore() const platform = usePlatformStore()
@@ -24,6 +25,10 @@ const agentConfigLoading = ref(false)
const agentConfigTesting = ref(false) const agentConfigTesting = ref(false)
const agentConfig = ref<AgentConfigSummary | null>(null) const agentConfig = ref<AgentConfigSummary | null>(null)
const retryingOutboxIds = ref<Set<string>>(new Set()) const retryingOutboxIds = ref<Set<string>>(new Set())
const outboxStatusFilter = ref<NotificationOutboxStatus | ''>('')
const outboxKeyword = ref('')
const auditResourceFilter = ref('')
const auditKeyword = ref('')
const agentConfigForm = reactive({ const agentConfigForm = reactive({
key: '', key: '',
baseURL: '', baseURL: '',
@@ -55,11 +60,142 @@ const outboxStatusMeta: Record<
failed: { label: '投递失败', type: 'danger' }, failed: { label: '投递失败', type: 'danger' },
dead: { label: '已终止', type: 'info' }, dead: { label: '已终止', type: 'info' },
} }
const outboxStatusOptions: Array<{ label: string; value: NotificationOutboxStatus }> = [
{ label: '待投递', value: 'pending' },
{ label: '已投递', value: 'sent' },
{ label: '投递失败', value: 'failed' },
{ label: '已终止', value: 'dead' },
]
onMounted(() => { onMounted(() => {
void loadAgentConfig() if (canManageSystemConfig.value) {
void loadAgentConfig()
}
}) })
const canManageSystemConfig = computed(() => session.role === 'super_admin')
const operationOverview = computed(() => {
const integrations = platform.integrationConfig.integrations
const configured = integrations.filter((item) => item.status === 'configured').length
const missing = integrations.filter((item) => item.status === 'missing').length
const partial = integrations.filter((item) => item.status === 'partial').length
const outboxAttention = platform.notificationOutbox.filter(
(item) => item.status === 'pending' || item.status === 'failed' || item.status === 'dead',
).length
return [
{ key: 'integrations', label: '集成项', value: integrations.length, tone: 'neutral' },
{ key: 'configured', label: '已配置', value: configured, tone: 'success' },
{
key: 'attention',
label: '配置关注',
value: missing + partial,
tone: missing + partial > 0 ? 'warning' : 'neutral',
},
{
key: 'agent',
label: 'Agent 密钥',
value: agentConfig.value?.hasKey ? '已保存' : '未配置',
tone: agentConfig.value?.hasKey ? 'success' : 'warning',
},
{
key: 'outbox',
label: '通知待排查',
value: outboxAttention,
tone: outboxAttention > 0 ? 'danger' : 'neutral',
},
{ key: 'audit', label: '审计记录', value: platform.auditLogs.length, tone: 'active' },
]
})
const agentConfigDisabledReason = computed(() => {
if (!canManageSystemConfig.value) {
return '仅超级管理员可维护 Agent 模型配置'
}
if (!agentConfigForm.baseURL.trim()) {
return '请填写 Base URL'
}
if (!agentConfigForm.model.trim()) {
return '请填写模型'
}
if (!agentConfig.value?.hasKey && !agentConfigForm.key.trim()) {
return '首次保存需要填写 API Key'
}
return ''
})
const canSaveAgentConfig = computed(() => !agentConfigDisabledReason.value)
const filteredNotificationOutbox = computed(() => {
const keyword = outboxKeyword.value.trim().toLowerCase()
return platform.notificationOutbox.filter((record) => {
if (outboxStatusFilter.value && record.status !== outboxStatusFilter.value) {
return false
}
if (!keyword) {
return true
}
return [
record.id,
record.deployRunId,
record.channel,
record.template,
record.lastError,
record.idempotencyKey,
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.some((value) => value.toLowerCase().includes(keyword))
})
})
const auditResourceOptions = computed(() => {
return Array.from(new Set(platform.auditLogs.map((record) => record.resourceType)))
.filter(Boolean)
.sort((left, right) => left.localeCompare(right))
})
const filteredAuditLogs = computed(() => {
const keyword = auditKeyword.value.trim().toLowerCase()
return platform.auditLogs.filter((record) => {
if (auditResourceFilter.value && record.resourceType !== auditResourceFilter.value) {
return false
}
if (!keyword) {
return true
}
return [
record.action,
record.resourceType,
record.resourceId,
record.actorName,
record.actorId,
record.requestId,
record.sourceIp,
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.some((value) => value.toLowerCase().includes(keyword))
})
})
const hasActiveOutboxFilters = computed(() =>
Boolean(outboxStatusFilter.value || outboxKeyword.value.trim()),
)
const hasActiveAuditFilters = computed(() =>
Boolean(auditResourceFilter.value || auditKeyword.value.trim()),
)
function configStatusLabel(status: IntegrationConfigState): string { function configStatusLabel(status: IntegrationConfigState): string {
return configStatusMeta[status]?.label ?? '未配置' return configStatusMeta[status]?.label ?? '未配置'
} }
@@ -229,6 +365,10 @@ function variableSourceLabel(item: IntegrationConfigItem, name: string): string
} }
async function loadAgentConfig() { async function loadAgentConfig() {
if (!canManageSystemConfig.value) {
return
}
agentConfigLoading.value = true agentConfigLoading.value = true
try { try {
@@ -240,6 +380,11 @@ async function loadAgentConfig() {
} }
async function saveAgentConfig() { async function saveAgentConfig() {
if (!canSaveAgentConfig.value) {
ElMessage.warning(agentConfigDisabledReason.value)
return
}
agentConfigLoading.value = true agentConfigLoading.value = true
try { try {
@@ -260,6 +405,11 @@ async function saveAgentConfig() {
} }
async function testAgentConfig() { async function testAgentConfig() {
if (!canManageSystemConfig.value) {
ElMessage.warning('仅超级管理员可测试 Agent 模型连接')
return
}
agentConfigTesting.value = true agentConfigTesting.value = true
try { try {
@@ -300,6 +450,16 @@ function applyAgentConfig(config: AgentConfigSummary) {
agentConfigForm.baseURL = config.baseURL agentConfigForm.baseURL = config.baseURL
agentConfigForm.model = config.model agentConfigForm.model = config.model
} }
function resetOutboxFilters() {
outboxStatusFilter.value = ''
outboxKeyword.value = ''
}
function resetAuditFilters() {
auditResourceFilter.value = ''
auditKeyword.value = ''
}
</script> </script>
<template> <template>
@@ -311,6 +471,18 @@ function applyAgentConfig(config: AgentConfigSummary) {
</div> </div>
</div> </div>
<section class="settings-overview-grid">
<article
v-for="item in operationOverview"
:key="item.key"
class="settings-overview-card"
:class="`tone-${item.tone}`"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</section>
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>后端集成配置状态</h2> <h2>后端集成配置状态</h2>
@@ -403,7 +575,7 @@ function applyAgentConfig(config: AgentConfigSummary) {
</section> </section>
</div> </div>
<section class="panel"> <section v-if="canManageSystemConfig" class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>Agent 模型配置</h2> <h2>Agent 模型配置</h2>
<span>{{ agentConfig?.hasKey ? '已保存密钥' : '未保存密钥' }}</span> <span>{{ agentConfig?.hasKey ? '已保存密钥' : '未保存密钥' }}</span>
@@ -446,6 +618,12 @@ function applyAgentConfig(config: AgentConfigSummary) {
测试连接 测试连接
</ElButton> </ElButton>
</div> </div>
<div
class="agent-config-check"
:class="{ ready: canSaveAgentConfig }"
>
{{ canSaveAgentConfig ? '保存前检查通过,API Key 只会提交给后端配置接口。' : agentConfigDisabledReason }}
</div>
</ElForm> </ElForm>
</div> </div>
<div class="agent-config-summary"> <div class="agent-config-summary">
@@ -470,15 +648,48 @@ function applyAgentConfig(config: AgentConfigSummary) {
</div> </div>
</section> </section>
<section v-else class="panel">
<div class="panel-header">
<h2>Agent 模型配置</h2>
<span>仅超级管理员可维护</span>
</div>
<div class="panel-body">
<div class="secret-boundary">
当前账号只能查看系统配置边界模型 keybaseURL model 的保存测试均需要后端超级管理员鉴权
</div>
</div>
</section>
<section v-if="session.role === 'super_admin'" class="panel"> <section v-if="session.role === 'super_admin'" class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>通知 outbox</h2> <h2>通知 outbox</h2>
<span>{{ filteredNotificationOutbox.length }} / {{ platform.notificationOutbox.length }} </span>
<ElButton :icon="Refresh" size="small" @click="refreshNotificationOutbox"> <ElButton :icon="Refresh" size="small" @click="refreshNotificationOutbox">
刷新 刷新
</ElButton> </ElButton>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<ElTable :data="platform.notificationOutbox" border> <div class="settings-filter-bar">
<ElSelect v-model="outboxStatusFilter" clearable placeholder="投递状态">
<ElOption
v-for="option in outboxStatusOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElInput
v-model="outboxKeyword"
clearable
:prefix-icon="Search"
placeholder="搜索渠道 / 模板 / 发布单 / 错误"
class="settings-search-input"
/>
<ElButton :disabled="!hasActiveOutboxFilters" @click="resetOutboxFilters">
重置筛选
</ElButton>
</div>
<ElTable :data="filteredNotificationOutbox" border empty-text="没有符合条件的通知 outbox 记录">
<ElTableColumn label="创建时间" min-width="150"> <ElTableColumn label="创建时间" min-width="150">
<template #default="{ row }"> <template #default="{ row }">
{{ formatCheckedAt(row.createdAt) }} {{ formatCheckedAt(row.createdAt) }}
@@ -535,8 +746,8 @@ function applyAgentConfig(config: AgentConfigSummary) {
</ElTableColumn> </ElTableColumn>
</ElTable> </ElTable>
<ElEmpty <ElEmpty
v-if="platform.notificationOutbox.length === 0" v-if="filteredNotificationOutbox.length === 0"
description="暂无通知 outbox 记录" :description="platform.notificationOutbox.length ? '暂无符合条件的通知 outbox 记录' : '暂无通知 outbox 记录'"
:image-size="72" :image-size="72"
/> />
</div> </div>
@@ -545,12 +756,33 @@ function applyAgentConfig(config: AgentConfigSummary) {
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>最近运维审计</h2> <h2>最近运维审计</h2>
<span>{{ filteredAuditLogs.length }} / {{ platform.auditLogs.length }} </span>
<ElButton :icon="Refresh" size="small" @click="platform.loadAuditLogs()"> <ElButton :icon="Refresh" size="small" @click="platform.loadAuditLogs()">
刷新 刷新
</ElButton> </ElButton>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<ElTable :data="platform.auditLogs" border> <div class="settings-filter-bar">
<ElSelect v-model="auditResourceFilter" clearable filterable placeholder="资源类型">
<ElOption
v-for="resource in auditResourceOptions"
:key="resource"
:label="resource"
:value="resource"
/>
</ElSelect>
<ElInput
v-model="auditKeyword"
clearable
:prefix-icon="Search"
placeholder="搜索动作 / 资源 / 操作者 / requestId"
class="settings-search-input"
/>
<ElButton :disabled="!hasActiveAuditFilters" @click="resetAuditFilters">
重置筛选
</ElButton>
</div>
<ElTable :data="filteredAuditLogs" border empty-text="没有符合条件的审计记录">
<ElTableColumn label="时间" min-width="150"> <ElTableColumn label="时间" min-width="150">
<template #default="{ row }"> <template #default="{ row }">
{{ formatCheckedAt(row.createdAt) }} {{ formatCheckedAt(row.createdAt) }}
@@ -574,8 +806,8 @@ function applyAgentConfig(config: AgentConfigSummary) {
</ElTableColumn> </ElTableColumn>
</ElTable> </ElTable>
<ElEmpty <ElEmpty
v-if="platform.auditLogs.length === 0" v-if="filteredAuditLogs.length === 0"
description="暂无审计记录" :description="platform.auditLogs.length ? '暂无符合条件的审计记录' : '暂无审计记录'"
:image-size="72" :image-size="72"
/> />
</div> </div>
@@ -607,6 +839,66 @@ function applyAgentConfig(config: AgentConfigSummary) {
</template> </template>
<style scoped> <style scoped>
.settings-overview-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.settings-overview-card {
min-width: 0;
padding: 12px 14px;
border: 1px solid #d8e2ef;
border-radius: 8px;
background: #fff;
}
.settings-overview-card span {
display: block;
color: #64748b;
font-size: 12px;
}
.settings-overview-card strong {
display: block;
margin-top: 6px;
color: #0f172a;
font-size: 22px;
line-height: 1;
}
.settings-overview-card.tone-active {
border-color: #bfdbfe;
background: #eff6ff;
}
.settings-overview-card.tone-success {
border-color: #bbf7d0;
background: #f0fdf4;
}
.settings-overview-card.tone-warning {
border-color: #fde68a;
background: #fffbeb;
}
.settings-overview-card.tone-danger {
border-color: #fecaca;
background: #fef2f2;
}
.settings-filter-bar {
display: grid;
grid-template-columns: 180px minmax(260px, 1fr) max-content;
gap: 10px;
margin-bottom: 12px;
align-items: center;
}
.settings-search-input {
min-width: 0;
}
.setting-card dl { .setting-card dl {
display: grid; display: grid;
grid-template-columns: 74px minmax(0, 1fr); grid-template-columns: 74px minmax(0, 1fr);
@@ -666,6 +958,23 @@ function applyAgentConfig(config: AgentConfigSummary) {
word-break: break-word; word-break: break-word;
} }
.agent-config-check {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid #fde68a;
border-radius: 8px;
background: #fffbeb;
color: #8a5a00;
font-size: 12px;
line-height: 1.6;
}
.agent-config-check.ready {
border-color: #bbf7d0;
background: #f0fdf4;
color: #166534;
}
.config-list { .config-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -777,6 +1086,14 @@ function applyAgentConfig(config: AgentConfigSummary) {
} }
@media (max-width: 1080px) { @media (max-width: 1080px) {
.settings-overview-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.settings-filter-bar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.variable-grid { .variable-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -787,6 +1104,11 @@ function applyAgentConfig(config: AgentConfigSummary) {
} }
@media (max-width: 680px) { @media (max-width: 680px) {
.settings-overview-grid,
.settings-filter-bar {
grid-template-columns: 1fr;
}
.config-row-main { .config-row-main {
flex-direction: column; flex-direction: column;
} }