初始提交:OCI 面板前端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:29 +08:00
commit db45a669e3
135 changed files with 25220 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { NPopselect } from 'naive-ui'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SidebarNav from './SidebarNav.vue'
import { logout as logoutApi } from '@/api/auth'
import SerialConsolePanel from '@/components/instance/SerialConsolePanel.vue'
import TenantPicker from '@/components/TenantPicker.vue'
import { regionCity } from '@/composables/useFormat'
import { useRegionAlias } from '@/composables/useRegionAlias'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { useConsoleStore } from '@/stores/console'
import { useScopeStore } from '@/stores/scope'
const app = useAppStore()
const auth = useAuthStore()
const scope = useScopeStore()
const consoleStore = useConsoleStore()
const route = useRoute()
const router = useRouter()
const { alias } = useRegionAlias()
/** 胶囊内区域只显城市短名(友好名括号内文字),完整名留在菜单与悬浮提示 */
const regionFull = computed(() => alias(scope.region))
const regionShort = computed(() => regionCity(regionFull.value))
function pickTenant(id: number | null) {
if (id !== null) scope.cfgId = id
}
const crumbTitle = computed(() => {
const map: Record<string, string> = {
overview: '总览',
tenants: '租户',
'tenant-detail': '租户',
instances: '实例',
'instance-detail': '实例',
network: '网络',
'vcn-detail': '网络',
'boot-volumes': '存储 / 引导卷',
tasks: '任务',
proxies: '代理',
logs: '日志',
settings: '设置',
}
return map[String(route.name)] ?? (route.meta.title as string | undefined) ?? ''
})
async function logout() {
// 先通知服务端拉黑当前令牌(失败不阻塞登出),再清本地会话
await logoutApi().catch(() => {})
auth.logout()
void router.push({ name: 'login' })
}
</script>
<template>
<div class="flex min-h-screen">
<SidebarNav :collapsed="app.sidebarCollapsed" @logout="logout" />
<main class="flex min-w-0 flex-1 flex-col">
<header
class="flex h-14 flex-none items-center justify-between gap-3 border-b border-line pr-6 pl-4"
>
<div class="flex min-w-0 items-center gap-2.5">
<button
class="flex h-8 w-8 flex-none cursor-pointer items-center justify-center rounded-md text-ink-2 hover:bg-wash hover:text-ink"
title="收起 / 展开侧栏"
aria-label="收起或展开侧栏"
@click="app.toggleSidebar()"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
</button>
<div class="min-w-0 truncate text-[13px] whitespace-nowrap text-ink-3">
OCI Portal <span class="mx-1.5">/</span>
<b class="font-medium text-ink">{{ crumbTitle }}</b>
</div>
</div>
<div class="flex flex-none items-center gap-2">
<button
class="flex h-8 w-8 flex-none cursor-pointer items-center justify-center rounded-md text-ink-2 hover:bg-wash hover:text-ink"
:title="app.dark ? '切换到浅色模式' : '切换到暗色模式'"
:aria-label="app.dark ? '切换到浅色模式' : '切换到暗色模式'"
@click="app.toggleDark()"
>
<!-- 暗色下显示太阳点击回浅色浅色下显示月亮 -->
<svg
v-if="app.dark"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="12" cy="12" r="4" />
<path
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32 1.41-1.41"
/>
</svg>
<svg
v-else
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
<!-- 全局作用域胶囊租户段(选择器面板自带分组导航与搜索) + 区域段 -->
<div
class="flex h-8 flex-none items-center overflow-hidden rounded-full border border-line bg-white"
>
<TenantPicker
:configs="scope.configs.data ?? []"
:loading="scope.configs.loading"
:value="scope.cfgId"
placement="bottom-end"
@update:value="pickTenant"
>
<template #trigger>
<button
class="flex h-8 cursor-pointer items-center gap-1.5 px-3.5 hover:bg-row-hover"
:title="scope.currentConfig?.tenancyName || scope.currentConfig?.alias"
>
<svg
class="h-[13px] w-[13px] flex-none text-ink-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 21h18M5 21V7l7-4 7 4v14M9 21v-6h6v6" />
</svg>
<span class="max-w-[200px] truncate text-[13px] max-lg:max-w-[120px] max-md:hidden">
{{ scope.currentConfig?.alias ?? '选择租户' }}
</span>
<svg
class="h-3 w-3 flex-none text-ink-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</template>
</TenantPicker>
<span class="h-[18px] w-px flex-none bg-line"></span>
<NPopselect
v-model:value="scope.region"
:options="scope.regionOptions"
trigger="click"
placement="bottom-end"
scrollable
:disabled="scope.regionDisabled"
>
<button
class="flex h-full items-center gap-1.5 px-3.5"
:class="
scope.regionDisabled
? 'cursor-default text-ink-3'
: 'cursor-pointer hover:bg-row-hover'
"
:title="
scope.regionDisabled ? `${regionFull} · 该租户未开启多区域支持,锁定默认区域` : regionFull
"
>
<svg
class="h-[13px] w-[13px] flex-none text-ink-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 0 20 15.3 15.3 0 0 1 0-20" />
</svg>
<span class="max-w-[160px] truncate text-[13px] max-md:hidden">
{{ regionShort }}
</span>
<svg
v-if="!scope.regionDisabled"
class="h-3 w-3 flex-none text-ink-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</NPopselect>
</div>
</div>
</header>
<!-- 内容区限宽居中避免超宽屏下的空置感 -->
<div class="mx-auto w-full max-w-[1560px]">
<RouterView />
</div>
</main>
<!-- 串行控制台全局挂载切换路由不断开会话 -->
<SerialConsolePanel
v-model:show="consoleStore.show"
:cfg-id="consoleStore.cfgId"
:instance-id="consoleStore.instanceId"
:region="consoleStore.region"
:instance-name="consoleStore.instanceName"
:root-password="consoleStore.rootPassword"
/>
</div>
</template>
+240
View File
@@ -0,0 +1,240 @@
<script setup lang="ts">
import { h, type Component } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import AppLogo from '@/components/AppLogo.vue'
defineProps<{ collapsed: boolean }>()
const emit = defineEmits<{ logout: [] }>()
const route = useRoute()
interface NavItem {
name: string
label: string
icon: Component
activeNames: string[]
}
interface NavGroup {
label: string
items: NavItem[]
}
function icon(paths: string): Component {
return () =>
h('svg', {
class: 'h-4 w-4 flex-none',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.5',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
innerHTML: paths,
})
}
const groups: NavGroup[] = [
{
label: '概览',
items: [
{
name: 'overview',
label: '总览',
activeNames: ['overview'],
icon: icon(
'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
),
},
],
},
{
label: '资源',
items: [
{
name: 'tenants',
label: '租户',
activeNames: ['tenants', 'tenant-detail'],
icon: icon(
'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
),
},
{
name: 'instances',
label: '实例',
activeNames: ['instances', 'instance-detail'],
icon: icon(
'<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>',
),
},
{
name: 'network',
label: '网络',
activeNames: ['network', 'vcn-detail'],
icon: icon(
'<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
),
},
{
name: 'boot-volumes',
label: '存储',
activeNames: ['boot-volumes'],
icon: icon(
'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/>',
),
},
],
},
{
label: '自动化',
items: [
{
name: 'tasks',
label: '任务',
activeNames: ['tasks'],
icon: icon('<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>'),
},
{
name: 'ai-gateway',
label: 'AI 网关',
activeNames: ['ai-gateway'],
icon: icon(
'<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z"/>',
),
},
],
},
{
label: '系统',
items: [
{
name: 'proxies',
label: '代理',
activeNames: ['proxies'],
icon: icon(
'<path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/>',
),
},
{
name: 'logs',
label: '日志',
activeNames: ['logs'],
icon: icon(
'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>',
),
},
{
name: 'settings',
label: '设置',
activeNames: ['settings'],
icon: icon(
'<line x1="21" y1="4" x2="14" y2="4"/><line x1="10" y1="4" x2="3" y2="4"/><line x1="21" y1="12" x2="12" y2="12"/><line x1="8" y1="12" x2="3" y2="12"/><line x1="21" y1="20" x2="16" y2="20"/><line x1="12" y1="20" x2="3" y2="20"/><line x1="14" y1="2" x2="14" y2="6"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="16" y1="18" x2="16" y2="22"/>',
),
},
],
},
]
function isActive(item: NavItem): boolean {
return item.activeNames.includes(String(route.name))
}
function itemClass(item: NavItem): string {
const base =
'mb-px flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-[7px] text-[13.5px] whitespace-nowrap'
return isActive(item)
? `${base} bg-wash-deep font-medium text-ink shadow-[inset_2px_0_0_var(--color-accent)]`
: `${base} text-ink-2 hover:bg-wash hover:text-ink`
}
</script>
<template>
<aside
class="sb-aside sticky top-0 flex h-screen flex-none flex-col border-r border-line bg-bg transition-[width] duration-150"
:class="collapsed ? 'collapsed' : ''"
>
<div
class="flex items-center gap-2 px-5 pt-5 pb-4 whitespace-nowrap"
:class="collapsed ? 'justify-center px-0!' : ''"
>
<AppLogo :size="24" class="flex-none" />
<span v-if="!collapsed" class="font-serif text-lg font-semibold max-md:hidden">
OCI Portal
</span>
</div>
<nav class="flex-1 overflow-x-hidden overflow-y-auto px-3">
<template v-for="group in groups" :key="group.label">
<div
v-if="!collapsed"
class="px-2 pt-3.5 pb-1.5 text-[11px] font-semibold tracking-wider whitespace-nowrap text-ink-3 max-md:hidden"
>
{{ group.label }}
</div>
<div v-else class="pt-3.5" />
<RouterLink
v-for="item in group.items"
:key="item.name"
:to="{ name: item.name }"
custom
>
<template #default="{ navigate }">
<div
:class="[itemClass(item), collapsed ? 'justify-center gap-0 px-0' : '']"
class="max-md:justify-center max-md:gap-0 max-md:px-0"
:title="item.label"
@click="navigate"
>
<component :is="item.icon" />
<span v-if="!collapsed" class="max-md:hidden">{{ item.label }}</span>
</div>
</template>
</RouterLink>
</template>
</nav>
<div
class="flex items-center gap-2.5 border-t border-line px-5 py-3.5"
:class="collapsed ? 'justify-center px-0!' : ''"
:data-collapsed="collapsed"
>
<div
class="flex h-7 w-7 flex-none items-center justify-center rounded-full border border-line bg-wash-deep text-xs font-semibold text-ink-2"
>
A
</div>
<div v-if="!collapsed" class="min-w-0 flex-1 max-md:hidden">
<div class="text-[13px] font-medium">admin</div>
<div class="text-[11.5px] text-ink-3">管理员</div>
</div>
<button
v-if="!collapsed"
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-ink-2 hover:bg-wash hover:text-ink max-md:hidden"
title="退出登录"
aria-label="退出登录"
@click="emit('logout')"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
</div>
</aside>
</template>
<style scoped>
.sb-aside {
width: 232px;
}
.sb-aside.collapsed {
width: 64px;
}
@media (max-width: 768px) {
.sb-aside {
width: 64px !important;
}
}
</style>