feat: 初始化DevOps平台前端

This commit is contained in:
湛兮
2026-06-11 20:49:59 +08:00
commit 60931e2c21
38 changed files with 7033 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+326
View File
@@ -0,0 +1,326 @@
<script setup lang="ts">
import 'bpmn-js/dist/assets/bpmn-js.css'
import 'bpmn-js/dist/assets/diagram-js.css'
import 'bpmn-js/dist/assets/bpmn-font/css/bpmn.css'
import BpmnViewer from 'bpmn-js/lib/Viewer'
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import type { PipelineStep, StepStatus } from '../types/devops'
import StatusPill from './StatusPill.vue'
const props = defineProps<{
xml: string
steps: PipelineStep[]
}>()
const canvasEl = ref<HTMLDivElement>()
const selectedNodeId = ref('')
const viewer = shallowRef<InstanceType<typeof BpmnViewer>>()
const markerClasses: Record<StepStatus, string> = {
pending: 'bpmn-step-pending',
queued: 'bpmn-step-queued',
running: 'bpmn-step-running',
success: 'bpmn-step-success',
failed: 'bpmn-step-failed',
skipped: 'bpmn-step-skipped',
canceled: 'bpmn-step-canceled',
}
function findStep(nodeId: string) {
return props.steps.find((step) => step.bpmnNodeId === nodeId)
}
const selectedStep = computed(() => {
if (!selectedNodeId.value) {
return undefined
}
return findStep(selectedNodeId.value)
})
async function renderDiagram() {
if (!canvasEl.value || !props.xml) {
return
}
if (!viewer.value) {
viewer.value = new BpmnViewer({
container: canvasEl.value,
})
}
await viewer.value.importXML(props.xml)
const canvas = viewer.value.get('canvas') as {
zoom: (mode: string) => void
addMarker: (id: string, marker: string) => void
removeMarker: (id: string, marker: string) => void
}
canvas.zoom('fit-viewport')
applyMarkers()
const eventBus = viewer.value.get('eventBus') as {
on: (event: string, callback: (event: { element?: { id: string } }) => void) => void
}
eventBus.on('element.click', (event) => {
const nodeId = event.element?.id
if (nodeId && findStep(nodeId)) {
selectedNodeId.value = nodeId
}
})
}
function applyMarkers() {
if (!viewer.value) {
return
}
const canvas = viewer.value.get('canvas') as {
addMarker: (id: string, marker: string) => void
removeMarker: (id: string, marker: string) => void
}
props.steps.forEach((step) => {
Object.values(markerClasses).forEach((marker) => {
canvas.removeMarker(step.bpmnNodeId, marker)
})
canvas.addMarker(step.bpmnNodeId, markerClasses[step.status] ?? markerClasses.pending)
})
}
watch(
() => [props.xml, props.steps.map((step) => `${step.bpmnNodeId}:${step.status}`).join('|')],
async () => {
await nextTick()
await renderDiagram()
},
{
immediate: true,
},
)
onBeforeUnmount(() => {
viewer.value?.destroy()
})
</script>
<template>
<div class="bpmn-viewer">
<div ref="canvasEl" class="bpmn-canvas" />
<div class="bpmn-side">
<div class="legend">
<span><i class="dot success" />成功</span>
<span><i class="dot queued" />排队</span>
<span><i class="dot running" />运行</span>
<span><i class="dot failed" />失败</span>
<span><i class="dot pending" />等待</span>
</div>
<div class="compact-list">
<button
v-for="step in steps"
:key="step.id"
class="step-row"
:class="{ active: selectedNodeId === step.bpmnNodeId }"
type="button"
@click="selectedNodeId = step.bpmnNodeId"
>
<span>
<strong>{{ step.name }}</strong>
<small>{{ step.owner }} · {{ step.duration }}</small>
</span>
<StatusPill :state="step.status" />
</button>
</div>
<div v-if="selectedStep" class="node-detail">
<strong>{{ selectedStep.name }}</strong>
<p>{{ selectedStep.summary }}</p>
<p v-if="selectedStep.message">
<span>执行消息</span>
{{ selectedStep.message }}
</p>
<p v-if="selectedStep.errorSummary">
<span>失败摘要</span>
{{ selectedStep.errorSummary }}
</p>
<pre v-if="selectedStep.logExcerpt">{{ selectedStep.logExcerpt }}</pre>
</div>
</div>
</div>
</template>
<style scoped>
.bpmn-viewer {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 14px;
min-height: 360px;
}
.bpmn-canvas {
min-height: 360px;
overflow: hidden;
border: 1px solid #dfe6f0;
border-radius: 8px;
background: #ffffff;
}
.bpmn-side {
display: flex;
flex-direction: column;
gap: 12px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
padding: 10px 12px;
border: 1px solid #e6ebf3;
border-radius: 8px;
background: #fbfcff;
color: #596578;
font-size: 12px;
}
.legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
}
.success {
background: #18a058;
}
.running {
background: #1d5fd0;
}
.queued {
background: #b7791f;
}
.failed {
background: #d03050;
}
.pending {
background: #9aa4b2;
}
.step-row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px;
border: 1px solid #e7edf5;
border-radius: 8px;
background: #ffffff;
color: inherit;
cursor: pointer;
text-align: left;
}
.step-row.active {
border-color: #1d5fd0;
background: #f3f7ff;
}
.step-row strong,
.step-row small {
display: block;
}
.step-row strong {
color: #182033;
font-size: 13px;
}
.step-row small {
margin-top: 3px;
color: #748093;
font-size: 12px;
}
.node-detail {
padding: 12px;
border: 1px solid #d9e5ff;
border-radius: 8px;
background: #f7faff;
}
.node-detail strong {
color: #172033;
font-size: 14px;
}
.node-detail p {
margin: 6px 0 0;
color: #596578;
font-size: 13px;
line-height: 1.6;
}
.node-detail p span {
display: inline-block;
margin-right: 8px;
color: #8a5a12;
font-weight: 600;
}
.node-detail pre {
max-height: 160px;
margin: 10px 0 0;
padding: 10px;
overflow: auto;
border-radius: 6px;
background: #172033;
color: #eef4ff;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
:global(.bpmn-step-success .djs-visual > :first-child) {
stroke: #18a058 !important;
stroke-width: 3px !important;
}
:global(.bpmn-step-queued .djs-visual > :first-child) {
stroke: #b7791f !important;
stroke-width: 3px !important;
stroke-dasharray: 6 4 !important;
}
:global(.bpmn-step-running .djs-visual > :first-child) {
stroke: #1d5fd0 !important;
stroke-width: 4px !important;
}
:global(.bpmn-step-failed .djs-visual > :first-child) {
stroke: #d03050 !important;
stroke-width: 4px !important;
}
:global(.bpmn-step-pending .djs-visual > :first-child),
:global(.bpmn-step-skipped .djs-visual > :first-child),
:global(.bpmn-step-canceled .djs-visual > :first-child) {
stroke: #9aa4b2 !important;
stroke-dasharray: 5 4 !important;
}
@media (max-width: 960px) {
.bpmn-viewer {
grid-template-columns: 1fr;
}
}
</style>
+357
View File
@@ -0,0 +1,357 @@
<script setup lang="ts">
import { Close, Promotion } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, ref, watch } from 'vue'
import { devopsApi } from '../services/devopsApi'
import { usePlatformStore } from '../stores/platform'
import type { AgentMode, AgentPurpose } from '../types/devops'
type AgentMessage = {
id: string
role: 'user' | 'assistant'
title: string
content: string
createdAt: string
}
const emit = defineEmits<{
close: []
}>()
const platform = usePlatformStore()
const selectedMode = ref<AgentMode>('risk')
const selectedProject = ref('')
const selectedRun = ref('')
const prompt = ref('帮我检查当前发布是否存在配置、构建或通知风险。')
const sending = ref(false)
const messages = ref<AgentMessage[]>([
{
id: 'welcome',
role: 'assistant',
title: 'DevOps Agent',
content: '请选择项目和任务类型,我会基于平台已有的发布、构建、流程和配置上下文生成结果。',
createdAt: formatTime(new Date().toISOString()),
},
])
const activeCard = computed(() => {
return platform.agentCards.find((card) => card.mode === selectedMode.value)
})
const failedRunOptions = computed(() => {
return platform.failedRuns.filter((run) => !selectedProject.value || run.projectKey === selectedProject.value)
})
const purpose = computed<AgentPurpose>(() => {
if (selectedMode.value === 'diagnosis') {
return 'failure-diagnosis'
}
if (selectedMode.value === 'runbook') {
return 'runbook-qa'
}
if (selectedMode.value === 'releaseNotes') {
return 'release-note'
}
return 'release-risk'
})
watch(
() => platform.projects,
(projects) => {
if (!selectedProject.value && projects[0]) {
selectedProject.value = projects[0].key
}
},
{ immediate: true },
)
watch(failedRunOptions, (runs) => {
if (selectedMode.value === 'diagnosis') {
selectedRun.value = runs[0]?.id ?? ''
}
})
watch(selectedMode, (mode) => {
if (mode === 'diagnosis') {
selectedRun.value = failedRunOptions.value[0]?.id ?? ''
prompt.value = '请根据最近失败 run 的 Jenkins 日志摘要和流程节点给出排查步骤。'
return
}
if (mode === 'runbook') {
prompt.value = '生产发布失败后,应该先检查哪些平台信息?'
return
}
if (mode === 'releaseNotes') {
prompt.value = '请为本次 master 发布生成发布说明草稿,并列出验证范围。'
return
}
prompt.value = '帮我检查当前发布是否存在配置、构建或通知风险。'
})
async function sendPrompt() {
const text = prompt.value.trim()
if (!text) {
return
}
sending.value = true
messages.value.push({
id: `user-${Date.now()}`,
role: 'user',
title: activeCard.value?.title ?? '运维问题',
content: text,
createdAt: formatTime(new Date().toISOString()),
})
try {
const invocation = await devopsApi.createAgentInvocation({
type: purpose.value,
projectKey: selectedProject.value || undefined,
runId: selectedMode.value === 'diagnosis' ? selectedRun.value || undefined : undefined,
promptSummary: text,
})
messages.value.push({
id: invocation.id,
role: 'assistant',
title: invocation.status === 'success' ? 'Agent 结果' : 'Agent 调用失败',
content: invocation.resultMarkdown || 'Agent 未返回内容',
createdAt: formatTime(invocation.createdAt),
})
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : 'Agent 调用失败')
} finally {
sending.value = false
}
}
function handleComposerKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) {
return
}
event.preventDefault()
void sendPrompt()
}
function formatTime(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date)
}
</script>
<template>
<aside class="agent-drawer">
<header class="agent-header">
<div>
<strong>Agent</strong>
<span>{{ activeCard?.title ?? 'DevOps 助手' }}</span>
</div>
<ElButton :icon="Close" circle text @click="emit('close')" />
</header>
<div class="agent-context">
<ElSegmented
v-model="selectedMode"
class="mode-switch"
:options="[
{ label: '风险', value: 'risk' },
{ label: '诊断', value: 'diagnosis' },
{ label: 'Runbook', value: 'runbook' },
{ label: '发布说明', value: 'releaseNotes' },
]"
/>
<ElSelect v-model="selectedProject" filterable placeholder="选择项目">
<ElOption
v-for="project in platform.projects"
:key="project.key"
:label="project.name"
:value="project.key"
/>
</ElSelect>
<ElSelect
v-if="selectedMode === 'diagnosis'"
v-model="selectedRun"
filterable
placeholder="选择失败 Run"
>
<ElOption
v-for="run in failedRunOptions"
:key="run.id"
:label="`${run.id} · ${run.ref}`"
:value="run.id"
/>
</ElSelect>
</div>
<div class="message-list">
<article v-for="message in messages" :key="message.id" class="agent-message" :class="message.role">
<div class="message-meta">
<strong>{{ message.title }}</strong>
<span>{{ message.createdAt }}</span>
</div>
<p>{{ message.content }}</p>
</article>
</div>
<footer class="agent-composer">
<ElInput
v-model="prompt"
type="textarea"
:rows="4"
maxlength="300"
show-word-limit
resize="none"
@keydown="handleComposerKeydown"
/>
<ElButton type="primary" :icon="Promotion" :loading="sending" @click="sendPrompt">
发送
</ElButton>
</footer>
</aside>
</template>
<style scoped>
.agent-drawer {
position: sticky;
top: 0;
display: flex;
flex: 0 0 420px;
flex-direction: column;
width: 420px;
height: 100vh;
border-left: 1px solid #dfe7f2;
background: #ffffff;
box-shadow: -8px 0 22px rgb(15 23 42 / 8%);
}
.agent-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 64px;
padding: 14px 16px;
border-bottom: 1px solid #edf1f7;
}
.agent-header strong,
.agent-header span {
display: block;
}
.agent-header strong {
color: #111827;
font-size: 16px;
}
.agent-header span {
margin-top: 3px;
color: #748093;
font-size: 12px;
}
.agent-context {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid #edf1f7;
background: #f8fafc;
}
.mode-switch {
width: 100%;
}
.message-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
padding: 16px;
overflow-y: auto;
}
.agent-message {
max-width: 100%;
padding: 12px;
border: 1px solid #e3e8f2;
border-radius: 8px;
background: #fbfcff;
}
.agent-message.user {
border-color: #bfdbfe;
background: #eff6ff;
}
.message-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
font-size: 12px;
}
.message-meta strong {
color: #172033;
}
.message-meta span {
flex-shrink: 0;
color: #8a95a7;
}
.agent-message p {
margin: 0;
color: #334155;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.agent-composer {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px 16px;
border-top: 1px solid #edf1f7;
background: #ffffff;
}
.agent-composer .el-button {
align-self: flex-end;
}
@media (max-width: 900px) {
.agent-drawer {
position: fixed;
right: 0;
z-index: 20;
width: min(420px, 100vw);
max-width: 100vw;
}
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
import type { Component } from 'vue'
defineProps<{
label: string
value: string | number
caption: string
icon: Component
tone?: 'neutral' | 'success' | 'warning' | 'danger'
}>()
</script>
<template>
<div class="metric-tile" :class="tone || 'neutral'">
<div class="metric-icon">
<ElIcon>
<component :is="icon" />
</ElIcon>
</div>
<div>
<span>{{ label }}</span>
<strong>{{ value }}</strong>
<small>{{ caption }}</small>
</div>
</div>
</template>
<style scoped>
.metric-tile {
display: flex;
gap: 12px;
min-height: 108px;
padding: 16px;
border: 1px solid #e3e8f2;
border-radius: 8px;
background: #ffffff;
}
.metric-icon {
display: grid;
flex: 0 0 38px;
width: 38px;
height: 38px;
place-items: center;
border-radius: 8px;
background: #edf4ff;
color: #1d5fd0;
}
span,
small {
display: block;
color: #748093;
font-size: 12px;
}
strong {
display: block;
margin: 7px 0 5px;
color: #111827;
font-size: 24px;
line-height: 1;
}
.success .metric-icon {
background: #e9f8f0;
color: #16844a;
}
.warning .metric-icon {
background: #fff6df;
color: #a96f00;
}
.danger .metric-icon {
background: #fff0f0;
color: #bc3434;
}
</style>
+248
View File
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ReleaseRun } from '../types/devops'
import StatusPill from './StatusPill.vue'
const props = defineProps<{
run: ReleaseRun
}>()
const notableSteps = computed(() =>
props.run.steps.filter(
(step) =>
step.status === 'failed' ||
step.status === 'skipped' ||
step.errorSummary ||
step.message ||
step.logExcerpt,
),
)
function noteLabel(status: string) {
return status === 'failed' ? '失败摘要' : '执行提示'
}
function displayText(value?: string | number) {
if (value === undefined || value === null || value === '' || value === '-') {
return '未返回'
}
return String(value)
}
function finishText() {
if (props.run.finishedAt && props.run.finishedAt !== '-') {
return props.run.finishedAt
}
if (props.run.status === 'queued' || props.run.status === 'running') {
return '未结束'
}
return '未返回'
}
function buildText() {
if (props.run.jenkinsBuildNumber !== undefined && props.run.jenkinsBuildNumber !== null) {
return `#${props.run.jenkinsBuildNumber}`
}
return '未返回'
}
</script>
<template>
<div class="run-execution-details">
<dl class="detail-grid">
<dt>Run ID</dt>
<dd class="mono">{{ displayText(run.id) }}</dd>
<dt>项目</dt>
<dd>{{ displayText(run.projectName) }}</dd>
<dt>环境</dt>
<dd>{{ displayText(run.environment) }}</dd>
<dt>ref/tag</dt>
<dd class="mono">{{ displayText(run.ref) }}</dd>
<dt>操作者</dt>
<dd>{{ displayText(run.actor) }}</dd>
<dt>状态</dt>
<dd><StatusPill :state="run.status" /></dd>
<dt>开始时间</dt>
<dd>{{ displayText(run.startedAt) }}</dd>
<dt>结束时间</dt>
<dd>{{ finishText() }}</dd>
<dt>Jenkins Queue</dt>
<dd class="mono">{{ displayText(run.jenkinsQueueId) }}</dd>
<dt>Jenkins Build</dt>
<dd class="mono">{{ buildText() }}</dd>
<dt>Jenkins 状态</dt>
<dd>{{ displayText(run.jenkinsSummary) }}</dd>
<dt>Build URL</dt>
<dd>
<a v-if="run.jenkinsBuildUrl" :href="run.jenkinsBuildUrl" target="_blank" rel="noreferrer">
{{ run.jenkinsBuildUrl }}
</a>
<span v-else>未返回</span>
</dd>
<dt>Commit</dt>
<dd>{{ displayText(run.commitSummary) }}</dd>
<dt>备注</dt>
<dd>{{ displayText(run.note) }}</dd>
</dl>
<div class="failure-block">
<div class="failure-header">
<strong>执行提示 / 日志摘要</strong>
<span>{{ notableSteps.length ? `${notableSteps.length} 个节点` : '未返回异常或提示' }}</span>
</div>
<div v-if="notableSteps.length" class="failure-list">
<article
v-for="step in notableSteps"
:key="step.id"
class="failure-item"
:class="{ danger: step.status === 'failed' }"
>
<div class="failure-title">
<strong>{{ step.name }}</strong>
<StatusPill :state="step.status" />
</div>
<p v-if="step.errorSummary">
<span>{{ noteLabel(step.status) }}</span>
{{ step.errorSummary }}
</p>
<p v-if="step.message">
<span>执行消息</span>
{{ step.message }}
</p>
<pre v-if="step.logExcerpt">{{ step.logExcerpt }}</pre>
<p v-if="!step.errorSummary && !step.message && !step.logExcerpt">
<span>节点摘要</span>
{{ step.summary }}
</p>
</article>
</div>
<div v-else class="empty-line">后端尚未返回异常摘要执行提示或日志摘要</div>
</div>
</div>
</template>
<style scoped>
.run-execution-details {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-grid {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 10px 12px;
margin: 0;
font-size: 13px;
}
.detail-grid dt {
color: #7b8798;
}
.detail-grid dd {
margin: 0;
min-width: 0;
color: #172033;
overflow-wrap: anywhere;
}
.detail-grid a {
color: #1d5fd0;
}
.failure-block {
border-top: 1px solid #edf1f7;
padding-top: 14px;
}
.failure-header,
.failure-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.failure-header strong,
.failure-title strong {
color: #172033;
font-size: 13px;
}
.failure-header span {
color: #7b8798;
font-size: 12px;
}
.failure-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.failure-item {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fbfcff;
}
.failure-item.danger {
border-color: #f0d5d5;
background: #fffafa;
}
.failure-item p {
margin: 8px 0 0;
color: #4b5565;
font-size: 13px;
line-height: 1.6;
}
.failure-item p span {
display: inline-block;
margin-right: 8px;
color: #62572c;
font-weight: 600;
}
.failure-item.danger p span {
color: #9f2f2f;
}
.failure-item pre {
max-height: 180px;
margin: 10px 0 0;
padding: 10px;
overflow: auto;
border-radius: 6px;
background: #172033;
color: #eef4ff;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
.empty-line {
margin-top: 10px;
color: #7b8798;
font-size: 13px;
}
@media (max-width: 760px) {
.detail-grid {
grid-template-columns: 1fr;
gap: 4px 0;
}
.detail-grid dd + dt {
margin-top: 8px;
}
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { HealthState, ReleaseStatus, StepStatus } from '../types/devops'
const props = defineProps<{
state: HealthState | ReleaseStatus | StepStatus
text?: string
}>()
const statusMap: Record<string, { label: string; type: 'success' | 'warning' | 'danger' | 'info' | 'primary' }> = {
healthy: { label: '正常', type: 'success' },
warning: { label: '待配置', type: 'warning' },
error: { label: '异常', type: 'danger' },
offline: { label: '离线', type: 'info' },
queued: { label: '排队中', type: 'info' },
running: { label: '运行中', type: 'primary' },
success: { label: '成功', type: 'success' },
failed: { label: '失败', type: 'danger' },
canceled: { label: '已取消', type: 'info' },
idle: { label: '空闲', type: 'info' },
pending: { label: '等待中', type: 'info' },
skipped: { label: '已跳过', type: 'info' },
}
const status = computed(() => statusMap[props.state] || statusMap.offline)
</script>
<template>
<ElTag :type="status.type" effect="light" round>
{{ text || status.label }}
</ElTag>
</template>
+219
View File
@@ -0,0 +1,219 @@
<script setup lang="ts">
import {
ChatLineRound,
Connection,
DataAnalysis,
DocumentChecked,
Fold,
Monitor,
Operation,
Setting,
SwitchButton,
Tickets,
Tools,
} from '@element-plus/icons-vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import GlobalAgentDrawer from '../components/GlobalAgentDrawer.vue'
import StatusPill from '../components/StatusPill.vue'
import { usePlatformStore } from '../stores/platform'
import { useSessionStore } from '../stores/session'
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const platform = usePlatformStore()
const collapsed = ref(false)
const agentOpen = ref(false)
const activeMenu = computed(() => route.path)
const menus = [
{ path: '/overview', label: '总览', icon: DataAnalysis },
{ path: '/projects', label: '项目配置', icon: Operation },
{ path: '/releases', label: '发布中心', icon: DocumentChecked },
{ path: '/runs', label: '运行记录', icon: Tickets },
{ path: '/settings', label: '系统配置', icon: Setting },
]
const jenkinsStatus = computed(() => {
return platform.integrations.find((item) => item.key === 'jenkins')?.state || 'offline'
})
const apiModeLabel = computed(() => {
return platform.dataSource === 'backend' ? '后端 API' : 'Mock 降级'
})
onMounted(() => {
if (!platform.projects.length) {
void platform.loadAll()
}
})
function logout() {
session.logout()
void router.push('/login')
}
</script>
<template>
<ElContainer class="layout">
<ElAside class="sidebar" :width="collapsed ? '72px' : '232px'">
<div class="brand" :class="{ collapsed }">
<ElIcon>
<Monitor />
</ElIcon>
<span v-if="!collapsed">DevOps 运维平台</span>
</div>
<ElMenu :default-active="activeMenu" router :collapse="collapsed" class="nav-menu">
<ElMenuItem v-for="item in menus" :key="item.path" :index="item.path">
<ElIcon>
<component :is="item.icon" />
</ElIcon>
<template #title>{{ item.label }}</template>
</ElMenuItem>
</ElMenu>
</ElAside>
<ElContainer class="workspace" :class="{ 'agent-open': agentOpen }">
<ElContainer class="content-shell">
<ElHeader class="topbar">
<div class="toolbar">
<ElButton :icon="Fold" circle @click="collapsed = !collapsed" />
<div class="env-strip">
<span>本地开发</span>
<StatusPill :state="jenkinsStatus" text="Jenkins" />
<ElTag :type="platform.dataSource === 'backend' ? 'success' : 'info'" round>
{{ apiModeLabel }}
</ElTag>
</div>
</div>
<div class="toolbar">
<ElButton :icon="ChatLineRound" :type="agentOpen ? 'primary' : 'default'" @click="agentOpen = !agentOpen">
Agent
</ElButton>
<ElTag type="primary" effect="plain" round>
<ElIcon><Connection /></ElIcon>
{{ platform.dataSource === 'backend' ? '后端已联通' : '后端不可用时自动降级' }}
</ElTag>
<ElDropdown>
<ElButton>
{{ session.operator }}
<ElIcon class="el-icon--right"><Tools /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem disabled>角色{{ session.role }}</ElDropdownItem>
<ElDropdownItem :icon="SwitchButton" @click="logout">退出登录</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</ElHeader>
<ElMain class="main-area" v-loading="platform.loading">
<RouterView />
</ElMain>
</ElContainer>
<GlobalAgentDrawer v-if="agentOpen" @close="agentOpen = false" />
</ElContainer>
</ElContainer>
</template>
<style scoped>
.layout {
min-height: 100vh;
background: #f5f7fb;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
border-right: 1px solid #e0e6ef;
background: #101828;
transition: width 0.2s ease;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
height: 64px;
padding: 0 20px;
border-bottom: 1px solid rgb(255 255 255 / 9%);
color: #ffffff;
font-weight: 700;
letter-spacing: 0;
white-space: nowrap;
}
.brand.collapsed {
justify-content: center;
padding: 0;
}
.brand .el-icon {
color: #7dd3fc;
font-size: 22px;
}
.nav-menu {
--el-menu-bg-color: #101828;
--el-menu-text-color: #b8c2d6;
--el-menu-active-color: #ffffff;
--el-menu-hover-bg-color: #1d2939;
padding: 12px;
}
.workspace,
.content-shell {
min-width: 0;
}
.workspace.agent-open .content-shell {
width: calc(100% - 420px);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
border-bottom: 1px solid #e0e6ef;
background: #ffffff;
}
.env-strip {
display: flex;
align-items: center;
gap: 8px;
color: #596578;
font-size: 13px;
}
.main-area {
padding: 22px;
overflow-x: hidden;
}
@media (max-width: 840px) {
.sidebar {
display: none;
}
.topbar {
flex-wrap: wrap;
height: auto;
min-height: 64px;
padding: 12px;
}
.main-area {
padding: 14px;
}
.workspace.agent-open .content-shell {
width: 100%;
}
}
</style>
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import ElementPlus from 'element-plus'
import { createPinia } from 'pinia'
import router from './router'
createApp(App).use(createPinia()).use(router).use(ElementPlus).mount('#app')
+638
View File
@@ -0,0 +1,638 @@
/**
* 本地降级数据只模拟 DevOps 运维展示,不包含任何真实 token、webhook 或服务地址。
*/
import type {
AgentCard,
DashboardSummary,
IntegrationConfigStatus,
IntegrationStatus,
PipelineStep,
ProjectConfig,
ReleaseRun,
SystemSetting,
} from '../types/devops'
export const releaseProcessXml = `<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_DevOpsRelease" targetNamespace="http://devops-platform.local/bpmn">
<bpmn:process id="Process_Release" name="DevOps release pipeline" isExecutable="false">
<bpmn:startEvent id="start-release" name="提交发布">
<bpmn:outgoing>Flow_Start_Gitea</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:serviceTask id="gitea-check" name="Gitea ref 校验">
<bpmn:incoming>Flow_Start_Gitea</bpmn:incoming>
<bpmn:outgoing>Flow_Gitea_Jenkins</bpmn:outgoing>
</bpmn:serviceTask>
<bpmn:serviceTask id="jenkins-build" name="Jenkins 构建">
<bpmn:incoming>Flow_Gitea_Jenkins</bpmn:incoming>
<bpmn:outgoing>Flow_Jenkins_Health</bpmn:outgoing>
</bpmn:serviceTask>
<bpmn:serviceTask id="health-check" name="健康检查">
<bpmn:incoming>Flow_Jenkins_Health</bpmn:incoming>
<bpmn:outgoing>Flow_Health_Notify</bpmn:outgoing>
</bpmn:serviceTask>
<bpmn:serviceTask id="notify-wecom" name="通知平台">
<bpmn:incoming>Flow_Health_Notify</bpmn:incoming>
<bpmn:outgoing>Flow_Notify_End</bpmn:outgoing>
</bpmn:serviceTask>
<bpmn:endEvent id="finish-release" name="归档审计">
<bpmn:incoming>Flow_Notify_End</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_Start_Gitea" sourceRef="start-release" targetRef="gitea-check" />
<bpmn:sequenceFlow id="Flow_Gitea_Jenkins" sourceRef="gitea-check" targetRef="jenkins-build" />
<bpmn:sequenceFlow id="Flow_Jenkins_Health" sourceRef="jenkins-build" targetRef="health-check" />
<bpmn:sequenceFlow id="Flow_Health_Notify" sourceRef="health-check" targetRef="notify-wecom" />
<bpmn:sequenceFlow id="Flow_Notify_End" sourceRef="notify-wecom" targetRef="finish-release" />
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_Release">
<bpmndi:BPMNPlane id="BPMNPlane_Release" bpmnElement="Process_Release">
<bpmndi:BPMNShape id="Shape_Start" bpmnElement="start-release">
<dc:Bounds x="120" y="145" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_Gitea" bpmnElement="gitea-check">
<dc:Bounds x="210" y="123" width="118" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_Jenkins" bpmnElement="jenkins-build">
<dc:Bounds x="380" y="123" width="118" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_Health" bpmnElement="health-check">
<dc:Bounds x="550" y="123" width="118" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_Notify" bpmnElement="notify-wecom">
<dc:Bounds x="720" y="123" width="118" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_End" bpmnElement="finish-release">
<dc:Bounds x="900" y="145" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Edge_Start_Gitea" bpmnElement="Flow_Start_Gitea">
<di:waypoint x="156" y="163" />
<di:waypoint x="210" y="163" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Edge_Gitea_Jenkins" bpmnElement="Flow_Gitea_Jenkins">
<di:waypoint x="328" y="163" />
<di:waypoint x="380" y="163" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Edge_Jenkins_Health" bpmnElement="Flow_Jenkins_Health">
<di:waypoint x="498" y="163" />
<di:waypoint x="550" y="163" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Edge_Health_Notify" bpmnElement="Flow_Health_Notify">
<di:waypoint x="668" y="163" />
<di:waypoint x="720" y="163" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Edge_Notify_End" bpmnElement="Flow_Notify_End">
<di:waypoint x="838" y="163" />
<di:waypoint x="900" y="163" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>`
export const integrations: IntegrationStatus[] = [
{
key: 'jenkins',
name: 'Jenkins',
state: 'healthy',
summary: 'folder jobs 已按 my-project 规划,等待后端真实接入',
checkedAt: '2026-06-11 17:40',
latencyMs: 86,
},
{
key: 'gitea',
name: 'Gitea',
state: 'healthy',
summary: '四个仓库 mock 同步完成,生产发布优先 tag',
checkedAt: '2026-06-11 17:40',
latencyMs: 42,
},
{
key: 'wecom',
name: '通知平台',
state: 'warning',
summary: '机器人未配置,当前保留企微/飞书/通用 webhook 方案',
checkedAt: '2026-06-11 17:38',
},
{
key: 'llm',
name: 'LLM Agent',
state: 'warning',
summary: 'baseURL/key/model 待后端提供,前端不保存密钥',
checkedAt: '2026-06-11 17:38',
},
]
export const integrationConfigStatus: IntegrationConfigStatus = {
checkedAt: '2026-06-11T17:40:00+08:00',
integrations: [
{
key: 'jenkins',
name: 'Jenkins',
status: 'partial',
required: ['JENKINS_BASE_URL', 'JENKINS_USERNAME', 'JENKINS_API_TOKEN'],
configured: ['JENKINS_BASE_URL', 'JENKINS_USERNAME'],
missing: ['JENKINS_API_TOKEN'],
optional: [],
note: '前端只展示变量名,Jenkins 凭据由后端读取。',
},
{
key: 'gitea',
name: 'Gitea',
status: 'configured',
required: ['GITEA_BASE_URL', 'GITEA_TOKEN'],
configured: ['GITEA_BASE_URL', 'GITEA_TOKEN'],
missing: [],
optional: ['GITEA_WEBHOOK_SECRET'],
note: '仓库访问统一走后端 API。',
},
{
key: 'notification',
name: '通知平台',
status: 'missing',
required: ['NOTIFICATION_PROVIDER'],
configured: [],
missing: ['NOTIFICATION_PROVIDER'],
optional: ['WECOM_WEBHOOK_URL', 'FEISHU_WEBHOOK_URL', 'NOTIFICATION_WEBHOOK_URL'],
note: '浏览器不保存 webhook;后端按 provider 投递。',
},
{
key: 'llm',
name: 'LLM Agent',
status: 'partial',
required: ['LLM_BASE_URL', 'LLM_API_KEY', 'LLM_MODEL'],
configured: ['LLM_MODEL'],
missing: ['LLM_BASE_URL', 'LLM_API_KEY'],
optional: [],
note: '模型供应商只由后端代理调用。',
},
{
key: 'database',
name: 'Database',
status: 'configured',
required: ['DATABASE_URL'],
configured: ['DATABASE_URL'],
missing: [],
optional: ['USE_DATABASE_READS', 'PRISMA_CONNECT_ON_BOOT'],
note: '数据库连接仅服务端可见。',
},
{
key: 'redis',
name: 'Redis',
status: 'configured',
required: ['REDIS_URL'],
configured: ['REDIS_URL'],
missing: [],
optional: [],
note: 'Redis 连接仅服务端可见。',
},
{
key: 'secrets',
name: 'Secrets',
status: 'missing',
required: ['SECRET_ENCRYPTION_KEY'],
configured: [],
missing: ['SECRET_ENCRYPTION_KEY'],
optional: [],
note: '应用密钥只在后端环境中读取。',
},
],
}
export const projects: ProjectConfig[] = [
{
key: 'access-manage',
name: 'access-manage',
repository: 'https://gitea.ops.mrzhan.top/my-project/access-manage.git',
owner: 'my-project',
repo: 'access-manage',
serviceType: '后端服务',
defaultBranch: 'develop',
tagPolicy: '生产发布必须选择 v* tag',
environments: [
{
name: 'test',
label: '测试环境',
branchPolicy: 'develop / feature/*',
status: 'success',
version: 'develop@8f31a2c',
lastRunId: 'run-1048',
lastReleasedAt: '2026-06-11 15:10',
lastActor: 'ops-admin',
jenkinsJob: 'my-project/access-manage-test',
wecomRobot: 'devops-test',
},
{
name: 'production',
label: '生产环境',
branchPolicy: 'v* tag',
status: 'running',
version: 'v2026.06.11-api.1',
lastRunId: 'run-1052',
lastReleasedAt: '2026-06-11 17:12',
lastActor: 'ops-admin',
jenkinsJob: 'my-project/access-manage-prod',
wecomRobot: 'devops-prod',
},
],
integrations: {
jenkins: 'healthy',
gitea: 'healthy',
wecom: 'warning',
llm: 'warning',
},
},
{
key: 'role-admin',
name: 'role-admin',
repository: 'https://gitea.ops.mrzhan.top/my-project/role-admin.git',
owner: 'my-project',
repo: 'role-admin',
serviceType: '管理后台',
defaultBranch: 'develop',
tagPolicy: '生产发布必须由 develop 合并后 tag',
environments: [
{
name: 'test',
label: '测试环境',
branchPolicy: 'develop',
status: 'success',
version: 'develop@b42e18d',
lastRunId: 'run-1043',
lastReleasedAt: '2026-06-11 14:35',
lastActor: 'frontend-worker',
jenkinsJob: 'my-project/role-admin-test',
wecomRobot: 'devops-test',
},
{
name: 'production',
label: '生产环境',
branchPolicy: 'v* tag',
status: 'idle',
version: 'v2026.06.05-envsplit.7',
lastRunId: 'run-1026',
lastReleasedAt: '2026-06-05 16:02',
lastActor: 'ops-admin',
jenkinsJob: 'my-project/role-admin-prod',
wecomRobot: 'devops-prod',
},
],
integrations: {
jenkins: 'healthy',
gitea: 'healthy',
wecom: 'healthy',
llm: 'warning',
},
},
{
key: 'role-user',
name: 'role-user',
repository: 'https://gitea.ops.mrzhan.top/my-project/role-user.git',
owner: 'my-project',
repo: 'role-user',
serviceType: 'C 端员工应用',
defaultBranch: 'develop',
tagPolicy: '生产发布仅允许 release tag',
environments: [
{
name: 'test',
label: '测试环境',
branchPolicy: 'develop / feature/*',
status: 'failed',
version: 'develop@4a09fd2',
lastRunId: 'run-1050',
lastReleasedAt: '2026-06-11 16:24',
lastActor: 'frontend-worker',
jenkinsJob: 'my-project/role-user-test',
wecomRobot: 'devops-test',
},
{
name: 'production',
label: '生产环境',
branchPolicy: 'v* tag',
status: 'idle',
version: 'v2026.06.05-envsplit.7',
lastRunId: 'run-1028',
lastReleasedAt: '2026-06-05 16:08',
lastActor: 'ops-admin',
jenkinsJob: 'my-project/role-user-prod',
wecomRobot: 'devops-prod',
},
],
integrations: {
jenkins: 'error',
gitea: 'healthy',
wecom: 'healthy',
llm: 'warning',
},
},
{
key: 'my-resume',
name: 'my-resume',
repository: 'https://gitea.ops.mrzhan.top/my-project/my-resume.git',
owner: 'my-project',
repo: 'my-resume',
serviceType: '个人站点',
defaultBranch: 'master',
tagPolicy: '生产发布允许 master 或 v* tag',
environments: [
{
name: 'test',
label: '测试环境',
branchPolicy: 'master / feature/*',
status: 'success',
version: 'master@6c92e81',
lastRunId: 'run-1039',
lastReleasedAt: '2026-06-11 12:18',
lastActor: 'frontend-worker',
jenkinsJob: 'my-project/my-resume-test',
wecomRobot: 'devops-test',
},
{
name: 'production',
label: '生产环境',
branchPolicy: 'master 或 v* tag',
status: 'success',
version: 'v2026.06.10-resume.2',
lastRunId: 'run-1033',
lastReleasedAt: '2026-06-10 21:42',
lastActor: 'ops-admin',
jenkinsJob: 'my-project/my-resume-prod',
wecomRobot: 'devops-prod',
},
],
integrations: {
jenkins: 'healthy',
gitea: 'healthy',
wecom: 'healthy',
llm: 'warning',
},
},
{
key: 'devops-platform-api',
name: 'devops-platform-api',
repository: 'https://gitea.ops.mrzhan.top/devops-platform/devops-platform-api.git',
owner: 'devops-platform',
repo: 'devops-platform-api',
serviceType: 'DevOps 后端',
defaultBranch: 'master',
tagPolicy: '合并到 master 即发布 devops.mrzhan.top API',
environments: [
{
name: 'production',
label: '生产环境',
branchPolicy: 'master',
status: 'idle',
version: 'master@local',
lastRunId: '-',
lastReleasedAt: '-',
lastActor: '-',
jenkinsJob: 'devops-platform/prod-devops-platform-api-master',
wecomRobot: '通知平台待配置',
},
],
integrations: {
jenkins: 'warning',
gitea: 'warning',
wecom: 'warning',
llm: 'warning',
},
},
{
key: 'devops-platform-web',
name: 'devops-platform-web',
repository: 'https://gitea.ops.mrzhan.top/devops-platform/devops-platform-web.git',
owner: 'devops-platform',
repo: 'devops-platform-web',
serviceType: 'DevOps 前端',
defaultBranch: 'master',
tagPolicy: '合并到 master 即发布 devops.mrzhan.top Web',
environments: [
{
name: 'production',
label: '生产环境',
branchPolicy: 'master',
status: 'idle',
version: 'master@local',
lastRunId: '-',
lastReleasedAt: '-',
lastActor: '-',
jenkinsJob: 'devops-platform/prod-devops-platform-web-master',
wecomRobot: '通知平台待配置',
},
],
integrations: {
jenkins: 'warning',
gitea: 'warning',
wecom: 'warning',
llm: 'warning',
},
},
]
const baseSteps: PipelineStep[] = [
{
id: 'step-submit',
bpmnNodeId: 'start-release',
name: '提交发布单',
owner: 'Platform',
status: 'success',
duration: '2s',
summary: '记录操作者、项目、环境、ref 与备注。',
},
{
id: 'step-gitea',
bpmnNodeId: 'gitea-check',
name: '校验 Gitea ref',
owner: 'Gitea',
status: 'success',
duration: '5s',
summary: '验证 ref 存在并读取最近 commit 摘要。',
},
{
id: 'step-jenkins',
bpmnNodeId: 'jenkins-build',
name: '触发 Jenkins 构建',
owner: 'Jenkins',
status: 'running',
duration: '2m 18s',
summary: '进入 queue 后解析 build number,等待构建完成。',
},
{
id: 'step-health',
bpmnNodeId: 'health-check',
name: '环境健康检查',
owner: 'HealthCheck',
status: 'pending',
duration: '-',
summary: '构建成功后检查服务可达性和关键接口。',
},
{
id: 'step-wecom',
bpmnNodeId: 'notify-wecom',
name: '发送通知',
owner: 'Notification',
status: 'pending',
duration: '-',
summary: '按开始、成功、失败、取消模板投递到已配置通知平台。',
},
{
id: 'step-archive',
bpmnNodeId: 'finish-release',
name: '归档审计',
owner: 'Platform',
status: 'pending',
duration: '-',
summary: '保存 run、step、审计日志和通知投递结果。',
},
]
const successSteps = baseSteps.map((step) => ({
...step,
status: 'success' as const,
duration: step.duration === '-' ? '3s' : step.duration,
}))
const failedSteps: PipelineStep[] = baseSteps.map((step) => {
if (step.bpmnNodeId === 'jenkins-build') {
return {
...step,
status: 'failed',
duration: '1m 44s',
summary: 'pnpm build 失败,mock 日志提示类型检查未通过。',
}
}
if (['health-check', 'notify-wecom', 'finish-release'].includes(step.bpmnNodeId)) {
return {
...step,
status: 'skipped',
summary: '上游构建失败,当前节点跳过。',
}
}
return {
...step,
status: 'success',
}
})
export const runs: ReleaseRun[] = [
{
id: 'run-1052',
projectKey: 'access-manage',
projectName: 'access-manage',
environment: 'production',
ref: 'v2026.06.11-api.1',
status: 'running',
actor: 'ops-admin',
startedAt: '2026-06-11 17:12',
duration: '2m 25s',
note: '生产 API 修复发布,等待健康检查。',
jenkinsBuildUrl: 'https://jenkins.ops.mrzhan.top/job/my-project/job/access-manage-prod/1052',
commitSummary: 'fix: stabilize access policy migration and health output',
steps: baseSteps,
},
{
id: 'run-1050',
projectKey: 'role-user',
projectName: 'role-user',
environment: 'test',
ref: 'develop@4a09fd2',
status: 'failed',
actor: 'frontend-worker',
startedAt: '2026-06-11 16:24',
duration: '1m 56s',
note: '测试环境自动部署失败,等待诊断。',
jenkinsBuildUrl: 'https://jenkins.ops.mrzhan.top/job/my-project/job/role-user-test/1050',
commitSummary: 'feat: add employee workbench filters',
steps: failedSteps,
},
{
id: 'run-1048',
projectKey: 'access-manage',
projectName: 'access-manage',
environment: 'test',
ref: 'develop@8f31a2c',
status: 'success',
actor: 'ops-admin',
startedAt: '2026-06-11 15:10',
duration: '4m 03s',
note: '测试环境部署成功。',
jenkinsBuildUrl: 'https://jenkins.ops.mrzhan.top/job/my-project/job/access-manage-test/1048',
commitSummary: 'chore: update deployment env schema',
steps: successSteps,
},
]
export const agentCards: AgentCard[] = [
{
mode: 'risk',
title: '发布风险摘要',
description: '基于 ref diff、项目配置、历史失败记录生成检查项。',
inputScope: ['项目', '环境', 'ref/tag', '最近失败 run'],
outputPreview: ['配置变更影响范围', '构建和迁移风险', '发布前人工确认项'],
},
{
mode: 'diagnosis',
title: '失败诊断',
description: '只读取 Jenkins 日志片段、run steps 和已知 runbook。',
inputScope: ['失败 run', '失败节点', '脱敏日志片段'],
outputPreview: ['失败原因摘要', '下一步排查命令', '建议责任边界'],
},
{
mode: 'runbook',
title: 'Runbook 问答',
description: '围绕平台文档、项目 README、历史发布记录回答运维问题。',
inputScope: ['白名单文档', '项目', '环境'],
outputPreview: ['操作步骤', '风险提醒', '需要升级给后端的事项'],
},
{
mode: 'releaseNotes',
title: '发布说明草稿',
description: '根据 commit、PR 和 tag 生成生产发布说明草稿。',
inputScope: ['commit 列表', 'PR 信息', 'tag'],
outputPreview: ['变更摘要', '验证范围', '回滚提示'],
},
]
export const settings: SystemSetting[] = [
{
key: 'api-contract',
name: 'API 契约来源',
status: 'warning',
owner: '后端 OpenAPI',
contract: '本阶段使用 mock/devops.ts,联调后切换到后端 Swagger/OpenAPI。',
boundary: '前端不写死真实 token,不直接访问 Jenkins/Gitea/LLM。',
},
{
key: 'bpmn',
name: 'BPMN 流程定义',
status: 'healthy',
owner: '平台流程配置',
contract: 'step.bpmnNodeId 必须和 BPMN XML 节点 id 一致。',
boundary: '前端只展示流程和状态,不推进状态机。',
},
{
key: 'agent',
name: 'Agent 边界',
status: 'warning',
owner: '后端 Agent proxy',
contract: '只支持发布风险、失败诊断、Runbook、发布说明四类用途。',
boundary: '无泛聊天入口;模型 key/baseURL/model 仅后端保存。',
},
{
key: 'notification',
name: '通知平台',
status: 'warning',
owner: '后端 notification adapter',
contract: '通知模板由后端返回,后续可接企微、飞书或通用 webhook。',
boundary: 'webhook 不进入前端,不在浏览器保存。',
},
]
export const dashboardSummary: DashboardSummary = {
integrations,
projects,
runs,
agentCards,
settings,
integrationConfig: integrationConfigStatus,
}
+84
View File
@@ -0,0 +1,84 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useSessionStore } from '../stores/session'
import MainLayout from '../layouts/MainLayout.vue'
import LoginView from '../views/LoginView.vue'
import OverviewView from '../views/OverviewView.vue'
import ProjectsView from '../views/ProjectsView.vue'
import ReleaseCenterView from '../views/ReleaseCenterView.vue'
import RunsView from '../views/RunsView.vue'
import SettingsView from '../views/SettingsView.vue'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: LoginView,
meta: {
public: true,
},
},
{
path: '/',
component: MainLayout,
redirect: '/overview',
children: [
{
path: 'overview',
name: 'overview',
component: OverviewView,
},
{
path: 'projects',
name: 'projects',
component: ProjectsView,
},
{
path: 'releases',
name: 'releases',
component: ReleaseCenterView,
},
{
path: 'runs',
name: 'runs',
component: RunsView,
},
{
path: 'agent',
redirect: '/overview',
},
{
path: 'settings',
name: 'settings',
component: SettingsView,
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to) => {
const session = useSessionStore()
if (!to.meta.public && !session.isAuthenticated) {
return {
name: 'login',
query: {
redirect: to.fullPath,
},
}
}
if (to.name === 'login' && session.isAuthenticated) {
return {
name: 'overview',
}
}
return true
})
export default router
+875
View File
@@ -0,0 +1,875 @@
/**
* 前端唯一 DevOps API 入口;优先联调后端,失败时降级到本地 mock 保持页面可用。
*/
import {
agentCards,
dashboardSummary,
integrationConfigStatus as mockIntegrationConfigStatus,
integrations,
projects as mockProjects,
releaseProcessXml,
runs as mockRuns,
settings,
} from '../mocks/devops'
import type {
AgentCard,
AgentInvocation,
AgentMode,
AgentPurpose,
CreateAgentInvocationPayload,
CreateDeployRunPayload,
DashboardSummary,
EnvironmentName,
HealthState,
IntegrationConfigKey,
IntegrationConfigState,
IntegrationConfigStatus,
IntegrationStatus,
JenkinsSyncSummary,
PipelineStep,
ProjectConfig,
ProjectRefs,
ReleaseRun,
ReleaseStatus,
StepStatus,
SystemSetting,
} from '../types/devops'
import { http } from './http'
type DataSource = 'backend' | 'mock'
type BackendHealthStatus = 'ok' | 'not_configured' | 'unavailable'
type BackendRunStatus = 'pending' | 'queued' | 'running' | 'success' | 'failed' | 'canceled'
interface BackendHealth {
status: BackendHealthStatus
checkedAt: string
dependencies: Record<string, { status: BackendHealthStatus; message?: string }>
}
interface BackendProject {
id: string
key: string
name: string
repositoryUrl: string
defaultBranch: string
status: 'active' | 'archived'
environments: Array<{
name: EnvironmentName
displayName: string
jenkinsJobPath: string
}>
}
interface BackendDeployRunStep {
id?: string
bpmnNodeId?: string
name?: string
status?: BackendRunStatus
order?: number
startedAt?: string
finishedAt?: string
message?: string
errorSummary?: string
logExcerpt?: string
}
interface BackendDeployRun {
id?: string
projectKey?: string
environment?: EnvironmentName
ref?: string
status?: BackendRunStatus
trigger?: 'manual' | 'gitea_webhook' | 'retry'
operator?: string
jenkinsQueueId?: string | number
jenkinsBuildNumber?: number
jenkinsBuildUrl?: string
startedAt?: string
finishedAt?: string
createdAt?: string
steps?: BackendDeployRunStep[]
}
interface BackendJenkinsSyncSummary {
checkedAt: string
totalCandidates: number
syncedRuns: BackendDeployRun[]
failedRuns: Array<{
id: string
projectKey: string
message: string
}>
}
interface BackendProcessDefinition {
key: 'release'
version: string
xml: string
nodeIds: string[]
}
let lastDataSource: DataSource = 'mock'
const wait = async () => new Promise((resolve) => window.setTimeout(resolve, 180))
const integrationConfigKeys: IntegrationConfigKey[] = [
'jenkins',
'gitea',
'notification',
'wecom',
'llm',
'database',
'redis',
'secrets',
]
const redactedVariableName = '非变量名内容已隐藏'
export const devopsApi = {
getLastDataSource(): DataSource {
return lastDataSource
},
async getDashboard(): Promise<DashboardSummary> {
return withBackendFallback(
async () => {
const [health, backendProjects, backendRuns, integrationConfig] = await Promise.all([
fetchHealth(),
fetchProjects(),
fetchRuns(),
fetchIntegrationConfigStatusWithFallback(),
])
const adaptedProjects = toProjectConfigs(backendProjects)
return {
integrations: toIntegrationStatuses(health),
projects: adaptedProjects,
runs: backendRuns.map((run) => toReleaseRun(run, adaptedProjects)),
agentCards,
settings: toSystemSettings(health, integrationConfig),
integrationConfig,
}
},
() => dashboardSummary,
)
},
async getIntegrations(): Promise<IntegrationStatus[]> {
return withBackendFallback(
async () => toIntegrationStatuses(await fetchHealth()),
() => integrations,
)
},
async getProjects(): Promise<ProjectConfig[]> {
return withBackendFallback(
async () => toProjectConfigs(await fetchProjects()),
() => mockProjects,
)
},
async getProjectRefs(projectKey: string, projectList = mockProjects): Promise<ProjectRefs> {
return withBackendFallback(
async () => {
const { data } = await http.get<ProjectRefs>(`/projects/${projectKey}/refs`)
return data
},
() => createFallbackRefs(projectKey, projectList),
)
},
async getRuns(projectList = mockProjects): Promise<ReleaseRun[]> {
return withBackendFallback(
async () => (await fetchRuns()).map((run) => toReleaseRun(run, projectList)),
() => mockRuns,
)
},
async createDeployRun(
payload: CreateDeployRunPayload,
projectList = mockProjects,
): Promise<ReleaseRun> {
return withBackendFallback(
async () => {
const { data } = await http.post<BackendDeployRun>('/deploy-runs', payload)
return toReleaseRun(data, projectList)
},
() => createFallbackRun(payload, projectList, 'queued'),
)
},
async cancelDeployRun(runId: string, source?: ReleaseRun): Promise<ReleaseRun> {
return withBackendFallback(
async () => {
const { data } = await http.post<BackendDeployRun>(`/deploy-runs/${runId}/cancel`)
return toReleaseRun(data, mockProjects)
},
() => ({
...(source ?? mockRuns[0]),
id: source?.id ?? runId,
status: 'canceled',
duration: source?.duration ?? '-',
steps: (source?.steps ?? mockRuns[0].steps).map((step) => ({
...step,
status: step.status === 'success' ? 'success' : 'canceled',
summary: step.status === 'success' ? step.summary : '人工取消,本节点未继续执行。',
})),
}),
)
},
async retryDeployRun(runId: string, source?: ReleaseRun): Promise<ReleaseRun> {
return withBackendFallback(
async () => {
const { data } = await http.post<BackendDeployRun>(`/deploy-runs/${runId}/retry`)
return toReleaseRun(data, mockProjects)
},
() =>
createFallbackRun(
{
projectKey: source?.projectKey ?? 'access-manage',
environment: source?.environment ?? 'test',
ref: source?.ref ?? 'develop',
operator: source?.actor ?? 'ops-admin',
remark: source ? `重试 ${source.id}` : `重试 ${runId}`,
},
mockProjects,
'queued',
),
)
},
async syncJenkinsRun(
runId: string,
source?: ReleaseRun,
projectList = mockProjects,
): Promise<ReleaseRun> {
return withBackendFallback(
async () => {
const { data } = await http.post<BackendDeployRun>(`/deploy-runs/${runId}/sync-jenkins`)
return toReleaseRun(data, projectList)
},
() => source ?? mockRuns.find((run) => run.id === runId) ?? mockRuns[0],
)
},
async syncJenkinsRuns(projectList = mockProjects): Promise<JenkinsSyncSummary> {
return withBackendFallback(
async () => {
const { data } = await http.post<BackendJenkinsSyncSummary>(
'/deploy-runs/sync-jenkins',
)
return toJenkinsSyncSummary(data, projectList)
},
() => ({
checkedAt: new Date().toISOString(),
totalCandidates: 0,
syncedRuns: [],
failedRuns: [],
}),
)
},
async getAgentCards(): Promise<AgentCard[]> {
await wait()
return agentCards
},
async getIntegrationConfigStatus(): Promise<IntegrationConfigStatus> {
return withBackendFallback(
async () => fetchIntegrationConfigStatus(),
() => mockIntegrationConfigStatus,
)
},
async createAgentInvocation(
payload: CreateAgentInvocationPayload,
): Promise<AgentInvocation> {
return withBackendFallback(
async () => {
const { data } = await http.post<AgentInvocation>('/agent/invocations', payload)
return data
},
() => createFallbackAgentInvocation(payload),
)
},
async getSettings(): Promise<SystemSetting[]> {
return withBackendFallback(
async () => {
const [health, integrationConfig] = await Promise.all([
fetchHealth(),
fetchIntegrationConfigStatusWithFallback(),
])
return toSystemSettings(health, integrationConfig)
},
() => settings,
)
},
async getReleaseProcessXml(): Promise<string> {
return withBackendFallback(
async () => {
const { data } = await http.get<BackendProcessDefinition>(
'/process-definitions/release',
)
return data.xml
},
() => releaseProcessXml,
)
},
}
async function withBackendFallback<T>(
request: () => Promise<T>,
fallback: () => T | Promise<T>,
): Promise<T> {
if (import.meta.env.VITE_DEVOPS_USE_MOCK === 'true') {
lastDataSource = 'mock'
await wait()
return fallback()
}
try {
const result = await request()
lastDataSource = 'backend'
return result
} catch {
lastDataSource = 'mock'
await wait()
return fallback()
}
}
async function fetchHealth(): Promise<BackendHealth> {
const { data } = await http.get<BackendHealth>('/health')
return data
}
async function fetchProjects(): Promise<BackendProject[]> {
const { data } = await http.get<BackendProject[]>('/projects')
return data
}
async function fetchRuns(): Promise<BackendDeployRun[]> {
const { data } = await http.get<BackendDeployRun[]>('/deploy-runs')
return data
}
async function fetchIntegrationConfigStatus(): Promise<IntegrationConfigStatus> {
const { data } = await http.get<IntegrationConfigStatus>('/settings/integration-config')
return sanitizeIntegrationConfigStatus(data)
}
async function fetchIntegrationConfigStatusWithFallback(): Promise<IntegrationConfigStatus> {
try {
return await fetchIntegrationConfigStatus()
} catch {
return mockIntegrationConfigStatus
}
}
function toIntegrationStatuses(health: BackendHealth): IntegrationStatus[] {
return [
toIntegrationStatus('jenkins', 'Jenkins', health),
toIntegrationStatus('gitea', 'Gitea', health),
toIntegrationStatus('wecom', '通知平台', health),
toIntegrationStatus('llm', 'LLM Agent', health),
]
}
function toIntegrationStatus(
key: IntegrationStatus['key'],
name: string,
health: BackendHealth,
): IntegrationStatus {
const dependency = health.dependencies[key] ?? { status: 'unavailable' as const }
return {
key,
name,
state: toHealthState(dependency.status),
summary: dependency.message || healthMessage(dependency.status),
checkedAt: formatDateTime(health.checkedAt),
}
}
function sanitizeIntegrationConfigStatus(
payload: IntegrationConfigStatus,
): IntegrationConfigStatus {
const integrations = Array.isArray(payload?.integrations) ? payload.integrations : []
return {
checkedAt:
typeof payload?.checkedAt === 'string'
? payload.checkedAt
: new Date().toISOString(),
integrations: integrations
.filter((item) => integrationConfigKeys.includes(item.key))
.map((item) => {
const required = sanitizeVariableNames(item.required)
const configured = sanitizeVariableNames(item.configured)
const missing = sanitizeVariableNames(item.missing)
return {
key: item.key,
name: typeof item.name === 'string' && item.name ? item.name : item.key,
status: sanitizeIntegrationConfigState(item.status),
required,
configured,
missing,
optional: sanitizeVariableNames(item.optional),
note: sanitizeIntegrationNote(item.note),
}
}),
}
}
function sanitizeVariableNames(values?: string[]): string[] {
if (!Array.isArray(values)) {
return []
}
return Array.from(
new Set(
values
.map((value) => String(value).trim())
.filter(Boolean)
.map((value) =>
/^[A-Z][A-Z0-9_]*$/.test(value) ? value : redactedVariableName,
),
),
)
}
function sanitizeIntegrationConfigState(
state: IntegrationConfigState,
): IntegrationConfigState {
return state === 'configured' || state === 'partial' || state === 'missing'
? state
: 'missing'
}
function sanitizeIntegrationNote(note?: string): string | undefined {
if (!note) {
return undefined
}
return note
.replace(/https?:\/\/\S+/g, '[已隐藏地址]')
.replace(/key=[^&\s]+/g, 'key=[已隐藏]')
}
function toProjectConfigs(backendProjects: BackendProject[]): ProjectConfig[] {
return backendProjects.map((project) => {
const mockProject = mockProjects.find((item) => item.key === project.key)
const repo = parseRepository(project.repositoryUrl, project.key)
return {
key: project.key,
name: project.name,
repository: project.repositoryUrl,
owner: repo.owner,
repo: repo.repo,
serviceType: mockProject?.serviceType ?? '服务',
defaultBranch: project.defaultBranch,
tagPolicy: mockProject?.tagPolicy ?? '生产发布优先选择 v* tag',
environments: project.environments.map((environment) => {
const mockEnvironment = mockProject?.environments.find(
(item) => item.name === environment.name,
)
return {
name: environment.name,
label: environment.displayName,
branchPolicy:
mockEnvironment?.branchPolicy ??
(environment.name === 'production' ? 'v* tag' : project.defaultBranch),
status: mockEnvironment?.status ?? 'idle',
version: mockEnvironment?.version ?? '-',
lastRunId: mockEnvironment?.lastRunId ?? '-',
lastReleasedAt: mockEnvironment?.lastReleasedAt ?? '-',
lastActor: mockEnvironment?.lastActor ?? '-',
jenkinsJob: environment.jenkinsJobPath,
wecomRobot: mockEnvironment?.wecomRobot ?? '后端通知配置',
}
}),
integrations: mockProject?.integrations ?? {
jenkins: 'warning',
gitea: 'warning',
wecom: 'warning',
llm: 'warning',
},
}
})
}
function toReleaseRun(run: BackendDeployRun, projectList: ProjectConfig[]): ReleaseRun {
const projectKey = run.projectKey || 'unknown-project'
const project = projectList.find((item) => item.key === projectKey)
const ref = run.ref || '-'
const trigger = run.trigger ? triggerLabel(run.trigger) : '-'
const startedAt = run.startedAt ?? run.createdAt
return {
id: run.id || `${projectKey}-${ref}-${run.createdAt ?? Date.now()}`,
projectKey,
projectName: project?.name ?? projectKey,
environment: toEnvironmentName(run.environment),
ref,
status: toReleaseStatus(run.status),
actor: run.operator || '-',
startedAt: formatDateTime(startedAt),
finishedAt: formatDateTime(run.finishedAt),
duration: formatDuration(startedAt, run.finishedAt, run.status),
note: trigger,
jenkinsQueueId: formatJenkinsQueueId(run.jenkinsQueueId),
jenkinsBuildNumber: run.jenkinsBuildNumber,
jenkinsBuildUrl: run.jenkinsBuildUrl,
jenkinsSummary: formatJenkinsBuild(run),
commitSummary: ref === '-' ? trigger : `${ref} · ${trigger}`,
steps: (Array.isArray(run.steps) ? run.steps : [])
.slice()
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0))
.map(toPipelineStep),
}
}
function toJenkinsSyncSummary(
summary: BackendJenkinsSyncSummary,
projectList: ProjectConfig[],
): JenkinsSyncSummary {
return {
checkedAt: summary.checkedAt,
totalCandidates: summary.totalCandidates,
syncedRuns: (Array.isArray(summary.syncedRuns) ? summary.syncedRuns : []).map((run) =>
toReleaseRun(run, projectList),
),
failedRuns: Array.isArray(summary.failedRuns) ? summary.failedRuns : [],
}
}
function toPipelineStep(step: BackendDeployRunStep): PipelineStep {
const bpmnNodeId = step.bpmnNodeId || step.id || 'unknown-step'
const name = step.name || bpmnNodeId
return {
id: step.id || bpmnNodeId,
bpmnNodeId,
name,
owner: stepOwner(bpmnNodeId),
status: toStepStatus(step.status),
duration: formatDuration(step.startedAt, step.finishedAt, step.status),
summary: step.errorSummary || step.message || name,
startedAt: formatDateTime(step.startedAt),
finishedAt: formatDateTime(step.finishedAt),
message: step.message,
errorSummary: step.errorSummary,
logExcerpt: step.logExcerpt,
}
}
function createFallbackRun(
payload: CreateDeployRunPayload,
projectList: ProjectConfig[],
status: ReleaseStatus,
): ReleaseRun {
const project = projectList.find((item) => item.key === payload.projectKey)
const now = new Date().toISOString()
return {
id: payload.idempotencyKey || `local-${Date.now()}`,
projectKey: payload.projectKey,
projectName: project?.name ?? payload.projectKey,
environment: payload.environment,
ref: payload.ref,
status,
actor: payload.operator,
startedAt: formatDateTime(now),
duration: status === 'queued' ? '等待 Jenkins queue' : '-',
note: payload.remark || '本地降级发布单',
jenkinsSummary: '等待 Jenkins 返回',
jenkinsBuildUrl: '',
commitSummary: '本地降级:后端不可用时保留操作上下文',
steps: mockRuns[0].steps.map((step) => ({
...step,
status: step.bpmnNodeId === 'start-release' ? 'success' : 'pending',
})),
}
}
function createFallbackAgentInvocation(
payload: CreateAgentInvocationPayload,
): AgentInvocation {
return {
id: `local-agent-${Date.now()}`,
runId: payload.runId,
projectKey: payload.projectKey,
type: payload.type,
status: 'success',
promptSummary: payload.promptSummary,
resultMarkdown: fallbackAgentResult(payload.type),
createdAt: new Date().toISOString(),
}
}
function createFallbackRefs(projectKey: string, projectList: ProjectConfig[]): ProjectRefs {
const project = projectList.find((item) => item.key === projectKey)
const defaultBranch = project?.defaultBranch ?? 'develop'
return {
source: 'mock',
branches: Array.from(new Set([defaultBranch, 'develop', 'master']))
.filter(Boolean)
.map((name) => ({ name })),
tags: [
{ name: 'v2026.06.05-envsplit.7' },
{ name: 'v2026.06.11-api.1' },
],
}
}
function toHealthState(status: BackendHealthStatus): HealthState {
if (status === 'ok') {
return 'healthy'
}
if (status === 'not_configured') {
return 'warning'
}
return 'error'
}
function toReleaseStatus(status?: BackendRunStatus): ReleaseStatus {
if (status === 'pending') {
return 'queued'
}
if (
status === 'queued' ||
status === 'running' ||
status === 'success' ||
status === 'failed' ||
status === 'canceled'
) {
return status
}
return 'idle'
}
function toStepStatus(status?: BackendRunStatus): StepStatus {
if (status === 'pending') {
return 'pending'
}
if (
status === 'queued' ||
status === 'running' ||
status === 'success' ||
status === 'failed' ||
status === 'canceled'
) {
return status
}
return 'pending'
}
function stepOwner(nodeId: string): PipelineStep['owner'] {
if (nodeId.includes('gitea')) {
return 'Gitea'
}
if (nodeId.includes('jenkins')) {
return 'Jenkins'
}
if (nodeId.includes('health')) {
return 'HealthCheck'
}
if (nodeId.includes('wecom') || nodeId.includes('notify')) {
return 'Notification'
}
return 'Platform'
}
function healthMessage(status: BackendHealthStatus): string {
if (status === 'ok') {
return '后端检查通过'
}
if (status === 'not_configured') {
return '本地未配置,等待接入真实密钥'
}
return '后端检查不可用'
}
function triggerLabel(trigger: BackendDeployRun['trigger']): string {
if (trigger === 'gitea_webhook') {
return 'Gitea 自动触发'
}
if (trigger === 'retry') {
return '失败后重试'
}
return '人工触发'
}
function formatJenkinsBuild(run: BackendDeployRun): string {
if (run.jenkinsBuildNumber !== undefined && run.jenkinsBuildNumber !== null) {
return `Jenkins build #${run.jenkinsBuildNumber}`
}
if (run.jenkinsQueueId) {
return `Jenkins queue ${run.jenkinsQueueId}`
}
return '等待 Jenkins 返回'
}
function formatJenkinsQueueId(queueId?: string | number): string | undefined {
if (queueId === undefined || queueId === null || queueId === '') {
return undefined
}
return String(queueId)
}
function toEnvironmentName(environment?: EnvironmentName): EnvironmentName {
return environment === 'production' ? 'production' : 'test'
}
function parseRepository(repositoryUrl: string, fallbackRepo: string) {
try {
const pathname = new URL(repositoryUrl).pathname.replace(/\.git$/, '')
const parts = pathname.split('/').filter(Boolean)
const repo = parts.at(-1) ?? fallbackRepo
const owner = parts.at(-2) ?? 'my-project'
return { owner, repo }
} catch {
return { owner: 'my-project', repo: fallbackRepo }
}
}
function formatDateTime(value?: string): string {
if (!value) {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
.format(date)
.replace(/\//g, '-')
}
function formatDuration(
startedAt?: string,
finishedAt?: string,
status?: BackendRunStatus,
): string {
if (!startedAt) {
return '-'
}
if (!finishedAt && !['queued', 'running', 'pending'].includes(status ?? 'success')) {
return '-'
}
const started = new Date(startedAt)
const finished = finishedAt ? new Date(finishedAt) : new Date()
if (Number.isNaN(started.getTime()) || Number.isNaN(finished.getTime())) {
return '-'
}
const seconds = Math.max(0, Math.round((finished.getTime() - started.getTime()) / 1000))
if (seconds < 60) {
return `${seconds}s`
}
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
}
function toSystemSettings(
health: BackendHealth,
integrationConfig = mockIntegrationConfigStatus,
): SystemSetting[] {
const integrationByKey = new Map(
integrationConfig.integrations.map((item) => [item.key, item]),
)
return settings.map((item) => {
if (item.key === 'api-contract') {
return {
...item,
status: toHealthState(health.status),
contract: '已优先读取 devops-platform-api 的统一 envelope;后端不可用时降级到本地 mock。',
}
}
if (item.key === 'agent') {
return {
...item,
status: toHealthStateFromConfig(integrationByKey.get('llm')?.status),
}
}
if (item.key === 'notification') {
const notificationConfig = integrationByKey.get('notification') ?? integrationByKey.get('wecom')
return {
...item,
status: toHealthStateFromConfig(notificationConfig?.status),
}
}
return item
})
}
function toHealthStateFromConfig(state?: IntegrationConfigState): HealthState {
if (state === 'configured') {
return 'healthy'
}
if (state === 'partial') {
return 'warning'
}
return 'error'
}
function fallbackAgentResult(type: AgentPurpose): string {
const mode: Record<AgentPurpose, AgentMode> = {
'release-risk': 'risk',
'failure-diagnosis': 'diagnosis',
'runbook-qa': 'runbook',
'release-note': 'releaseNotes',
'incident-review': 'diagnosis',
}
const card = agentCards.find((item) => item.mode === mode[type])
return [
`${card?.title ?? 'Agent'} 本地降级结果`,
'后端或 LLM 未配置时,前端只保留 DevOps 场景输入,不保存任何模型密钥。',
].join('\n\n')
}
+66
View File
@@ -0,0 +1,66 @@
import axios from 'axios'
/**
* DevOps 平台统一 HTTP 实例,只识别后端 envelope,不承接任何第三方密钥。
*/
export const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 12_000,
withCredentials: true,
})
http.interceptors.response.use(
(response) => {
response.data = unwrapApiPayload(response.data)
return response
},
(error) => {
const message =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'请求失败,请稍后重试'
return Promise.reject(new Error(message))
},
)
interface ApiEnvelope<T> {
success: boolean
data?: T
error?: {
code: string
message: string
details?: unknown
}
}
function unwrapApiPayload(payload: unknown): unknown {
if (!isRecord(payload)) {
return payload
}
if (isApiEnvelope(payload)) {
const envelope = payload
if (envelope.success) {
return envelope.data
}
throw new Error(envelope.error?.message || '请求失败,请稍后重试')
}
if (Object.keys(payload).length === 1 && 'data' in payload) {
return payload.data
}
return payload
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isApiEnvelope(value: unknown): value is ApiEnvelope<unknown> {
return isRecord(value) && typeof value.success === 'boolean'
}
+126
View File
@@ -0,0 +1,126 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { devopsApi } from '../services/devopsApi'
import type {
AgentCard,
CreateDeployRunPayload,
IntegrationStatus,
IntegrationConfigStatus,
ProjectConfig,
ProjectRefs,
JenkinsSyncSummary,
ReleaseRun,
SystemSetting,
} from '../types/devops'
/**
* 平台运行态聚合后端和 mock 降级数据,页面只消费统一后的展示模型。
*/
export const usePlatformStore = defineStore('platform', () => {
const loading = ref(false)
const dataSource = ref<'backend' | 'mock'>('mock')
const projects = ref<ProjectConfig[]>([])
const runs = ref<ReleaseRun[]>([])
const integrations = ref<IntegrationStatus[]>([])
const agentCards = ref<AgentCard[]>([])
const settings = ref<SystemSetting[]>([])
const integrationConfig = ref<IntegrationConfigStatus>({
checkedAt: '',
integrations: [],
})
const releaseProcessXml = ref('')
const projectRefs = ref<Record<string, ProjectRefs>>({})
const failedRuns = computed(() => runs.value.filter((run) => run.status === 'failed'))
const runningRuns = computed(() =>
runs.value.filter((run) => run.status === 'queued' || run.status === 'running'),
)
async function loadAll() {
loading.value = true
try {
const summary = await devopsApi.getDashboard()
projects.value = summary.projects
runs.value = summary.runs
integrations.value = summary.integrations
agentCards.value = summary.agentCards
settings.value = summary.settings
integrationConfig.value = summary.integrationConfig
releaseProcessXml.value = await devopsApi.getReleaseProcessXml()
dataSource.value = devopsApi.getLastDataSource()
} finally {
loading.value = false
}
}
async function createDeployRun(payload: CreateDeployRunPayload) {
const run = await devopsApi.createDeployRun(payload, projects.value)
upsertRun(run)
dataSource.value = devopsApi.getLastDataSource()
return run
}
async function cancelDeployRun(run: ReleaseRun) {
const canceled = await devopsApi.cancelDeployRun(run.id, run)
upsertRun(canceled)
dataSource.value = devopsApi.getLastDataSource()
return canceled
}
async function retryDeployRun(run: ReleaseRun) {
const retried = await devopsApi.retryDeployRun(run.id, run)
upsertRun(retried)
dataSource.value = devopsApi.getLastDataSource()
return retried
}
async function syncJenkinsRun(run: ReleaseRun) {
const synced = await devopsApi.syncJenkinsRun(run.id, run, projects.value)
upsertRun(synced)
dataSource.value = devopsApi.getLastDataSource()
return synced
}
async function syncJenkinsRuns(): Promise<JenkinsSyncSummary> {
const result = await devopsApi.syncJenkinsRuns(projects.value)
result.syncedRuns.forEach(upsertRun)
dataSource.value = devopsApi.getLastDataSource()
return result
}
async function loadProjectRefs(projectKey: string) {
const refs = await devopsApi.getProjectRefs(projectKey, projects.value)
projectRefs.value = {
...projectRefs.value,
[projectKey]: refs,
}
dataSource.value = devopsApi.getLastDataSource()
return refs
}
function upsertRun(run: ReleaseRun) {
runs.value = [run, ...runs.value.filter((item) => item.id !== run.id)]
}
return {
loading,
dataSource,
projects,
runs,
integrations,
agentCards,
settings,
integrationConfig,
releaseProcessXml,
projectRefs,
failedRuns,
runningRuns,
loadAll,
createDeployRun,
cancelDeployRun,
retryDeployRun,
syncJenkinsRun,
syncJenkinsRuns,
loadProjectRefs,
}
})
+28
View File
@@ -0,0 +1,28 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
export const useSessionStore = defineStore('session', () => {
const operator = ref('ops-admin')
const role = ref<'admin' | 'operator' | 'viewer'>('admin')
const token = ref('mock-session')
const isAuthenticated = computed(() => Boolean(token.value))
function login(name: string) {
operator.value = name || 'ops-admin'
token.value = 'mock-session'
}
function logout() {
token.value = ''
}
return {
operator,
role,
token,
isAuthenticated,
login,
logout,
}
})
+226
View File
@@ -0,0 +1,226 @@
@import 'element-plus/dist/index.css';
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
sans-serif;
color: #172033;
background: #f5f7fb;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: #f5f7fb;
}
button,
input,
textarea,
select {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
#app {
min-height: 100vh;
}
.page-shell {
display: flex;
flex-direction: column;
gap: 18px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.page-header h1 {
margin: 0;
color: #111827;
font-size: 24px;
font-weight: 700;
letter-spacing: 0;
}
.page-header p {
margin: 8px 0 0;
color: #697386;
font-size: 14px;
line-height: 1.6;
}
.panel {
border: 1px solid #e3e8f2;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 1px 2px rgb(15 23 42 / 5%);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid #edf1f7;
}
.panel-header h2 {
margin: 0;
color: #182033;
font-size: 16px;
font-weight: 700;
letter-spacing: 0;
}
.panel-header span {
color: #748093;
font-size: 13px;
}
.panel-body {
padding: 18px;
}
.muted {
color: #748093;
}
.mono {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
}
.status-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.two-column {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(360px, 0.85fr);
gap: 16px;
align-items: start;
}
.three-column {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.section-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.compact-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.list-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid #edf1f7;
border-radius: 8px;
background: #fbfcff;
}
.list-row strong {
color: #172033;
font-size: 14px;
}
.list-row small {
display: block;
margin-top: 4px;
color: #7b8798;
font-size: 12px;
}
.field-hint {
margin-top: 6px;
color: #7b8798;
font-size: 12px;
line-height: 1.5;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.el-card,
.el-table,
.el-input__wrapper,
.el-select__wrapper,
.el-textarea__inner,
.el-button,
.el-tag {
border-radius: 8px;
}
.el-menu {
border-right: 0;
}
.el-table th.el-table__cell {
background: #f7f9fc;
color: #4b5565;
}
.danger-note {
border: 1px solid #ffd9d9;
background: #fff7f7;
color: #a63232;
}
@media (max-width: 1180px) {
.status-grid,
.three-column,
.section-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.two-column {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.page-header {
flex-direction: column;
}
.status-grid,
.three-column,
.section-grid {
grid-template-columns: 1fr;
}
}
+222
View File
@@ -0,0 +1,222 @@
/**
* DevOps 运维平台前端展示模型,所有第三方集成密钥只允许以变量名状态呈现。
*/
export type HealthState = 'healthy' | 'warning' | 'error' | 'offline'
export type EnvironmentName = 'test' | 'production'
export type ReleaseStatus =
| 'queued'
| 'running'
| 'success'
| 'failed'
| 'canceled'
| 'idle'
export type StepStatus =
| 'pending'
| 'queued'
| 'running'
| 'success'
| 'failed'
| 'skipped'
| 'canceled'
export type AgentMode = 'risk' | 'diagnosis' | 'runbook' | 'releaseNotes'
export type AgentPurpose =
| 'release-risk'
| 'failure-diagnosis'
| 'runbook-qa'
| 'release-note'
| 'incident-review'
export type IntegrationConfigKey =
| 'jenkins'
| 'gitea'
| 'notification'
| 'wecom'
| 'llm'
| 'database'
| 'redis'
| 'secrets'
export type IntegrationConfigState = 'configured' | 'partial' | 'missing'
/**
* 后端安全配置检查结果;数组字段只承载变量名,不承载任何密钥值或 URL 原文。
*/
export interface IntegrationConfigItem {
key: IntegrationConfigKey
name: string
status: IntegrationConfigState
required: string[]
configured: string[]
missing: string[]
optional?: string[]
note?: string
}
/**
* /settings/integration-config 的前端模型,已由 Axios envelope 解包。
*/
export interface IntegrationConfigStatus {
checkedAt: string
integrations: IntegrationConfigItem[]
}
export interface IntegrationStatus {
key: 'jenkins' | 'gitea' | 'wecom' | 'llm'
name: string
state: HealthState
summary: string
checkedAt: string
latencyMs?: number
}
export interface EnvironmentStatus {
name: EnvironmentName
label: string
branchPolicy: string
status: ReleaseStatus
version: string
lastRunId: string
lastReleasedAt: string
lastActor: string
jenkinsJob: string
wecomRobot: string
}
export interface ProjectConfig {
key: string
name: string
repository: string
owner: string
repo: string
serviceType: string
defaultBranch: string
tagPolicy: string
environments: EnvironmentStatus[]
integrations: {
jenkins: HealthState
gitea: HealthState
wecom: HealthState
llm: HealthState
}
}
export interface ProjectRefs {
source: 'gitea' | 'fallback' | 'mock'
branches: Array<{
name: string
commitSha?: string
}>
tags: Array<{
name: string
commitSha?: string
}>
}
export interface PipelineStep {
id: string
bpmnNodeId: string
name: string
owner: 'Gitea' | 'Jenkins' | 'HealthCheck' | 'Notification' | 'Platform'
status: StepStatus
duration: string
summary: string
startedAt?: string
finishedAt?: string
message?: string
errorSummary?: string
logExcerpt?: string
}
export interface ReleaseRun {
id: string
projectKey: string
projectName: string
environment: EnvironmentName
ref: string
status: ReleaseStatus
actor: string
startedAt: string
finishedAt?: string
duration: string
note: string
jenkinsQueueId?: string
jenkinsBuildNumber?: number
jenkinsBuildUrl?: string
jenkinsSummary?: string
commitSummary: string
steps: PipelineStep[]
}
export interface JenkinsSyncError {
id: string
projectKey: string
message: string
}
export interface JenkinsSyncSummary {
checkedAt: string
totalCandidates: number
syncedRuns: ReleaseRun[]
failedRuns: JenkinsSyncError[]
}
export interface AgentCard {
mode: AgentMode
title: string
description: string
inputScope: string[]
outputPreview: string[]
}
export interface SystemSetting {
key: string
name: string
status: HealthState
owner: string
contract: string
boundary: string
}
/**
* 首屏聚合数据,系统配置页通过 integrationConfig 展示真实安全配置状态。
*/
export interface DashboardSummary {
integrations: IntegrationStatus[]
projects: ProjectConfig[]
runs: ReleaseRun[]
agentCards: AgentCard[]
settings: SystemSetting[]
integrationConfig: IntegrationConfigStatus
}
export interface CreateDeployRunPayload {
projectKey: string
environment: EnvironmentName
ref: string
operator: string
remark?: string
idempotencyKey?: string
}
export interface AgentInvocation {
id: string
runId?: string
projectKey?: string
type: AgentPurpose
status: 'pending' | 'running' | 'success' | 'failed'
promptSummary: string
resultMarkdown?: string
createdAt: string
}
export interface CreateAgentInvocationPayload {
type: AgentPurpose
runId?: string
projectKey?: string
promptSummary: string
}
+243
View File
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue'
import { devopsApi } from '../services/devopsApi'
import { usePlatformStore } from '../stores/platform'
import type { AgentMode, AgentPurpose } from '../types/devops'
const platform = usePlatformStore()
const selectedMode = ref<AgentMode>('risk')
const selectedProject = ref('access-manage')
const selectedRun = ref('run-1050')
const runbookQuestion = ref('测试环境 Jenkins 构建失败时应该先看哪些信息?')
const generating = ref(false)
const resultMarkdown = ref('')
const activeCard = computed(() => {
return platform.agentCards.find((card) => card.mode === selectedMode.value)
})
const preview = computed(() => {
if (selectedMode.value === 'risk') {
return ['检查生产 tag 是否来自 develop 合并记录', '确认 Jenkins prod job 参数模板已配置', '通知平台未配置时不阻断发布流程']
}
if (selectedMode.value === 'diagnosis') {
return ['失败节点:Jenkins 构建', '建议先查看 pnpm build 类型错误和 env 注入', '重试前确认依赖锁文件未被并行任务改写']
}
if (selectedMode.value === 'runbook') {
return ['查看 run steps 中的失败节点和 build URL', '读取 progressiveText 的最后 200 行', '若为配置缺失,升级到后端维护密钥和 Job mapping']
}
return ['发布对象、环境、tag 和操作者', '主要变更和验证范围', '回滚入口与通知对象']
})
const purpose = computed<AgentPurpose>(() => {
if (selectedMode.value === 'diagnosis') {
return 'failure-diagnosis'
}
if (selectedMode.value === 'runbook') {
return 'runbook-qa'
}
if (selectedMode.value === 'releaseNotes') {
return 'release-note'
}
return 'release-risk'
})
async function generateAgentResult() {
generating.value = true
try {
const invocation = await devopsApi.createAgentInvocation({
type: purpose.value,
runId: selectedMode.value === 'diagnosis' ? selectedRun.value : undefined,
projectKey: selectedProject.value,
promptSummary:
selectedMode.value === 'runbook'
? runbookQuestion.value
: `${activeCard.value?.title ?? 'Agent'} · ${selectedProject.value}`,
})
resultMarkdown.value = invocation.resultMarkdown || 'Agent 未返回内容'
ElMessage.success('Agent 结果已生成')
} finally {
generating.value = false
}
}
</script>
<template>
<div class="page-shell">
<div class="page-header">
<div>
<h1>Agent 辅助</h1>
<p>仅提供 DevOps 场景能力发布风险失败诊断Runbook 问答和发布说明草稿</p>
</div>
<ElTag type="warning" effect="plain" round>无泛聊天入口</ElTag>
</div>
<div class="three-column">
<button
v-for="card in platform.agentCards"
:key="card.mode"
class="agent-card"
:class="{ active: selectedMode === card.mode }"
type="button"
@click="selectedMode = card.mode"
>
<strong>{{ card.title }}</strong>
<span>{{ card.description }}</span>
</button>
</div>
<div class="two-column">
<section class="panel">
<div class="panel-header">
<h2>{{ activeCard?.title }}</h2>
<span>输入被限制在运维对象内</span>
</div>
<div class="panel-body">
<ElForm label-position="top">
<ElFormItem label="项目">
<ElSelect v-model="selectedProject">
<ElOption
v-for="project in platform.projects"
:key="project.key"
:label="project.name"
:value="project.key"
/>
</ElSelect>
</ElFormItem>
<ElFormItem v-if="selectedMode === 'diagnosis'" label="失败 Run">
<ElSelect v-model="selectedRun">
<ElOption
v-for="run in platform.failedRuns"
:key="run.id"
:label="`${run.id} · ${run.projectName} · ${run.ref}`"
:value="run.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem v-if="selectedMode === 'runbook'" label="Runbook 问题">
<ElInput v-model="runbookQuestion" type="textarea" :rows="3" maxlength="120" show-word-limit />
<div class="field-hint">问题必须围绕发布构建通知流程状态或回滚</div>
</ElFormItem>
<ElFormItem v-if="selectedMode === 'releaseNotes'" label="发布 tag">
<ElSelect model-value="v2026.06.11-api.1">
<ElOption label="v2026.06.11-api.1" value="v2026.06.11-api.1" />
<ElOption label="v2026.06.05-envsplit.7" value="v2026.06.05-envsplit.7" />
</ElSelect>
</ElFormItem>
<ElButton type="primary" :loading="generating" @click="generateAgentResult">
生成结果
</ElButton>
</ElForm>
</div>
</section>
<section class="panel">
<div class="panel-header">
<h2>输出预览</h2>
<span>后端代理 LLM 后替换</span>
</div>
<div class="panel-body">
<div class="scope-block">
<strong>允许输入</strong>
<ElTag v-for="scope in activeCard?.inputScope" :key="scope" type="info" effect="plain">
{{ scope }}
</ElTag>
</div>
<div class="scope-block">
<strong>预期输出</strong>
<ul>
<li v-for="item in preview" :key="item">{{ item }}</li>
</ul>
</div>
<div v-if="resultMarkdown" class="result-block">
<strong>生成结果</strong>
<p>{{ resultMarkdown }}</p>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.agent-card {
min-height: 118px;
padding: 16px;
border: 1px solid #e3e8f2;
border-radius: 8px;
background: #ffffff;
cursor: pointer;
text-align: left;
}
.agent-card.active {
border-color: #1d5fd0;
background: #f3f7ff;
}
.agent-card strong,
.agent-card span {
display: block;
}
.agent-card strong {
color: #172033;
font-size: 15px;
}
.agent-card span {
margin-top: 10px;
color: #697386;
font-size: 13px;
line-height: 1.6;
}
.scope-block {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 0;
}
.scope-block + .scope-block {
border-top: 1px solid #edf1f7;
}
.scope-block strong {
width: 100%;
color: #172033;
font-size: 14px;
}
ul {
margin: 0;
padding-left: 18px;
color: #596578;
line-height: 1.8;
}
.result-block {
padding-top: 12px;
border-top: 1px solid #edf1f7;
}
.result-block strong {
display: block;
color: #172033;
font-size: 14px;
}
.result-block p {
margin: 8px 0 0;
color: #334155;
line-height: 1.7;
white-space: pre-wrap;
}
</style>
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { Lock, Monitor } from '@element-plus/icons-vue'
import { reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '../stores/session'
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const form = reactive({
operator: 'ops-admin',
password: 'mock-password',
})
function submit() {
session.login(form.operator)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/overview'
void router.push(redirect)
}
</script>
<template>
<main class="login-page">
<section class="login-panel">
<div class="login-copy">
<ElIcon><Monitor /></ElIcon>
<h1>DevOps 运维平台</h1>
<p>第一阶段使用 mock session仅用于本地控制台联调真实身份RBAC 和审计由后端接入后替换</p>
</div>
<ElForm class="login-form" label-position="top" @submit.prevent="submit">
<ElFormItem label="操作员">
<ElInput v-model="form.operator" size="large" autocomplete="username" />
</ElFormItem>
<ElFormItem label="口令">
<ElInput
v-model="form.password"
size="large"
type="password"
autocomplete="current-password"
show-password
/>
</ElFormItem>
<ElButton type="primary" size="large" :icon="Lock" native-type="submit">
进入控制台
</ElButton>
</ElForm>
</section>
</main>
</template>
<style scoped>
.login-page {
display: grid;
min-height: 100vh;
place-items: center;
padding: 24px;
background: #eef2f7;
}
.login-panel {
display: grid;
width: min(860px, 100%);
grid-template-columns: 1fr 360px;
overflow: hidden;
border: 1px solid #dfe6f0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 18px 50px rgb(15 23 42 / 10%);
}
.login-copy {
padding: 44px;
border-right: 1px solid #e6ebf3;
background: #f8fafc;
}
.login-copy .el-icon {
color: #1d5fd0;
font-size: 34px;
}
.login-copy h1 {
margin: 18px 0 10px;
color: #111827;
font-size: 28px;
letter-spacing: 0;
}
.login-copy p {
max-width: 420px;
color: #697386;
font-size: 14px;
line-height: 1.8;
}
.login-form {
padding: 44px 36px;
}
.login-form .el-button {
width: 100%;
margin-top: 8px;
}
@media (max-width: 780px) {
.login-panel {
grid-template-columns: 1fr;
}
.login-copy {
border-right: 0;
border-bottom: 1px solid #e6ebf3;
}
}
</style>
+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { CircleCheck, Cpu, Failed, Finished } from '@element-plus/icons-vue'
import { computed } from 'vue'
import MetricTile from '../components/MetricTile.vue'
import StatusPill from '../components/StatusPill.vue'
import { usePlatformStore } from '../stores/platform'
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)
</script>
<template>
<div class="page-shell">
<div class="page-header">
<div>
<h1>运维总览</h1>
<p>集中查看纳管项目环境最近发布Jenkins/Gitea/通知平台/LLM 当前状态</p>
</div>
<ElButton type="primary" plain @click="platform.loadAll">刷新数据</ElButton>
</div>
<div class="status-grid">
<MetricTile label="纳管项目" :value="platform.projects.length" caption="含 DevOps 平台自管理项目" :icon="Cpu" />
<MetricTile label="运行中发布" :value="runningCount" caption="当前 Jenkins 或健康检查处理中" :icon="Finished" tone="warning" />
<MetricTile label="成功记录" :value="successCount" caption="最近 mock run 成功数" :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>{{ platform.dataSource === 'backend' ? '来自后端 health' : '后端不可用,当前使用 mock' }}</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>
</div>
</section>
<section class="panel">
<div class="panel-header">
<h2>最近发布</h2>
<span> startedAt 倒序</span>
</div>
<div class="panel-body compact-list">
<div v-for="run in platform.runs" :key="run.id" class="list-row">
<span>
<strong>{{ run.projectName }} · {{ run.ref }}</strong>
<small>{{ run.environment }} · {{ run.actor }} · {{ run.duration }}</small>
</span>
<StatusPill :state="run.status" />
</div>
</div>
</section>
</div>
<section class="panel">
<div class="panel-header">
<h2>项目环境</h2>
<span>测试/生产状态和集成摘要</span>
</div>
<div class="project-grid panel-body">
<article v-for="project in platform.projects" :key="project.key" class="project-card">
<div class="project-head">
<div>
<strong>{{ project.name }}</strong>
<small>{{ project.serviceType }}</small>
</div>
<ElTag type="info" round>{{ project.defaultBranch }}</ElTag>
</div>
<div class="compact-list">
<div v-for="env in project.environments" :key="env.name" class="env-row">
<span>
<strong>{{ env.label }}</strong>
<small>{{ env.version }} · {{ env.lastReleasedAt }}</small>
</span>
<StatusPill :state="env.status" />
</div>
</div>
</article>
</div>
</section>
</div>
</template>
<style scoped>
.integration-tail {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 5px;
}
.project-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.project-card {
padding: 14px;
border: 1px solid #e3e8f2;
border-radius: 8px;
background: #fbfcff;
}
.project-head,
.env-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.project-head {
margin-bottom: 12px;
}
.project-head strong,
.env-row strong {
display: block;
color: #172033;
font-size: 14px;
}
.project-head small,
.env-row small {
display: block;
margin-top: 3px;
color: #7b8798;
font-size: 12px;
}
.env-row {
padding: 10px;
border-radius: 8px;
background: #ffffff;
}
@media (max-width: 1180px) {
.project-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.project-grid {
grid-template-columns: 1fr;
}
}
</style>
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import StatusPill from '../components/StatusPill.vue'
import { usePlatformStore } from '../stores/platform'
const platform = usePlatformStore()
</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>
<span>{{ platform.dataSource === 'backend' ? '由后端 projects 接口返回' : '当前使用本地 mock 配置' }}</span>
</div>
<div class="panel-body">
<ElTable :data="platform.projects" row-key="key">
<ElTableColumn type="expand">
<template #default="{ row }">
<div class="env-config">
<div v-for="env in row.environments" :key="env.name" class="env-config-card">
<div class="env-config-head">
<strong>{{ env.label }}</strong>
<StatusPill :state="env.status" />
</div>
<dl>
<dt>分支策略</dt>
<dd>{{ env.branchPolicy }}</dd>
<dt>Jenkins Job</dt>
<dd class="mono">{{ env.jenkinsJob }}</dd>
<dt>通知通道</dt>
<dd>{{ env.wecomRobot }}</dd>
<dt>最近发布</dt>
<dd>{{ env.lastRunId }} · {{ env.version }}</dd>
</dl>
</div>
</div>
</template>
</ElTableColumn>
<ElTableColumn prop="name" label="项目" min-width="150" />
<ElTableColumn prop="serviceType" label="类型" min-width="130" />
<ElTableColumn prop="repository" label="Gitea 仓库" min-width="320">
<template #default="{ row }">
<span class="mono">{{ row.repository }}</span>
</template>
</ElTableColumn>
<ElTableColumn prop="defaultBranch" label="默认分支" width="120" />
<ElTableColumn prop="tagPolicy" label="Tag 策略" min-width="220" />
<ElTableColumn label="集成状态" width="280">
<template #default="{ row }">
<div class="status-tags">
<StatusPill :state="row.integrations.jenkins" text="Jenkins" />
<StatusPill :state="row.integrations.gitea" text="Gitea" />
<StatusPill :state="row.integrations.wecom" text="通知" />
<StatusPill :state="row.integrations.llm" text="LLM" />
</div>
</template>
</ElTableColumn>
</ElTable>
</div>
</section>
</div>
</template>
<style scoped>
.env-config {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 14px 46px;
background: #f8fafc;
}
.env-config-card {
padding: 14px;
border: 1px solid #e1e8f2;
border-radius: 8px;
background: #ffffff;
}
.env-config-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
dl {
display: grid;
grid-template-columns: 100px minmax(0, 1fr);
gap: 8px 12px;
margin: 0;
color: #596578;
font-size: 13px;
}
dt {
color: #7b8798;
}
dd {
margin: 0;
word-break: break-word;
}
.status-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
@media (max-width: 760px) {
.env-config {
grid-template-columns: 1fr;
padding: 12px;
}
}
</style>
+360
View File
@@ -0,0 +1,360 @@
<script setup lang="ts">
import { CircleClose, Promotion, RefreshRight, VideoPause } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, reactive, ref, watch } from 'vue'
import BpmnStatusViewer from '../components/BpmnStatusViewer.vue'
import RunExecutionDetails from '../components/RunExecutionDetails.vue'
import StatusPill from '../components/StatusPill.vue'
import { usePlatformStore } from '../stores/platform'
import type { EnvironmentName, ReleaseRun, ReleaseStatus } from '../types/devops'
const platform = usePlatformStore()
const form = reactive({
projectKey: 'access-manage',
environment: 'test' as EnvironmentName,
ref: 'develop',
note: '测试环境部署验证',
confirmed: false,
})
const localStatus = ref<ReleaseStatus>('idle')
const activeRun = ref<ReleaseRun | null>(null)
const submitting = ref(false)
const refsLoading = ref(false)
const selectedProject = computed(() => {
return platform.projects.find((project) => project.key === form.projectKey)
})
const currentRefs = computed(() => platform.projectRefs[form.projectKey])
const environmentOptions = computed(() => {
const environments = selectedProject.value?.environments ?? []
if (!environments.length) {
return [
{ label: '测试环境', value: 'test' },
{ label: '生产环境', value: 'production' },
]
}
return environments.map((environment) => ({
label: environment.label,
value: environment.name,
}))
})
const selectedEnvironment = computed(() => {
return selectedProject.value?.environments.find((environment) => environment.name === form.environment)
})
const useBranchRefs = computed(() => {
return selectedEnvironment.value?.branchPolicy === 'master'
})
const refOptions = computed(() => {
const refs = currentRefs.value
if (form.environment === 'production' && !useBranchRefs.value) {
return refs?.tags.map((tag) => tag.name) ?? ['v2026.06.05-envsplit.7']
}
return refs?.branches.map((branch) => branch.name) ?? [selectedProject.value?.defaultBranch ?? 'develop']
})
const refSourceText = computed(() => {
const source = currentRefs.value?.source
if (source === 'gitea') {
return 'Gitea 实时 refs'
}
if (source === 'fallback') {
return '后端 fallback refs'
}
return '本地 mock refs'
})
const canTrigger = computed(() => {
if (!form.projectKey || !form.environment || !form.ref) {
return false
}
return form.environment === 'production' ? form.confirmed : true
})
const shownRun = computed(() => {
return activeRun.value || platform.runs[0]
})
const canSyncJenkins = computed(() => {
return shownRun.value?.status === 'queued' || shownRun.value?.status === 'running'
})
async function triggerRelease() {
submitting.value = true
try {
const run = await platform.createDeployRun({
projectKey: form.projectKey,
environment: form.environment,
ref: form.ref,
operator: 'ops-admin',
remark: form.note,
idempotencyKey: `${form.projectKey}:${form.environment}:${form.ref}:${Date.now()}`,
})
activeRun.value = run
localStatus.value = run.status
ElMessage.success(platform.dataSource === 'backend' ? '发布单已提交到后端' : '后端不可用,已生成本地发布单')
} finally {
submitting.value = false
}
}
async function cancelRelease() {
if (!shownRun.value) {
return
}
submitting.value = true
try {
const run = await platform.cancelDeployRun(shownRun.value)
activeRun.value = run
localStatus.value = run.status
ElMessage.success('发布单已取消')
} finally {
submitting.value = false
}
}
async function retryRelease() {
if (!shownRun.value) {
return
}
submitting.value = true
try {
const run = await platform.retryDeployRun(shownRun.value)
activeRun.value = run
localStatus.value = run.status
ElMessage.success(platform.dataSource === 'backend' ? '已提交重试发布单' : '后端不可用,已生成本地重试单')
} finally {
submitting.value = false
}
}
async function syncJenkinsRun() {
if (!shownRun.value) {
return
}
submitting.value = true
try {
const run = await platform.syncJenkinsRun(shownRun.value)
activeRun.value = run
localStatus.value = run.status
ElMessage.success(platform.dataSource === 'backend' ? '已同步 Jenkins 状态' : '后端不可用,保留本地状态')
} finally {
submitting.value = false
}
}
async function loadProjectRefs() {
if (!form.projectKey) {
return
}
refsLoading.value = true
try {
await platform.loadProjectRefs(form.projectKey)
} finally {
refsLoading.value = false
}
}
function useFailedRun() {
const failed = platform.failedRuns[0]
if (!failed) {
return
}
form.projectKey = failed.projectKey
form.environment = failed.environment
form.ref = failed.ref
form.note = `重试 ${failed.id}`
form.confirmed = failed.environment !== 'production'
localStatus.value = 'failed'
activeRun.value = failed
}
watch(
() => [form.projectKey, platform.projects.length],
() => {
const firstEnvironment = selectedProject.value?.environments[0]
if (firstEnvironment && !selectedProject.value?.environments.some((env) => env.name === form.environment)) {
form.environment = firstEnvironment.name
}
void loadProjectRefs()
},
{ immediate: true },
)
watch(refOptions, (options) => {
if (options.length > 0 && !options.includes(form.ref)) {
form.ref = options[0]
}
})
</script>
<template>
<div class="page-shell">
<div class="page-header">
<div>
<h1>发布中心</h1>
<p>选择项目环境和 ref/tag填写备注后触发发布生产环境必须完成二次确认</p>
</div>
<div class="toolbar">
<ElButton :icon="RefreshRight" @click="useFailedRun">载入最近失败</ElButton>
<StatusPill :state="localStatus" />
</div>
</div>
<div class="two-column">
<section class="panel">
<div class="panel-header">
<h2>发布参数</h2>
<span>{{ platform.dataSource === 'backend' ? '通过后端创建发布单' : '后端不可用时使用本地降级' }}</span>
</div>
<div class="panel-body">
<ElForm label-position="top">
<div class="form-grid">
<ElFormItem label="项目">
<ElSelect v-model="form.projectKey" filterable>
<ElOption
v-for="project in platform.projects"
:key="project.key"
:label="project.name"
:value="project.key"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="环境">
<ElSegmented
v-model="form.environment"
:options="environmentOptions"
/>
</ElFormItem>
<ElFormItem label="ref / tag">
<ElSelect
v-model="form.ref"
filterable
allow-create
default-first-option
:loading="refsLoading"
>
<ElOption v-for="item in refOptions" :key="item" :label="item" :value="item" />
</ElSelect>
<div class="field-hint">
{{ form.environment === 'production' ? selectedProject?.tagPolicy : selectedProject?.defaultBranch }}
· {{ refSourceText }}
</div>
</ElFormItem>
<ElFormItem label="备注">
<ElInput v-model="form.note" type="textarea" :rows="3" maxlength="120" show-word-limit />
</ElFormItem>
</div>
<ElAlert
v-if="form.environment === 'production'"
class="danger-note"
title="生产发布需要二次确认:已确认 tag、回滚方案、通知对象和业务窗口。"
type="warning"
:closable="false"
/>
<ElCheckbox v-model="form.confirmed" class="confirm-line">
我确认本次发布参数目标环境和通知范围无误
</ElCheckbox>
<div class="action-row">
<ElButton
type="primary"
:icon="Promotion"
:disabled="!canTrigger || submitting"
:loading="submitting"
@click="triggerRelease"
>
触发发布
</ElButton>
<ElButton :icon="VideoPause" :disabled="localStatus !== 'queued' || submitting" @click="cancelRelease">
取消
</ElButton>
<ElButton :icon="CircleClose" :disabled="localStatus !== 'failed' || submitting" @click="retryRelease">
重试
</ElButton>
</div>
</ElForm>
</div>
</section>
<section class="panel">
<div class="panel-header">
<h2>当前发布单</h2>
<div class="toolbar compact-toolbar">
<ElButton
size="small"
:icon="RefreshRight"
:disabled="!canSyncJenkins || submitting"
:loading="submitting && canSyncJenkins"
@click="syncJenkinsRun"
>
同步 Jenkins
</ElButton>
<StatusPill :state="shownRun?.status || 'idle'" />
</div>
</div>
<div class="panel-body" v-if="shownRun">
<RunExecutionDetails :run="shownRun" />
</div>
</section>
</div>
<section class="panel">
<div class="panel-header">
<h2>BPMN 流程状态</h2>
<span>step.bpmnNodeId BPMN XML 节点 id 对齐后高亮</span>
</div>
<div class="panel-body">
<BpmnStatusViewer
v-if="platform.releaseProcessXml && shownRun"
:xml="platform.releaseProcessXml"
:steps="shownRun.steps"
/>
</div>
</section>
</div>
</template>
<style scoped>
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 14px;
}
.confirm-line {
margin-top: 14px;
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 16px;
}
@media (max-width: 760px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { RefreshRight } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue'
import BpmnStatusViewer from '../components/BpmnStatusViewer.vue'
import RunExecutionDetails from '../components/RunExecutionDetails.vue'
import StatusPill from '../components/StatusPill.vue'
import { usePlatformStore } from '../stores/platform'
import type { ReleaseRun } from '../types/devops'
const platform = usePlatformStore()
const selectedRunId = ref(platform.runs[0]?.id || '')
const syncingAll = ref(false)
const selectedRun = computed<ReleaseRun | undefined>(() => {
return platform.runs.find((run) => run.id === selectedRunId.value) || platform.runs[0]
})
const canSyncSelectedRun = computed(() => {
return canSyncRun(selectedRun.value)
})
const canSyncAnyRun = computed(() => platform.runs.some((run) => canSyncRun(run)))
function canSyncRun(run?: ReleaseRun): boolean {
return (
Boolean(run?.jenkinsQueueId || run?.jenkinsBuildNumber) &&
(run?.status === 'queued' || run?.status === 'running')
)
}
async function syncSelectedRun() {
if (!selectedRun.value) {
return
}
const run = await platform.syncJenkinsRun(selectedRun.value)
selectedRunId.value = run.id
ElMessage.success(platform.dataSource === 'backend' ? '已同步 Jenkins 状态' : '后端不可用,保留本地状态')
}
async function syncAllJenkinsRuns() {
syncingAll.value = true
try {
const result = await platform.syncJenkinsRuns()
if (result.failedRuns.length > 0) {
ElMessage.warning(
`已同步 ${result.syncedRuns.length} 个 run${result.failedRuns.length} 个同步失败`,
)
return
}
ElMessage.success(
result.totalCandidates > 0
? `已同步 ${result.syncedRuns.length} 个 Jenkins run`
: '当前没有需要同步的 Jenkins run',
)
} finally {
syncingAll.value = false
}
}
</script>
<template>
<div class="page-shell">
<div class="page-header">
<div>
<h1>运行记录</h1>
<p>查看发布 runJenkins 构建链接耗时失败节点和流程轨迹</p>
</div>
<ElButton
:icon="RefreshRight"
:loading="syncingAll"
:disabled="!canSyncAnyRun"
@click="syncAllJenkinsRuns"
>
同步全部 Jenkins
</ElButton>
</div>
<section class="panel">
<div class="panel-header">
<h2>Run 列表</h2>
<span>点击行切换下方流程图</span>
</div>
<div class="panel-body">
<ElTable
:data="platform.runs"
row-key="id"
highlight-current-row
@row-click="(row: ReleaseRun) => (selectedRunId = row.id)"
>
<ElTableColumn prop="id" label="Run ID" width="110" />
<ElTableColumn prop="projectName" label="项目" min-width="140" />
<ElTableColumn prop="environment" label="环境" width="120" />
<ElTableColumn prop="ref" label="ref/tag" min-width="180">
<template #default="{ row }">
<span class="mono">{{ row.ref }}</span>
</template>
</ElTableColumn>
<ElTableColumn prop="status" label="状态" width="110">
<template #default="{ row }">
<StatusPill :state="row.status" />
</template>
</ElTableColumn>
<ElTableColumn prop="actor" label="操作者" width="130" />
<ElTableColumn prop="duration" label="耗时" width="100" />
<ElTableColumn prop="note" label="备注" min-width="220" />
</ElTable>
</div>
</section>
<section class="panel" v-if="selectedRun">
<div class="panel-header">
<h2>{{ selectedRun.id }} 执行详情</h2>
<div class="toolbar compact-toolbar">
<ElButton
size="small"
:icon="RefreshRight"
:disabled="!canSyncSelectedRun"
@click="syncSelectedRun"
>
同步 Jenkins
</ElButton>
<StatusPill :state="selectedRun.status" />
</div>
</div>
<div class="panel-body">
<RunExecutionDetails :run="selectedRun" />
</div>
</section>
<section class="panel" v-if="selectedRun">
<div class="panel-header">
<h2>{{ selectedRun.id }} 流程轨迹</h2>
<StatusPill :state="selectedRun.status" />
</div>
<div class="panel-body">
<BpmnStatusViewer :xml="platform.releaseProcessXml" :steps="selectedRun.steps" />
</div>
</section>
</div>
</template>
+297
View File
@@ -0,0 +1,297 @@
<script setup lang="ts">
import StatusPill from '../components/StatusPill.vue'
import { usePlatformStore } from '../stores/platform'
import type { IntegrationConfigState } from '../types/devops'
const platform = usePlatformStore()
const configStatusMeta: Record<
IntegrationConfigState,
{ label: string; type: 'success' | 'warning' | 'danger' }
> = {
configured: { label: '已配置', type: 'success' },
partial: { label: '部分配置', type: 'warning' },
missing: { label: '未配置', type: 'danger' },
}
function configStatusLabel(status: IntegrationConfigState): string {
return configStatusMeta[status]?.label ?? '未配置'
}
function configStatusType(
status: IntegrationConfigState,
): 'success' | 'warning' | 'danger' {
return configStatusMeta[status]?.type ?? 'danger'
}
function formatCheckedAt(value: string): string {
if (!value) {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
</script>
<template>
<div class="page-shell">
<div class="page-header">
<div>
<h1>系统配置</h1>
<p>展示后端集成配置状态API 契约来源BPMN 映射Agent 边界和通知维护状态</p>
</div>
</div>
<section class="panel">
<div class="panel-header">
<h2>后端集成配置状态</h2>
<span>检查时间{{ formatCheckedAt(platform.integrationConfig.checkedAt) }}</span>
</div>
<div class="panel-body">
<div class="secret-boundary">
这里只展示环境变量名和配置状态Jenkins tokenGitea token通知 webhookLLM key数据库连接串不会进入浏览器
</div>
<div class="config-list">
<article
v-for="item in platform.integrationConfig.integrations"
:key="item.key"
class="config-row"
>
<div class="config-row-main">
<div>
<h3>{{ item.name }}</h3>
<p v-if="item.note">{{ item.note }}</p>
</div>
<ElTag :type="configStatusType(item.status)" effect="light" round>
{{ configStatusLabel(item.status) }}
</ElTag>
</div>
<div class="variable-grid">
<div class="variable-group">
<strong>必需变量</strong>
<div class="variable-tags">
<ElTag v-for="name in item.required" :key="name" class="mono" effect="plain">
{{ name }}
</ElTag>
</div>
</div>
<div class="variable-group">
<strong>已识别</strong>
<div class="variable-tags">
<ElTag
v-for="name in item.configured"
:key="name"
class="mono"
type="success"
effect="plain"
>
{{ name }}
</ElTag>
<span v-if="item.configured.length === 0" class="empty-value">暂无</span>
</div>
</div>
<div class="variable-group">
<strong>缺失</strong>
<div class="variable-tags">
<ElTag
v-for="name in item.missing"
:key="name"
class="mono"
type="danger"
effect="plain"
>
{{ name }}
</ElTag>
<span v-if="item.missing.length === 0" class="empty-value"></span>
</div>
</div>
<div class="variable-group">
<strong>可选变量</strong>
<div class="variable-tags">
<ElTag
v-for="name in item.optional ?? []"
:key="name"
class="mono"
type="info"
effect="plain"
>
{{ name }}
</ElTag>
<span v-if="!item.optional?.length" class="empty-value">暂无</span>
</div>
</div>
</div>
</article>
</div>
</div>
</section>
<div class="section-grid">
<section v-for="item in platform.settings" :key="item.key" class="panel">
<div class="panel-header">
<h2>{{ item.name }}</h2>
<StatusPill :state="item.status" />
</div>
<div class="panel-body setting-card">
<dl>
<dt>责任方</dt>
<dd>{{ item.owner }}</dd>
<dt>契约</dt>
<dd>{{ item.contract }}</dd>
<dt>边界</dt>
<dd>{{ item.boundary }}</dd>
</dl>
</div>
</section>
</div>
<section class="panel">
<div class="panel-header">
<h2>前端环境变量</h2>
<span>第一阶段仅保留 API 地址</span>
</div>
<div class="panel-body compact-list">
<div class="list-row">
<span>
<strong class="mono">VITE_API_BASE_URL</strong>
<small>联调时指向后端网关未配置时默认 /api</small>
</span>
<ElTag type="info" round>{{ platform.dataSource === 'backend' ? '后端已联通' : '/api 或 mock 降级' }}</ElTag>
</div>
<div class="list-row">
<span>
<strong>密钥策略</strong>
<small>Jenkins tokenGitea token通知 webhookLLM key 均不得进入前端</small>
</span>
<ElTag type="success" round>前端隔离</ElTag>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.setting-card dl {
display: grid;
grid-template-columns: 74px minmax(0, 1fr);
gap: 10px 12px;
margin: 0;
font-size: 13px;
}
.setting-card dt {
color: #7b8798;
}
.setting-card dd {
margin: 0;
color: #172033;
line-height: 1.6;
}
.secret-boundary {
margin-bottom: 14px;
padding: 12px 14px;
border: 1px solid #dbeafe;
border-radius: 8px;
background: #eff6ff;
color: #25508f;
font-size: 13px;
line-height: 1.6;
}
.config-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.config-row {
padding: 14px;
border: 1px solid #edf1f7;
border-radius: 8px;
background: #fbfcff;
}
.config-row-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.config-row-main h3 {
margin: 0;
color: #182033;
font-size: 15px;
font-weight: 700;
letter-spacing: 0;
}
.config-row-main p {
margin: 6px 0 0;
color: #748093;
font-size: 12px;
line-height: 1.5;
}
.variable-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.variable-group {
min-width: 0;
}
.variable-group strong {
display: block;
margin-bottom: 8px;
color: #4b5565;
font-size: 12px;
}
.variable-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 24px;
}
.empty-value {
color: #9aa4b2;
font-size: 12px;
line-height: 24px;
}
@media (max-width: 1080px) {
.variable-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 680px) {
.config-row-main {
flex-direction: column;
}
.variable-grid {
grid-template-columns: 1fr;
}
}
</style>