feat(frontend): replace mock panels with live data sources

- dashboard: load container summary, machine overview, mysql service ledger, active delivery tasks and recent task logs in parallel via Promise.allSettled so one failed source does not block the page
- tenants: back panels with real container summary and mapped Wayne namespaces per business line
- subsystem detail: render real subsystem data instead of mock profile
- components.d.ts: auto-generated ElTag registration
This commit is contained in:
Hungerdream
2026-07-30 18:14:19 +08:00
parent 90434ee39b
commit b39f8c313d
4 changed files with 1320 additions and 403 deletions
+1
View File
@@ -26,6 +26,7 @@ declare module 'vue' {
ElSlider: typeof import('element-plus/es')['ElSlider']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SubsystemCard: typeof import('./src/components/SubsystemCard.vue')['default']
+533 -210
View File
@@ -3,49 +3,54 @@
<div class="page-head">
<div>
<h1>资源大盘</h1>
<p>跨机房 / 多云容器资源统一视图 · 最近更新于 12 秒前</p>
<p>{{ currentBusinessLineName }} 业务线资源视图 · {{ lastUpdatedText }}</p>
</div>
<el-button @click="refresh">⟳ 刷新</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
</div>
<div v-if="loadErrors.length" class="error-line">
<strong>部分数据未同步</strong>
<span>{{ loadErrors.join(';') }}</span>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="label">RKE2 集群</div>
<div class="value">{{ profile.clusters }}</div>
<div class="delta">{{ profile.clusters }} 机房在线</div>
<div class="value">{{ containerSummary.clusters }}</div>
<div class="delta">{{ containerSummary.readyNodes }} / {{ containerSummary.nodes }} Ready 节点</div>
</div>
<div class="stat-card">
<div class="label">节点总数</div>
<div class="value">{{ profile.nodes }}</div>
<div class="delta up">↑ {{ Math.max(1, Math.round(profile.nodes / 24)) }} 本周新增</div>
<div class="label">容器节点</div>
<div class="value">{{ containerSummary.nodes }}</div>
<div class="delta">{{ containerSummary.schedulableNodes }} 个可调度</div>
</div>
<div class="stat-card">
<div class="label">CPU 已分配</div>
<div class="value">{{ profile.cpuAllocated }}%</div>
<div class="delta">{{ Math.round(profile.nodes * 16 * profile.cpuAllocated / 100) }} / {{ profile.nodes * 16 }} 核</div>
<div class="label">纳管机器</div>
<div class="value">{{ machineOverview.total }}</div>
<div class="delta">物理机 {{ machineOverview.physical }} · 虚机 {{ machineOverview.virtual }}</div>
</div>
<div class="stat-card">
<div class="label">组件实例</div>
<div class="value">{{ profile.components }}</div>
<div class="delta">MySQL {{ profile.mysql }} · Redis {{ profile.redis }} · 其他 {{ profile.components - profile.mysql - profile.redis }}</div>
<div class="label">服务实例</div>
<div class="value">{{ serviceInstanceCount }}</div>
<div class="delta">MySQL {{ mysqlInstanceCount }} · 容器工作负载 {{ containerSummary.workloads }}</div>
</div>
<div class="stat-card">
<div class="label">进行中任务</div>
<div class="value warn">{{ profile.tasksRunning }}</div>
<div class="delta warn">{{ Math.min(profile.tasksRunning, 1) }} 个待关注</div>
<div class="label">进行中交付</div>
<div :class="['value', { warn: activeTasks.length > 0 }]">{{ activeTasks.length }}</div>
<div :class="['delta', { warn: activeTasks.length > 0 }]">MySQL 自动交付任务</div>
</div>
</div>
<div class="panel">
<section class="panel">
<div class="panel-head">
<h3>容器化服务 · 资源概览</h3>
<span class="meta">{{ containerLoading ? '同步中...' : containerMetaText }}</span>
<h3>容器资源</h3>
<span class="meta">Wayne / Kubernetes 实时汇总</span>
</div>
<div class="container-overview">
<div class="container-metric">
<span>命名空间</span>
<strong>{{ containerSummary.namespaces }}</strong>
<small>{{ currentName }} 业务线绑定</small>
<small>当前业务线绑定</small>
</div>
<div class="container-metric">
<span>工作负载</span>
@@ -57,263 +62,581 @@
<strong>{{ containerSummary.pods }}</strong>
<small>{{ containerSummary.readyPods }} ready</small>
</div>
<div class="container-metric">
<span>CPU 已用</span>
<strong>{{ formatMetric(containerSummary.cpuUsed) }}C</strong>
<small>{{ containerSummary.cpuPercent }}% of allocatable</small>
<div class="container-metric resource-metric">
<div class="metric-head">
<span>CPU 已用</span>
<strong>{{ normalizePercent(containerSummary.cpuPercent) }}%</strong>
</div>
<div class="resource-track" role="progressbar" aria-label="CPU 使用率" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="normalizePercent(containerSummary.cpuPercent)">
<span :class="['resource-fill', usageTone(containerSummary.cpuPercent)]" :style="{ width: `${normalizePercent(containerSummary.cpuPercent)}%` }"></span>
</div>
<small>{{ formatMetric(containerSummary.cpuUsed) }} / {{ formatMetric(containerSummary.cpuTotal) }} 核</small>
</div>
<div class="container-metric">
<span>Memory 已用</span>
<strong>{{ formatMetric(containerSummary.memoryUsed) }}G</strong>
<small>{{ containerSummary.memoryPercent }}% of allocatable</small>
<div class="container-metric resource-metric">
<div class="metric-head">
<span>内存已用</span>
<strong>{{ normalizePercent(containerSummary.memoryPercent) }}%</strong>
</div>
<div class="resource-track" role="progressbar" aria-label="内存使用率" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="normalizePercent(containerSummary.memoryPercent)">
<span :class="['resource-fill', usageTone(containerSummary.memoryPercent)]" :style="{ width: `${normalizePercent(containerSummary.memoryPercent)}%` }"></span>
</div>
<small>{{ formatMetric(containerSummary.memoryUsed) }} / {{ formatMetric(containerSummary.memoryTotal) }} GiB</small>
</div>
</div>
</div>
</section>
<div class="cols-2">
<div class="panel">
<div class="dashboard-grid">
<section class="panel topology-panel">
<div class="panel-head">
<h3>集群拓扑 · 按机房 / 业务线标签</h3>
<span class="meta">node-label 调度视图</span>
<h3>机器资源分布</h3>
<router-link to="/machine/management" class="meta">查看机器详情</router-link>
</div>
<div class="topo">
<div class="topo-cluster">
<div class="tname">
<span class="tag zone-a">{{ profile.primaryZone }}</span>
<span class="mono text-dim">{{ Math.round(profile.nodes * 0.42) }}节点</span>
<div class="topology-list">
<div v-if="!machineGroups.length && !loading" class="empty-state">当前业务线暂无机器数据</div>
<div v-for="(group, groupIndex) in machineGroups" :key="group.location" class="topology-group">
<div class="topology-summary">
<div class="topology-name">
<span :class="['tag', groupTone(groupIndex)]">{{ group.location }}</span>
</div>
<span class="machine-count mono">{{ group.items.length }} 台</span>
</div>
<div class="node-grid">
<div v-for="i in 24" :key="i" :class="['node-cell', i <= 18 ? 'a' : '']"></div>
<div class="machine-grid">
<div
v-for="machine in visibleMachines(group.items)"
:key="`${machine.hostname}-${machine.ip}`"
:class="['machine-cell', machineVisualClass(machine)]"
:title="`${machine.hostname} · ${machine.ip || '-'} · ${machine.type} · ${machine.status || '状态未知'}`"
></div>
<div v-if="hiddenMachineCount(group.items) > 0" class="machine-more mono">
+{{ hiddenMachineCount(group.items) }}
</div>
</div>
</div>
<div class="topo-cluster">
<div class="tname">
<span class="tag zone-b">{{ profile.secondaryZone }}</span>
<span class="mono text-dim">{{ Math.round(profile.nodes * 0.36) }}节点</span>
</div>
<div class="node-grid">
<div v-for="i in 24" :key="i" :class="['node-cell', i <= 20 ? 'b' : '']"></div>
</div>
</div>
<div class="topo-cluster">
<div class="tname">
<span class="tag zone-c">弹性云资源池</span>
<span class="mono text-dim">{{ Math.max(1, profile.nodes - Math.round(profile.nodes * 0.42) - Math.round(profile.nodes * 0.36)) }}节点</span>
</div>
<div class="node-grid">
<div v-for="i in 18" :key="i" :class="['node-cell', i <= 12 ? 'c' : '']"></div>
<div class="topology-breakdown">
<span>物理机 {{ group.physical }}</span>
<span>虚机 {{ group.virtual }}</span>
</div>
</div>
</div>
<div class="legend">
<span><span class="node-cell a inline"></span>{{ currentName }}主资源池</span>
<span><span class="node-cell b inline"></span>{{ currentName }}扩展池</span>
<span><span class="node-cell c inline"></span>{{ currentName }}弹性池</span>
<span><span class="node-cell empty inline"></span>空闲</span>
<div v-if="machineGroups.length" class="topology-legend">
<span><i class="machine-cell physical"></i>物理机</span>
<span><i class="machine-cell virtual"></i>虚机</span>
<span><i class="machine-cell abnormal"></i>异常</span>
</div>
</div>
</section>
<div class="panel">
<section class="panel recent-panel">
<div class="panel-head">
<h3>最近任务</h3>
<router-link to="/task/log" class="meta">查看全部 →</router-link>
<router-link to="/task/log" class="meta">查看全部</router-link>
</div>
<div class="panel-body">
<table>
<tbody>
<tr class="tr-hover">
<td class="status-text ok">● 成功</td>
<td class="strong">{{ profile.servicePrefix }} MySQL 主从部署</td>
<td class="mono">2m14s</td>
</tr>
<tr class="tr-hover">
<td class="status-text warn">● 执行中</td>
<td class="strong">{{ profile.servicePrefix }} RKE2 节点加入</td>
<td class="mono">38s</td>
</tr>
<tr class="tr-hover">
<td class="status-text ok">● 成功</td>
<td class="strong">{{ profile.servicePrefix }} Redis Cluster 部署</td>
<td class="mono">3m02s</td>
</tr>
<tr class="tr-hover">
<td class="status-text err">● 失败</td>
<td class="strong">{{ profile.servicePrefix }} openresty 网关部署</td>
<td class="mono">41s</td>
</tr>
<tr class="tr-hover">
<td class="status-text ok">● 成功</td>
<td class="strong">业务线标签同步</td>
<td class="mono">5s</td>
</tr>
</tbody>
</table>
<div class="recent-list">
<div v-if="!recentTasks.length && !loading" class="empty-state">当前业务线暂无任务记录</div>
<router-link v-for="task in recentTasks" :key="task.id" to="/task/log" class="recent-row">
<span :class="['status-text', task.status_class]">● {{ task.status_text }}</span>
<span class="recent-main">
<strong>{{ task.name }}</strong>
<small class="mono">{{ task.runner }}</small>
</span>
<time class="recent-time mono" :datetime="task.updated_at">{{ formatTaskTime(task.updated_at || task.created_at) }}</time>
</router-link>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { containerServiceApi, emptyContainerServiceSummary, type ContainerServiceSummary } from '@/api/containerService'
import { deliveryApi, type DeliveryTask, type MySQLServiceLedgerItem } from '@/api/delivery'
import { emptyMachineOverview, machineApi, type MachineOverview, type MachineResource } from '@/api/machine'
import { taskLogApi, type TaskLogSummary } from '@/api/taskLog'
import { useBusinessLineStore } from '@/stores/businessLine'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
const { currentName, profile } = useBusinessLineMockProfile()
const MACHINE_PAGE_SIZE = 200
const MAX_VISIBLE_MACHINES = 36
const businessLineStore = useBusinessLineStore()
const loading = ref(false)
const loadErrors = ref<string[]>([])
const lastUpdatedAt = ref<Date | null>(null)
const containerSummary = ref<ContainerServiceSummary>({ ...emptyContainerServiceSummary })
const containerLoading = ref(false)
const containerError = ref('')
const machineOverview = ref<MachineOverview>({ ...emptyMachineOverview })
const machines = ref<MachineResource[]>([])
const mysqlServices = ref<MySQLServiceLedgerItem[]>([])
const activeTasks = ref<DeliveryTask[]>([])
const recentTasks = ref<TaskLogSummary[]>([])
const containerMetaText = computed(() => {
if (containerError.value) {
return containerError.value
const currentBusinessLineName = computed(() => businessLineStore.current?.name || '未选择业务线')
const mysqlInstanceCount = computed(() => mysqlServices.value.reduce((total, service) => total + Number(service.instances || 0), 0))
const serviceInstanceCount = computed(() => mysqlInstanceCount.value + containerSummary.value.workloads)
const lastUpdatedText = computed(() => lastUpdatedAt.value ? `更新于 ${lastUpdatedAt.value.toLocaleTimeString('zh-CN', { hour12: false })}` : '等待同步')
const machineGroups = computed(() => {
const grouped = new Map<string, MachineResource[]>()
for (const machine of machines.value) {
const location = machine.location?.trim() || '未标注机房'
const items = grouped.get(location) || []
items.push(machine)
grouped.set(location, items)
}
const errors = containerSummary.value.errors?.length || 0
return errors > 0 ? `数据源:Kubernetes 集群运行态 · ${errors} 项同步失败` : '数据源:Kubernetes 集群运行态'
return Array.from(grouped.entries())
.map(([location, items]) => ({
location,
items,
physical: items.filter((item) => item.type === '物理机').length,
virtual: items.filter((item) => item.type !== '物理机').length,
}))
.sort((a, b) => b.items.length - a.items.length || a.location.localeCompare(b.location, 'zh-CN'))
})
const loadContainerSummary = async () => {
const businessLineId = businessLineStore.current?.id
if (!businessLineId) {
containerSummary.value = { ...emptyContainerServiceSummary }
return
}
containerLoading.value = true
containerError.value = ''
try {
containerSummary.value = await containerServiceApi.getSummary(businessLineId)
} catch (error) {
containerSummary.value = { ...emptyContainerServiceSummary }
containerError.value = error instanceof Error ? error.message : '容器化服务数据同步失败'
} finally {
containerLoading.value = false
}
}
const refresh = () => {
loadContainerSummary()
}
const formatMetric = (value: number) => Number(value || 0).toFixed(1).replace(/\.0$/, '')
watch(
() => businessLineStore.current?.id,
() => {
loadContainerSummary()
void loadDashboard()
},
{ immediate: true },
)
async function loadDashboard() {
const businessLineId = businessLineStore.current?.id
if (!businessLineId) {
resetDashboard()
loadErrors.value = ['请先选择业务线']
return
}
loading.value = true
loadErrors.value = []
const [containerResult, machineResult, machineListResult, serviceResult, activeTaskResult, taskLogResult] = await Promise.allSettled([
containerServiceApi.getSummary(businessLineId),
machineApi.getOverview(businessLineId),
loadAllMachines(businessLineId),
deliveryApi.listMySQLServices(businessLineId),
deliveryApi.listTasks({ businessLineId, active: true }),
taskLogApi.list({ businessLineId, page: 1, pageSize: 5 }),
])
if (containerResult.status === 'fulfilled') {
containerSummary.value = containerResult.value
if (containerResult.value.errors?.length) {
loadErrors.value.push(...containerResult.value.errors)
}
} else {
containerSummary.value = { ...emptyContainerServiceSummary }
}
machineOverview.value = machineResult.status === 'fulfilled' ? machineResult.value : { ...emptyMachineOverview }
machines.value = machineListResult.status === 'fulfilled' ? machineListResult.value : []
mysqlServices.value = serviceResult.status === 'fulfilled' ? serviceResult.value : []
activeTasks.value = activeTaskResult.status === 'fulfilled' ? activeTaskResult.value : []
recentTasks.value = taskLogResult.status === 'fulfilled' ? taskLogResult.value.items : []
const requestResults = [containerResult, machineResult, machineListResult, serviceResult, activeTaskResult, taskLogResult]
for (const result of requestResults) {
if (result.status === 'rejected') {
loadErrors.value.push(result.reason instanceof Error ? result.reason.message : '数据同步失败')
}
}
lastUpdatedAt.value = new Date()
loading.value = false
}
function resetDashboard() {
containerSummary.value = { ...emptyContainerServiceSummary }
machineOverview.value = { ...emptyMachineOverview }
machines.value = []
mysqlServices.value = []
activeTasks.value = []
recentTasks.value = []
lastUpdatedAt.value = null
}
async function loadAllMachines(businessLineId: number) {
const firstPage = await machineApi.listResources({ page: 1, size: MACHINE_PAGE_SIZE, businessLineId })
const pageCount = Math.ceil(firstPage.total / MACHINE_PAGE_SIZE)
if (pageCount <= 1) return firstPage.items
const remainingPages = await Promise.all(
Array.from({ length: pageCount - 1 }, (_, index) =>
machineApi.listResources({ page: index + 2, size: MACHINE_PAGE_SIZE, businessLineId }),
),
)
return [firstPage.items, ...remainingPages.map((page) => page.items)].flat()
}
function visibleMachines(items: MachineResource[]) {
return items.slice(0, MAX_VISIBLE_MACHINES)
}
function hiddenMachineCount(items: MachineResource[]) {
return Math.max(0, items.length - MAX_VISIBLE_MACHINES)
}
function machineVisualClass(machine: MachineResource) {
const status = String(machine.status || '').trim().toLowerCase()
if (['failed', 'error', 'offline', '异常', '故障'].includes(status)) return 'abnormal'
return machine.type === '物理机' ? 'physical' : 'virtual'
}
function groupTone(index: number) {
return ['zone-a', 'zone-b', 'zone-c'][index % 3]
}
function normalizePercent(value: number) {
if (!Number.isFinite(value)) return 0
return Math.min(100, Math.max(0, Math.round(value)))
}
function usageTone(value: number) {
const percent = normalizePercent(value)
if (percent >= 90) return 'critical'
if (percent >= 70) return 'warning'
return 'normal'
}
function formatMetric(value: number) {
const metric = Number(value || 0)
return Number.isInteger(metric) ? String(metric) : metric.toFixed(1)
}
function formatTaskTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).replace('/', '-')
}
</script>
<style scoped>
/* 公共样式(.page-head, .stat-row, .cols-2, .panel, table, .tag, .status-text)已在 global.css 中定义 */
.topo {
padding: 18px 16px;
.error-line {
display: flex;
gap: 14px;
}
.topo-cluster {
flex: 1;
border: 1px dashed var(--line);
border-radius: var(--radius-lg);
padding: 12px;
}
.topo-cluster .tname {
align-items: flex-start;
gap: 10px;
margin-bottom: 14px;
padding: 10px 12px;
border-left: 3px solid var(--warn);
background: color-mix(in srgb, var(--warn) 8%, transparent);
color: var(--warn);
font-size: 12px;
font-weight: 600;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.node-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 5px;
}
.node-cell {
aspect-ratio: 1;
border-radius: 3px;
background: var(--node-empty-bg);
border: 1px solid var(--line-soft);
}
.node-cell.a {
background: var(--node-a-bg);
border-color: var(--node-a-border);
}
.node-cell.b {
background: var(--node-b-bg);
border-color: var(--node-b-border);
}
.node-cell.c {
background: var(--node-c-bg);
border-color: var(--node-c-border);
}
.node-cell.empty {
background: transparent;
border-style: dashed;
}
.node-cell.inline {
display: inline-block;
width: 9px;
height: 9px;
margin-right: 5px;
}
.legend {
padding: 0 16px 14px;
display: flex;
gap: 16px;
font-size: 11px;
color: var(--text-dim);
.error-line span {
color: var(--text-mid);
}
.container-overview {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
padding: 16px;
border-top: 1px solid var(--line-soft);
}
.container-metric {
min-width: 0;
padding: 13px 14px;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--bg-panel-2);
padding: 16px 18px;
border-right: 1px solid var(--line-soft);
}
.container-metric span,
.container-metric small {
.container-metric:last-child {
border-right: 0;
}
.container-metric > span,
.container-metric small,
.metric-head > span {
display: block;
color: var(--text-dim);
font-size: 11px;
}
.container-metric strong {
.container-metric > strong {
display: block;
margin: 6px 0 4px;
margin: 7px 0 5px;
color: var(--text-hi);
font-family: var(--mono);
font-size: 20px;
font-size: 21px;
}
@media (max-width: 960px) {
.metric-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 11px;
}
.metric-head strong {
color: var(--text-hi);
font-family: var(--mono);
font-size: 15px;
}
.resource-track {
width: 100%;
height: 8px;
display: block;
overflow: hidden;
margin-bottom: 8px;
border: 1px solid var(--line-soft);
border-radius: 4px;
background: var(--bar-bg);
}
.resource-fill {
height: 100%;
display: block;
border-radius: 3px;
background: #20a67a;
transition: width 240ms ease;
}
.resource-fill.warning {
background: var(--warn);
}
.resource-fill.critical {
background: var(--err);
}
.dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.9fr);
gap: 18px;
align-items: stretch;
margin-top: 18px;
}
.topology-panel,
.recent-panel {
min-width: 0;
height: 430px;
display: flex;
flex-direction: column;
}
.topology-list {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 14px;
padding: 16px;
border-top: 1px solid var(--line-soft);
overflow-y: auto;
}
.recent-list {
flex: 1;
min-height: 0;
border-top: 1px solid var(--line-soft);
overflow-y: auto;
}
.topology-group {
min-width: 0;
padding: 12px;
border: 1px dashed var(--line);
border-radius: 6px;
}
.topology-summary,
.topology-name {
display: flex;
align-items: center;
}
.topology-summary {
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.topology-name .tag {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.machine-count {
color: var(--text-dim);
font-size: 11px;
}
.machine-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
}
.machine-cell {
width: 100%;
aspect-ratio: 1;
display: block;
border: 1px solid var(--line-soft);
border-radius: 3px;
background: var(--node-empty-bg);
}
.machine-cell.physical {
border-color: var(--node-a-border);
background: var(--node-a-bg);
}
.machine-cell.virtual {
border-color: var(--node-b-border);
background: var(--node-b-bg);
}
.machine-cell.abnormal {
border-color: color-mix(in srgb, var(--err) 45%, var(--line));
background: color-mix(in srgb, var(--err) 13%, var(--bg-panel));
}
.machine-more {
aspect-ratio: 1;
display: grid;
place-items: center;
border: 1px dashed var(--line);
border-radius: 3px;
background: var(--bg-panel-2);
color: var(--text-dim);
font-size: 9px;
}
.topology-breakdown {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
color: var(--text-dim);
font-size: 10.5px;
}
.topology-legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 0 16px 14px;
color: var(--text-dim);
font-size: 11px;
}
.topology-legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.topology-legend .machine-cell {
width: 10px;
height: 10px;
aspect-ratio: auto;
}
.recent-row {
min-height: 66px;
display: grid;
grid-template-columns: 72px minmax(0, 1fr) 92px;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--line-soft);
color: inherit;
text-decoration: none;
}
.recent-row:last-child {
border-bottom: 0;
}
.recent-row:hover {
background: var(--bg-panel-2);
}
.recent-main {
min-width: 0;
}
.recent-main strong,
.recent-main small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-main strong {
color: var(--text-hi);
font-size: 12.5px;
}
.recent-main small {
margin-top: 5px;
color: var(--text-dim);
font-size: 10.5px;
}
.recent-time {
color: var(--text-dim);
font-size: 10.5px;
text-align: right;
white-space: nowrap;
}
.empty-state {
padding: 30px 18px;
color: var(--text-dim);
font-size: 13px;
text-align: center;
}
@media (max-width: 1180px) {
.container-overview {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.container-metric {
border-bottom: 1px solid var(--line-soft);
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.topology-panel,
.recent-panel {
height: auto;
}
.topology-list,
.recent-list {
overflow-y: visible;
}
}
@media (max-width: 760px) {
.container-overview {
grid-template-columns: 1fr 1fr;
}
.topology-summary {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.recent-row {
grid-template-columns: 68px minmax(0, 1fr);
}
.recent-time {
display: none;
}
}
</style>
+425 -107
View File
@@ -3,138 +3,456 @@
<div class="page-head">
<div>
<h1>业务配额</h1>
<p>按业务线划分资源配额与隔离策略 · RBAC 权限随业务线自动具备</p>
<p>{{ currentBusinessLineName }} 的容器资源使用与命名空间隔离范围</p>
</div>
<div class="page-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
<el-button
v-if="businessLineStore.isCurrentAdmin"
type="primary"
:icon="Setting"
@click="router.push('/business-line/assignment')"
>
配置命名空间
</el-button>
</div>
<el-button type="primary">+ 新增业务线</el-button>
</div>
<div class="panel">
<div v-if="error" class="error-line">{{ error }}</div>
<section class="panel overview-panel" v-loading="loading">
<div class="panel-head">
<h3>业务线列表</h3>
<span class="meta">按业务线隔离命名空间、节点标签与配额</span>
<div>
<h3>资源概览</h3>
<span class="meta">Wayne 集群实时汇总</span>
</div>
<el-tag type="warning" effect="plain">配额上限未接入</el-tag>
</div>
<div class="panel-body">
<div class="panel-body overview-body">
<div class="metric-strip">
<div class="metric-item">
<span>命名空间</span>
<strong>{{ namespaces.length }}</strong>
</div>
<div class="metric-item">
<span>集群</span>
<strong>{{ summary.clusters }}</strong>
</div>
<div class="metric-item">
<span>Ready 节点</span>
<strong>{{ summary.readyNodes }}<small>/ {{ summary.nodes }}</small></strong>
</div>
<div class="metric-item">
<span>工作负载</span>
<strong>{{ summary.workloads }}</strong>
</div>
<div class="metric-item">
<span>Pod</span>
<strong>{{ summary.readyPods }}<small>/ {{ summary.pods }}</small></strong>
</div>
</div>
<div class="usage-grid">
<div class="usage-block">
<div class="usage-head">
<div>
<span class="usage-label">CPU 使用</span>
<strong>{{ formatAmount(summary.cpuUsed) }} / {{ formatAmount(summary.cpuTotal) }} 核</strong>
</div>
<span :class="['usage-percent', usageTone(summary.cpuPercent)]">{{ normalizePercent(summary.cpuPercent) }}%</span>
</div>
<div class="usage-track" role="progressbar" aria-label="CPU 使用率" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="normalizePercent(summary.cpuPercent)">
<span :class="['usage-fill', usageTone(summary.cpuPercent)]" :style="{ width: `${normalizePercent(summary.cpuPercent)}%` }"></span>
</div>
</div>
<div class="usage-block">
<div class="usage-head">
<div>
<span class="usage-label">内存使用</span>
<strong>{{ formatAmount(summary.memoryUsed) }} / {{ formatAmount(summary.memoryTotal) }} GiB</strong>
</div>
<span :class="['usage-percent', usageTone(summary.memoryPercent)]">{{ normalizePercent(summary.memoryPercent) }}%</span>
</div>
<div class="usage-track" role="progressbar" aria-label="内存使用率" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="normalizePercent(summary.memoryPercent)">
<span :class="['usage-fill', usageTone(summary.memoryPercent)]" :style="{ width: `${normalizePercent(summary.memoryPercent)}%` }"></span>
</div>
</div>
</div>
<div v-if="summary.errors?.length" class="source-warning">
<strong>部分数据未同步</strong>
<span>{{ summary.errors.join(';') }}</span>
</div>
</div>
</section>
<section class="panel namespace-panel">
<div class="panel-head">
<div>
<h3>命名空间与隔离范围</h3>
<span class="meta">当前业务线已绑定的 Wayne namespace</span>
</div>
<span class="binding-count">{{ namespaces.length }} 个绑定</span>
</div>
<div class="panel-body namespace-body">
<table>
<thead>
<tr>
<th>Wayne namespace</th>
<th>Kubernetes namespace</th>
<th>业务线</th>
<th>标识</th>
<th>命名空间</th>
<th>节点数</th>
<th>CPU 配额</th>
<th>内存配额</th>
<th>访问角色</th>
<th>状态</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="tenant in currentTenants" :key="tenant.key" class="tr-hover">
<td class="strong">{{ tenant.name }}</td>
<td class="mono">{{ tenant.key }}</td>
<td class="mono">{{ tenant.namespace }}</td>
<td class="mono">{{ tenant.nodes }}</td>
<td>
<span class="bar-wrap">
<span class="bar-fill" :class="tenant.cpuClass" :style="{ width: tenant.cpuPct + '%' }"></span>
</span>
{{ tenant.cpuPct }}%
</td>
<td>
<span class="bar-wrap">
<span class="bar-fill" :class="tenant.memClass" :style="{ width: tenant.memPct + '%' }"></span>
</span>
{{ tenant.memPct }}%
</td>
<td :class="['status-text', tenant.statusClass]">● {{ tenant.status }}</td>
<td>
<el-button text>详情</el-button>
</td>
<tr v-if="!namespaces.length && !loading">
<td colspan="5" class="empty-row">当前业务线尚未绑定 Wayne namespace</td>
</tr>
<tr v-for="namespace in namespaces" :key="namespace.id" class="tr-hover">
<td class="strong">{{ namespace.name || '-' }}</td>
<td class="mono">{{ namespace.kubeNamespace || '-' }}</td>
<td>{{ currentBusinessLineName }}</td>
<td>{{ businessLineStore.current?.role || '-' }}</td>
<td class="status-text ok">● 已绑定</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="tooltip-note" style="max-width: 720px;">
<b>配额与隔离策略:</b>每个业务线绑定独立的 K8s namespace 与 node-label,资源配额通过 ResourceQuota 限制 CPU / 内存上限。
RBAC 角色(viewer / operator / admin)随业务线自动具备,子系统(Wayne / CloudDM)凭 SSO 身份免登进入。
</div>
<div class="scope-strip">
<div>
<span>资源边界</span>
<strong>Kubernetes namespace</strong>
</div>
<div>
<span>节点隔离</span>
<strong>business-line node-label</strong>
</div>
<div>
<span>身份访问</span>
<strong>业务线 RBAC</strong>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Refresh, Setting } from '@element-plus/icons-vue'
import { businessLineApi, type WayneNamespace } from '@/api/businessLine'
import { containerServiceApi, emptyContainerServiceSummary, type ContainerServiceSummary } from '@/api/containerService'
import { useBusinessLineStore } from '@/stores/businessLine'
const { currentName, profile } = useBusinessLineMockProfile()
const router = useRouter()
const businessLineStore = useBusinessLineStore()
const loading = ref(false)
const error = ref('')
const namespaces = ref<WayneNamespace[]>([])
const summary = ref<ContainerServiceSummary>({ ...emptyContainerServiceSummary })
const tenants = ref([
{
name: 'kodo',
key: 'kodo',
namespace: 'ns-kodo-prod',
nodes: 8,
cpuPct: 45,
cpuClass: '',
memPct: 52,
memClass: '',
status: '正常',
statusClass: 'ok',
},
{
name: 'linxi',
key: 'linxi',
namespace: 'ns-linxi-prod',
nodes: 6,
cpuPct: 82,
cpuClass: 'warn',
memPct: 78,
memClass: '',
status: '资源告警',
statusClass: 'warn',
},
{
name: 'xinfra',
key: 'xinfra',
namespace: 'ns-xinfra-prod',
nodes: 4,
cpuPct: 23,
cpuClass: '',
memPct: 31,
memClass: '',
status: '正常',
statusClass: 'ok',
},
{
name: 'las',
key: 'las',
namespace: 'ns-las-prod',
nodes: 12,
cpuPct: 64,
cpuClass: '',
memPct: 71,
memClass: '',
status: '正常',
statusClass: 'ok',
},
])
const currentBusinessLineName = computed(() => businessLineStore.current?.name || '未选择业务线')
const currentTenants = computed(() => {
const tenant = tenants.value.find((item) => item.key === currentName.value)
if (!tenant) return []
return [{
...tenant,
nodes: Math.max(1, Math.round(profile.value.nodes / 12)),
cpuPct: profile.value.cpuAllocated,
cpuClass: profile.value.cpuAllocated > 75 ? 'warn' : '',
memPct: Math.min(96, profile.value.cpuAllocated + 8),
memClass: profile.value.cpuAllocated > 75 ? 'warn' : '',
status: profile.value.alertsP1 ? '资源告警' : '正常',
statusClass: profile.value.alertsP1 ? 'warn' : 'ok',
}]
})
watch(
() => businessLineStore.current?.id,
() => {
void loadData()
},
{ immediate: true },
)
async function loadData() {
const businessLineId = businessLineStore.current?.id
if (!businessLineId) {
namespaces.value = []
summary.value = { ...emptyContainerServiceSummary }
error.value = '请先选择业务线'
return
}
loading.value = true
error.value = ''
const [summaryResult, namespaceResult] = await Promise.allSettled([
containerServiceApi.getSummary(businessLineId),
businessLineApi.listMappedWayneNamespaces(businessLineId),
])
if (summaryResult.status === 'fulfilled') {
summary.value = summaryResult.value
} else {
summary.value = { ...emptyContainerServiceSummary }
}
if (namespaceResult.status === 'fulfilled') {
namespaces.value = namespaceResult.value
} else {
namespaces.value = []
}
const failures = [summaryResult, namespaceResult]
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map((result) => result.reason instanceof Error ? result.reason.message : '数据加载失败')
error.value = failures.join(';')
loading.value = false
}
function normalizePercent(value: number) {
if (!Number.isFinite(value)) return 0
return Math.min(100, Math.max(0, Math.round(value)))
}
function usageTone(value: number) {
const percent = normalizePercent(value)
if (percent >= 90) return 'critical'
if (percent >= 70) return 'warning'
return 'normal'
}
function formatAmount(value: number) {
if (!Number.isFinite(value)) return '0'
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
</script>
<style scoped>
/* 公共样式已在 global.css 中定义 */
.page-actions {
display: flex;
align-items: center;
gap: 10px;
}
.error-line {
margin-bottom: 14px;
padding: 10px 12px;
border-left: 3px solid var(--err);
background: color-mix(in srgb, var(--err) 7%, transparent);
color: var(--err);
font-size: 13px;
}
.overview-panel,
.namespace-panel {
margin-bottom: 18px;
}
.panel-head > div {
min-width: 0;
display: flex;
align-items: baseline;
gap: 12px;
}
.overview-body {
padding: 0;
}
.metric-strip {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
border-bottom: 1px solid var(--line-soft);
}
.metric-item {
min-width: 0;
padding: 18px;
border-right: 1px solid var(--line-soft);
}
.metric-item:last-child {
border-right: 0;
}
.metric-item span,
.scope-strip span {
display: block;
margin-bottom: 6px;
color: var(--text-dim);
font-size: 12px;
}
.metric-item strong {
color: var(--text-hi);
font-family: var(--mono);
font-size: 22px;
font-weight: 650;
}
.metric-item small {
margin-left: 4px;
color: var(--text-dim);
font-size: 12px;
font-weight: 500;
}
.usage-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 28px;
padding: 20px 18px 22px;
}
.usage-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.usage-label {
display: block;
margin-bottom: 5px;
color: var(--text-dim);
font-size: 12px;
}
.usage-head strong {
color: var(--text-hi);
font-family: var(--mono);
font-size: 14px;
}
.usage-percent {
flex: none;
font-family: var(--mono);
font-size: 14px;
font-weight: 700;
}
.usage-track {
width: 100%;
height: 9px;
display: block;
overflow: hidden;
border: 1px solid var(--line-soft);
border-radius: 4px;
background: var(--bar-bg);
}
.usage-fill {
height: 100%;
display: block;
border-radius: 3px;
transition: width 240ms ease;
}
.normal {
color: #168a68;
}
.usage-fill.normal {
background: #20a67a;
}
.warning {
color: var(--warn);
}
.usage-fill.warning {
background: var(--warn);
}
.critical {
color: var(--err);
}
.usage-fill.critical {
background: var(--err);
}
.source-warning {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 18px;
border-top: 1px solid var(--line-soft);
color: var(--warn);
font-size: 12px;
}
.source-warning span {
color: var(--text-mid);
}
.binding-count {
color: var(--text-dim);
font-size: 12px;
}
.namespace-body {
padding: 0;
}
.empty-row {
padding: 28px 18px;
color: var(--text-dim);
text-align: center;
}
.scope-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-top: 1px solid var(--line-soft);
background: var(--bg-panel-2);
}
.scope-strip > div {
min-width: 0;
padding: 14px 18px;
border-right: 1px solid var(--line-soft);
}
.scope-strip > div:last-child {
border-right: 0;
}
.scope-strip strong {
color: var(--text-mid);
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
}
@media (max-width: 960px) {
.metric-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-item {
border-bottom: 1px solid var(--line-soft);
}
.usage-grid,
.scope-strip {
grid-template-columns: 1fr;
}
.scope-strip > div {
border-right: 0;
border-bottom: 1px solid var(--line-soft);
}
}
@media (max-width: 640px) {
.page-actions {
width: 100%;
flex-wrap: wrap;
}
.panel-head > div {
display: block;
}
.panel-head .meta {
display: block;
margin-top: 4px;
}
.metric-strip {
grid-template-columns: 1fr 1fr;
}
}
</style>
+361 -86
View File
@@ -1,82 +1,110 @@
<template>
<div v-if="system">
<div class="page-head">
<div>
<h1>{{ system.name }}</h1>
<p>{{ system.description }} · {{ system.domain }}</p>
</div>
<span class="env-pill" :class="system.status === 'active' ? '' : 'warn'">
{{ system.status === 'active' ? '● 在线' : '● 接入中' }}
</span>
</div>
<div v-if="system" class="subsystem-detail">
<router-link to="/subsystem" class="back-link">
<el-icon><ArrowLeft /></el-icon>
<span>子系统导航</span>
</router-link>
<div class="stat-row">
<div class="stat-card">
<div class="label">接入状态</div>
<div class="value" :class="system.status === 'active' ? 'accent' : 'warn'">
{{ system.status === 'active' ? '在线' : '接入中' }}
<div class="page-head system-page-head">
<div class="system-identity">
<div class="system-mark" :style="{ background: bgColor, color: iconColor }">
{{ system.icon }}
</div>
<div class="delta">{{ system.category }}</div>
</div>
<div class="stat-card">
<div class="label">SSO 登录</div>
<div class="value" :class="system.sso_enabled ? 'accent' : ''">
{{ system.sso_enabled ? '已启用' : '未启用' }}
<div>
<span class="system-category">{{ system.category }}</span>
<h1>{{ system.name }}</h1>
<p>{{ system.label }} · <span class="mono">{{ system.domain }}</span></p>
</div>
<div class="delta">LDAP 原生配置 · 同账号同密码</div>
</div>
<div class="stat-card">
<div class="label">访问域名</div>
<div class="value" style="font-size:14px;">{{ system.domain }}</div>
<div class="delta">内网 DNS 解析</div>
<div class="page-actions">
<span class="status-pill" :class="{ warn: system.status !== 'active' }">
<i></i>{{ system.status === 'active' ? '在线' : '接入中' }}
</span>
<el-button
type="primary"
:icon="TopRight"
:disabled="system.status !== 'active'"
:loading="opening"
@click="openPortal"
>
{{ system.status === 'active' ? `打开 ${system.name}` : '暂未开放' }}
</el-button>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>统一入口</h3>
<span class="meta">{{ system.name }} · LDAP 单点登录</span>
<section class="panel system-panel">
<div class="system-overview">
<div class="system-copy">
<span class="section-label">系统说明</span>
<h2>{{ system.description }}</h2>
<p v-if="detailBody">{{ detailBody }}</p>
</div>
<dl class="access-details">
<div>
<dt>访问域名</dt>
<dd class="mono">{{ system.domain }}</dd>
</div>
<div>
<dt>认证方式</dt>
<dd>{{ system.sso_enabled ? 'LDAP 单点登录' : '独立账号登录' }}</dd>
</div>
<div>
<dt>网络范围</dt>
<dd>内网访问</dd>
</div>
<div>
<dt>当前状态</dt>
<dd :class="['detail-status', { warn: system.status !== 'active' }]">
<span></span>{{ system.status === 'active' ? '服务正常' : '接入配置中' }}
</dd>
</div>
</dl>
</div>
<div class="panel-body" style="padding:16px;">
<div class="ext-card" style="max-width:560px;">
<div class="ext-top">
<div class="ext-logo" :style="{ background: bgColor, color: iconColor }">
{{ system.icon }}
</div>
<div>
<h4>{{ system.name }}</h4>
<div class="ext-sub">{{ system.domain }}</div>
</div>
</div>
<p>{{ detailDesc }}</p>
<div class="sso-row">
<span class="sso-dot" :class="{ warn: system.status !== 'active' }"></span>
{{ system.status === 'active' ? 'LDAP 原生配置接入 · 在线' : 'LDAP 接入改造中' }}
</div>
<button
class="btn btn-primary"
:class="{ 'btn-disabled': system.status !== 'active' }"
:disabled="system.status !== 'active'"
style="margin-top:8px;align-self:flex-start;"
@click="openPortal"
>
{{ system.status !== 'active' ? '暂未开放' : '打开 ' + system.name + ' ↗' }}
</button>
<div class="system-facts">
<div class="system-fact">
<span>系统类型</span>
<strong>{{ system.category }}</strong>
<small>统一基础设施子系统</small>
</div>
<div class="system-fact">
<span>账号体系</span>
<strong>{{ system.sso_enabled ? 'LDAP' : '独立账号' }}</strong>
<small>{{ system.sso_enabled ? '统一身份认证' : '由子系统独立维护' }}</small>
</div>
<div class="system-fact">
<span>接入方式</span>
<strong>{{ system.sso_enabled ? '单点登录' : '外部链接' }}</strong>
<small>{{ system.status === 'active' ? '入口已开放' : '入口暂未开放' }}</small>
</div>
<div class="system-fact address-fact">
<span>访问地址</span>
<strong class="mono">{{ system.domain }}</strong>
<small>内网 DNS 解析</small>
</div>
</div>
</div>
</section>
</div>
<div v-else class="page-head">
<div><h1>子系统未找到</h1><p>请从左侧导航选择一个子系统</p></div>
<div v-else class="empty-system">
<div class="system-mark">?</div>
<h1>子系统未找到</h1>
<p>当前地址没有对应的子系统配置。</p>
<router-link to="/subsystem" class="back-link centered">
<el-icon><ArrowLeft /></el-icon>
<span>返回子系统导航</span>
</router-link>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { ArrowLeft, TopRight } from '@element-plus/icons-vue'
import { useRoute } from 'vue-router'
import { subsystemApi, type Subsystem } from '@/api/subsystem'
const route = useRoute()
const opening = ref(false)
const system = computed<Subsystem | undefined>(() => {
const name = route.params.name as string
@@ -125,65 +153,312 @@ const detailDesc = computed(() => {
return descs[system.value.name] || system.value.description
})
const detailBody = computed(() => {
if (!system.value) return ''
const summary = system.value.description.trim()
const detail = detailDesc.value.trim()
return detail.startsWith(summary) ? detail.slice(summary.length).trim() : detail
})
const openPortal = async () => {
if (!system.value) return
opening.value = true
if (system.value.sso_enabled) {
try {
const { data } = await subsystemApi.getSSOUrl(system.value.id)
window.location.assign(data.sso_url)
} catch {
// 错误已由 request 拦截器统一处理
} finally {
opening.value = false
}
} else {
window.open(system.value.url, '_blank')
opening.value = false
}
}
</script>
<style scoped>
.env-pill {
font-size: 13px;
.subsystem-detail {
width: 100%;
}
.back-link {
width: fit-content;
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 14px;
color: var(--text-dim);
font-size: 12px;
text-decoration: none;
transition: color 0.15s ease;
}
.back-link:hover {
color: var(--accent);
background: var(--accent-dim);
padding: 4px 14px;
border-radius: 20px;
white-space: nowrap;
}
.env-pill.warn {
color: var(--warn);
background: rgba(255, 180, 0, 0.1);
.system-page-head {
align-items: center;
margin-bottom: 20px;
}
.panel-body .ext-card {
.system-identity {
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.system-mark {
width: 54px;
height: 54px;
flex: 0 0 54px;
display: grid;
place-items: center;
border-radius: 8px;
background: var(--bg-panel-2);
color: var(--text-mid);
font-family: var(--mono);
font-size: 17px;
font-weight: 700;
}
.system-category {
display: block;
margin-bottom: 4px;
color: var(--text-dim);
font-size: 11px;
}
.system-identity h1 {
margin-bottom: 4px;
}
.system-identity p {
overflow-wrap: anywhere;
}
.page-actions {
display: flex;
align-items: center;
gap: 10px;
}
.btn-primary {
background: var(--accent);
color: #fff;
border: none;
padding: 8px 18px;
.status-pill {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 10px;
border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line));
border-radius: 6px;
background: color-mix(in srgb, var(--accent) 8%, var(--bg-panel));
color: var(--accent);
font-size: 12px;
white-space: nowrap;
}
.status-pill i,
.detail-status span {
width: 7px;
height: 7px;
display: block;
border-radius: 50%;
background: currentColor;
}
.status-pill.warn,
.access-details dd.detail-status.warn {
color: var(--warn);
}
.system-panel {
overflow: hidden;
}
.system-overview {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.8fr);
gap: 44px;
padding: 30px 32px;
}
.section-label {
display: block;
margin-bottom: 10px;
color: var(--text-dim);
font-size: 11px;
}
.system-copy h2 {
max-width: 680px;
margin: 0;
color: var(--text-hi);
font-size: 20px;
line-height: 1.45;
}
.system-copy p {
max-width: 720px;
margin: 14px 0 0;
color: var(--text-mid);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
line-height: 1.75;
}
.btn-primary:hover {
opacity: 0.85;
.access-details {
margin: 0;
padding-left: 28px;
border-left: 1px solid var(--line-soft);
}
.btn-disabled {
background: var(--text-dim);
cursor: not-allowed;
opacity: 0.6;
.access-details > div {
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
gap: 16px;
padding: 9px 0;
}
.btn-disabled:hover {
opacity: 0.6;
.access-details dt {
color: var(--text-dim);
font-size: 11px;
}
.access-details dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--text-hi);
font-size: 12px;
}
.access-details dd.detail-status {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--accent);
}
.system-facts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-top: 1px solid var(--line-soft);
background: var(--bg-panel-2);
}
.system-fact {
min-width: 0;
padding: 18px 22px;
border-right: 1px solid var(--line-soft);
}
.system-fact:last-child {
border-right: 0;
}
.system-fact span,
.system-fact small {
display: block;
color: var(--text-dim);
font-size: 10.5px;
}
.system-fact strong {
display: block;
margin: 7px 0 5px;
overflow: hidden;
color: var(--text-hi);
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.address-fact strong {
font-size: 12px;
}
.empty-system {
min-height: 420px;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
color: var(--text-mid);
text-align: center;
}
.empty-system h1 {
margin: 18px 0 8px;
color: var(--text-hi);
font-size: 22px;
}
.empty-system p {
margin: 0 0 18px;
color: var(--text-dim);
font-size: 13px;
}
.back-link.centered {
margin-bottom: 0;
}
@media (max-width: 900px) {
.system-overview {
grid-template-columns: 1fr;
gap: 24px;
padding: 24px;
}
.access-details {
padding-top: 18px;
padding-left: 0;
border-top: 1px solid var(--line-soft);
border-left: 0;
}
.system-facts {
grid-template-columns: 1fr 1fr;
}
.system-fact:nth-child(2) {
border-right: 0;
}
.system-fact:nth-child(-n + 2) {
border-bottom: 1px solid var(--line-soft);
}
}
@media (max-width: 640px) {
.system-page-head {
align-items: flex-start;
flex-direction: column;
gap: 16px;
}
.page-actions {
width: 100%;
}
.page-actions :deep(.el-button) {
flex: 1;
}
.system-facts {
grid-template-columns: 1fr;
}
.system-fact,
.system-fact:nth-child(2) {
border-right: 0;
border-bottom: 1px solid var(--line-soft);
}
.system-fact:last-child {
border-bottom: 0;
}
}
</style>