Merge pull request #82 from 2311719626/feature/frontend-bl-scoped-delivery

feat(frontend): v3→v4 前端对齐重构
review完没有问题仅微调前端代码
This commit is contained in:
ztkkOip
2026-07-17 11:42:19 +08:00
committed by GitHub
15 changed files with 1341 additions and 95 deletions
+1
View File
@@ -11,6 +11,7 @@ declare module 'vue' {
AppMain: typeof import('./src/components/Layout/AppMain.vue')['default']
AppSidebar: typeof import('./src/components/Layout/AppSidebar.vue')['default']
AuditLogTable: typeof import('./src/components/AuditLogTable.vue')['default']
BusinessLineSwitcher: typeof import('./src/components/BusinessLineSwitcher.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
+33 -8
View File
@@ -5,9 +5,12 @@ import { redirectToSSO } from '@/utils/sso'
export interface Subsystem {
id: number
name: string
label: string
description: string
icon: string
url: string
domain: string
category: string
status: 'integrated' | 'integrating'
sso_enabled: boolean
}
@@ -21,54 +24,72 @@ const mockSubsystems: Subsystem[] = [
{
id: 1,
name: 'Wayne',
description: '多集群容器管理平台',
label: '多集群发布平台',
description: '业务容器发布、命名空间与配额管理,复用 Wayne 原生多租户能力。',
icon: 'W',
url: 'https://wayne.qiniu.com',
domain: 'wayne.xinfra.internal',
category: '容器管理',
status: 'integrated',
sso_enabled: true,
},
{
id: 2,
name: 'CloudDM',
description: '数据库管理与SQL审核',
label: 'SQL 审核平台',
description: '数据库 SQL 上线统一审核,支持 LDAP 用户组到角色的自动映射。',
icon: 'DM',
url: 'https://clouddm.qiniu.com',
domain: 'clouddm.xinfra.internal',
category: '数据库',
status: 'integrated',
sso_enabled: true,
},
{
id: 3,
name: 'CacheCloud',
description: 'Redis 云管理平台',
label: 'Redis 管理平台',
description: 'Redis 实例全生命周期管理,登录与监控运维统一入口。',
icon: 'CC',
url: 'https://cachecloud.qiniu.com',
status: 'integrated',
domain: 'cachecloud.xinfra.internal',
category: '缓存',
status: 'integrating',
sso_enabled: true,
},
{
id: 4,
name: 'Apollo',
description: '配置中心',
label: '配置中心',
description: '统一配置管理,按机房 Cluster 隔离,支持灰度发布、版本回滚与变更审计。',
icon: 'AP',
url: 'https://apollo.xinfra.internal',
status: 'integrated',
domain: 'apollo.xinfra.internal',
category: '配置中心',
status: 'integrating',
sso_enabled: true,
},
{
id: 5,
name: 'qpass',
description: '密码管理平台',
label: '七牛统一运维平台',
description: '七牛云内部系统。',
icon: 'QP',
url: 'https://qpass.xinfra.internal',
domain: 'qpass.xinfra.internal',
category: '内部系统',
status: 'integrated',
sso_enabled: true,
},
{
id: 6,
name: 'Grafana',
description: '监控可视化平台',
label: '指标可视化平台',
description: '对接 VictoriaMetrics 数据源,K8s / 容器 / 业务指标统一仪表盘展示。',
icon: 'GF',
url: 'https://grafana.xinfra.internal',
domain: 'grafana.xinfra.internal',
category: '可观测性',
status: 'integrated',
sso_enabled: true,
},
@@ -83,6 +104,10 @@ export const subsystemApi = {
})
},
getSubsystemByName(name: string): Subsystem | undefined {
return mockSubsystems.find((s) => s.name.toLowerCase() === name.toLowerCase())
},
async getSSOUrl(id: number): Promise<ApiResponse<SSOResponse>> {
const subsystem = mockSubsystems.find((s) => s.id === id)
if (!subsystem) {
@@ -0,0 +1,230 @@
<template>
<div class="bl-switch" ref="switchRef">
<div class="bl-trigger" @click="toggleMenu">
<div
class="bl-ic"
:style="{ background: currentBL?.iconBg, color: currentBL?.iconColor }"
>
{{ currentBL?.iconText }}
</div>
<span class="bl-name-trigger">{{ currentBL?.name || '未选择' }}</span>
<span class="bl-role" v-if="currentBL">{{ currentBL.role }} · 授权 {{ authCount }} 子系统</span>
<span class="caret">▼</span>
</div>
<Transition name="bl-menu">
<div class="bl-menu" v-show="menuVisible">
<div class="bl-menu-label">切换业务线(仅展示被授权范围)</div>
<div
v-for="bl in businessLines"
:key="bl.id"
class="bl-opt"
:class="{ sel: bl.id === currentBL?.id, locked: !bl.authorized }"
@click="handleSelect(bl)"
>
<div
class="bl-ic"
:style="{ background: bl.iconBg, color: bl.iconColor }"
>
{{ bl.iconText }}
</div>
<div>
<div class="bl-name">{{ bl.name }}</div>
<div class="bl-sub">{{ bl.ou }} · {{ bl.role }}</div>
</div>
<span v-if="bl.authorized" class="bl-check">✓</span>
<span v-else class="bl-lock">🔒 无权限</span>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useBusinessLineStore, type BusinessLine } from '@/stores/businessLine'
const blStore = useBusinessLineStore()
const businessLines = computed(() => blStore.businessLines)
const currentBL = computed(() => blStore.currentBL)
const authCount = computed(() => blStore.authorizedBLs.length)
const menuVisible = ref(false)
const switchRef = ref<HTMLElement | null>(null)
function toggleMenu() {
menuVisible.value = !menuVisible.value
}
function handleSelect(bl: BusinessLine) {
if (!bl.authorized) return
blStore.switchBL(bl.id)
menuVisible.value = false
ElMessage.success(`已切换业务线:${bl.name}(资源与告警按授权范围过滤)`)
}
function onClickOutside(e: MouseEvent) {
if (switchRef.value && !switchRef.value.contains(e.target as Node)) {
menuVisible.value = false
}
}
onMounted(() => {
document.addEventListener('click', onClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', onClickOutside)
})
</script>
<style scoped>
.bl-switch {
position: relative;
}
.bl-trigger {
display: flex;
align-items: center;
gap: 9px;
background: var(--bg-panel-2);
border: 1px solid var(--line);
border-radius: 8px;
padding: 6px 12px;
color: var(--text-hi);
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: border-color 0.2s;
}
.bl-trigger:hover {
border-color: var(--accent);
}
.bl-trigger .bl-ic {
width: 20px;
height: 20px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-size: 10px;
font-weight: 600;
}
.bl-trigger .caret {
color: var(--text-dim);
font-size: 10px;
margin-left: 2px;
}
.bl-trigger .bl-role {
font-family: var(--mono);
font-size: 10px;
color: var(--text-dim);
}
.bl-name-trigger {
font-size: 12.5px;
}
.bl-menu {
position: absolute;
top: 44px;
left: 0;
width: 280px;
z-index: 60;
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 10px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.18);
padding: 6px;
}
.bl-menu-label {
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
padding: 8px 10px 4px;
}
.bl-opt {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: 7px;
cursor: pointer;
transition: background 0.15s;
}
.bl-opt:hover {
background: var(--bg-panel-2);
}
.bl-opt.sel {
background: var(--accent-dim);
}
.bl-opt .bl-ic {
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
}
.bl-opt .bl-name {
font-size: 13px;
color: var(--text-hi);
font-weight: 500;
}
.bl-opt .bl-sub {
font-size: 10.5px;
color: var(--text-dim);
font-family: var(--mono);
}
.bl-opt .bl-check {
margin-left: auto;
color: var(--accent);
font-size: 13px;
opacity: 0;
}
.bl-opt.sel .bl-check {
opacity: 1;
}
.bl-opt.locked {
opacity: 0.45;
cursor: not-allowed;
}
.bl-opt.locked .bl-lock {
margin-left: auto;
color: var(--text-dim);
font-size: 11px;
}
/* 下拉动画 */
.bl-menu-enter-active,
.bl-menu-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.bl-menu-enter-from,
.bl-menu-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>
+3 -1
View File
@@ -5,6 +5,7 @@
xinfra <span class="sub">统一基础设施平台</span>
</div>
<span class="env-pill">● 生产环境</span>
<BusinessLineSwitcher />
<div class="spacer"></div>
<div class="search-box">
<el-icon><Search /></el-icon>
@@ -23,7 +24,7 @@
<div class="avatar">{{ userInitial }}</div>
<div>
<div>{{ user?.display_name || '未登录' }}</div>
<div class="ldap-tag">LDAP · {{ user?.business_line || '' }}业务线</div>
<div class="ldap-tag">LDAP · {{ user?.business_line || '' }}</div>
</div>
<el-button class="logout-button" text @click="handleLogout">退出</el-button>
</div>
@@ -36,6 +37,7 @@ import { useAuthStore } from '@/stores/auth'
import { useTheme } from '@/composables/useTheme'
import { useAuth } from '@/composables/useAuth'
import { Moon, Sunny } from '@element-plus/icons-vue'
import BusinessLineSwitcher from '@/components/BusinessLineSwitcher.vue'
const authStore = useAuthStore()
const { logout } = useAuth()
+116 -24
View File
@@ -7,46 +7,59 @@
</router-link>
</div>
<div class="nav-group">
<div class="nav-label">机房 &amp; 网络</div>
<router-link to="/resource/status" class="nav-item" active-class="active">
<span class="ic">⬡</span>资源状态看板
<div class="nav-label">业务交付</div>
<router-link to="/service/catalog" class="nav-item" active-class="active">
<span class="ic">▤</span>基础服务
</router-link>
<router-link to="/cluster" class="nav-item" active-class="active">
<span class="ic">◆</span>集群 / 节点<span class="badge">7</span>
<router-link to="/tasks" class="nav-item" active-class="active">
<span class="ic">▧</span>任务日志<span class="badge">2</span>
</router-link>
</div>
<div class="nav-group">
<div class="nav-label">资源管理</div>
<div class="nav-label">资源纳管</div>
<router-link to="/resource/mgmt" class="nav-item" active-class="active">
<span class="ic">▥</span>资源管理<span class="badge">CMDB</span>
</router-link>
<router-link to="/service/mgmt" class="nav-item" active-class="active">
<span class="ic">◈</span>服务管理<span class="badge">Consul</span>
<router-link to="/cluster" class="nav-item" active-class="active">
<span class="ic">◆</span>集群与容器<span class="badge">3</span>
</router-link>
</div>
<div class="nav-group">
<div class="nav-label">服务交付</div>
<router-link to="/service/catalog" class="nav-item" active-class="active">
<span class="ic">▤</span>基础服务目录
</router-link>
<router-link to="/tasks" class="nav-item" active-class="active">
<span class="ic">▧</span>任务中心<span class="badge">2</span>
</router-link>
<router-link to="/config" class="nav-item" active-class="active">
<span class="ic">⚙</span>配置中心管理<span class="badge">Apollo</span>
<router-link to="/tenants" class="nav-item" active-class="active">
<span class="ic">▦</span>业务配额
</router-link>
</div>
<div class="nav-group">
<div class="nav-label">可观测性</div>
<router-link to="/monitor" class="nav-item" active-class="active">
<span class="ic">◎</span>监控 / 日志 / 告警
<span class="ic">◎</span>监控
</router-link>
<router-link to="/alert" class="nav-item" active-class="active">
<span class="ic">⚡</span>告警
</router-link>
</div>
<div class="nav-group">
<div class="nav-label">统一入口</div>
<router-link to="/subsystem" class="nav-item" active-class="active">
<span class="ic">↗</span>子系统导航
</router-link>
<div class="nav-item nav-expand" :class="{ active: isPortalActive }">
<router-link to="/subsystem" class="expand-label">
<span class="ic">↗</span>子系统导航
</router-link>
<span class="expand-toggle" @click="portalExpanded = !portalExpanded">
{{ portalExpanded ? '▾' : '▸' }}
</span>
</div>
<div v-show="portalExpanded" class="nav-children">
<router-link
v-for="sys in subsystems"
:key="sys.name"
:to="`/subsystem/detail/${sys.name.toLowerCase()}`"
class="nav-item nav-child"
active-class="active"
>
<span class="child-dot" :style="{ background: dotColor(sys.icon) }"></span>{{ sys.label }} {{ sys.name }}
</router-link>
<router-link to="/subsystem" class="nav-item nav-child" active-class="active">
<span class="ic" style="font-size:12px;">▦</span>全部总览
</router-link>
</div>
<router-link to="/subsystem/authz" class="nav-item" active-class="active">
<span class="ic">▦</span>子系统赋权
</router-link>
@@ -61,12 +74,41 @@
</router-link>
</div>
<div class="sidebar-foot">
xinfra v3 一期 · 七机房容器化<br>RKE2 · Calico BGP · Ceph · LAS<br>AWS TGW + WireGuard · LDAP 认证
</div>
</aside>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const portalExpanded = ref(true)
const subsystems = [
{ name: 'Wayne', label: '多集群发布平台', icon: 'W' },
{ name: 'CloudDM', label: 'SQL 审核平台', icon: 'DM' },
{ name: 'CacheCloud', label: 'Redis 管理平台', icon: 'CC' },
{ name: 'Apollo', label: '配置中心', icon: 'AP' },
{ name: 'qpass', label: '七牛统一运维平台', icon: 'QP' },
{ name: 'Grafana', label: '指标可视化平台', icon: 'GF' },
]
const isPortalActive = computed(() => {
return route.path.startsWith('/subsystem/detail/') || route.path === '/subsystem'
})
function dotColor(icon: string): string {
const colors: Record<string, string> = {
W: 'var(--tag-blue-text)',
DM: 'var(--tag-green-text)',
CC: 'var(--err)',
AP: 'var(--tag-blue-text)',
QP: 'var(--tag-amber-text)',
GF: 'var(--warn)',
}
return colors[icon] || 'var(--text-dim)'
}
</script>
<style scoped>
@@ -126,6 +168,10 @@
border-color: var(--accent);
}
.nav-item.active .expand-label {
color: var(--accent);
}
.nav-item .badge {
margin-left: auto;
font-family: var(--mono);
@@ -151,4 +197,50 @@
color: var(--text-dim);
line-height: 1.6;
}
.nav-expand {
justify-content: flex-start;
padding: 0;
user-select: none;
}
.expand-label {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
padding: 10px;
text-decoration: none;
color: inherit;
}
.expand-toggle {
padding: 10px;
font-size: 12px;
opacity: 0.6;
cursor: pointer;
border-radius: 0 6px 6px 0;
}
.expand-toggle:hover {
opacity: 1;
background: var(--bg-panel-2);
}
.nav-children {
padding-left: 8px;
}
.nav-child {
padding: 7px 10px 7px 26px;
font-size: 14px;
gap: 8px;
}
.child-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
</style>
+3 -15
View File
@@ -5,11 +5,11 @@
{{ system.icon }}
</div>
<div>
<h4>{{ system.name }}</h4>
<div class="ext-sub">{{ system.description }}</div>
<h4>{{ system.label }} {{ system.name }}</h4>
<div class="ext-sub">{{ system.domain }}</div>
</div>
</div>
<p>{{ description }}</p>
<p>{{ system.description }}</p>
<div class="sso-row">
<span class="sso-dot" :class="{ warn: system.status === 'integrating' }"></span>
{{ system.status === 'integrated' ? 'LDAP 原生配置接入 · 零代码' : 'LDAP 接入改造中' }}
@@ -49,18 +49,6 @@ const iconColor = computed(() => {
return colors[props.system.icon] || 'var(--text-mid)'
})
const description = computed(() => {
const descs: Record<string, string> = {
Wayne: '业务容器发布、命名空间与配额管理,复用 Wayne 原生多租户能力。',
CloudDM: '数据库 SQL 上线统一审核,支持 LDAP 用户组到角色的自动映射。',
CacheCloud: 'Redis 实例全生命周期管理,登录与监控运维统一入口。',
Apollo: '统一配置管理,按机房 Cluster 隔离,支持灰度发布、版本回滚与变更审计。',
qpass: '夜莺、Zabbix、VictoriaMetrics 告警统一收敛与值班通知,按 P0/P1 分级推送。',
Grafana: '对接 VictoriaMetrics 数据源,K8s / 容器 / 业务指标统一仪表盘展示。',
}
return descs[props.system.name] || ''
})
const handleClick = async () => {
if (props.system.sso_enabled) {
const { data } = await subsystemApi.getSSOUrl(props.system.id)
+20 -2
View File
@@ -34,6 +34,12 @@ const router = createRouter({
component: () => import('@/views/subsystem/Navigation.vue'),
meta: { title: '子系统导航' },
},
{
path: 'subsystem/detail/:name',
name: 'SubsystemDetail',
component: () => import('@/views/subsystem/SubsystemDetail.vue'),
meta: { title: '子系统详情' },
},
{
path: 'subsystem/authz',
name: 'SubsystemAuthz',
@@ -68,7 +74,13 @@ const router = createRouter({
path: 'cluster',
name: 'Cluster',
component: () => import('@/views/cluster/ClusterList.vue'),
meta: { title: '集群 / 节点' },
meta: { title: '集群与容器' },
},
{
path: 'tenants',
name: 'Tenants',
component: () => import('@/views/resource/Tenants.vue'),
meta: { title: '业务配额' },
},
{
path: 'service/catalog',
@@ -98,7 +110,13 @@ const router = createRouter({
path: 'monitor',
name: 'Monitor',
component: () => import('@/views/monitor/Monitor.vue'),
meta: { title: '监控 / 日志 / 告警' },
meta: { title: '监控' },
},
{
path: 'alert',
name: 'Alert',
component: () => import('@/views/monitor/Alert.vue'),
meta: { title: '告警' },
},
],
},
+107
View File
@@ -0,0 +1,107 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface BusinessLine {
id: string
name: string
ou: string
role: string
iconText: string
iconBg: string
iconColor: string
authorized: boolean
}
const STORAGE_KEY = 'xinfra-current-bl'
// TODO: 替换为真实 API 调用
const MOCK_BUSINESS_LINES: BusinessLine[] = [
{
id: 'las',
name: 'LAS 业务线',
ou: 'ou=las',
role: 'SRE',
iconText: 'LA',
iconBg: '#1A1430',
iconColor: '#C9A6FF',
authorized: true,
},
{
id: 'kodo',
name: 'Kodo 业务线',
ou: 'ou=kodo',
role: '研发',
iconText: 'KO',
iconBg: '#0E1C2C',
iconColor: '#8EC8FF',
authorized: true,
},
{
id: 'lingxi',
name: '灵矽 业务线',
ou: 'ou=lingxi',
role: '未授权',
iconText: 'LX',
iconBg: '#241B0A',
iconColor: '#FFC97A',
authorized: false,
},
{
id: 'ltoken',
name: 'LTOKEN 业务线',
ou: 'ou=ltoken',
role: '研发',
iconText: 'LT',
iconBg: '#1A2A1A',
iconColor: '#A6FFB0',
authorized: true,
},
{
id: 'maas',
name: 'MAAS 业务线',
ou: 'ou=maas',
role: '未授权',
iconText: 'MA',
iconBg: '#2A1A2A',
iconColor: '#FFB0E0',
authorized: false,
},
]
function loadSavedBL(): BusinessLine | null {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) return JSON.parse(saved)
} catch {
// ignore
}
return null
}
export const useBusinessLineStore = defineStore('businessLine', () => {
const businessLines = ref<BusinessLine[]>(MOCK_BUSINESS_LINES)
const currentBL = ref<BusinessLine | null>(loadSavedBL())
// 默认选中第一个已授权的业务线
if (!currentBL.value) {
currentBL.value = businessLines.value.find((bl) => bl.authorized) || null
}
const authorizedBLs = computed(() =>
businessLines.value.filter((bl) => bl.authorized),
)
function switchBL(id: string) {
const bl = businessLines.value.find((b) => b.id === id)
if (!bl || !bl.authorized) return
currentBL.value = bl
localStorage.setItem(STORAGE_KEY, JSON.stringify(bl))
}
return {
businessLines,
currentBL,
authorizedBLs,
switchBL,
}
})
+3 -3
View File
@@ -2,10 +2,10 @@
<div>
<div class="page-head">
<div>
<h1>集群 / 节点</h1>
<p>按机房切分的 RKE2 集群,Calico BGP 扁平网络</p>
<h1>集群与容器</h1>
<p>统一建设容器池 · RKE2 上建 Wayne 后新服务的标准发布流程 · Calico BGP 扁平网络</p>
</div>
<el-button type="primary">+ 加入新节点</el-button>
<el-button type="primary">同步</el-button>
</div>
<div class="panel">
+85
View File
@@ -0,0 +1,85 @@
<template>
<div>
<div class="page-head">
<div>
<h1>告警</h1>
<p>夜莺(Nightingale)统一告警引擎 · qpass 推送</p>
</div>
</div>
<div class="cols-2-equal">
<div class="panel">
<div class="panel-head">
<h3>当前告警</h3>
<span class="meta">实时</span>
</div>
<div class="panel-body">
<table>
<tbody>
<tr class="tr-hover">
<td>硬件告警</td>
<td class="status-text warn">● 2 条</td>
</tr>
<tr class="tr-hover">
<td>业务告警</td>
<td class="status-text ok">● 0 条</td>
</tr>
<tr class="tr-hover">
<td>今日已推送</td>
<td class="mono strong">5</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>告警通道</h3>
<span class="meta">qpass</span>
</div>
<div class="panel-body">
<table>
<tbody>
<tr class="tr-hover">
<td>推送状态</td>
<td class="status-text ok">● 正常</td>
</tr>
<tr class="tr-hover">
<td>通道类型</td>
<td>企业微信 · 钉钉 · 短信</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>告警记录</h3>
<span class="meta">近 24h</span>
</div>
<div class="panel-body log-stream">
<div v-for="(alert, index) in alerts" :key="index" :class="['task-log-line', alert.class]">
<span class="t">{{ alert.time }}</span>{{ alert.message }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const alerts = ref([
{ time: '07:38:55', message: '[Zabbix] yzh-las-014 风扇转速低于阈值(FAN_6: 1920 RPM)', class: '' },
{ time: '07:38:55', message: '[Zabbix] yzh-las-014 风扇转速低于阈值(FAN_5: 1880 RPM)', class: '' },
{ time: '07:30:00', message: '[qpass] 告警已合并推送:1 条硬件告警 + 0 条业务告警', class: '' },
{ time: '06:15:22', message: '[Nightingale] sg-las-007 CPU 使用率恢复(当前 42%)', class: 'ok' },
{ time: '05:48:10', message: '[Nightingale] sg-las-007 CPU 使用率超过 90% 持续 5 分钟', class: '' },
])
</script>
<style scoped>
</style>
+3 -23
View File
@@ -2,8 +2,8 @@
<div>
<div class="page-head">
<div>
<h1>监控 / 日志 / 告警</h1>
<p>VictoriaMetrics 指标 · Zabbix 硬件状态 · ELK 日志中心 · 夜莺(Nightingale)统一告警引擎对接 qpass</p>
<h1>监控</h1>
<p>VictoriaMetrics 指标 · Zabbix 硬件状态</p>
</div>
</div>
@@ -67,32 +67,12 @@
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>日志 + 统一告警</h3>
<span class="meta">Filebeat → Kafka → ES → Kibana · VM + Zabbix → qpass</span>
</div>
<div class="panel-body log-stream">
<div v-for="(log, index) in logs" :key="index" :class="['task-log-line', log.class]">
<span class="t">{{ log.time }}</span>{{ log.message }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const logs = ref([
{ time: '07:39:10', message: 'CMDB 采集器同步完成:xs291 · 华东-杭州下沙机房', class: '' },
{ time: '07:38:55', message: '[Zabbix] 告警:yzh-las-014 风扇转速低于阈值(FAN_6: 1920 RPM)', class: '' },
{ time: '07:38:02', message: '[VictoriaMetrics] 七机房抓取任务全部正常', class: 'ok' },
{ time: '07:35:41', message: '[ELK] sg-las-007 应用日志接入 Kafka topic:las-sg-app', class: '' },
{ time: '07:30:00', message: '[qpass] 告警已合并推送:1 条硬件告警 + 0 条业务告警', class: '' },
])
</script>
<style scoped>
/* .page-head, .cols-2-equal, .panel, table, .status-text, .log-stream, .task-log-line 已在 global.css 中定义 */
/* .page-head, .cols-2-equal, .panel, table, .status-text 已在 global.css 中定义 */
</style>
+1 -1
View File
@@ -5,7 +5,7 @@
<h1>资源管理</h1>
<p>统一纳管物理机与虚机资源 · 基础数据来自 SINA CMDB</p>
</div>
<el-button type="primary">+ 创建虚机</el-button>
<el-button type="primary">同步</el-button>
</div>
<div class="stat-row">
+122
View File
@@ -0,0 +1,122 @@
<template>
<div>
<div class="page-head">
<div>
<h1>业务配额</h1>
<p>按业务线划分资源配额与隔离策略 · RBAC 权限随业务线自动具备</p>
</div>
<el-button type="primary">+ 新增业务线</el-button>
</div>
<div class="panel">
<div class="panel-head">
<h3>业务线列表</h3>
<span class="meta">按业务线隔离命名空间、节点标签与配额</span>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>业务线</th>
<th>标识</th>
<th>命名空间</th>
<th>节点数</th>
<th>CPU 配额</th>
<th>内存配额</th>
<th>状态</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="tenant in tenants" :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>
</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>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const tenants = ref([
{
name: 'LAS',
key: 'las',
namespace: 'ns-las-prod',
nodes: 12,
cpuPct: 64,
cpuClass: '',
memPct: 71,
memClass: '',
status: '正常',
statusClass: 'ok',
},
{
name: 'KODO',
key: 'kodo',
namespace: 'ns-kodo-prod',
nodes: 8,
cpuPct: 45,
cpuClass: '',
memPct: 52,
memClass: '',
status: '正常',
statusClass: 'ok',
},
{
name: 'PILI',
key: 'pili',
namespace: 'ns-pili-prod',
nodes: 6,
cpuPct: 82,
cpuClass: 'warn',
memPct: 78,
memClass: '',
status: '资源告警',
statusClass: 'warn',
},
{
name: 'QVM',
key: 'qvm',
namespace: 'ns-qvm-prod',
nodes: 4,
cpuPct: 23,
cpuClass: '',
memPct: 31,
memClass: '',
status: '正常',
statusClass: 'ok',
},
])
</script>
<style scoped>
/* 公共样式已在 global.css 中定义 */
</style>
+442 -18
View File
@@ -3,59 +3,304 @@
<div class="page-head">
<div>
<h1>基础服务目录</h1>
<p>点击卡片,通过封装好的 ansible-playbook 一键部署标准化基础服务</p>
<p>点击卡片,通过封装好的 ansible-playbook 一键标准化交付,完成后自动注册进对应子系统</p>
</div>
</div>
<div class="svc-grid">
<div v-for="service in services" :key="service.name" class="svc-card" @click="handleDeploy(service)">
<div
v-for="service in services"
:key="service.name"
class="svc-card"
:class="{ disabled: service.disabled }"
:style="service.disabled ? { opacity: service.opacity } : {}"
@click="handleCardClick(service)"
>
<div class="svc-top">
<div class="svc-ic" :style="{ background: service.bgColor, color: service.iconColor }">
{{ service.icon }}
</div>
<h4>{{ service.name }}</h4>
<h4 :style="service.disabled && service.icon === '+' ? { color: 'var(--text-dim)' } : {}">{{ service.name }}</h4>
</div>
<div class="svc-desc">{{ service.description }}</div>
<div class="svc-foot">
<span class="playbook">{{ service.playbook }}</span>
<span>{{ service.version }}</span>
<span :style="service.statusColor ? { color: service.statusColor } : {}">{{ service.version }}</span>
</div>
</div>
</div>
<!-- 弹窗:发布服务 · 跳 Wayne -->
<Teleport to="body">
<div v-if="showWayneModal" class="overlay" @click.self="showWayneModal = false">
<div class="modal">
<div class="modal-head">
<h3>发布服务 · 跳 Wayne</h3>
<button class="close" @click="showWayneModal = false">&times;</button>
</div>
<div class="modal-body">
<div class="field-row">
<div class="field">
<label>业务线</label>
<select v-model="wayneForm.bl">
<option value="las">las(当前)</option>
<option value="kodo">kodo</option>
</select>
</div>
<div class="field">
<label>命名空间</label>
<select v-model="wayneForm.namespace">
<option value="ns-las-prod">ns-las-prod</option>
</select>
</div>
</div>
<div class="field-row">
<div class="field">
<label>目标集群</label>
<select v-model="wayneForm.cluster">
<option value="rke2-sh-prod-01">rke2-sh-prod-01(华东)</option>
<option value="rke2-bj-prod-01">rke2-bj-prod-01(华北)</option>
</select>
</div>
<div class="field">
<label>规格</label>
<select v-model="wayneForm.spec">
<option value="2C4G">2C 4G × 3 副本</option>
<option value="4C8G">4C 8G × 3 副本</option>
</select>
</div>
</div>
<div class="field">
<label>服务名称</label>
<input v-model="wayneForm.serviceName" placeholder="las-search-api" />
</div>
<div class="tooltip-note">
凭 SSO 身份免登进入 <b>Wayne</b>,在 ns-las-prod 下部署服务;权限随业务线 / 角色自动具备,仅可操作被授权 namespace。
</div>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" @click="showWayneModal = false">取消</button>
<button class="btn btn-primary" @click="handleWayneSubmit">跳 Wayne 发布 ↗</button>
</div>
</div>
</div>
</Teleport>
<!-- 弹窗:数据库标准化交付 · MySQL -->
<Teleport to="body">
<div v-if="showMysqlModal" class="overlay" @click.self="showMysqlModal = false">
<div class="modal">
<div class="modal-head">
<h3>数据库标准化交付 · MySQL</h3>
<button class="close" @click="showMysqlModal = false">&times;</button>
</div>
<div class="modal-body">
<div class="field-row">
<div class="field">
<label>业务线</label>
<select v-model="mysqlForm.bl">
<option value="las">las(当前)</option>
<option value="kodo">kodo</option>
</select>
</div>
<div class="field">
<label>架构模式</label>
<select v-model="mysqlForm.topology">
<option value="1m2r">一主两从</option>
<option value="1m1r">一主一从</option>
</select>
</div>
</div>
<div class="field-row">
<div class="field">
<label>规格</label>
<select v-model="mysqlForm.spec">
<option value="8C32G">8C 32G · 500G SSD(Ceph RBD)</option>
<option value="16C64G">16C 64G · 1T SSD</option>
</select>
</div>
<div class="field">
<label>版本</label>
<select v-model="mysqlForm.version">
<option value="8.0.36">MySQL 8.0.36</option>
<option value="5.7.44">MySQL 5.7.44</option>
</select>
</div>
</div>
<div class="field">
<label>实例名称</label>
<input v-model="mysqlForm.instanceName" placeholder="mysql-las-billing-02" />
</div>
<div class="yaml-preview"><span class="c"># ansible-runner 调用预览</span>
<span class="k">playbook</span>: roles/mysql-deploy
<span class="k">tenant</span>: <span class="s">{{ mysqlForm.bl }}</span>
<span class="k">topology</span>: <span class="s">{{ mysqlForm.topology === '1m2r' ? '1-master-2-replica' : '1-master-1-replica' }}</span>
<span class="k">node_selector</span>: <span class="s">business-line={{ mysqlForm.bl }}</span>
<span class="k">storage_class</span>: <span class="s">ceph-rbd-ssd</span>
<span class="k">register_to</span>: <span class="s">clouddm</span> <span class="c"># 自动注册数据源,跳 CloudDM 上线</span></div>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" @click="showMysqlModal = false">取消</button>
<button class="btn btn-primary" @click="handleMysqlSubmit">确认交付</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
const services = ref([
{ name: 'MySQL', icon: 'My', bgColor: 'var(--tag-blue-bg)', iconColor: 'var(--tag-blue-text)', description: '一主多从,自动注册进 open-cdm 数据源,支持 LDAP 账号统一审核 SQL 上线。', playbook: 'roles/mysql-deploy', version: 'v8.0.36' },
{ name: 'Redis', icon: 'Rd', bgColor: 'var(--logo-cc-bg)', iconColor: 'var(--err)', description: '支持 Standalone / Sentinel / Cluster,部署完成后自动注册进 CacheCloud。', playbook: 'roles/redis-deploy', version: 'v7.2' },
{ name: 'openresty', icon: 'Or', bgColor: 'var(--logo-dm-bg)', iconColor: 'var(--tag-green-text)', description: '容器化部署在 K8s 内,作为业务边缘网关,支持灰度路由配置。', playbook: 'roles/openresty-deploy', version: 'v1.25' },
{ name: 'dpvs', icon: 'Dp', bgColor: 'var(--logo-qp-bg)', iconColor: 'var(--tag-amber-text)', description: '四层负载均衡,直接管理宿主机网络命名空间,不纳入容器编排。', playbook: 'roles/dpvs-deploy', version: 'v1.9' },
{ name: 'PgSQL', icon: 'Pg', bgColor: 'var(--tag-purple-bg)', iconColor: 'var(--tag-purple-text)', description: '流复制主从架构,规划中 · 第二期接入 open-cdm 统一审核体系。', playbook: 'roles/pgsql-deploy', version: '规划中' },
const router = useRouter()
interface Service {
name: string
icon: string
bgColor: string
iconColor: string
description: string
playbook: string
version: string
statusColor?: string
disabled?: boolean
opacity?: number
modal?: string
}
const services = ref<Service[]>([
{
name: '容器服务(Wayne)',
icon: 'Wy',
bgColor: '#1C2A3A',
iconColor: '#7FB8FF',
description: '在 RKE2 容器池标准发布业务服务,跳 Wayne 免登管理多集群与命名空间。',
playbook: 'wayne · ns-las-prod',
version: '',
modal: 'wayne',
},
{
name: 'MySQL',
icon: 'My',
bgColor: '#1C2E4A',
iconColor: '#7FB8FF',
description: '一主多从标准化交付,完成后自动注册进 CloudDM / open-cdm 数据源,统一审核 SQL 上线。',
playbook: 'roles/mysql-deploy',
version: 'v8.0.36',
modal: 'mysql',
},
{
name: 'openresty',
icon: 'Or',
bgColor: '#1C3A2E',
iconColor: '#7FFFC2',
description: '容器化部署在 K8s 内,作为业务边缘网关,支持灰度路由配置。',
playbook: 'roles/openresty-deploy',
version: 'v1.25',
modal: 'openresty',
},
{
name: 'PgSQL',
icon: 'Pg',
bgColor: '#2E1C3A',
iconColor: '#C9A6FF',
description: '流复制主从架构,规划中 · 接入 CloudDM 统一审核体系。',
playbook: 'roles/pgsql-deploy',
version: '规划中',
statusColor: 'var(--text-dim)',
disabled: true,
opacity: 0.8,
},
{
name: 'Redis',
icon: 'Rd',
bgColor: '#3A1C28',
iconColor: '#FF8E9C',
description: 'Redis 管理由 CacheCloud 承载,规划接入后开放标准化交付。',
playbook: 'CacheCloud',
version: '规划接入',
disabled: true,
opacity: 0.6,
},
{
name: '接入新服务',
icon: '+',
bgColor: '#262C38',
iconColor: 'var(--text-dim)',
description: '封装新的 ansible-playbook,注册进基础服务目录',
playbook: '',
version: '',
disabled: true,
opacity: 0.5,
},
])
const handleDeploy = (service: any) => {
ElMessage.success(`${service.name} 部署任务已提交`)
// 弹窗状态
const showWayneModal = ref(false)
const showMysqlModal = ref(false)
const wayneForm = reactive({
bl: 'las',
namespace: 'ns-las-prod',
cluster: 'rke2-sh-prod-01',
spec: '2C4G',
serviceName: 'las-search-api',
})
const mysqlForm = reactive({
bl: 'las',
topology: '1m2r',
spec: '8C32G',
version: '8.0.36',
instanceName: 'mysql-las-billing-02',
})
const handleCardClick = (service: Service) => {
if (service.disabled) {
if (service.name === '接入新服务') return
ElMessage.warning(`${service.name} 功能规划中,敬请期待`)
return
}
if (service.modal === 'wayne') {
showWayneModal.value = true
} else if (service.modal === 'mysql') {
showMysqlModal.value = true
} else if (service.modal === 'openresty') {
ElMessage.info('openresty 部署功能对接中,敬请期待')
}
}
const handleWayneSubmit = () => {
showWayneModal.value = false
ElMessage.success('已跳转 Wayne 并提交发布任务')
router.push({ name: 'Tasks' })
}
const handleMysqlSubmit = () => {
showMysqlModal.value = false
ElMessage.success('MySQL 标准化交付已提交,完成后自动注册 CloudDM')
router.push({ name: 'Tasks' })
}
</script>
<style scoped>
/* .page-head, .svc-grid 已在 global.css 中定义 */
.svc-card {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: border-color 0.15s;
}
.svc-card:hover {
border-color: var(--hover-border);
.svc-card:hover:not(.disabled) {
border-color: #3A4356;
}
.svc-card.disabled {
cursor: not-allowed;
}
.svc-top {
@@ -102,4 +347,183 @@ h4 {
font-family: var(--mono);
color: var(--text-mid);
}
/* ========== overlay / modal ========== */
.overlay {
position: fixed;
inset: 0;
background: rgba(7, 9, 13, 0.72);
backdrop-filter: blur(2px);
display: flex;
align-items: flex-start;
justify-content: center;
z-index: 50;
padding-top: 6vh;
}
.modal {
width: 560px;
max-width: 92vw;
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.5);
}
.modal-head {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--line-soft, var(--line));
}
.modal-head h3 {
margin: 0;
font-size: 14.5px;
}
.modal-head .close {
margin-left: auto;
color: var(--text-dim);
font-size: 18px;
border: none;
background: none;
cursor: pointer;
padding: 4px;
line-height: 1;
}
.modal-body {
padding: 18px;
}
.modal-foot {
padding: 14px 18px;
border-top: 1px solid var(--line-soft, var(--line));
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* ========== form fields ========== */
.field {
margin-bottom: 14px;
}
.field label {
display: block;
font-size: 11.5px;
color: var(--text-dim);
margin-bottom: 6px;
}
.field select,
.field input {
width: 100%;
background: var(--bg-panel-2, var(--bg-panel));
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px 10px;
color: var(--text-hi);
font-size: 12.5px;
font-family: var(--sans, inherit);
outline: none;
}
.field select:focus,
.field input:focus {
border-color: var(--accent-dim, var(--accent));
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
/* ========== yaml preview ========== */
.yaml-preview {
background: #0B0D12;
border: 1px solid var(--line-soft, var(--line));
border-radius: 6px;
padding: 12px 14px;
font-family: var(--mono);
font-size: 11.5px;
color: #8EC8FF;
line-height: 1.7;
white-space: pre;
overflow-x: auto;
}
.yaml-preview .k {
color: #7FB8FF;
}
.yaml-preview .s {
color: #FFC97A;
}
.yaml-preview .c {
color: var(--text-dim);
}
/* ========== tooltip note ========== */
.tooltip-note {
background: #0F261D;
border: 1px solid var(--accent-dim, var(--accent));
border-radius: 8px;
padding: 12px 14px;
font-size: 12px;
color: var(--text-mid);
line-height: 1.6;
margin-top: 4px;
}
.tooltip-note b {
color: var(--accent);
}
/* ========== buttons ========== */
.btn {
border-radius: 6px;
border: 1px solid var(--line);
background: var(--bg-panel-2, var(--bg-panel));
color: var(--text-hi);
padding: 8px 14px;
font-size: 12.5px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-family: inherit;
}
.btn:hover {
border-color: #3A4356;
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #06150F;
font-weight: 600;
}
.btn-primary:hover {
background: #55E8B3;
}
.btn-ghost {
background: transparent;
color: var(--text-mid);
border-color: transparent;
}
.btn-ghost:hover {
color: var(--text-hi);
background: var(--bg-panel-2, var(--bg-panel));
}
</style>
@@ -0,0 +1,172 @@
<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 === 'integrated' ? '' : 'warn'">
{{ system.status === 'integrated' ? '● 已接入' : '● 接入中' }}
</span>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="label">接入状态</div>
<div class="value" :class="system.status === 'integrated' ? 'accent' : 'warn'">
{{ system.status === 'integrated' ? '已接入' : '接入中' }}
</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>
<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>
</div>
<div class="panel">
<div class="panel-head">
<h3>统一入口</h3>
<span class="meta">{{ system.name }} · LDAP 单点登录</span>
</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 === 'integrating' }"></span>
{{ system.status === 'integrated' ? 'LDAP 原生配置接入 · 已上线' : 'LDAP 接入改造中' }}
</div>
<button class="btn btn-primary" style="margin-top:8px;align-self:flex-start;" @click="openPortal">
打开 {{ system.name }} ↗
</button>
</div>
</div>
</div>
</div>
<div v-else class="page-head">
<div><h1>子系统未找到</h1><p>请从左侧导航选择一个子系统</p></div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { subsystemApi, type Subsystem } from '@/api/subsystem'
import { getToken } from '@/utils/auth'
import { redirectToSSO } from '@/utils/sso'
const route = useRoute()
const system = computed<Subsystem | undefined>(() => {
const name = route.params.name as string
return subsystemApi.getSubsystemByName(name)
})
const bgColor = computed(() => {
if (!system.value) return 'var(--bg-panel-2)'
const colors: Record<string, string> = {
W: 'var(--logo-w-bg)',
DM: 'var(--logo-dm-bg)',
CC: 'var(--logo-cc-bg)',
AP: 'var(--logo-ap-bg)',
QP: 'var(--logo-qp-bg)',
GF: 'var(--logo-gf-bg)',
}
return colors[system.value.icon] || 'var(--bg-panel-2)'
})
const iconColor = computed(() => {
if (!system.value) return 'var(--text-mid)'
const colors: Record<string, string> = {
W: 'var(--tag-blue-text)',
DM: 'var(--tag-green-text)',
CC: 'var(--err)',
AP: 'var(--tag-blue-text)',
QP: 'var(--tag-amber-text)',
GF: 'var(--warn)',
}
return colors[system.value.icon] || 'var(--text-mid)'
})
const detailDesc = computed(() => {
if (!system.value) return ''
const descs: Record<string, string> = {
Wayne: '业务容器发布、命名空间与配额管理,复用 Wayne 原生多租户能力。所有集群的容器发布均可通过此入口统一操作。',
CloudDM: '数据库 SQL 上线统一审核,支持 LDAP 用户组到角色的自动映射。变更记录可追溯,审计合规。',
CacheCloud: 'Redis 实例全生命周期管理,登录与监控运维统一入口。按业务线隔离实例,统一密码管理。',
Apollo: '统一管理多机房 Config Service Cluster 的配置发布、灰度、回滚与变更审计,所有机房配置项均可在此单一入口检索与编辑。',
qpass: '七牛云内部系统。',
Grafana: '对接 VictoriaMetrics 数据源,K8s / 容器 / 业务指标统一仪表盘展示。预置核心看板,支持自定义。',
}
return descs[system.value.name] || system.value.description
})
const openPortal = async () => {
if (!system.value) return
if (system.value.sso_enabled) {
try {
const { data } = await subsystemApi.getSSOUrl(system.value.id)
window.location.assign(data.sso_url)
} catch {
// 错误已由 request 拦截器统一处理
}
} else {
window.open(system.value.url, '_blank')
}
}
</script>
<style scoped>
.env-pill {
font-size: 13px;
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);
}
.panel-body .ext-card {
display: flex;
flex-direction: column;
gap: 10px;
}
.btn-primary {
background: var(--accent);
color: #fff;
border: none;
padding: 8px 18px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-primary:hover {
opacity: 0.85;
}
</style>