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

This commit is contained in:
湛兮
2026-06-12 16:36:20 +08:00
parent 0f92eb7b2b
commit 878125fe76
14 changed files with 2957 additions and 129 deletions
+1 -1
View File
@@ -29,5 +29,5 @@
- BPMN 组件只展示流程图、节点状态和节点详情,不推进状态机。
- `step.bpmnNodeId` 必须与 BPMN XML 节点 id 一致。
- Agent 只能作为全局右侧抽屉工具存在,不作为独立侧边栏路由;能力只允许 DevOps 场景:发布风险摘要、失败诊断、Runbook 问答、发布说明草稿。
- Agent 只能作为全局右侧抽屉工具存在,不作为独立侧边栏路由;能力只允许 DevOps 场景:发布风险摘要、失败诊断、Runbook 问答、发布说明草稿和事故复盘草稿
- 禁止在前端新增泛聊天入口。
+8 -4
View File
@@ -6,11 +6,14 @@ Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于
- 登录、总览、项目配置、成员权限、发布中心、运行记录、系统配置,以及全局右侧 Agent 抽屉。
- 所有项目、发布、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 问答
- 发布说明草稿
- 事故复盘草稿
模型调用、脱敏、审计、白名单知识库和密钥管理由后端代理实现。
+8 -6
View File
@@ -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 问答、发布说明草稿和事故复盘草稿。
+45 -5
View File
@@ -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;
+87
View File
@@ -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>
+61 -2
View File
@@ -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 '人工触发'
}
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -2,13 +2,82 @@
/**
* 项目配置页展示项目仓库、发布环境、Gitea 仓库诊断和真实 Jenkins job 诊断状态。
*/
import { onMounted, ref } from 'vue'
import { Promotion, Refresh, Search } from '@element-plus/icons-vue'
import { computed, onMounted, ref } from 'vue'
import StatusPill from '../components/StatusPill.vue'
import { usePlatformStore } from '../stores/platform'
import type { EnvironmentStatus, ProjectConfig } from '../types/devops'
import type { EnvironmentStatus, HealthState, ProjectConfig, ProjectPermissionLevel } from '../types/devops'
const platform = usePlatformStore()
const diagnosticsLoading = ref(false)
const searchKeyword = ref('')
const readinessFilter = ref<ProjectReadinessState | ''>('')
const permissionFilter = ref<ProjectPermissionLevel | ''>('')
type ProjectReadinessState = 'ready' | 'attention' | 'blocked' | 'readonly'
const readinessOptions: Array<{ label: string; value: ProjectReadinessState }> = [
{ label: '可发布', value: 'ready' },
{ label: '需关注', value: 'attention' },
{ label: '有阻断', value: 'blocked' },
{ label: '只读', value: 'readonly' },
]
const permissionOptions: Array<{ label: string; value: ProjectPermissionLevel }> = [
{ label: '构建权限', value: 'build' },
{ label: '只读权限', value: 'read' },
]
const filteredProjects = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return platform.projects.filter((project) => {
if (readinessFilter.value && projectReadinessState(project) !== readinessFilter.value) {
return false
}
if (permissionFilter.value && platform.projectPermissionLevel(project.key) !== permissionFilter.value) {
return false
}
if (!keyword) {
return true
}
return [
project.name,
project.key,
project.serviceType,
project.repository,
project.defaultBranch,
project.tagPolicy,
project.permissionLevelName,
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.some((value) => value.toLowerCase().includes(keyword))
})
})
const projectOverview = computed(() => {
const ready = platform.projects.filter((project) => projectReadinessState(project) === 'ready').length
const attention = platform.projects.filter((project) => projectReadinessState(project) === 'attention').length
const blocked = platform.projects.filter((project) => projectReadinessState(project) === 'blocked').length
const readOnly = platform.projects.filter((project) => projectReadinessState(project) === 'readonly').length
return [
{ key: 'total', label: '纳管项目', value: platform.projects.length, tone: 'neutral' },
{ key: 'ready', label: '可发布', value: ready, tone: 'success' },
{ key: 'attention', label: '需关注', value: attention, tone: attention > 0 ? 'warning' : 'neutral' },
{ key: 'blocked', label: '有阻断', value: blocked, tone: blocked > 0 ? 'danger' : 'neutral' },
{ key: 'readonly', label: '只读项目', value: readOnly, tone: readOnly > 0 ? 'muted' : 'neutral' },
]
})
const hasActiveFilters = computed(() => Boolean(
searchKeyword.value.trim() ||
readinessFilter.value ||
permissionFilter.value,
))
onMounted(() => {
void refreshProjectDiagnostics()
@@ -80,23 +149,136 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
return `#${hook.id} ${hook.type} · ${active} · ${events}${branch}${matched}`
}
function projectReadinessState(project: ProjectConfig): ProjectReadinessState {
if (projectHasBlockingIssue(project)) {
return 'blocked'
}
if (projectHasWarningIssue(project)) {
return 'attention'
}
if (!platform.canBuildProject(project.key)) {
return 'readonly'
}
return 'ready'
}
function projectReadinessLabel(project: ProjectConfig): string {
const state = projectReadinessState(project)
if (state === 'ready') {
return '可发布'
}
if (state === 'blocked') {
return '有阻断'
}
if (state === 'readonly') {
return '只读'
}
return '需关注'
}
function projectReadinessTagType(
project: ProjectConfig,
): 'success' | 'warning' | 'danger' | 'info' {
const state = projectReadinessState(project)
if (state === 'ready') {
return 'success'
}
if (state === 'blocked') {
return 'danger'
}
return state === 'attention' ? 'warning' : 'info'
}
function projectReadinessSummary(project: ProjectConfig): string {
const issues = projectIssueSummaries(project)
if (issues.length > 0) {
return issues[0]
}
if (!platform.canBuildProject(project.key)) {
return '当前账号只有只读权限'
}
return 'Gitea、Jenkins、Webhook 和通知链路均未发现异常'
}
function projectIssueSummaries(project: ProjectConfig): string[] {
const issues: string[] = []
appendHealthIssue(issues, 'Gitea 仓库', project.giteaStatus, project.giteaMessage)
appendHealthIssue(issues, 'Webhook / Relay', project.giteaWebhookStatus, project.giteaWebhookMessage)
for (const env of project.environments) {
appendHealthIssue(issues, `${env.label} Jenkins`, env.jenkinsStatus, env.jenkinsMessage)
}
appendHealthIssue(issues, '通知链路', project.integrations.wecom)
return issues
}
function appendHealthIssue(
issues: string[],
label: string,
state: HealthState,
message?: string,
) {
if (state === 'healthy') {
return
}
const stateText = state === 'warning' ? '待配置' : state === 'offline' ? '离线' : '异常'
issues.push(`${label}${message ? `${message}` : `${stateText}`}`)
}
function projectHasBlockingIssue(project: ProjectConfig): boolean {
return projectHealthStates(project).some((state) => state === 'error' || state === 'offline')
}
function projectHasWarningIssue(project: ProjectConfig): boolean {
return projectHealthStates(project).some((state) => state === 'warning')
}
function projectHealthStates(project: ProjectConfig): HealthState[] {
return [
project.giteaStatus,
project.giteaWebhookStatus,
project.integrations.wecom,
...project.environments.map((env) => env.jenkinsStatus),
]
}
function resetProjectFilters() {
searchKeyword.value = ''
readinessFilter.value = ''
permissionFilter.value = ''
}
</script>
<template>
<div class="page-shell">
<div class="page-header">
<div>
<h1>项目配置</h1>
<p>展示后端返回的项目环境Jenkins JobGitea 仓库和通知通道绑定关系</p>
</div>
<ElButton type="primary" plain disabled>新增项目</ElButton>
</div>
<section class="panel">
<div class="panel-header">
<h2>项目清单</h2>
<h1>项目配置</h1>
<p>展示后端返回的项目环境Jenkins JobGitea 仓库和通知通道绑定关系</p>
</div>
<div class="toolbar">
<ElTag type="info" round>后端配置源</ElTag>
<ElButton
size="small"
:icon="Refresh"
type="primary"
plain
:loading="diagnosticsLoading"
@click="refreshProjectDiagnostics"
@@ -104,8 +286,55 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
刷新项目诊断
</ElButton>
</div>
</div>
<section class="project-overview-grid">
<article
v-for="item in projectOverview"
:key="item.key"
class="project-overview-card"
:class="`tone-${item.tone}`"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</section>
<section class="panel">
<div class="panel-header">
<h2>项目清单</h2>
<span>{{ filteredProjects.length }} / {{ platform.projects.length }} 个项目</span>
</div>
<div class="panel-body">
<ElTable :data="platform.projects" row-key="key">
<div class="project-filter-bar">
<ElInput
v-model="searchKeyword"
clearable
:prefix-icon="Search"
placeholder="搜索项目 / 仓库 / 默认分支"
class="project-search-input"
/>
<ElSelect v-model="readinessFilter" clearable placeholder="发布态">
<ElOption
v-for="option in readinessOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElSelect v-model="permissionFilter" clearable placeholder="权限">
<ElOption
v-for="option in permissionOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElButton :disabled="!hasActiveFilters" @click="resetProjectFilters">
重置筛选
</ElButton>
</div>
<ElTable :data="filteredProjects" row-key="key" empty-text="没有符合条件的项目">
<ElTableColumn type="expand">
<template #default="{ row }">
<div class="env-config">
@@ -216,6 +445,23 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
</template>
</ElTableColumn>
<ElTableColumn prop="name" label="项目" min-width="150" />
<ElTableColumn label="发布态" min-width="190">
<template #default="{ row }">
<div class="readiness-cell">
<ElTag :type="projectReadinessTagType(row)" effect="light" round>
{{ projectReadinessLabel(row) }}
</ElTag>
<small>{{ projectReadinessSummary(row) }}</small>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="权限" width="120">
<template #default="{ row }">
<ElTag :type="platform.canBuildProject(row.key) ? 'success' : 'info'" effect="plain" round>
{{ platform.canBuildProject(row.key) ? '构建' : '只读' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="serviceType" label="类型" min-width="130" />
<ElTableColumn prop="repository" label="Gitea 仓库" min-width="320">
<template #default="{ row }">
@@ -234,6 +480,30 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
</div>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="170" fixed="right">
<template #default="{ row }">
<div class="project-actions">
<RouterLink
v-slot="{ navigate }"
custom
:to="{ name: 'releases', query: { projectKey: row.key } }"
>
<ElButton
:icon="Promotion"
link
type="primary"
:disabled="!platform.canBuildProject(row.key)"
@click="navigate"
>
发布
</ElButton>
</RouterLink>
<RouterLink :to="{ name: 'runs', query: { projectKey: row.key } }">
<ElButton link type="primary">运行记录</ElButton>
</RouterLink>
</div>
</template>
</ElTableColumn>
</ElTable>
</div>
</section>
@@ -241,6 +511,66 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri
</template>
<style scoped>
.project-overview-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.project-overview-card {
min-width: 0;
padding: 12px 14px;
border: 1px solid #d8e2ef;
border-radius: 8px;
background: #fff;
}
.project-overview-card span {
display: block;
color: #64748b;
font-size: 12px;
}
.project-overview-card strong {
display: block;
margin-top: 6px;
color: #0f172a;
font-size: 22px;
line-height: 1;
}
.project-overview-card.tone-success {
border-color: #bbf7d0;
background: #f0fdf4;
}
.project-overview-card.tone-warning {
border-color: #fde68a;
background: #fffbeb;
}
.project-overview-card.tone-danger {
border-color: #fecaca;
background: #fef2f2;
}
.project-overview-card.tone-muted {
border-color: #e2e8f0;
background: #f8fafc;
}
.project-filter-bar {
display: grid;
grid-template-columns: minmax(260px, 1.4fr) 150px 150px max-content;
gap: 10px;
margin-bottom: 12px;
align-items: center;
}
.project-search-input {
min-width: 0;
}
.env-config {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -295,6 +625,24 @@ dd {
gap: 6px;
}
.readiness-cell {
display: grid;
gap: 6px;
min-width: 0;
}
.readiness-cell small {
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.project-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.diagnostic-message {
margin-left: 8px;
color: #596578;
@@ -340,7 +688,22 @@ dd {
text-decoration: underline;
}
@media (max-width: 1080px) {
.project-overview-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.project-filter-bar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.project-overview-grid,
.project-filter-bar {
grid-template-columns: 1fr;
}
.env-config {
grid-template-columns: 1fr;
padding: 12px;
+477 -9
View File
@@ -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/commitJenkins 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
View File
@@ -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>查看发布 runJenkins 构建链接耗时失败节点和流程轨迹</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
View File
@@ -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">
当前账号只能查看系统配置边界模型 keybaseURL 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;
}