feat: 完善运维控制台产品化体验
This commit is contained in:
@@ -29,5 +29,5 @@
|
||||
|
||||
- BPMN 组件只展示流程图、节点状态和节点详情,不推进状态机。
|
||||
- `step.bpmnNodeId` 必须与 BPMN XML 节点 id 一致。
|
||||
- Agent 只能作为全局右侧抽屉工具存在,不作为独立侧边栏路由;能力只允许 DevOps 场景:发布风险摘要、失败诊断、Runbook 问答、发布说明草稿。
|
||||
- Agent 只能作为全局右侧抽屉工具存在,不作为独立侧边栏路由;能力只允许 DevOps 场景:发布风险摘要、失败诊断、Runbook 问答、发布说明草稿和事故复盘草稿。
|
||||
- 禁止在前端新增泛聊天入口。
|
||||
|
||||
@@ -6,11 +6,14 @@ Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于
|
||||
|
||||
- 登录、总览、项目配置、成员权限、发布中心、运行记录、系统配置,以及全局右侧 Agent 抽屉。
|
||||
- 所有项目、发布、refs、流程和集成状态均调用 `devops-platform-api`,前端不再提供本地 mock 数据降级。
|
||||
- 成员权限页调用后端 `/members` 和 `/messages`,维护普通成员账号、项目权限、密码重置和平台消息。
|
||||
- 发布中心包含项目、环境、ref/tag、备注、生产确认提示、触发、取消、重试和 Jenkins 状态同步入口。
|
||||
- 运行记录展示 Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要。
|
||||
- 总览页提供值班焦点、优先 Run 队列、项目发布健康、项目环境摘要和跨页快捷入口,用于快速定位失败发布、项目阻断、配置缺口和通知 outbox 风险。
|
||||
- 成员权限页调用后端 `/members` 和 `/messages`,展示账号/构建权限/安全状态概览,支持角色、启停、安全状态和关键词筛选,并维护普通成员账号、项目权限、密码重置和平台消息。
|
||||
- 项目配置提供项目可发布性概览、项目/仓库搜索、发布态/权限筛选、Gitea/Jenkins/Webhook 诊断展开和发布/运行记录上下文跳转。
|
||||
- 发布中心包含项目、环境、后端返回的 ref/tag、备注、发布前检查、生产发布回滚/窗口/通知确认、触发、取消、重试和 Jenkins 状态同步入口。
|
||||
- 运行记录展示状态概览、项目/环境/状态/关键词筛选、Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要,并提供超级管理员导入 Jenkins 直接构建记录的入口。
|
||||
- 系统配置页展示集成配置、Agent 密钥状态、通知 outbox 和审计日志概览,支持通知投递与审计记录筛选,用于定位配置缺失、通知失败和敏感操作。
|
||||
- BPMN 状态区使用后端流程 XML 和 run steps 作为状态来源,左侧展示节点输出或 Jenkins 构建日志,右侧粘性展示进度统计、节点时间线和详情面板。
|
||||
- Agent 作为全局工具从顶部打开,只包含发布风险摘要、失败诊断、Runbook 问答、发布说明草稿,并通过后端 Agent API 创建调用记录。
|
||||
- Agent 作为全局工具从顶部打开,只包含发布风险摘要、失败诊断、Runbook 问答、发布说明草稿和事故复盘草稿,并通过后端 Agent API 创建调用记录。
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -65,5 +68,6 @@ pnpm build
|
||||
- 失败诊断
|
||||
- Runbook 问答
|
||||
- 发布说明草稿
|
||||
- 事故复盘草稿
|
||||
|
||||
模型调用、脱敏、审计、白名单知识库和密钥管理由后端代理实现。
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
## 页面结构
|
||||
|
||||
- `/login`:真实平台账号登录入口,支持 token 恢复和首次改密。
|
||||
- `/overview`:总览,展示服务状态、最近发布和项目环境。
|
||||
- `/projects`:项目配置,展示 Gitea 仓库、Jenkins Job、通知通道和环境策略。
|
||||
- `/members`:成员权限,仅超级管理员可见,维护普通成员、项目权限和密码重置消息。
|
||||
- `/releases`:发布中心,提供发布参数、二次确认、触发、取消、重试和 BPMN 流程状态。
|
||||
- `/runs`:运行记录,展示 run 列表和流程轨迹。
|
||||
- `/overview`:总览,展示值班焦点、优先 Run 队列、项目发布健康、服务状态和项目环境快捷入口。
|
||||
- `/projects`:项目配置,展示项目可发布性概览、发布态/权限筛选、Gitea 仓库、Jenkins Job、通知通道、环境策略和上下文跳转。
|
||||
- `/members`:成员权限,仅超级管理员可见,展示账号/构建权限/安全状态概览,支持成员筛选,并维护普通成员、项目权限和密码重置消息。
|
||||
- `/releases`:发布中心,提供发布参数、后端 ref/tag 选择、发布前检查、生产发布确认、触发、取消、重试和 BPMN 流程状态。
|
||||
- `/runs`:运行记录,展示 run 状态概览、筛选列表、执行详情、流程轨迹,并支持超级管理员导入 Jenkins 直接构建记录。
|
||||
- 顶部 Agent 按钮:打开全局右侧抽屉,只保留 DevOps 场景能力;`/agent` 仅重定向到总览,不作为独立侧边栏入口。
|
||||
- `/settings`:系统配置,展示 API、BPMN、Agent、通知边界,并维护后端 Agent 模型配置。
|
||||
- `/settings`:系统配置,展示集成配置、Agent、通知 outbox 和审计日志概览,支持通知/审计筛选,并维护后端 Agent 模型配置。
|
||||
|
||||
## 数据约定
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
- `devopsApi` 所有项目、发布、refs、流程、成员、消息和 Agent 调用均走后端接口。
|
||||
- 发布中心触发按钮接入后端发布单创建接口。
|
||||
- 运行记录接入后端 run/step 查询接口。
|
||||
- Jenkins 直接构建不由前端直连 Jenkins;运行记录页调用后端 `/deploy-runs/import-jenkins` 扫描已纳管 job 并导入 run。
|
||||
- BPMN XML 由后端流程定义接口返回。
|
||||
- Agent 面板调用后端 Agent proxy,前端只传 DevOps 场景参数。
|
||||
- Agent 配置页调用后端 `/agent/config`,前端不持久化 key、baseURL 或 model。
|
||||
- Agent 能力限定为发布风险摘要、失败诊断、Runbook 问答、发布说明草稿和事故复盘草稿。
|
||||
|
||||
@@ -55,11 +55,27 @@ const failedRunOptions = computed(() => {
|
||||
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>(() => {
|
||||
if (selectedMode.value === 'diagnosis') {
|
||||
return 'failure-diagnosis'
|
||||
}
|
||||
|
||||
if (selectedMode.value === 'incidentReview') {
|
||||
return 'incident-review'
|
||||
}
|
||||
|
||||
if (selectedMode.value === 'runbook') {
|
||||
return 'runbook-qa'
|
||||
}
|
||||
@@ -82,18 +98,26 @@ watch(
|
||||
)
|
||||
|
||||
watch(failedRunOptions, (runs) => {
|
||||
if (selectedMode.value === 'diagnosis') {
|
||||
if (requiresRunSelection.value) {
|
||||
selectedRun.value = runs[0]?.id ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedMode, (mode) => {
|
||||
if (mode === 'diagnosis') {
|
||||
if (mode === 'diagnosis' || mode === 'incidentReview') {
|
||||
selectedRun.value = failedRunOptions.value[0]?.id ?? ''
|
||||
}
|
||||
|
||||
if (mode === 'diagnosis') {
|
||||
prompt.value = '请根据最近失败 run 的 Jenkins 日志摘要和流程节点给出排查步骤。'
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'incidentReview') {
|
||||
prompt.value = '请基于这个失败 run 生成事故复盘草稿,包含时间线、影响、根因假设和预防项。'
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'runbook') {
|
||||
prompt.value = '生产发布失败后,应该先检查哪些平台信息?'
|
||||
return
|
||||
@@ -147,7 +171,7 @@ async function sendPrompt() {
|
||||
const invocation = await devopsApi.createAgentInvocation({
|
||||
type: purpose.value,
|
||||
projectKey: selectedProject.value || undefined,
|
||||
runId: selectedMode.value === 'diagnosis' ? selectedRun.value || undefined : undefined,
|
||||
runId: requiresRunSelection.value ? selectedRun.value || undefined : undefined,
|
||||
promptSummary: text,
|
||||
})
|
||||
|
||||
@@ -240,6 +264,7 @@ function formatTime(value: string): string {
|
||||
{ label: '诊断', value: 'diagnosis' },
|
||||
{ label: 'Runbook', value: 'runbook' },
|
||||
{ label: '发布说明', value: 'releaseNotes' },
|
||||
{ label: '复盘', value: 'incidentReview' },
|
||||
]"
|
||||
/>
|
||||
|
||||
@@ -253,7 +278,7 @@ function formatTime(value: string): string {
|
||||
</ElSelect>
|
||||
|
||||
<ElSelect
|
||||
v-if="selectedMode === 'diagnosis'"
|
||||
v-if="requiresRunSelection"
|
||||
v-model="selectedRun"
|
||||
filterable
|
||||
placeholder="选择失败 Run"
|
||||
@@ -265,6 +290,9 @@ function formatTime(value: string): string {
|
||||
:value="run.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
<div v-if="requiresRunSelection && !selectedRun" class="agent-context-hint">
|
||||
当前项目没有可用于分析的失败 run。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-list">
|
||||
@@ -291,7 +319,13 @@ function formatTime(value: string): string {
|
||||
resize="none"
|
||||
@keydown="handleComposerKeydown"
|
||||
/>
|
||||
<ElButton type="primary" :icon="Promotion" :loading="sending" @click="sendPrompt">
|
||||
<ElButton
|
||||
type="primary"
|
||||
:icon="Promotion"
|
||||
:loading="sending"
|
||||
:disabled="sendDisabled"
|
||||
@click="sendPrompt"
|
||||
>
|
||||
发送
|
||||
</ElButton>
|
||||
</footer>
|
||||
@@ -351,6 +385,12 @@ function formatTime(value: string): string {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.agent-context-hint {
|
||||
color: #8a5a00;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
@@ -116,6 +116,27 @@ async function logout() {
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</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">
|
||||
<RouterView />
|
||||
</ElMain>
|
||||
@@ -189,6 +210,51 @@ async function logout() {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -207,6 +273,10 @@ async function logout() {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
@@ -222,4 +292,21 @@ async function logout() {
|
||||
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>
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
IntegrationConfigStatus,
|
||||
IntegrationVariableHelp,
|
||||
IntegrationStatus,
|
||||
JenkinsImportSummary,
|
||||
JenkinsBuildLog,
|
||||
JenkinsSyncSummary,
|
||||
LoginPayload,
|
||||
@@ -107,7 +108,7 @@ interface BackendDeployRun {
|
||||
environment?: EnvironmentName
|
||||
ref?: string
|
||||
status?: BackendRunStatus
|
||||
trigger?: 'manual' | 'gitea_webhook' | 'retry'
|
||||
trigger?: 'manual' | 'gitea_webhook' | 'retry' | 'jenkins_external'
|
||||
operator?: string
|
||||
jenkinsQueueId?: string | 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 {
|
||||
key: 'release'
|
||||
version: string
|
||||
@@ -205,6 +226,13 @@ const agentCards: AgentCard[] = [
|
||||
inputScope: ['commit 列表', 'PR 信息', 'tag'],
|
||||
outputPreview: ['变更摘要', '验证范围', '回滚提示'],
|
||||
},
|
||||
{
|
||||
mode: 'incidentReview',
|
||||
title: '事故复盘草稿',
|
||||
description: '基于失败 run、流程耗时、Jenkins 摘要和 Runbook 生成复盘草稿。',
|
||||
inputScope: ['失败 run', 'BPMN 节点', '脱敏日志片段', 'Runbook'],
|
||||
outputPreview: ['时间线', '根因假设', '修复和预防项'],
|
||||
},
|
||||
]
|
||||
|
||||
const settings: SystemSetting[] = [
|
||||
@@ -229,7 +257,7 @@ const settings: SystemSetting[] = [
|
||||
name: 'Agent 边界',
|
||||
status: 'warning',
|
||||
owner: '后端 Agent proxy',
|
||||
contract: '只支持发布风险、失败诊断、Runbook、发布说明四类用途。',
|
||||
contract: '只支持发布风险、失败诊断、Runbook、发布说明和事故复盘五类用途。',
|
||||
boundary: '无泛聊天入口;模型 key/baseURL/model 仅后端保存。',
|
||||
},
|
||||
{
|
||||
@@ -439,6 +467,14 @@ export const devopsApi = {
|
||||
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> {
|
||||
const { data } = await http.get<JenkinsBuildLog>(`/deploy-runs/${runId}/jenkins-log`, {
|
||||
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 {
|
||||
return {
|
||||
id: safeText(record.id, 'audit-unknown'),
|
||||
@@ -1216,6 +1271,10 @@ function triggerLabel(trigger: BackendDeployRun['trigger']): string {
|
||||
return '失败后重试'
|
||||
}
|
||||
|
||||
if (trigger === 'jenkins_external') {
|
||||
return 'Jenkins 直构导入'
|
||||
}
|
||||
|
||||
return '人工触发'
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
ProjectJenkinsDiagnosticsSummary,
|
||||
ProjectPermissionLevel,
|
||||
ProjectRefs,
|
||||
JenkinsImportSummary,
|
||||
JenkinsSyncSummary,
|
||||
NotificationOutboxMessage,
|
||||
ReleaseRun,
|
||||
@@ -140,6 +141,15 @@ export const usePlatformStore = defineStore('platform', () => {
|
||||
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 列表并同步项目环境摘要。
|
||||
*/
|
||||
@@ -360,6 +370,7 @@ export const usePlatformStore = defineStore('platform', () => {
|
||||
retryDeployRun,
|
||||
syncJenkinsRun,
|
||||
syncJenkinsRuns,
|
||||
importJenkinsBuilds,
|
||||
refreshRuns,
|
||||
loadProjectRefs,
|
||||
loadProjectJenkinsDiagnostics,
|
||||
|
||||
+30
-1
@@ -22,7 +22,12 @@ export type StepStatus =
|
||||
| 'skipped'
|
||||
| 'canceled'
|
||||
|
||||
export type AgentMode = 'risk' | 'diagnosis' | 'runbook' | 'releaseNotes'
|
||||
export type AgentMode =
|
||||
| 'risk'
|
||||
| 'diagnosis'
|
||||
| 'runbook'
|
||||
| 'releaseNotes'
|
||||
| 'incidentReview'
|
||||
|
||||
export type AgentPurpose =
|
||||
| 'release-risk'
|
||||
@@ -402,6 +407,30 @@ export interface JenkinsSyncSummary {
|
||||
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 {
|
||||
id: string
|
||||
action: string
|
||||
|
||||
+334
-29
@@ -9,6 +9,7 @@ import {
|
||||
Lock,
|
||||
Plus,
|
||||
Refresh,
|
||||
Search,
|
||||
User,
|
||||
} from '@element-plus/icons-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 { usePlatformStore } from '../stores/platform'
|
||||
import type {
|
||||
AuthRole,
|
||||
AuthStatus,
|
||||
CreateMemberPayload,
|
||||
MemberSummary,
|
||||
PlatformMessageState,
|
||||
PlatformMessageSummary,
|
||||
ProjectConfig,
|
||||
ProjectPermissionLevel,
|
||||
@@ -30,6 +34,7 @@ type PermissionProjectGroup = {
|
||||
label: string
|
||||
children: ProjectConfig[]
|
||||
}
|
||||
type MemberSecurityFilter = 'normal' | 'mustChangePassword' | 'passwordResetRequest'
|
||||
|
||||
const route = useRoute()
|
||||
const platform = usePlatformStore()
|
||||
@@ -39,6 +44,10 @@ const editingMember = ref<MemberSummary | null>(null)
|
||||
const members = ref<MemberSummary[]>([])
|
||||
const messages = ref<PlatformMessageSummary[]>([])
|
||||
const accountKeyword = ref('')
|
||||
const roleFilter = ref<AuthRole | ''>('')
|
||||
const statusFilter = ref<AuthStatus | ''>('')
|
||||
const securityFilter = ref<MemberSecurityFilter | ''>('')
|
||||
const messageStatusFilter = ref<PlatformMessageState | ''>('')
|
||||
const createForm = reactive({
|
||||
account: '',
|
||||
displayName: '',
|
||||
@@ -51,19 +60,54 @@ const permissionOptions: Array<{ label: string; value: ProjectPermissionLevel }>
|
||||
{ label: '只读', value: 'read' },
|
||||
{ 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 keyword = accountKeyword.value.trim().toLowerCase()
|
||||
|
||||
if (!keyword) {
|
||||
return members.value
|
||||
}
|
||||
|
||||
return members.value.filter((member) => {
|
||||
return (
|
||||
member.account.toLowerCase().includes(keyword) ||
|
||||
member.displayName.toLowerCase().includes(keyword)
|
||||
)
|
||||
if (roleFilter.value && member.role !== roleFilter.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
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'),
|
||||
)
|
||||
|
||||
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 groupByOwner = new Map<string, ProjectConfig[]>()
|
||||
|
||||
@@ -280,6 +374,60 @@ function canMutate(member: MemberSummary) {
|
||||
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() {
|
||||
setAllProjectPermissions(currentPermissionMap(), 'build')
|
||||
}
|
||||
@@ -377,12 +525,6 @@ function formatDateTime(value?: string) {
|
||||
<p>维护平台账号、项目只读/构建权限和密码重置消息。</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<ElInput
|
||||
v-model="accountKeyword"
|
||||
class="member-search"
|
||||
clearable
|
||||
placeholder="搜索账号或名称"
|
||||
/>
|
||||
<ElButton :icon="Refresh" @click="loadData">刷新</ElButton>
|
||||
<ElButton type="primary" :icon="Plus" @click="openCreateDialog">
|
||||
新增成员
|
||||
@@ -390,15 +532,40 @@ function formatDateTime(value?: string) {
|
||||
</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">
|
||||
<div class="panel-header">
|
||||
<h2>密码消息</h2>
|
||||
<span>{{ pendingMessages.length }} 条待处理</span>
|
||||
<span>{{ filteredMessages.length }} / {{ messages.length }} 条,{{ pendingMessages.length }} 条待处理</span>
|
||||
</div>
|
||||
<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
|
||||
v-for="message in messages"
|
||||
v-for="message in filteredMessages"
|
||||
:key="message.id"
|
||||
class="message-row"
|
||||
:class="{ handled: message.status === 'handled' }"
|
||||
@@ -422,7 +589,11 @@ function formatDateTime(value?: string) {
|
||||
<ElTag v-else type="success" round>已处理</ElTag>
|
||||
</article>
|
||||
</div>
|
||||
<ElEmpty v-else description="暂无平台消息" :image-size="72" />
|
||||
<ElEmpty
|
||||
v-else
|
||||
:description="messages.length ? '暂无符合条件的平台消息' : '暂无平台消息'"
|
||||
:image-size="72"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -432,7 +603,43 @@ function formatDateTime(value?: string) {
|
||||
<span>{{ filteredMembers.length }} / {{ members.length }}</span>
|
||||
</div>
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
<div class="member-cell">
|
||||
@@ -460,15 +667,18 @@ function formatDateTime(value?: string) {
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="权限" min-width="340">
|
||||
<template #default="{ row }">
|
||||
<div class="permission-tags">
|
||||
<ElTag
|
||||
v-for="permission in row.projectPermissions"
|
||||
:key="permission.projectKey"
|
||||
:type="permission.level === 'build' ? 'success' : 'info'"
|
||||
effect="plain"
|
||||
>
|
||||
{{ permission.projectName }}:{{ permission.levelName }}
|
||||
</ElTag>
|
||||
<div class="permission-cell">
|
||||
<strong>{{ memberPermissionSummary(row) }}</strong>
|
||||
<div class="permission-tags">
|
||||
<ElTag
|
||||
v-for="permission in row.projectPermissions"
|
||||
:key="permission.projectKey"
|
||||
:type="permission.level === 'build' ? 'success' : 'info'"
|
||||
effect="plain"
|
||||
>
|
||||
{{ permission.projectName }}:{{ permission.levelName }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
@@ -642,8 +852,75 @@ function formatDateTime(value?: string) {
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
width: 220px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
@@ -715,6 +992,18 @@ function formatDateTime(value?: string) {
|
||||
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) {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
@@ -866,12 +1155,28 @@ function formatDateTime(value?: string) {
|
||||
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) {
|
||||
.member-overview-grid,
|
||||
.member-filter-bar,
|
||||
.member-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.member-form-grid {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.message-filter-bar .el-select,
|
||||
.member-search,
|
||||
.permission-radio-group {
|
||||
width: 100%;
|
||||
|
||||
+607
-36
@@ -1,15 +1,317 @@
|
||||
<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 type { RouteLocationRaw } from 'vue-router'
|
||||
import MetricTile from '../components/MetricTile.vue'
|
||||
import StatusPill from '../components/StatusPill.vue'
|
||||
import { usePlatformStore } from '../stores/platform'
|
||||
import type { HealthState, ProjectConfig, ReleaseRun } from '../types/devops'
|
||||
|
||||
const platform = usePlatformStore()
|
||||
|
||||
const successCount = computed(() => platform.runs.filter((run) => run.status === 'success').length)
|
||||
const failedCount = computed(() => platform.failedRuns.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>
|
||||
|
||||
<template>
|
||||
@@ -17,62 +319,128 @@ const runningCount = computed(() => platform.runningRuns.length)
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>运维总览</h1>
|
||||
<p>集中查看纳管项目环境、最近发布、Jenkins/Gitea/通知平台/LLM 当前状态。</p>
|
||||
<p>集中查看纳管项目环境、待处理发布、Jenkins/Gitea/通知平台/LLM 当前状态。</p>
|
||||
</div>
|
||||
<ElButton type="primary" plain @click="platform.loadAll">刷新数据</ElButton>
|
||||
<ElButton :icon="Refresh" type="primary" plain :loading="platform.loading" @click="platform.loadAll">
|
||||
刷新数据
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="status-grid">
|
||||
<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="successCount" caption="最近发布成功数" :icon="CircleCheck" tone="success" />
|
||||
<MetricTile label="失败提醒" :value="failedCount" caption="需要诊断或重试的 run" :icon="Failed" tone="danger" />
|
||||
</div>
|
||||
|
||||
<div class="section-grid">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>集成状态</h2>
|
||||
<span>来自后端 health</span>
|
||||
<h2>值班焦点</h2>
|
||||
<span>{{ focusItems.length }} 项</span>
|
||||
</div>
|
||||
<div class="panel-body compact-list">
|
||||
<div v-for="item in platform.integrations" :key="item.key" class="list-row">
|
||||
<span>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<small>{{ item.summary }}</small>
|
||||
</span>
|
||||
<span class="integration-tail">
|
||||
<StatusPill :state="item.state" />
|
||||
<small>{{ item.latencyMs ? `${item.latencyMs}ms` : item.checkedAt }}</small>
|
||||
</span>
|
||||
<div class="panel-body focus-list">
|
||||
<div
|
||||
v-for="item in focusItems"
|
||||
:key="item.key"
|
||||
class="focus-row"
|
||||
:class="`tone-${item.tone}`"
|
||||
>
|
||||
<div class="focus-main">
|
||||
<span>{{ item.label }}</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>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>最近发布</h2>
|
||||
<span>按 startedAt 倒序</span>
|
||||
<h2>优先 Run 队列</h2>
|
||||
<span>{{ priorityRuns.length }} / {{ platform.runs.length }} 条</span>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<ElEmpty v-if="priorityRuns.length === 0" description="暂无运行记录" />
|
||||
</div>
|
||||
</section>
|
||||
</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">
|
||||
<div class="panel-header">
|
||||
<h2>项目环境</h2>
|
||||
<span>测试/生产状态和集成摘要</span>
|
||||
<span>测试/生产状态和快捷入口</span>
|
||||
</div>
|
||||
<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>
|
||||
<strong>{{ project.name }}</strong>
|
||||
@@ -80,6 +448,12 @@ const runningCount = computed(() => platform.runningRuns.length)
|
||||
</div>
|
||||
<ElTag type="info" round>{{ project.defaultBranch }}</ElTag>
|
||||
</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 v-for="env in project.environments" :key="env.name" class="env-row">
|
||||
<span>
|
||||
@@ -89,6 +463,26 @@ const runningCount = computed(() => platform.runningRuns.length)
|
||||
<StatusPill :state="env.status" />
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
@@ -96,26 +490,170 @@ const runningCount = computed(() => platform.runningRuns.length)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.integration-tail {
|
||||
.focus-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 5px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
.focus-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 86px;
|
||||
padding: 14px;
|
||||
border: 1px solid #e3e8f2;
|
||||
border-radius: 8px;
|
||||
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,
|
||||
.env-row {
|
||||
display: flex;
|
||||
@@ -125,7 +663,7 @@ const runningCount = computed(() => platform.runningRuns.length)
|
||||
}
|
||||
|
||||
.project-head {
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.project-head strong,
|
||||
@@ -149,15 +687,48 @@ const runningCount = computed(() => platform.runningRuns.length)
|
||||
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) {
|
||||
.project-grid {
|
||||
.project-grid,
|
||||
.project-attention-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.project-grid {
|
||||
.project-grid,
|
||||
.project-attention-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.focus-row,
|
||||
.attention-row,
|
||||
.attention-empty {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+376
-13
@@ -2,13 +2,82 @@
|
||||
/**
|
||||
* 项目配置页展示项目仓库、发布环境、Gitea 仓库诊断和真实 Jenkins job 诊断状态。
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Promotion, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import StatusPill from '../components/StatusPill.vue'
|
||||
import { usePlatformStore } from '../stores/platform'
|
||||
import type { EnvironmentStatus, ProjectConfig } from '../types/devops'
|
||||
import type { EnvironmentStatus, HealthState, ProjectConfig, ProjectPermissionLevel } from '../types/devops'
|
||||
|
||||
const platform = usePlatformStore()
|
||||
const diagnosticsLoading = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const readinessFilter = ref<ProjectReadinessState | ''>('')
|
||||
const permissionFilter = ref<ProjectPermissionLevel | ''>('')
|
||||
|
||||
type ProjectReadinessState = 'ready' | 'attention' | 'blocked' | 'readonly'
|
||||
|
||||
const readinessOptions: Array<{ label: string; value: ProjectReadinessState }> = [
|
||||
{ label: '可发布', value: 'ready' },
|
||||
{ label: '需关注', value: 'attention' },
|
||||
{ label: '有阻断', value: 'blocked' },
|
||||
{ label: '只读', value: 'readonly' },
|
||||
]
|
||||
|
||||
const permissionOptions: Array<{ label: string; value: ProjectPermissionLevel }> = [
|
||||
{ label: '构建权限', value: 'build' },
|
||||
{ label: '只读权限', value: 'read' },
|
||||
]
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
|
||||
return platform.projects.filter((project) => {
|
||||
if (readinessFilter.value && projectReadinessState(project) !== readinessFilter.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (permissionFilter.value && platform.projectPermissionLevel(project.key) !== permissionFilter.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!keyword) {
|
||||
return true
|
||||
}
|
||||
|
||||
return [
|
||||
project.name,
|
||||
project.key,
|
||||
project.serviceType,
|
||||
project.repository,
|
||||
project.defaultBranch,
|
||||
project.tagPolicy,
|
||||
project.permissionLevelName,
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
})
|
||||
})
|
||||
|
||||
const projectOverview = computed(() => {
|
||||
const ready = platform.projects.filter((project) => projectReadinessState(project) === 'ready').length
|
||||
const attention = platform.projects.filter((project) => projectReadinessState(project) === 'attention').length
|
||||
const blocked = platform.projects.filter((project) => projectReadinessState(project) === 'blocked').length
|
||||
const readOnly = platform.projects.filter((project) => projectReadinessState(project) === 'readonly').length
|
||||
|
||||
return [
|
||||
{ key: 'total', label: '纳管项目', value: platform.projects.length, tone: 'neutral' },
|
||||
{ key: 'ready', label: '可发布', value: ready, tone: 'success' },
|
||||
{ key: 'attention', label: '需关注', value: attention, tone: attention > 0 ? 'warning' : 'neutral' },
|
||||
{ key: 'blocked', label: '有阻断', value: blocked, tone: blocked > 0 ? 'danger' : 'neutral' },
|
||||
{ key: 'readonly', label: '只读项目', value: readOnly, tone: readOnly > 0 ? 'muted' : 'neutral' },
|
||||
]
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() => Boolean(
|
||||
searchKeyword.value.trim() ||
|
||||
readinessFilter.value ||
|
||||
permissionFilter.value,
|
||||
))
|
||||
|
||||
onMounted(() => {
|
||||
void refreshProjectDiagnostics()
|
||||
@@ -80,23 +149,136 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
|
||||
|
||||
return `#${hook.id} ${hook.type} · ${active} · ${events}${branch}${matched}`
|
||||
}
|
||||
|
||||
function projectReadinessState(project: ProjectConfig): ProjectReadinessState {
|
||||
if (projectHasBlockingIssue(project)) {
|
||||
return 'blocked'
|
||||
}
|
||||
|
||||
if (projectHasWarningIssue(project)) {
|
||||
return 'attention'
|
||||
}
|
||||
|
||||
if (!platform.canBuildProject(project.key)) {
|
||||
return 'readonly'
|
||||
}
|
||||
|
||||
return 'ready'
|
||||
}
|
||||
|
||||
function projectReadinessLabel(project: ProjectConfig): string {
|
||||
const state = projectReadinessState(project)
|
||||
|
||||
if (state === 'ready') {
|
||||
return '可发布'
|
||||
}
|
||||
|
||||
if (state === 'blocked') {
|
||||
return '有阻断'
|
||||
}
|
||||
|
||||
if (state === 'readonly') {
|
||||
return '只读'
|
||||
}
|
||||
|
||||
return '需关注'
|
||||
}
|
||||
|
||||
function projectReadinessTagType(
|
||||
project: ProjectConfig,
|
||||
): 'success' | 'warning' | 'danger' | 'info' {
|
||||
const state = projectReadinessState(project)
|
||||
|
||||
if (state === 'ready') {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
if (state === 'blocked') {
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
return state === 'attention' ? 'warning' : 'info'
|
||||
}
|
||||
|
||||
function projectReadinessSummary(project: ProjectConfig): string {
|
||||
const issues = projectIssueSummaries(project)
|
||||
|
||||
if (issues.length > 0) {
|
||||
return issues[0]
|
||||
}
|
||||
|
||||
if (!platform.canBuildProject(project.key)) {
|
||||
return '当前账号只有只读权限'
|
||||
}
|
||||
|
||||
return 'Gitea、Jenkins、Webhook 和通知链路均未发现异常'
|
||||
}
|
||||
|
||||
function projectIssueSummaries(project: ProjectConfig): string[] {
|
||||
const issues: string[] = []
|
||||
|
||||
appendHealthIssue(issues, 'Gitea 仓库', project.giteaStatus, project.giteaMessage)
|
||||
appendHealthIssue(issues, 'Webhook / Relay', project.giteaWebhookStatus, project.giteaWebhookMessage)
|
||||
|
||||
for (const env of project.environments) {
|
||||
appendHealthIssue(issues, `${env.label} Jenkins`, env.jenkinsStatus, env.jenkinsMessage)
|
||||
}
|
||||
|
||||
appendHealthIssue(issues, '通知链路', project.integrations.wecom)
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function appendHealthIssue(
|
||||
issues: string[],
|
||||
label: string,
|
||||
state: HealthState,
|
||||
message?: string,
|
||||
) {
|
||||
if (state === 'healthy') {
|
||||
return
|
||||
}
|
||||
|
||||
const stateText = state === 'warning' ? '待配置' : state === 'offline' ? '离线' : '异常'
|
||||
issues.push(`${label}${message ? `:${message}` : `:${stateText}`}`)
|
||||
}
|
||||
|
||||
function projectHasBlockingIssue(project: ProjectConfig): boolean {
|
||||
return projectHealthStates(project).some((state) => state === 'error' || state === 'offline')
|
||||
}
|
||||
|
||||
function projectHasWarningIssue(project: ProjectConfig): boolean {
|
||||
return projectHealthStates(project).some((state) => state === 'warning')
|
||||
}
|
||||
|
||||
function projectHealthStates(project: ProjectConfig): HealthState[] {
|
||||
return [
|
||||
project.giteaStatus,
|
||||
project.giteaWebhookStatus,
|
||||
project.integrations.wecom,
|
||||
...project.environments.map((env) => env.jenkinsStatus),
|
||||
]
|
||||
}
|
||||
|
||||
function resetProjectFilters() {
|
||||
searchKeyword.value = ''
|
||||
readinessFilter.value = ''
|
||||
permissionFilter.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>项目配置</h1>
|
||||
<p>展示后端返回的项目、环境、Jenkins Job、Gitea 仓库和通知通道绑定关系。</p>
|
||||
</div>
|
||||
<ElButton type="primary" plain disabled>新增项目</ElButton>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>项目清单</h2>
|
||||
<h1>项目配置</h1>
|
||||
<p>展示后端返回的项目、环境、Jenkins Job、Gitea 仓库和通知通道绑定关系。</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<ElTag type="info" round>后端配置源</ElTag>
|
||||
<ElButton
|
||||
size="small"
|
||||
:icon="Refresh"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="diagnosticsLoading"
|
||||
@click="refreshProjectDiagnostics"
|
||||
@@ -104,8 +286,55 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
|
||||
刷新项目诊断
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="project-overview-grid">
|
||||
<article
|
||||
v-for="item in projectOverview"
|
||||
:key="item.key"
|
||||
class="project-overview-card"
|
||||
:class="`tone-${item.tone}`"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>项目清单</h2>
|
||||
<span>{{ filteredProjects.length }} / {{ platform.projects.length }} 个项目</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<ElTable :data="platform.projects" row-key="key">
|
||||
<div class="project-filter-bar">
|
||||
<ElInput
|
||||
v-model="searchKeyword"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
placeholder="搜索项目 / 仓库 / 默认分支"
|
||||
class="project-search-input"
|
||||
/>
|
||||
<ElSelect v-model="readinessFilter" clearable placeholder="发布态">
|
||||
<ElOption
|
||||
v-for="option in readinessOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElSelect v-model="permissionFilter" clearable placeholder="权限">
|
||||
<ElOption
|
||||
v-for="option in permissionOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElButton :disabled="!hasActiveFilters" @click="resetProjectFilters">
|
||||
重置筛选
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElTable :data="filteredProjects" row-key="key" empty-text="没有符合条件的项目">
|
||||
<ElTableColumn type="expand">
|
||||
<template #default="{ row }">
|
||||
<div class="env-config">
|
||||
@@ -216,6 +445,23 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="name" label="项目" min-width="150" />
|
||||
<ElTableColumn label="发布态" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<div class="readiness-cell">
|
||||
<ElTag :type="projectReadinessTagType(row)" effect="light" round>
|
||||
{{ projectReadinessLabel(row) }}
|
||||
</ElTag>
|
||||
<small>{{ projectReadinessSummary(row) }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="权限" width="120">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="platform.canBuildProject(row.key) ? 'success' : 'info'" effect="plain" round>
|
||||
{{ platform.canBuildProject(row.key) ? '构建' : '只读' }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="serviceType" label="类型" min-width="130" />
|
||||
<ElTableColumn prop="repository" label="Gitea 仓库" min-width="320">
|
||||
<template #default="{ row }">
|
||||
@@ -234,6 +480,30 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="project-actions">
|
||||
<RouterLink
|
||||
v-slot="{ navigate }"
|
||||
custom
|
||||
:to="{ name: 'releases', query: { projectKey: row.key } }"
|
||||
>
|
||||
<ElButton
|
||||
:icon="Promotion"
|
||||
link
|
||||
type="primary"
|
||||
:disabled="!platform.canBuildProject(row.key)"
|
||||
@click="navigate"
|
||||
>
|
||||
发布
|
||||
</ElButton>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'runs', query: { projectKey: row.key } }">
|
||||
<ElButton link type="primary">运行记录</ElButton>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
</section>
|
||||
@@ -241,6 +511,66 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-overview-card {
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #d8e2ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.project-overview-card span {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-overview-card strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #0f172a;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.project-overview-card.tone-success {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.project-overview-card.tone-warning {
|
||||
border-color: #fde68a;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.project-overview-card.tone-danger {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.project-overview-card.tone-muted {
|
||||
border-color: #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.project-filter-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1.4fr) 150px 150px max-content;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project-search-input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.env-config {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -295,6 +625,24 @@ dd {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.readiness-cell {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.readiness-cell small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.project-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diagnostic-message {
|
||||
margin-left: 8px;
|
||||
color: #596578;
|
||||
@@ -340,7 +688,22 @@ dd {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.project-overview-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.project-filter-bar {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.project-overview-grid,
|
||||
.project-filter-bar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.env-config {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 12px;
|
||||
|
||||
@@ -5,21 +5,31 @@
|
||||
import { CircleClose, Promotion, RefreshRight, VideoPause } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BpmnStatusViewer from '../components/BpmnStatusViewer.vue'
|
||||
import RunExecutionDetails from '../components/RunExecutionDetails.vue'
|
||||
import StatusPill from '../components/StatusPill.vue'
|
||||
import { isDevopsApiError } from '../services/http'
|
||||
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 session = useSessionStore()
|
||||
const route = useRoute()
|
||||
|
||||
const form = reactive({
|
||||
projectKey: '',
|
||||
projectKey: queryProjectKey(),
|
||||
environment: 'test' as EnvironmentName,
|
||||
ref: '',
|
||||
note: '发布验证',
|
||||
})
|
||||
const productionConfirmation = reactive({
|
||||
reviewed: false,
|
||||
rollbackPlan: '',
|
||||
releaseWindow: '',
|
||||
notifyTarget: '',
|
||||
})
|
||||
|
||||
const localStatus = ref<ReleaseStatus>('idle')
|
||||
const activeRun = ref<ReleaseRun | null>(null)
|
||||
@@ -57,6 +67,10 @@ const selectedEnvironment = computed(() => {
|
||||
return selectedProject.value?.environments.find((environment) => environment.name === form.environment)
|
||||
})
|
||||
|
||||
const notificationIntegration = computed(() => {
|
||||
return platform.integrations.find((integration) => integration.key === 'notification')
|
||||
})
|
||||
|
||||
const useBranchRefs = computed(() => {
|
||||
return selectedEnvironment.value?.branchPolicy === 'master'
|
||||
})
|
||||
@@ -71,6 +85,8 @@ const refOptions = computed(() => {
|
||||
return refs?.branches.map((branch) => branch.name) ?? []
|
||||
})
|
||||
|
||||
const hasRefOptions = computed(() => refOptions.value.length > 0)
|
||||
|
||||
const refSourceText = computed(() => {
|
||||
const source = currentRefs.value?.source
|
||||
|
||||
@@ -98,6 +114,10 @@ const selectedRefDetail = computed<ProjectRefItem | undefined>(() => {
|
||||
return candidates.find((item) => item.name === form.ref)
|
||||
})
|
||||
|
||||
const selectedRefExists = computed(() => {
|
||||
return Boolean(form.ref && refOptions.value.includes(form.ref))
|
||||
})
|
||||
|
||||
const selectedCommitMessage = computed(() => {
|
||||
return selectedRefDetail.value?.commit?.message?.split('\n')[0] || ''
|
||||
})
|
||||
@@ -135,12 +155,182 @@ const selectedPullRequestMeta = computed(() => {
|
||||
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(() => {
|
||||
if (!canBuildSelectedProject.value || !form.projectKey || !form.environment || !form.ref) {
|
||||
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(() => {
|
||||
@@ -173,7 +363,6 @@ async function triggerRelease() {
|
||||
projectKey: form.projectKey,
|
||||
environment: form.environment,
|
||||
ref: form.ref,
|
||||
operator: 'ops-admin',
|
||||
remark: form.note,
|
||||
idempotencyKey: `${form.projectKey}:${form.environment}:${form.ref}:${Date.now()}`,
|
||||
productionConfirmation:
|
||||
@@ -181,7 +370,7 @@ async function triggerRelease() {
|
||||
? {
|
||||
confirmed: true,
|
||||
confirmedAt: new Date().toISOString(),
|
||||
summary: '已确认 ref、回滚方案、通知对象和业务窗口',
|
||||
summary: buildProductionConfirmationSummary(),
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
@@ -212,6 +401,44 @@ function formatCommitTime(value?: string): string {
|
||||
}).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() {
|
||||
if (!shownRun.value) {
|
||||
return
|
||||
@@ -296,6 +523,10 @@ function useFailedRun() {
|
||||
activeRun.value = failed
|
||||
}
|
||||
|
||||
function queryProjectKey(): string {
|
||||
return typeof route.query.projectKey === 'string' ? route.query.projectKey : ''
|
||||
}
|
||||
|
||||
async function handleReleaseActionError(
|
||||
error: unknown,
|
||||
fallbackMessage: string,
|
||||
@@ -343,11 +574,32 @@ watch(
|
||||
{ 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) => {
|
||||
if (options.length > 0 && !options.includes(form.ref)) {
|
||||
form.ref = options[0]
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [form.projectKey, form.environment, form.ref],
|
||||
() => {
|
||||
productionConfirmation.reviewed = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -395,9 +647,10 @@ watch(refOptions, (options) => {
|
||||
<ElSelect
|
||||
v-model="form.ref"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
:disabled="!hasRefOptions"
|
||||
:loading="refsLoading"
|
||||
no-data-text="后端未返回可选 ref/tag"
|
||||
>
|
||||
<ElOption v-for="item in refOptions" :key="item" :label="item" :value="item" />
|
||||
</ElSelect>
|
||||
@@ -405,6 +658,9 @@ watch(refOptions, (options) => {
|
||||
{{ form.environment === 'production' ? selectedProject?.tagPolicy : selectedProject?.defaultBranch }}
|
||||
· {{ refSourceText }}
|
||||
</div>
|
||||
<div v-if="form.ref && !selectedRefExists" class="field-warning">
|
||||
当前值不在后端返回列表中,请重新选择。
|
||||
</div>
|
||||
<div v-if="selectedCommitSha" class="commit-preview">
|
||||
<div class="commit-main">
|
||||
<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 />
|
||||
</ElFormItem>
|
||||
</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/commit、Jenkins Job、回滚方案、通知对象和业务窗口
|
||||
</ElCheckbox>
|
||||
</div>
|
||||
<ElAlert
|
||||
v-if="form.environment === 'production'"
|
||||
class="danger-note"
|
||||
title="生产发布将提交确认信息,请确认 tag、回滚方案、通知对象和业务窗口。"
|
||||
title="生产发布会写入确认摘要和审计日志;确认信息不完整时不能提交。"
|
||||
type="warning"
|
||||
: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">
|
||||
<ElButton
|
||||
type="primary"
|
||||
@@ -458,8 +778,8 @@ watch(refOptions, (options) => {
|
||||
<ElButton :icon="CircleClose" :disabled="!canRetryShownRun || submitting" @click="retryRelease">
|
||||
重试
|
||||
</ElButton>
|
||||
<span v-if="form.projectKey && !canBuildSelectedProject" class="permission-action-hint">
|
||||
当前账号只有只读权限,发布操作已禁用
|
||||
<span v-if="triggerDisabledReason" class="permission-action-hint">
|
||||
{{ triggerDisabledReason }}
|
||||
</span>
|
||||
</div>
|
||||
</ElForm>
|
||||
@@ -601,6 +921,146 @@ watch(refOptions, (options) => {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -677,6 +1137,14 @@ watch(refOptions, (options) => {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.production-confirmation-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.preflight-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
|
||||
+580
-13
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* 运行记录页展示历史发布单、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 { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
@@ -12,16 +12,101 @@ import StatusPill from '../components/StatusPill.vue'
|
||||
import { isDevopsApiError } from '../services/http'
|
||||
import { usePlatformStore } from '../stores/platform'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import type { ReleaseRun } from '../types/devops'
|
||||
import type {
|
||||
EnvironmentName,
|
||||
JenkinsImportSummary,
|
||||
ReleaseRun,
|
||||
ReleaseStatus,
|
||||
} from '../types/devops'
|
||||
|
||||
const platform = usePlatformStore()
|
||||
const session = useSessionStore()
|
||||
const route = useRoute()
|
||||
const selectedRunId = ref(platform.runs[0]?.id || '')
|
||||
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>(() => {
|
||||
return platform.runs.find((run) => run.id === selectedRunId.value) || platform.runs[0]
|
||||
return filteredRuns.value.find((run) => run.id === selectedRunId.value)
|
||||
})
|
||||
|
||||
const canSyncSelectedRun = computed(() => {
|
||||
@@ -36,6 +121,33 @@ const canSyncAnyRun = computed(
|
||||
() => 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(
|
||||
() => [route.query.runId, platform.runs.map((run) => run.id).join('|')],
|
||||
() => {
|
||||
@@ -53,6 +165,34 @@ watch(
|
||||
{ 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 {
|
||||
return (
|
||||
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) {
|
||||
if (isDevopsApiError(error) && error.code === 'CONFLICT') {
|
||||
await platform.refreshRuns()
|
||||
@@ -134,6 +306,60 @@ async function handleRunActionError(error: unknown, fallbackMessage: string) {
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -143,26 +369,134 @@ async function handleRunActionError(error: unknown, fallbackMessage: string) {
|
||||
<h1>运行记录</h1>
|
||||
<p>查看发布 run、Jenkins 构建链接、耗时、失败节点和流程轨迹。</p>
|
||||
</div>
|
||||
<ElButton
|
||||
:icon="RefreshRight"
|
||||
:loading="syncingAll"
|
||||
:disabled="!canSyncAnyRun"
|
||||
@click="syncAllJenkinsRuns"
|
||||
>
|
||||
同步全部 Jenkins
|
||||
</ElButton>
|
||||
<div class="page-actions">
|
||||
<ElButton
|
||||
:icon="Download"
|
||||
:loading="importingJenkins"
|
||||
:disabled="!canImportJenkinsBuilds"
|
||||
@click="importJenkinsBuilds"
|
||||
>
|
||||
导入 Jenkins 直构
|
||||
</ElButton>
|
||||
<ElButton
|
||||
:icon="RefreshRight"
|
||||
:loading="syncingAll"
|
||||
:disabled="!canSyncAnyRun"
|
||||
@click="syncAllJenkinsRuns"
|
||||
>
|
||||
同步全部 Jenkins
|
||||
</ElButton>
|
||||
</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">
|
||||
<div class="panel-header">
|
||||
<h2>Run 列表</h2>
|
||||
<span>点击行切换下方流程图</span>
|
||||
<span>{{ filteredRuns.length }} / {{ platform.runs.length }} 条,点击行切换下方流程图</span>
|
||||
</div>
|
||||
<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
|
||||
:data="platform.runs"
|
||||
:data="filteredRuns"
|
||||
row-key="id"
|
||||
highlight-current-row
|
||||
:row-class-name="runRowClassName"
|
||||
empty-text="没有符合条件的运行记录"
|
||||
@row-click="(row: ReleaseRun) => (selectedRunId = row.id)"
|
||||
>
|
||||
<ElTableColumn prop="id" label="Run ID" width="110" />
|
||||
@@ -178,6 +512,11 @@ async function handleRunActionError(error: unknown, fallbackMessage: string) {
|
||||
<StatusPill :state="row.status" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="开始时间" width="135">
|
||||
<template #default="{ row }">
|
||||
{{ formatRunTime(row.startedAt) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="actor" label="操作者" width="130" />
|
||||
<ElTableColumn prop="duration" label="耗时" width="100" />
|
||||
<ElTableColumn prop="note" label="备注" min-width="220" />
|
||||
@@ -232,12 +571,240 @@ async function handleRunActionError(error: unknown, fallbackMessage: string) {
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
color: #8a5a00;
|
||||
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>
|
||||
|
||||
+332
-10
@@ -2,9 +2,9 @@
|
||||
/**
|
||||
* 系统配置页展示集成变量说明、配置边界和审计日志脱敏 JSON。
|
||||
*/
|
||||
import { Connection, Refresh } from '@element-plus/icons-vue'
|
||||
import { Connection, Refresh, Search } from '@element-plus/icons-vue'
|
||||
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 StatusPill from '../components/StatusPill.vue'
|
||||
import { devopsApi } from '../services/devopsApi'
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
IntegrationConfigItem,
|
||||
IntegrationConfigState,
|
||||
NotificationOutboxMessage,
|
||||
NotificationOutboxStatus,
|
||||
} from '../types/devops'
|
||||
|
||||
const platform = usePlatformStore()
|
||||
@@ -24,6 +25,10 @@ const agentConfigLoading = ref(false)
|
||||
const agentConfigTesting = ref(false)
|
||||
const agentConfig = ref<AgentConfigSummary | null>(null)
|
||||
const retryingOutboxIds = ref<Set<string>>(new Set())
|
||||
const outboxStatusFilter = ref<NotificationOutboxStatus | ''>('')
|
||||
const outboxKeyword = ref('')
|
||||
const auditResourceFilter = ref('')
|
||||
const auditKeyword = ref('')
|
||||
const agentConfigForm = reactive({
|
||||
key: '',
|
||||
baseURL: '',
|
||||
@@ -55,11 +60,142 @@ const outboxStatusMeta: Record<
|
||||
failed: { label: '投递失败', type: 'danger' },
|
||||
dead: { label: '已终止', type: 'info' },
|
||||
}
|
||||
const outboxStatusOptions: Array<{ label: string; value: NotificationOutboxStatus }> = [
|
||||
{ label: '待投递', value: 'pending' },
|
||||
{ label: '已投递', value: 'sent' },
|
||||
{ label: '投递失败', value: 'failed' },
|
||||
{ label: '已终止', value: 'dead' },
|
||||
]
|
||||
|
||||
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 {
|
||||
return configStatusMeta[status]?.label ?? '未配置'
|
||||
}
|
||||
@@ -229,6 +365,10 @@ function variableSourceLabel(item: IntegrationConfigItem, name: string): string
|
||||
}
|
||||
|
||||
async function loadAgentConfig() {
|
||||
if (!canManageSystemConfig.value) {
|
||||
return
|
||||
}
|
||||
|
||||
agentConfigLoading.value = true
|
||||
|
||||
try {
|
||||
@@ -240,6 +380,11 @@ async function loadAgentConfig() {
|
||||
}
|
||||
|
||||
async function saveAgentConfig() {
|
||||
if (!canSaveAgentConfig.value) {
|
||||
ElMessage.warning(agentConfigDisabledReason.value)
|
||||
return
|
||||
}
|
||||
|
||||
agentConfigLoading.value = true
|
||||
|
||||
try {
|
||||
@@ -260,6 +405,11 @@ async function saveAgentConfig() {
|
||||
}
|
||||
|
||||
async function testAgentConfig() {
|
||||
if (!canManageSystemConfig.value) {
|
||||
ElMessage.warning('仅超级管理员可测试 Agent 模型连接')
|
||||
return
|
||||
}
|
||||
|
||||
agentConfigTesting.value = true
|
||||
|
||||
try {
|
||||
@@ -300,6 +450,16 @@ function applyAgentConfig(config: AgentConfigSummary) {
|
||||
agentConfigForm.baseURL = config.baseURL
|
||||
agentConfigForm.model = config.model
|
||||
}
|
||||
|
||||
function resetOutboxFilters() {
|
||||
outboxStatusFilter.value = ''
|
||||
outboxKeyword.value = ''
|
||||
}
|
||||
|
||||
function resetAuditFilters() {
|
||||
auditResourceFilter.value = ''
|
||||
auditKeyword.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -311,6 +471,18 @@ function applyAgentConfig(config: AgentConfigSummary) {
|
||||
</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">
|
||||
<div class="panel-header">
|
||||
<h2>后端集成配置状态</h2>
|
||||
@@ -403,7 +575,7 @@ function applyAgentConfig(config: AgentConfigSummary) {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<section v-if="canManageSystemConfig" class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Agent 模型配置</h2>
|
||||
<span>{{ agentConfig?.hasKey ? '已保存密钥' : '未保存密钥' }}</span>
|
||||
@@ -446,6 +618,12 @@ function applyAgentConfig(config: AgentConfigSummary) {
|
||||
测试连接
|
||||
</ElButton>
|
||||
</div>
|
||||
<div
|
||||
class="agent-config-check"
|
||||
:class="{ ready: canSaveAgentConfig }"
|
||||
>
|
||||
{{ canSaveAgentConfig ? '保存前检查通过,API Key 只会提交给后端配置接口。' : agentConfigDisabledReason }}
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
<div class="agent-config-summary">
|
||||
@@ -470,15 +648,48 @@ function applyAgentConfig(config: AgentConfigSummary) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Agent 模型配置</h2>
|
||||
<span>仅超级管理员可维护</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="secret-boundary">
|
||||
当前账号只能查看系统配置边界;模型 key、baseURL 和 model 的保存、测试均需要后端超级管理员鉴权。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="session.role === 'super_admin'" class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>通知 outbox</h2>
|
||||
<span>{{ filteredNotificationOutbox.length }} / {{ platform.notificationOutbox.length }} 条</span>
|
||||
<ElButton :icon="Refresh" size="small" @click="refreshNotificationOutbox">
|
||||
刷新
|
||||
</ElButton>
|
||||
</div>
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
{{ formatCheckedAt(row.createdAt) }}
|
||||
@@ -535,8 +746,8 @@ function applyAgentConfig(config: AgentConfigSummary) {
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<ElEmpty
|
||||
v-if="platform.notificationOutbox.length === 0"
|
||||
description="暂无通知 outbox 记录"
|
||||
v-if="filteredNotificationOutbox.length === 0"
|
||||
:description="platform.notificationOutbox.length ? '暂无符合条件的通知 outbox 记录' : '暂无通知 outbox 记录'"
|
||||
:image-size="72"
|
||||
/>
|
||||
</div>
|
||||
@@ -545,12 +756,33 @@ function applyAgentConfig(config: AgentConfigSummary) {
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>最近运维审计</h2>
|
||||
<span>{{ filteredAuditLogs.length }} / {{ platform.auditLogs.length }} 条</span>
|
||||
<ElButton :icon="Refresh" size="small" @click="platform.loadAuditLogs()">
|
||||
刷新
|
||||
</ElButton>
|
||||
</div>
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
{{ formatCheckedAt(row.createdAt) }}
|
||||
@@ -574,8 +806,8 @@ function applyAgentConfig(config: AgentConfigSummary) {
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<ElEmpty
|
||||
v-if="platform.auditLogs.length === 0"
|
||||
description="暂无审计记录"
|
||||
v-if="filteredAuditLogs.length === 0"
|
||||
:description="platform.auditLogs.length ? '暂无符合条件的审计记录' : '暂无审计记录'"
|
||||
:image-size="72"
|
||||
/>
|
||||
</div>
|
||||
@@ -607,6 +839,66 @@ function applyAgentConfig(config: AgentConfigSummary) {
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
display: grid;
|
||||
grid-template-columns: 74px minmax(0, 1fr);
|
||||
@@ -666,6 +958,23 @@ function applyAgentConfig(config: AgentConfigSummary) {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -777,6 +1086,14 @@ function applyAgentConfig(config: AgentConfigSummary) {
|
||||
}
|
||||
|
||||
@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 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -787,6 +1104,11 @@ function applyAgentConfig(config: AgentConfigSummary) {
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.settings-overview-grid,
|
||||
.settings-filter-bar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-row-main {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user