Files
SEONexusAdmin/src/views/site/bootstrapEnvOverview.vue
Your Name 845d68110b debug
2026-04-15 12:00:09 +08:00

1112 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="bootstrap-overview">
<el-alert
:type="summary.allow_all_risk ? 'warning' : 'success'"
:closable="false"
show-icon
:title="summary.allow_all_risk ? 'Bootstrap allowlist 存在空值风险' : 'Bootstrap allowlist 已显式收口'"
:description="summary.warning || '当前概览基于 .env 的 bootstrap 键实时判断'"
/>
<el-alert
v-if="latestAction()"
:type="latestAction()?.status === 'applied' ? 'warning' : 'info'"
:closable="false"
show-icon
class="latest-action-summary"
:title="(latestAction()?.impact_groups ?? []).map(item => item.label).join(' / ')
? `最近一次环境模板动作命中:${(latestAction()?.impact_groups ?? []).map(item => item.label).join(' / ')}`
: `最近一次环境模板动作:${latestAction()?.template || '-'}`
"
:description="`来源:${latestAction()?.source || '-'};模板:${latestAction()?.template || '-'};状态:${latestAction()?.status || '-'};时间:${latestAction()?.action_at || '-'};影响门禁:${(latestAction()?.impact_groups ?? []).map(item => `${item.label} ${item.count}`).join(' / ') || '无'}`"
/>
<el-alert
v-if="latestAction() && gateCards().some(item => item.affectedByLatestAction)"
type="warning"
:closable="false"
show-icon
class="latest-action-conclusion"
:title="latestAction()?.status === 'applied' ? '最近一次 apply 后当前值班结论' : '最近一次预览后的当前值班结论'"
:description="gateCards()
.filter(item => item.affectedByLatestAction)
.map(item => `${item.label}${item.status}`)
.join('')"
/>
<el-alert
v-if="latestActionDeltaState() && (latestActionDeltaState().changedCards.length > 0 || latestActionDeltaState().affectedCards.length > 0)"
:type="latestActionDeltaState().changedCards.length > 0 ? 'error' : 'success'"
:closable="false"
show-icon
class="latest-action-delta"
:title="latestAction()?.status === 'applied'
? (latestActionDeltaState().changedCards.length > 0 ? '最近一次 apply 带来的门禁状态变化' : '最近一次 apply 未改变门禁状态')
: (latestActionDeltaState().changedCards.length > 0 ? '最近一次预览后的门禁变化推演' : '最近一次预览未改变门禁状态')
"
:description="(
latestActionDeltaState().changedCards.length > 0
? latestActionDeltaState().changedCards
: latestActionDeltaState().affectedCards
)
.map(item => (item.changed ? `${item.label}${item.beforeStatus} -> ${item.afterStatus}` : `${item.label}:保持 ${item.afterStatus}`))
.join('')"
/>
<el-alert
v-if="latestActionRiskDeltaState() && latestActionRiskDeltaState().total > 0"
:type="!latestActionRiskDeltaState() ? 'info' : latestActionRiskDeltaState().movedToRisk > 0 ? 'error' : latestActionRiskDeltaState().movedToSafe > 0 ? 'success' : 'info'"
:closable="false"
show-icon
class="latest-action-risk-delta"
:title="latestAction()?.status === 'applied'
? '最近一次 apply 的风险变化数量摘要'
: '最近一次预览的风险变化数量摘要'
"
:description="latestActionRiskDeltaState()
? `共影响 ${latestActionRiskDeltaState().total} 组门禁;${latestActionRiskDeltaState().movedToRisk} 组变风险,${latestActionRiskDeltaState().movedToSafe} 组回安全,${latestActionRiskDeltaState().unchanged} 组保持不变。`
: ''
"
/>
<div v-if="riskDeltaCards().length > 0" class="risk-delta-grid">
<button
v-for="card in riskDeltaCards()"
:key="card.label"
type="button"
class="risk-delta-card"
:class="`risk-delta-card--${card.state}`"
@click="$emit('open-dialog', card.focusModes as OverviewDialogPayload)"
>
<div class="risk-delta-card__label">{{ card.label }}</div>
<div class="risk-delta-card__value">{{ card.value }}</div>
<div class="risk-delta-card__meta">{{ card.meta }}</div>
</button>
</div>
<div class="overview-grid">
<div class="overview-card overview-card--primary">
<div class="overview-card__label">当前环境</div>
<div class="overview-card__value">{{ summary.current_profile.closest_template_name || "-" }}</div>
<div class="overview-card__meta">
{{ summary.current_profile.exact_match ? "已完整匹配模板" : "当前最接近模板" }}
</div>
<div class="overview-card__tags">
<el-tag :type="summary.current_profile.exact_match ? 'success' : 'warning'">
{{ summary.current_profile.drift_count ?? 0 }} 偏移
</el-tag>
<el-tag type="info">{{ summary.current_profile.matched_key_count ?? 0 }} 匹配</el-tag>
</div>
</div>
<button
v-for="card in gateCards()"
:key="card.label"
type="button"
class="overview-card"
:class="{ 'overview-card--affected': card.affectedByLatestAction }"
@click="$emit('open-dialog', card.key)"
>
<div class="overview-card__label">{{ card.label }}</div>
<div class="overview-card__value">
<el-tag :type="card.type">{{ card.status }}</el-tag>
</div>
<div class="overview-card__meta">{{ card.description }}</div>
<div v-if="card.affectedByLatestAction" class="overview-card__impact">
<el-tag size="small" type="danger" effect="plain">
最近动作命中
</el-tag>
<span class="overview-card__impact-text">
{{ latestAction()?.source || "-" }} / {{ latestAction()?.status || "-" }}
</span>
</div>
</button>
</div>
<div class="overview-actions">
<el-button :loading="loading" @click="loadSummary">刷新概览</el-button>
<el-button type="warning" @click="$emit('open-dialog')">{{ actionText }}</el-button>
</div>
<el-card v-if="latestAction()" shadow="never" class="latest-action-card">
<template #header>
<div class="latest-action-card__header">
<span>最近一次环境模板动作</span>
<el-tag size="small" :type="latestAction()?.status === 'applied' ? 'warning' : 'info'">
{{ latestAction()?.status }}
</el-tag>
</div>
</template>
<div class="latest-action-card__meta">
<span>来源{{ latestAction()?.source || "-" }}</span>
<span>模板{{ latestAction()?.template || "-" }}</span>
<span>时间{{ latestAction()?.action_at || "-" }}</span>
</div>
<div class="latest-action-card__groups">
<el-tag v-for="item in latestAction()?.impact_groups || []" :key="item.group" size="small" effect="plain">
{{ item.label }} {{ item.count }}
</el-tag>
</div>
</el-card>
<el-card
v-if="summary.action_history_summary.total_count > 0"
shadow="never"
class="history-summary-card"
>
<template #header>
<div class="latest-action-card__header">
<span>环境模板动作历史摘要</span>
<el-tag size="small" type="info">{{ summary.action_history_summary.total_count }} </el-tag>
</div>
</template>
<div class="history-summary-grid">
<div class="history-summary-metric">
<div class="history-summary-metric__label">总动作</div>
<div class="history-summary-metric__value">{{ summary.action_history_summary.total_count }}</div>
</div>
<div class="history-summary-metric">
<div class="history-summary-metric__label">正式应用</div>
<div class="history-summary-metric__value">{{ summary.action_history_summary.applied_count }}</div>
</div>
<div class="history-summary-metric">
<div class="history-summary-metric__label">Dry-run</div>
<div class="history-summary-metric__value">{{ summary.action_history_summary.dry_run_count }}</div>
</div>
<div class="history-summary-metric">
<div class="history-summary-metric__label">恢复预览</div>
<div class="history-summary-metric__value">{{ summary.action_history_summary.restore_preview_count || 0 }}</div>
</div>
<div class="history-summary-metric">
<div class="history-summary-metric__label">恢复应用</div>
<div class="history-summary-metric__value">{{ summary.action_history_summary.restore_apply_count || 0 }}</div>
</div>
</div>
<div class="latest-action-card__meta">
<span>最近时间{{ summary.action_history_summary.latest_action_at || "-" }}</span>
<span>
热点分组{{
(summary.action_history_summary.group_buckets ?? [])
.slice(0, 3)
.map(item => `${item.label} ${item.count}`)
.join(" / ") || "-"
}}
</span>
<span>
热点来源{{
(summary.action_history_summary.source_buckets ?? [])
.slice(0, 2)
.map(item => `${item.label} ${item.count}`)
.join(" / ") || "-"
}}
</span>
</div>
<div class="latest-action-card__groups">
<el-tag
v-for="item in (summary.action_history_summary.action_kind_buckets || []).slice(0, 4)"
:key="item.key"
size="small"
effect="plain"
>
{{ item.label }} {{ item.count }}
</el-tag>
</div>
<div
v-if="(
summary.action_history ?? []
).filter(item => ['template_preview', 'template_apply'].includes(item.action_kind || (item.status === 'applied' ? 'template_apply' : 'template_preview')) ||
['history_restore_preview', 'history_restore_apply'].includes(item.action_kind || '')
).length > 0"
class="compare-quick-grid"
>
<button
v-for="card in [
...(
(summary.action_history ?? [])
.filter(item => ['template_preview', 'template_apply'].includes(item.action_kind || (item.status === 'applied' ? 'template_apply' : 'template_preview')))
.length > 0
? [{
key: 'template_family',
label: '模板动作家族',
value: `${(summary.action_history ?? []).filter(item => ['template_preview', 'template_apply'].includes(item.action_kind || (item.status === 'applied' ? 'template_apply' : 'template_preview'))).length} 条`,
meta: `preview ${summary.action_history_summary.template_preview_count || 0} / apply ${summary.action_history_summary.template_apply_count || 0}`,
preset: 'template_family' as HistoryActionPreset,
focusModes: (() => {
const focusModes = Array.from(
new Set(
(summary.action_history ?? [])
.filter(item => ['template_preview', 'template_apply'].includes(item.action_kind || (item.status === 'applied' ? 'template_apply' : 'template_preview')))
.flatMap(item => (item.impact_groups ?? []).map(group => group.group))
.filter((item): item is OverviewFocusMode => compareFocusModeAllowlist.has(item as OverviewFocusMode))
)
) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : ('all' as OverviewFocusPayload);
})()
}]
: []
),
...(
(summary.action_history ?? [])
.filter(item => ['history_restore_preview', 'history_restore_apply'].includes(item.action_kind || ''))
.length > 0
? [{
key: 'restore_family',
label: '恢复动作家族',
value: `${(summary.action_history ?? []).filter(item => ['history_restore_preview', 'history_restore_apply'].includes(item.action_kind || '')).length} 条`,
meta: `preview ${summary.action_history_summary.restore_preview_count || 0} / apply ${summary.action_history_summary.restore_apply_count || 0}`,
preset: 'restore_family' as HistoryActionPreset,
focusModes: (() => {
const focusModes = Array.from(
new Set(
(summary.action_history ?? [])
.filter(item => ['history_restore_preview', 'history_restore_apply'].includes(item.action_kind || ''))
.flatMap(item => (item.impact_groups ?? []).map(group => group.group))
.filter((item): item is OverviewFocusMode => compareFocusModeAllowlist.has(item as OverviewFocusMode))
)
) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : ('all' as OverviewFocusPayload);
})()
}]
: []
),
...(
(summary.action_history_summary.restore_preview_count || 0) > 0
? [{
key: 'restore_preview_only',
label: '只看恢复预览',
value: `${summary.action_history_summary.restore_preview_count || 0} 条`,
meta: '专看恢复 dry-run 动作',
preset: 'restore_preview_only' as HistoryActionPreset,
focusModes: (() => {
const focusModes = Array.from(
new Set(
(summary.action_history ?? [])
.filter(item => item.action_kind === 'history_restore_preview')
.flatMap(item => (item.impact_groups ?? []).map(group => group.group))
.filter((item): item is OverviewFocusMode => compareFocusModeAllowlist.has(item as OverviewFocusMode))
)
) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : ('all' as OverviewFocusPayload);
})()
}]
: []
),
...(
(summary.action_history_summary.restore_apply_count || 0) > 0
? [{
key: 'restore_apply_only',
label: '只看恢复应用',
value: `${summary.action_history_summary.restore_apply_count || 0} 条`,
meta: '专看恢复正式应用动作',
preset: 'restore_apply_only' as HistoryActionPreset,
focusModes: (() => {
const focusModes = Array.from(
new Set(
(summary.action_history ?? [])
.filter(item => item.action_kind === 'history_restore_apply')
.flatMap(item => (item.impact_groups ?? []).map(group => group.group))
.filter((item): item is OverviewFocusMode => compareFocusModeAllowlist.has(item as OverviewFocusMode))
)
) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : ('all' as OverviewFocusPayload);
})()
}]
: []
)
]"
:key="card.key"
type="button"
class="compare-quick-card compare-quick-card--history-family"
@click="$emit('open-dialog', { historyActionPreset: card.preset, focusMode: card.focusModes })"
>
<div class="compare-quick-card__title">{{ card.label }}</div>
<div class="compare-quick-card__value">{{ card.value }}</div>
<div class="compare-quick-card__meta">{{ card.meta }}</div>
</button>
</div>
<div v-if="compareQuickCards().length > 0" class="compare-quick-grid">
<button
v-for="card in compareQuickCards()"
:key="card.key"
type="button"
class="compare-quick-card"
@click="$emit('open-dialog', { comparePreset: card.preset, focusMode: card.focusModes })"
>
<div class="compare-quick-card__title">{{ card.label }}</div>
<div class="compare-quick-card__value">{{ card.value }}</div>
<div class="compare-quick-card__meta">{{ card.meta }}</div>
</button>
</div>
<div v-if="(summary.action_history ?? []).find(item => item.restore_supported)" class="compare-quick-grid">
<button
v-for="card in ([
...((() => {
const latestRestorable = (summary.action_history ?? []).find(item => item.restore_supported);
if (!latestRestorable) return [];
return (['after', 'before'] as Array<'after' | 'before'>)
.map(side => {
const preview = latestRestorable.restore_preview?.[side];
if (!preview) return null;
return {
key: side === 'after' ? 'latest_restore_after_preview' : 'latest_restore_before_preview',
label: side === 'after' ? '恢复最近动作后态' : '恢复最近动作前态',
value: `${preview.changed_count ?? 0} 项`,
meta: `${latestRestorable.template || '-'} / ${(preview.impact_groups ?? []).map(item => `${item.label} ${item.count}`).join(' / ') || '无门禁变化'}`,
preset: (side === 'after' ? 'latest_restore_after_preview' : 'latest_restore_before_preview') as RestorePreset,
focusModes: (() => {
const focusModes = Array.from(
new Set(
(preview.impact_groups ?? [])
.map(item => item.group)
.filter((item): item is OverviewFocusMode => compareFocusModeAllowlist.has(item as OverviewFocusMode))
)
) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : (['all'] as OverviewFocusPayload);
})()
};
})
.filter((item): item is NonNullable<typeof item> => !!item);
})())
])"
:key="card.key"
type="button"
class="compare-quick-card compare-quick-card--restore"
@click="$emit('open-dialog', { restorePreset: card.preset, focusMode: card.focusModes })"
>
<div class="compare-quick-card__title">{{ card.label }}</div>
<div class="compare-quick-card__value">{{ card.value }}</div>
<div class="compare-quick-card__meta">{{ card.meta }}</div>
</button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { SiteList } from "@/api/interface/site/list";
import { getBootstrapEnvTemplateList } from "@/api/modules/site/list";
withDefaults(
defineProps<{
actionText?: string;
}>(),
{
actionText: "打开环境模板面板"
}
);
type OverviewFocusMode = "all" | "task" | "oncall" | "pipeline" | "import" | "release";
type OverviewFocusPayload = OverviewFocusMode | OverviewFocusMode[];
type ComparePreset =
| "latest_two"
| "latest_applied_vs_dry_run"
| "latest_template_two"
| "latest_template_apply_vs_preview"
| "latest_restore_two"
| "latest_restore_apply_vs_preview";
type RestorePreset = "latest_restore_after_preview" | "latest_restore_before_preview";
type HistoryActionPreset = "template_family" | "restore_family" | "restore_preview_only" | "restore_apply_only";
type OverviewDialogPayload =
| OverviewFocusPayload
| {
focusMode?: OverviewFocusPayload;
comparePreset?: ComparePreset;
restorePreset?: RestorePreset;
historyActionPreset?: HistoryActionPreset;
};
defineEmits<{
(e: "open-dialog", payload?: OverviewDialogPayload): void;
}>();
type GateTagType = "success" | "warning" | "info" | "primary" | "danger";
type GateCard = {
key: "task" | "import" | "oncall" | "release";
label: string;
status: string;
description: string;
type: GateTagType;
affectedByLatestAction: boolean;
};
type GateKey = GateCard["key"];
const loading = ref(false);
const summary = ref<SiteList.BootstrapEnvTemplateListData>({
env_file: "",
warning: "",
allow_all_risk: false,
templates: [],
current_values: [],
current_profile: {
closest_template_key: "",
closest_template_name: "",
exact_match: false,
drift_count: 0,
matched_key_count: 0,
drift_items: [],
label: ""
},
latest_action: {},
action_history: [],
action_history_summary: {
total_count: 0,
applied_count: 0,
dry_run_count: 0,
restore_supported_count: 0,
template_preview_count: 0,
template_apply_count: 0,
restore_preview_count: 0,
restore_apply_count: 0,
latest_action_at: "",
status_buckets: [],
group_buckets: [],
source_buckets: [],
template_buckets: [],
action_kind_buckets: []
}
});
const currentValueMap = () =>
summary.value.current_values.reduce<Record<string, string>>((acc, item) => {
acc[item.key] = item.value ?? "";
return acc;
}, {});
const latestAction = () => {
const action = summary.value.latest_action;
if (!action || !("status" in action)) {
return null;
}
return action as SiteList.BootstrapEnvTemplateActionData;
};
const compareFocusModeAllowlist = new Set<OverviewFocusMode>(["task", "oncall", "pipeline", "import", "release"]);
const compareQuickCards = () => {
const history = summary.value.action_history ?? [];
const cards: Array<{
key: string;
label: string;
value: string;
meta: string;
preset: ComparePreset;
focusModes: OverviewFocusPayload;
}> = [];
if (history.length >= 2) {
const latest = history[0];
const previous = history[1];
const groups = Array.from(
new Set([...(latest.impact_groups ?? []).map(item => item.group), ...(previous.impact_groups ?? []).map(item => item.group)])
);
const diffValue = `${latest.changed_count ?? 0} vs ${previous.changed_count ?? 0}`;
cards.push({
key: "latest_two",
label: "最近两次动作对比",
value: diffValue,
meta: `${latest.template || "-"} / ${previous.template || "-"};命中 ${groups.length || 0} 组门禁`,
preset: "latest_two",
focusModes: (() => {
const focusModes = Array.from(
new Set(groups.filter((item): item is OverviewFocusMode => compareFocusModeAllowlist.has(item as OverviewFocusMode)))
) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : ("all" as OverviewFocusPayload);
})()
});
}
const latestApplied = history.find(item => item.status === "applied");
const latestDryRun = history.find(item => item.status === "dry_run");
if (latestApplied && latestDryRun) {
const groups = Array.from(
new Set([...(latestApplied.impact_groups ?? []).map(item => item.group), ...(latestDryRun.impact_groups ?? []).map(item => item.group)])
);
cards.push({
key: "latest_applied_vs_dry_run",
label: "正式应用 vs Dry-run",
value: `${latestApplied.changed_count ?? 0} vs ${latestDryRun.changed_count ?? 0}`,
meta: `${latestApplied.template || "-"} / ${latestDryRun.template || "-"};命中 ${groups.length || 0} 组门禁`,
preset: "latest_applied_vs_dry_run",
focusModes: (() => {
const focusModes = Array.from(
new Set(groups.filter((item): item is OverviewFocusMode => compareFocusModeAllowlist.has(item as OverviewFocusMode)))
) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : ("all" as OverviewFocusPayload);
})()
});
}
const templateHistory = history.filter(item =>
["template_preview", "template_apply"].includes(item.action_kind || (item.status === "applied" ? "template_apply" : "template_preview"))
);
if (templateHistory.length >= 2) {
const latest = templateHistory[0];
const previous = templateHistory[1];
const groups = Array.from(
new Set([...(latest.impact_groups ?? []).map(item => item.group), ...(previous.impact_groups ?? []).map(item => item.group)])
);
cards.push({
key: "latest_template_two",
label: "最近两次模板动作对比",
value: `${latest.changed_count ?? 0} vs ${previous.changed_count ?? 0}`,
meta: `${latest.template || "-"} / ${previous.template || "-"};命中 ${groups.length || 0} 组门禁`,
preset: "latest_template_two",
focusModes: (() => {
const focusModes = Array.from(
new Set(groups.filter((item): item is OverviewFocusMode => compareFocusModeAllowlist.has(item as OverviewFocusMode)))
) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : ("all" as OverviewFocusPayload);
})()
});
}
const latestTemplateApply = history.find(item => (item.action_kind || (item.status === "applied" ? "template_apply" : "template_preview")) === "template_apply");
const latestTemplatePreview = history.find(item => (item.action_kind || (item.status === "applied" ? "template_apply" : "template_preview")) === "template_preview");
if (latestTemplateApply && latestTemplatePreview) {
const groups = Array.from(
new Set([...(latestTemplateApply.impact_groups ?? []).map(item => item.group), ...(latestTemplatePreview.impact_groups ?? []).map(item => item.group)])
);
cards.push({
key: "latest_template_apply_vs_preview",
label: "模板应用 vs 模板预览",
value: `${latestTemplateApply.changed_count ?? 0} vs ${latestTemplatePreview.changed_count ?? 0}`,
meta: `${latestTemplateApply.template || "-"} / ${latestTemplatePreview.template || "-"};命中 ${groups.length || 0} 组门禁`,
preset: "latest_template_apply_vs_preview",
focusModes: (() => {
const focusModes = Array.from(
new Set(groups.filter((item): item is OverviewFocusMode => compareFocusModeAllowlist.has(item as OverviewFocusMode)))
) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : ("all" as OverviewFocusPayload);
})()
});
}
const restoreHistory = history.filter(item =>
["history_restore_preview", "history_restore_apply"].includes(item.action_kind || "")
);
if (restoreHistory.length >= 2) {
const latest = restoreHistory[0];
const previous = restoreHistory[1];
const groups = Array.from(
new Set([...(latest.impact_groups ?? []).map(item => item.group), ...(previous.impact_groups ?? []).map(item => item.group)])
);
cards.push({
key: "latest_restore_two",
label: "最近两次恢复动作对比",
value: `${latest.changed_count ?? 0} vs ${previous.changed_count ?? 0}`,
meta: `${latest.template || "-"} / ${previous.template || "-"};命中 ${groups.length || 0} 组门禁`,
preset: "latest_restore_two",
focusModes: (() => {
const focusModes = Array.from(
new Set(groups.filter((item): item is OverviewFocusMode => compareFocusModeAllowlist.has(item as OverviewFocusMode)))
) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : ("all" as OverviewFocusPayload);
})()
});
}
const latestRestoreApply = history.find(item => item.action_kind === "history_restore_apply");
const latestRestorePreview = history.find(item => item.action_kind === "history_restore_preview");
if (latestRestoreApply && latestRestorePreview) {
const groups = Array.from(
new Set([...(latestRestoreApply.impact_groups ?? []).map(item => item.group), ...(latestRestorePreview.impact_groups ?? []).map(item => item.group)])
);
cards.push({
key: "latest_restore_apply_vs_preview",
label: "恢复应用 vs 恢复预览",
value: `${latestRestoreApply.changed_count ?? 0} vs ${latestRestorePreview.changed_count ?? 0}`,
meta: `${latestRestoreApply.template || "-"} / ${latestRestorePreview.template || "-"};命中 ${groups.length || 0} 组门禁`,
preset: "latest_restore_apply_vs_preview",
focusModes: (() => {
const focusModes = Array.from(
new Set(groups.filter((item): item is OverviewFocusMode => compareFocusModeAllowlist.has(item as OverviewFocusMode)))
) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : ("all" as OverviewFocusPayload);
})()
});
}
return cards;
};
const impactedGroups = () => new Set((latestAction()?.impact_groups ?? []).map(item => item.group));
const buildGateCards = (valueMap: Record<string, string>, affectedGroups: Set<string>): GateCard[] => {
const allowlist = (valueMap.PLAN_TASK_CODE_ALLOWLIST ?? "").trim();
const oncallPrepare = ["1", "true", "yes", "on"].includes((valueMap.SEO_COPY_BOOTSTRAP_ONCALL_ALLOW_PREPARE ?? "").trim().toLowerCase());
const pipelineAllowImport = ["1", "true", "yes", "on"].includes((valueMap.SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_IMPORT ?? "").trim().toLowerCase());
const pipelineImportArmed = ["1", "true", "yes", "on"].includes((valueMap.SEO_COPY_BOOTSTRAP_PIPELINE_IMPORT_ARMED ?? "").trim().toLowerCase());
const pipelineAiPath =
(valueMap.SEO_COPY_BOOTSTRAP_PIPELINE_AI_RESULT_DIR ?? "").trim() ||
(valueMap.SEO_COPY_BOOTSTRAP_PIPELINE_AI_RESULT_ROOT ?? "").trim();
const pipelineRelease = ["1", "true", "yes", "on"].includes((valueMap.SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_RELEASE_DRY_RUN ?? "").trim().toLowerCase());
const oncallPublish = ["1", "true", "yes", "on"].includes((valueMap.SEO_COPY_BOOTSTRAP_ONCALL_ALLOW_PUBLISH ?? "").trim().toLowerCase());
const pipelinePublish = ["1", "true", "yes", "on"].includes((valueMap.SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_PUBLISH ?? "").trim().toLowerCase());
return [
{
key: "task",
label: "任务放行",
status: allowlist === "" ? "空 allowlist 风险" : "已显式收口",
description: allowlist === "" ? "当前空值会放行所有已启用任务" : allowlist,
type: (allowlist === "" ? "danger" : "success") as GateTagType,
affectedByLatestAction: affectedGroups.has("task")
},
{
key: "import",
label: "AI 导入",
status: !pipelineAllowImport ? "off" : pipelineImportArmed ? "armed" : "requested-not-armed",
description: !pipelineAllowImport
? "当前不允许 pipeline 执行 AI 导入"
: pipelineImportArmed
? `已武装${pipelineAiPath ? `,来源:${pipelineAiPath}` : ""}`
: "已请求导入,但尚未 armed",
type: (!pipelineAllowImport ? "info" : pipelineImportArmed ? "warning" : "danger") as GateTagType,
affectedByLatestAction: affectedGroups.has("import")
},
{
key: "oncall",
label: "Prepare 门禁",
status: oncallPrepare ? "已放行" : "默认关闭",
description: oncallPrepare ? "oncall 可继续推进 prepare" : "当前仍需显式放行 prepare",
type: (oncallPrepare ? "warning" : "info") as GateTagType,
affectedByLatestAction: affectedGroups.has("oncall") || affectedGroups.has("pipeline")
},
{
key: "release",
label: "发布门禁",
status: oncallPublish || pipelinePublish ? "存在 publish 放行" : pipelineRelease ? "仅放行 dry-run" : "默认关闭",
description: oncallPublish || pipelinePublish ? "至少一条链已允许 publish" : pipelineRelease ? "当前只允许 release dry-run" : "release dry-run 与 publish 都未放行",
type: (oncallPublish || pipelinePublish ? "danger" : pipelineRelease ? "warning" : "success") as GateTagType,
affectedByLatestAction: affectedGroups.has("release")
}
];
};
const gateCards = () => buildGateCards(currentValueMap(), impactedGroups());
const latestActionDeltaState = () => {
if (!latestAction()) {
return {
affectedCards: [],
changedCards: []
};
}
const beforeMap = { ...currentValueMap() };
const afterMap = { ...currentValueMap() };
for (const change of latestAction()?.changes ?? []) {
if (latestAction()?.status === "applied") {
beforeMap[change.key] = change.old ?? "";
afterMap[change.key] = currentValueMap()[change.key] ?? change.new ?? "";
} else {
beforeMap[change.key] = currentValueMap()[change.key] ?? change.old ?? "";
afterMap[change.key] = change.new ?? "";
}
}
const beforeCards = buildGateCards(beforeMap, impactedGroups());
const afterCards = buildGateCards(afterMap, impactedGroups());
const affectedCards = afterCards
.filter(item => item.affectedByLatestAction)
.map(item => {
const before = beforeCards.find(card => card.key === item.key);
return {
key: item.key,
label: item.label,
beforeStatus: before?.status || "-",
afterStatus: item.status,
changed: (before?.status || "-") !== item.status
};
});
return {
affectedCards,
changedCards: affectedCards.filter(item => item.changed)
};
};
const latestActionRiskDeltaState = () => {
const beforeMap = { ...currentValueMap() };
const afterMap = { ...currentValueMap() };
for (const change of latestAction()?.changes ?? []) {
if (latestAction()?.status === "applied") {
beforeMap[change.key] = change.old ?? "";
afterMap[change.key] = currentValueMap()[change.key] ?? change.new ?? "";
} else {
beforeMap[change.key] = currentValueMap()[change.key] ?? change.old ?? "";
afterMap[change.key] = change.new ?? "";
}
}
const beforeCards = buildGateCards(beforeMap, impactedGroups());
const afterCards = buildGateCards(afterMap, impactedGroups());
const affectedCards = afterCards
.filter(item => item.affectedByLatestAction)
.map(item => {
const before = beforeCards.find(card => card.key === item.key);
const beforeRisk = before ? ["warning", "danger"].includes(before.type) : false;
const afterRisk = ["warning", "danger"].includes(item.type);
return {
key: item.key,
label: item.label,
beforeRisk,
afterRisk
};
});
const movedToRiskCards = affectedCards.filter(item => !item.beforeRisk && item.afterRisk);
const movedToSafeCards = affectedCards.filter(item => item.beforeRisk && !item.afterRisk);
const unchangedCards = affectedCards.filter(item => item.beforeRisk === item.afterRisk);
return {
total: affectedCards.length,
movedToRisk: movedToRiskCards.length,
movedToSafe: movedToSafeCards.length,
unchanged: unchangedCards.length,
affectedCards,
movedToRiskCards,
movedToSafeCards,
unchangedCards
};
};
const riskDeltaCards = () => {
const state = latestActionRiskDeltaState();
if (!state || state.total === 0) {
return [];
}
return [
{
label: "受影响门禁",
value: `${state.total} 组`,
meta:
state.affectedCards.length > 0
? `命中:${state.affectedCards.map(item => gateCards().find(card => card.key === item.key)?.label || item.label).join("、")}`
: "最近一次动作命中的门禁总数",
state: "neutral",
focusModes: (() => {
const focusModes = Array.from(new Set(state.affectedCards.map(item => item.key))) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : (["all"] as OverviewFocusPayload);
})()
},
{
label: "变风险",
value: `${state.movedToRisk} 组`,
meta:
state.movedToRisk > 0
? `新增风险:${state.movedToRiskCards.map(item => gateCards().find(card => card.key === item.key)?.label || item.label).join("、")}`
: "本轮没有新增风险",
state: state.movedToRisk > 0 ? "danger" : "safe",
focusModes: (() => {
const focusModes = Array.from(new Set(state.movedToRiskCards.map(item => item.key))) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : (["all"] as OverviewFocusPayload);
})()
},
{
label: "回安全",
value: `${state.movedToSafe} 组`,
meta:
state.movedToSafe > 0
? `回安全:${state.movedToSafeCards.map(item => gateCards().find(card => card.key === item.key)?.label || item.label).join("、")}`
: "本轮没有回安全项",
state: state.movedToSafe > 0 ? "success" : "neutral",
focusModes: (() => {
const focusModes = Array.from(new Set(state.movedToSafeCards.map(item => item.key))) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : (["all"] as OverviewFocusPayload);
})()
},
{
label: "保持不变",
value: `${state.unchanged} 组`,
meta:
state.unchangedCards.length > 0
? `保持不变:${state.unchangedCards.map(item => gateCards().find(card => card.key === item.key)?.label || item.label).join("、")}`
: "命中但状态未发生变化",
state: "neutral",
focusModes: (() => {
const focusModes = Array.from(new Set(state.unchangedCards.map(item => item.key))) as OverviewFocusMode[];
return focusModes.length > 0 ? focusModes : (["all"] as OverviewFocusPayload);
})()
}
];
};
const loadSummary = async () => {
loading.value = true;
try {
const res = await getBootstrapEnvTemplateList();
summary.value = res.data;
} finally {
loading.value = false;
}
};
onMounted(() => {
void loadSummary();
});
</script>
<style scoped lang="scss">
.bootstrap-overview {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.risk-delta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.risk-delta-card {
border: 1px solid var(--el-border-color);
border-radius: 12px;
background: var(--el-bg-color);
padding: 14px 16px;
text-align: left;
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.risk-delta-card:hover {
border-color: var(--el-color-primary-light-5);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04);
transform: translateY(-1px);
}
.risk-delta-card__label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.risk-delta-card__value {
margin-top: 8px;
font-size: 22px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.risk-delta-card__meta {
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-regular);
}
.risk-delta-card--danger {
border-color: var(--el-color-danger-light-5);
background: linear-gradient(135deg, var(--el-color-danger-light-9), var(--el-bg-color));
}
.risk-delta-card--success {
border-color: var(--el-color-success-light-5);
background: linear-gradient(135deg, var(--el-color-success-light-9), var(--el-bg-color));
}
.risk-delta-card--safe {
border-color: var(--el-color-success-light-7);
}
.latest-action-summary {
margin-top: -4px;
}
.latest-action-conclusion {
margin-top: -8px;
}
.latest-action-delta,
.latest-action-risk-delta {
margin-top: -8px;
}
.overview-card {
border: 1px solid var(--el-border-color);
border-radius: 12px;
background: var(--el-bg-color);
padding: 16px;
text-align: left;
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.overview-card:hover {
border-color: var(--el-color-primary-light-5);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04);
transform: translateY(-1px);
}
.overview-card--primary {
cursor: default;
background: linear-gradient(135deg, var(--el-color-primary-light-9), var(--el-bg-color));
}
.overview-card--primary:hover {
transform: none;
box-shadow: none;
border-color: var(--el-border-color);
}
.overview-card__label {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.overview-card__value {
margin-top: 10px;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.overview-card__meta {
margin-top: 10px;
min-height: 36px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-regular);
word-break: break-all;
}
.overview-card__tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.overview-card__impact {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.overview-card__impact-text {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.overview-card--affected {
border-color: var(--el-color-danger-light-5);
box-shadow: 0 0 0 1px var(--el-color-danger-light-8) inset;
}
.overview-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.latest-action-card :deep(.el-card__body) {
display: flex;
flex-direction: column;
gap: 10px;
}
.latest-action-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.latest-action-card__meta {
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 13px;
color: var(--el-text-color-regular);
}
.latest-action-card__groups {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.history-summary-card :deep(.el-card__body) {
display: flex;
flex-direction: column;
gap: 14px;
}
.history-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
.history-summary-metric {
border: 1px solid var(--el-border-color-light);
border-radius: 10px;
padding: 12px;
background: var(--el-fill-color-lighter);
}
.history-summary-metric__label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.history-summary-metric__value {
margin-top: 8px;
font-size: 24px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.compare-quick-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.compare-quick-card {
border: 1px solid var(--el-border-color);
border-radius: 12px;
background: var(--el-bg-color-page);
padding: 14px;
text-align: left;
cursor: pointer;
transition:
border-color 0.2s ease,
transform 0.2s ease;
}
.compare-quick-card:hover {
border-color: var(--el-color-primary-light-5);
transform: translateY(-1px);
}
.compare-quick-card__title {
font-weight: 600;
color: var(--el-text-color-primary);
}
.compare-quick-card__value {
margin-top: 8px;
font-size: 22px;
font-weight: 700;
color: var(--el-color-primary);
}
.compare-quick-card__meta {
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
</style>