444 lines
11 KiB
Vue
444 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { Refresh } from '@element-plus/icons-vue'
|
||
import AuditJsonCell from '../components/AuditJsonCell.vue'
|
||
import StatusPill from '../components/StatusPill.vue'
|
||
import { usePlatformStore } from '../stores/platform'
|
||
import type {
|
||
AuditLogEntry,
|
||
IntegrationConfigItem,
|
||
IntegrationConfigState,
|
||
} from '../types/devops'
|
||
|
||
const platform = usePlatformStore()
|
||
type VariableTagType = 'success' | 'warning' | 'danger' | 'info'
|
||
type VariableGroup = {
|
||
key: string
|
||
label: string
|
||
names: string[]
|
||
emptyText: string
|
||
tagType?: VariableTagType
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
function auditActor(record: AuditLogEntry): string {
|
||
return record.actorName || record.actorId || '-'
|
||
}
|
||
|
||
function auditResource(record: AuditLogEntry): string {
|
||
return record.resourceId
|
||
? `${record.resourceType} / ${record.resourceId}`
|
||
: record.resourceType
|
||
}
|
||
|
||
function auditPayload(record: AuditLogEntry): unknown {
|
||
return record.after ?? record.parameterDigest ?? record.before ?? null
|
||
}
|
||
|
||
function variableGroups(item: IntegrationConfigItem): VariableGroup[] {
|
||
return [
|
||
{
|
||
key: 'required',
|
||
label: '必需变量',
|
||
names: item.required,
|
||
emptyText: '暂无',
|
||
},
|
||
{
|
||
key: 'configured',
|
||
label: '已识别',
|
||
names: item.configured,
|
||
emptyText: '暂无',
|
||
tagType: 'success',
|
||
},
|
||
{
|
||
key: 'missing',
|
||
label: '缺失',
|
||
names: item.missing,
|
||
emptyText: '无',
|
||
tagType: 'danger',
|
||
},
|
||
{
|
||
key: 'optional',
|
||
label: '可选变量',
|
||
names: item.optional ?? [],
|
||
emptyText: '暂无',
|
||
tagType: 'info',
|
||
},
|
||
]
|
||
}
|
||
|
||
function variableHelp(item: IntegrationConfigItem, name: string) {
|
||
return item.variableHelp?.[name]
|
||
}
|
||
|
||
function variableDescription(
|
||
item: IntegrationConfigItem,
|
||
name: string,
|
||
group: VariableGroup,
|
||
): string {
|
||
const help = variableHelp(item, name)
|
||
|
||
if (help?.description) {
|
||
return help.description
|
||
}
|
||
|
||
if (item.note) {
|
||
return item.note
|
||
}
|
||
|
||
return `后端暂未返回 ${item.name} ${group.label} ${name} 的变量说明;请在 /settings/integration-config 响应中补充 variableHelp。`
|
||
}
|
||
|
||
function variableSourceLabel(item: IntegrationConfigItem, name: string): string {
|
||
return variableHelp(item, name)?.description ? '后端返回' : '等待后端说明'
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-shell">
|
||
<div class="page-header">
|
||
<div>
|
||
<h1>系统配置</h1>
|
||
<p>展示后端集成配置状态、API 契约来源、流程映射、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 token、Gitea token、通知 webhook、LLM 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
|
||
v-for="group in variableGroups(item)"
|
||
:key="group.key"
|
||
class="variable-group"
|
||
>
|
||
<strong>{{ group.label }}</strong>
|
||
<div class="variable-tags">
|
||
<ElTooltip
|
||
v-for="name in group.names"
|
||
:key="`${group.key}-${name}`"
|
||
effect="light"
|
||
placement="top"
|
||
:show-after="160"
|
||
>
|
||
<template #content>
|
||
<div class="variable-tooltip">
|
||
<strong class="mono">{{ name }}</strong>
|
||
<p>{{ variableDescription(item, name, group) }}</p>
|
||
<small>{{ group.label }} · {{ variableSourceLabel(item, name) }}</small>
|
||
<small v-if="variableHelp(item, name)?.example">
|
||
示例:{{ variableHelp(item, name)?.example }}
|
||
</small>
|
||
<a
|
||
v-if="variableHelp(item, name)?.docUrl"
|
||
:href="variableHelp(item, name)?.docUrl"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
查看官方文档
|
||
</a>
|
||
</div>
|
||
</template>
|
||
<ElTag class="mono variable-tag" :type="group.tagType" effect="plain">
|
||
{{ name }}
|
||
</ElTag>
|
||
</ElTooltip>
|
||
<span v-if="group.names.length === 0" class="empty-value">
|
||
{{ group.emptyText }}
|
||
</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>
|
||
<ElButton :icon="Refresh" size="small" @click="platform.loadAuditLogs()">
|
||
刷新
|
||
</ElButton>
|
||
</div>
|
||
<div class="panel-body">
|
||
<ElTable :data="platform.auditLogs" border>
|
||
<ElTableColumn label="时间" min-width="150">
|
||
<template #default="{ row }">
|
||
{{ formatCheckedAt(row.createdAt) }}
|
||
</template>
|
||
</ElTableColumn>
|
||
<ElTableColumn prop="action" label="动作" min-width="220" />
|
||
<ElTableColumn label="资源" min-width="220">
|
||
<template #default="{ row }">
|
||
<span class="mono">{{ auditResource(row) }}</span>
|
||
</template>
|
||
</ElTableColumn>
|
||
<ElTableColumn label="操作者" min-width="130">
|
||
<template #default="{ row }">
|
||
{{ auditActor(row) }}
|
||
</template>
|
||
</ElTableColumn>
|
||
<ElTableColumn label="脱敏摘要" min-width="420">
|
||
<template #default="{ row }">
|
||
<AuditJsonCell :value="auditPayload(row)" />
|
||
</template>
|
||
</ElTableColumn>
|
||
</ElTable>
|
||
<ElEmpty
|
||
v-if="platform.auditLogs.length === 0"
|
||
description="暂无审计记录"
|
||
:image-size="72"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<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>后端接口</ElTag>
|
||
</div>
|
||
<div class="list-row">
|
||
<span>
|
||
<strong>密钥策略</strong>
|
||
<small>Jenkins token、Gitea token、通知 webhook、LLM 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;
|
||
}
|
||
|
||
.variable-tag {
|
||
cursor: help;
|
||
}
|
||
|
||
.variable-tooltip {
|
||
max-width: 320px;
|
||
}
|
||
|
||
.variable-tooltip strong,
|
||
.variable-tooltip small,
|
||
.variable-tooltip a {
|
||
display: block;
|
||
}
|
||
|
||
.variable-tooltip p {
|
||
margin: 6px 0;
|
||
color: #4b5565;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.variable-tooltip small {
|
||
margin-top: 4px;
|
||
color: #7b8798;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.variable-tooltip a {
|
||
margin-top: 6px;
|
||
color: #1d5fd0;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.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>
|