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:
Vendored
+1
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user