1112 lines
48 KiB
Vue
1112 lines
48 KiB
Vue
<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>
|