初始提交: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
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import {
NConfigProvider,
NMessageProvider,
NDialogProvider,
darkTheme,
zhCN,
dateZhCN,
} from 'naive-ui'
import { computed } from 'vue'
import { useAppStore } from '@/stores/app'
import { darkThemeOverrides, themeOverrides } from '@/theme/naive'
const app = useAppStore()
const theme = computed(() => (app.dark ? darkTheme : null))
const overrides = computed(() => (app.dark ? darkThemeOverrides : themeOverrides))
</script>
<template>
<NConfigProvider
:theme="theme"
:theme-overrides="overrides"
:locale="zhCN"
:date-locale="dateZhCN"
>
<NMessageProvider>
<NDialogProvider>
<RouterView />
</NDialogProvider>
</NMessageProvider>
</NConfigProvider>
</template>
+96
View File
@@ -0,0 +1,96 @@
import { request } from './request'
import type { AiCallLog, AiChannel, AiContentLog, AiKey, AiModel, AiModelCacheItem } from '@/types/api'
// ---- 网关密钥 ----
export function listAiKeys(): Promise<AiKey[]> {
return request<{ items: AiKey[] }>('/ai-keys').then((r) => r.items)
}
export interface CreateAiKeyRequest {
name: string
value?: string
group?: string
}
/** 创建密钥;key 为明文,仅本次响应返回 */
export function createAiKey(body: CreateAiKeyRequest): Promise<{ key: string; item: AiKey }> {
return request('/ai-keys', { method: 'POST', body })
}
export function updateAiKey(
id: number,
body: { name?: string; enabled?: boolean; group?: string },
): Promise<void> {
return request(`/ai-keys/${id}`, { method: 'PUT', body })
}
export function deleteAiKey(id: number): Promise<void> {
return request(`/ai-keys/${id}`, { method: 'DELETE' })
}
/** 设置密钥内容日志窗口:0 关闭,>0 从现在起开启 N 小时(上限 168 = 7 天),返回最新密钥 */
export function updateAiKeyContentLog(id: number, hours: number): Promise<AiKey> {
return request(`/ai-keys/${id}/content-log`, { method: 'PUT', body: { hours } })
}
// ---- 渠道(号池)----
export function listAiChannels(): Promise<AiChannel[]> {
return request<{ items: AiChannel[] }>('/ai-channels').then((r) => r.items)
}
export interface AiChannelInput {
ociConfigId?: number
region?: string
name?: string
group?: string
enabled?: boolean
priority?: number
weight?: number
}
export function createAiChannel(body: AiChannelInput): Promise<AiChannel> {
return request('/ai-channels', { method: 'POST', body })
}
export function updateAiChannel(id: number, body: AiChannelInput): Promise<void> {
return request(`/ai-channels/${id}`, { method: 'PUT', body })
}
export function deleteAiChannel(id: number): Promise<void> {
return request(`/ai-channels/${id}`, { method: 'DELETE' })
}
export function probeAiChannel(id: number): Promise<AiChannel> {
return request(`/ai-channels/${id}/probe`, { method: 'POST' })
}
export function syncAiChannelModels(id: number): Promise<AiModelCacheItem[]> {
return request<{ items: AiModelCacheItem[] }>(`/ai-channels/${id}/sync-models`, {
method: 'POST',
}).then((r) => r.items)
}
// ---- 聚合模型与调用日志 ----
export function listAiModels(): Promise<AiModel[]> {
return request<{ items: AiModel[] }>('/ai-models').then((r) => r.items)
}
export function listAiCallLogs(params: {
page: number
size: number
}): Promise<{ items: AiCallLog[]; total: number }> {
return request('/ai-logs', { query: params })
}
/** 内容日志(红线例外);keyId / callLogId 可选过滤 */
export function listAiContentLogs(params: {
page: number
size: number
keyId?: number
callLogId?: number
}): Promise<{ items: AiContentLog[]; total: number }> {
return request('/ai-content-logs', { query: params })
}
+28
View File
@@ -0,0 +1,28 @@
import { mockCosts, mockTraffic } from './mock'
import { mockOn, mocked, request } from './request'
import type { CostItem, InstanceTraffic } from '@/types/api'
export function getInstanceTraffic(
cfgId: number,
instanceId: string,
days = 30,
region?: string,
): Promise<InstanceTraffic> {
if (mockOn) return mocked(mockTraffic, 800)
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/traffic`, {
query: { days, region },
})
}
export interface CostQuery {
startTime?: string
endTime?: string
granularity?: 'DAILY' | 'MONTHLY'
queryType?: 'COST' | 'USAGE'
groupBy?: 'service' | 'skuName' | 'region' | 'compartmentName'
}
export function getCosts(cfgId: number, query: CostQuery = {}): Promise<CostItem[]> {
if (mockOn) return mocked(mockCosts)
return request(`/oci-configs/${cfgId}/costs`, { query: { ...query } })
}
+151
View File
@@ -0,0 +1,151 @@
import { mockOn, mocked, request } from './request'
import type {
CredentialsInfo,
OauthProviderInfo,
LoginRequest,
LoginResponse,
OAuthSettings,
TotpSetup,
TotpStatus,
UpdateCredentialsRequest,
UpdateOAuthRequest,
UserIdentityInfo,
} from '@/types/api'
export function login(body: LoginRequest): Promise<LoginResponse> {
if (mockOn) {
if (body.password === 'wrong') return Promise.reject(new Error('用户名或密码错误'))
return mocked({ token: 'mock-token', expiresAt: '2099-01-01T00:00:00Z' }, 400)
}
return request('/auth/login', { method: 'POST', body })
}
/** 服务端登出:当前令牌拉黑至自然过期;调用方无论成败都应清本地会话 */
export function logout(): Promise<void> {
if (mockOn) return mocked(undefined, 100)
return request('/auth/logout', { method: 'POST' })
}
// ---- 登录凭据 ----
export function getCredentials(): Promise<CredentialsInfo> {
if (mockOn) return mocked({ username: 'admin', passwordLoginDisabled: false })
return request('/auth/credentials')
}
/** 修改用户名 / 密码;成功后旧 token 随即失效,调用方应登出重登 */
export function updateCredentials(body: UpdateCredentialsRequest): Promise<void> {
if (mockOn) return mocked(undefined, 400)
return request('/auth/credentials', { method: 'PUT', body })
}
/** 保存密码登录禁用开关;开启要求至少绑定一个外部身份(后端 409) */
export function updatePasswordLogin(disabled: boolean): Promise<void> {
if (mockOn) return mocked(undefined, 300)
return request('/auth/password-login', { method: 'PUT', body: { disabled } })
}
// ---- 两步验证(TOTP) ----
export function getTotpStatus(): Promise<TotpStatus> {
if (mockOn) return mocked({ enabled: false })
return request('/auth/totp')
}
/** 生成待激活密钥;10 分钟内输入验证码激活,重复调用覆盖旧暂存 */
export function setupTotp(): Promise<TotpSetup> {
if (mockOn)
return mocked({
secret: 'JBSWY3DPEHPK3PXP',
otpauthUri: 'otpauth://totp/oci-portal:admin?secret=JBSWY3DPEHPK3PXP&issuer=oci-portal',
})
return request('/auth/totp/setup', { method: 'POST' })
}
export function activateTotp(code: string): Promise<void> {
if (mockOn) return mocked(undefined, 300)
return request('/auth/totp/activate', { method: 'POST', body: { code } })
}
/** 停用两步验证;密码或当前验证码任一确认 */
export function disableTotp(body: { password?: string; code?: string }): Promise<void> {
if (mockOn) return mocked(undefined, 300)
return request('/auth/totp/disable', { method: 'POST', body })
}
// ---- 外部身份(OAuth) ----
/** 可登录的 provider 列表(登录页公开接口;已禁用的不返回);
* passwordLoginDisabled 为 true 且有可用 provider 时,登录页隐藏密码表单 */
export function getOauthProviders(): Promise<{
providers: OauthProviderInfo[]
passwordLoginDisabled: boolean
}> {
if (mockOn)
return mocked({
providers: [
{ provider: 'github', displayName: 'GitHub' },
{ provider: 'oidc', displayName: 'OIDC SSO' },
],
passwordLoginDisabled: false,
})
return request('/auth/oauth/providers')
}
/** 获取授权跳转 URL;bind 模式要求已登录(带 JWT) */
export function getOauthAuthorizeUrl(
provider: string,
mode: 'login' | 'bind',
): Promise<{ url: string }> {
if (mockOn) return mocked({ url: 'https://example.com/oauth/authorize?mock=1' })
return request(`/auth/oauth/${provider}/authorize`, { query: { mode } })
}
export function listIdentities(): Promise<{ items: UserIdentityInfo[] }> {
if (mockOn)
return mocked({
items: [
{ id: 1, provider: 'github', display: 'octocat', createdAt: '2026-07-07T10:00:00+08:00' },
],
})
return request('/auth/identities')
}
export function unbindIdentity(id: number): Promise<void> {
if (mockOn) return mocked(undefined, 300)
return request(`/auth/identities/${id}`, { method: 'DELETE' })
}
// ---- OAuth provider 配置(设置页) ----
export function getOAuthSettings(): Promise<OAuthSettings> {
if (mockOn)
return mocked({
oidcIssuer: '',
oidcClientId: '',
oidcSecretSet: false,
oidcDisplayName: '',
oidcDisabled: false,
githubClientId: '',
githubSecretSet: false,
githubDisplayName: '',
githubDisabled: false,
})
return request('/settings/oauth')
}
export function updateOAuthSettings(body: UpdateOAuthRequest): Promise<OAuthSettings> {
if (mockOn)
return mocked({
oidcIssuer: body.oidcIssuer,
oidcClientId: body.oidcClientId,
oidcSecretSet: !!body.oidcClientSecret,
oidcDisplayName: body.oidcDisplayName,
oidcDisabled: body.oidcDisabled,
githubClientId: body.githubClientId,
githubSecretSet: !!body.githubClientSecret,
githubDisplayName: body.githubDisplayName,
githubDisabled: body.githubDisabled,
})
return request('/settings/oauth', { method: 'PUT', body })
}
+59
View File
@@ -0,0 +1,59 @@
import { mockBootVolumes } from './mock'
import { mockOn, mocked, request } from './request'
import type { BootVolume, UpdateBootVolumeRequest } from '@/types/api'
export function listBootVolumes(
cfgId: number,
availabilityDomain?: string,
compartmentId?: string,
region?: string,
): Promise<BootVolume[]> {
if (mockOn) return mocked(compartmentId ? [] : (mockBootVolumes[cfgId] ?? []))
return request(`/oci-configs/${cfgId}/boot-volumes`, {
query: { availabilityDomain, compartmentId, region },
})
}
export function getBootVolume(
cfgId: number,
bootVolumeId: string,
region?: string,
): Promise<BootVolume> {
if (mockOn) {
const found = Object.values(mockBootVolumes)
.flat()
.find((b) => b.id === bootVolumeId)
return found ? mocked(found) : Promise.reject(new Error('引导卷不存在'))
}
return request(`/oci-configs/${cfgId}/boot-volumes/${encodeURIComponent(bootVolumeId)}`, {
query: { region },
})
}
export function updateBootVolume(
cfgId: number,
bootVolumeId: string,
body: UpdateBootVolumeRequest,
region?: string,
): Promise<BootVolume> {
if (mockOn) {
const base = Object.values(mockBootVolumes).flat()[0]
return mocked({ ...base, ...body }, 700)
}
return request(`/oci-configs/${cfgId}/boot-volumes/${encodeURIComponent(bootVolumeId)}`, {
method: 'PUT',
body: { region: region ?? '', ...body },
})
}
export function deleteBootVolume(
cfgId: number,
bootVolumeId: string,
region?: string,
): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${cfgId}/boot-volumes/${encodeURIComponent(bootVolumeId)}`, {
method: 'DELETE',
query: { region },
})
}
+121
View File
@@ -0,0 +1,121 @@
import { mockCompartments, mockConfigs, mockOverview, mockRegionSubs, mockRegions } from './mock'
import { mockOn, mocked, request } from './request'
import type {
Compartment,
ImportConfigRequest,
OciConfig,
OciConfigSummary,
Overview,
RegionInfo,
RegionSubscription,
UpdateConfigRequest,
VerifyResult,
} from '@/types/api'
export function getOverview(): Promise<Overview> {
if (mockOn) return mocked(mockOverview)
return request('/overview')
}
export function listCompartments(id: number): Promise<Compartment[]> {
if (mockOn) return mocked(mockCompartments[id] ?? [])
return request(`/oci-configs/${id}/compartments`)
}
/** 列表返回瘦身摘要,订阅 / 促销等详情字段走 getConfig 单查 */
export function listConfigs(): Promise<OciConfigSummary[]> {
if (mockOn) return mocked(mockConfigs)
return request('/oci-configs')
}
export function getConfig(id: number): Promise<OciConfig> {
if (mockOn) {
const found = mockConfigs.find((c) => c.id === id)
return found ? mocked(found) : Promise.reject(new Error('配置不存在'))
}
return request(`/oci-configs/${id}`)
}
export function importConfig(body: ImportConfigRequest): Promise<OciConfig> {
if (mockOn) {
const id = Math.max(...mockConfigs.map((c) => c.id)) + 1
const created = { ...mockConfigs[0], id, alias: body.alias, accountType: 'unknown' as const }
mockConfigs.push(created)
return mocked(created, 1200)
}
return request('/oci-configs', { method: 'POST', body })
}
export function updateConfig(id: number, body: UpdateConfigRequest): Promise<VerifyResult> {
if (mockOn) {
const found = mockConfigs.find((c) => c.id === id)
if (!found) return Promise.reject(new Error('配置不存在'))
if (body.alias) found.alias = body.alias
if (body.group !== undefined) found.group = body.group
if (body.region) found.region = body.region
if (body.multiRegion !== undefined) found.multiRegion = body.multiRegion
if (body.multiCompartment !== undefined) found.multiCompartment = body.multiCompartment
return mocked({ config: found, changes: {} }, 900)
}
return request(`/oci-configs/${id}`, { method: 'PUT', body })
}
export function verifyConfig(id: number): Promise<VerifyResult> {
if (mockOn) return mocked({ config: mockConfigs.find((c) => c.id === id) ?? mockConfigs[0], changes: {} }, 800)
return request(`/oci-configs/${id}/verify`, { method: 'POST' })
}
export function deleteConfig(id: number): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${id}`, { method: 'DELETE' })
}
export function listRegions(): Promise<RegionInfo[]> {
if (mockOn) return mocked(mockRegions)
return request('/regions')
}
export function listRegionSubscriptions(id: number): Promise<RegionSubscription[]> {
if (mockOn) return mocked(mockRegionSubs)
return request(`/oci-configs/${id}/region-subscriptions`)
}
export function subscribeRegion(id: number, regionKey: string): Promise<RegionSubscription[]> {
if (mockOn)
return mocked([
...mockRegionSubs,
{ key: regionKey, name: '', status: 'IN_PROGRESS', isHomeRegion: false, alias: '' },
])
return request(`/oci-configs/${id}/region-subscriptions`, { method: 'POST', body: { regionKey } })
}
/** 筛选器用区域列表(缓存):未开多区域支持只含默认区域;有非 READY 时服务端实时刷新 */
export function listCachedRegions(id: number): Promise<RegionSubscription[]> {
if (mockOn) {
const cfg = mockConfigs.find((c) => c.id === id)
if (!cfg) return mocked([])
if (!cfg.multiRegion) {
const info = mockRegions.find((r) => r.name === cfg.region)
return mocked([
{
key: info?.key ?? cfg.homeRegionKey,
name: cfg.region,
status: 'READY',
isHomeRegion: true,
alias: info?.alias ?? '',
},
])
}
return mocked(mockRegionSubs)
}
return request(`/oci-configs/${id}/cached-regions`)
}
/** 筛选器用区间列表(缓存):未开多区间支持返回空数组(前端锁定根) */
export function listCachedCompartments(id: number): Promise<Compartment[]> {
if (mockOn) {
const cfg = mockConfigs.find((c) => c.id === id)
return mocked(cfg?.multiCompartment ? (mockCompartments[id] ?? []) : [])
}
return request(`/oci-configs/${id}/cached-compartments`)
}
+380
View File
@@ -0,0 +1,380 @@
import {
mockAds,
mockBvAttachments,
mockConsoleConns,
mockImages,
mockInstances,
mockShapes,
mockVolAttachments,
mockVolumes,
} from './mock'
import { mockOn, mocked, request } from './request'
import type {
AttachVnicRequest,
BootVolumeAttachment,
ComputeShape,
ConsoleConnection,
CreateInstanceRequest,
CreateInstancesResponse,
ImageInfo,
Instance,
PowerAction,
UpdateInstanceRequest,
Vnic,
Volume,
VolumeAttachment,
} from '@/types/api'
// ---- 创建实例前置查询 ----
export interface ImagesQuery {
region?: string
operatingSystem?: string
shape?: string
}
export function listImages(cfgId: number, query: ImagesQuery = {}): Promise<ImageInfo[]> {
if (mockOn) return mocked(mockImages)
return request(`/oci-configs/${cfgId}/images`, { query: { ...query } })
}
export function getImage(cfgId: number, imageId: string, region?: string): Promise<ImageInfo> {
if (mockOn) {
const found = mockImages.find((i) => i.id === imageId) ?? {
...mockImages[0],
id: imageId,
displayName: 'Canonical-Ubuntu-24.04-aarch64-2026.05.20-0',
}
return mocked(found)
}
return request(`/oci-configs/${cfgId}/images/${encodeURIComponent(imageId)}`, {
query: { region },
})
}
export function listAvailabilityDomains(cfgId: number, region?: string): Promise<string[]> {
if (mockOn) return mocked(mockAds)
return request(`/oci-configs/${cfgId}/availability-domains`, { query: { region } })
}
export function listShapes(
cfgId: number,
region?: string,
availabilityDomain?: string,
): Promise<ComputeShape[]> {
if (mockOn) return mocked(mockShapes)
return request(`/oci-configs/${cfgId}/shapes`, { query: { region, availabilityDomain } })
}
export function listVolumes(
cfgId: number,
availabilityDomain?: string,
compartmentId?: string,
region?: string,
): Promise<Volume[]> {
if (mockOn) return mocked(mockVolumes[cfgId] ?? [])
return request(`/oci-configs/${cfgId}/volumes`, {
query: { availabilityDomain, compartmentId, region },
})
}
export function listInstances(
cfgId: number,
region?: string,
compartmentId?: string,
): Promise<Instance[]> {
if (mockOn) return mocked(compartmentId ? [] : (mockInstances[cfgId] ?? []))
return request(`/oci-configs/${cfgId}/instances`, { query: { region, compartmentId } })
}
export function getInstance(cfgId: number, instanceId: string, region?: string): Promise<Instance> {
if (mockOn) {
const all = Object.values(mockInstances).flat()
const found = all.find((i) => i.id === instanceId)
return found ? mocked(found) : Promise.reject(new Error('实例不存在'))
}
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}`, {
query: { region },
})
}
export function createInstances(
cfgId: number,
body: CreateInstanceRequest,
): Promise<CreateInstancesResponse> {
if (mockOn) return mocked({ instances: [mockInstances[1][0]], errors: [] }, 1200)
return request(`/oci-configs/${cfgId}/instances`, { method: 'POST', body })
}
export function updateInstance(
cfgId: number,
instanceId: string,
body: UpdateInstanceRequest,
region?: string,
): Promise<Instance> {
if (mockOn) return mocked({ ...mockInstances[1][0], ...body })
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}`, {
method: 'PUT',
body: { region: region ?? '', ...body },
})
}
export function terminateInstance(
cfgId: number,
instanceId: string,
preserveBootVolume: boolean,
region?: string,
): Promise<void> {
if (mockOn) return mocked(undefined, 600)
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}`, {
method: 'DELETE',
query: { preserveBootVolume, region },
})
}
export function instanceAction(
cfgId: number,
instanceId: string,
action: PowerAction,
region?: string,
): Promise<Instance> {
if (mockOn) {
const next = action === 'STOP' || action === 'SOFTSTOP' ? 'STOPPING' : 'STARTING'
return mocked({ ...mockInstances[1][0], lifecycleState: next }, 600)
}
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/action`, {
method: 'POST',
body: { region: region ?? '', action },
})
}
export function changePublicIp(
cfgId: number,
instanceId: string,
region?: string,
): Promise<{ publicIp: string }> {
if (mockOn) return mocked({ publicIp: '130.61.99.201' }, 900)
return request(
`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/change-public-ip`,
{ method: 'POST', body: { region: region ?? '' } },
)
}
export function addIpv6(
cfgId: number,
instanceId: string,
address = '',
region?: string,
): Promise<{ ipv6Address: string }> {
if (mockOn) return mocked({ ipv6Address: address || '2603:c020:400d:aa01::2' }, 700)
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/ipv6-addresses`, {
method: 'POST',
body: { region: region ?? '', address },
})
}
export function removeIpv6(
cfgId: number,
instanceId: string,
address: string,
region?: string,
): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/ipv6-addresses`, {
method: 'DELETE',
query: { address, region },
})
}
// ---- VNIC 管理 ----
export function listVnics(cfgId: number, instanceId: string, region?: string): Promise<Vnic[]> {
if (mockOn) return mocked([])
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/vnics`, {
query: { region },
})
}
export function attachVnic(
cfgId: number,
instanceId: string,
body: AttachVnicRequest,
): Promise<Vnic> {
if (mockOn) return Promise.reject(new Error('mock 环境不支持附加 VNIC'))
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/vnics`, {
method: 'POST',
body: { ...body, region: body.region ?? '' },
})
}
export function detachVnic(cfgId: number, attachmentId: string, region?: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${cfgId}/vnic-attachments/${encodeURIComponent(attachmentId)}`, {
method: 'DELETE',
query: { region },
})
}
/** 为指定 VNIC 添加 IPv6address 留空自动分配。删除复用实例级 removeIpv6(按地址遍历全部网卡) */
export function addVnicIpv6(
cfgId: number,
vnicId: string,
address = '',
region?: string,
): Promise<{ address: string }> {
if (mockOn) return mocked({ address: address || '2603:c020:400d:aa01::3' }, 700)
return request(`/oci-configs/${cfgId}/vnics/${encodeURIComponent(vnicId)}/ipv6-addresses`, {
method: 'POST',
body: { region: region ?? '', address },
})
}
// ---- 网页控制台(串行 / VNC----
export type ConsoleSessionType = 'serial' | 'vnc'
export function createConsoleSession(
cfgId: number,
instanceId: string,
type: ConsoleSessionType,
region?: string,
): Promise<{ sessionId: string; type: ConsoleSessionType }> {
if (mockOn) return Promise.reject(new Error('mock 环境不支持网页控制台'))
return request(
`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/console-sessions`,
{ method: 'POST', body: { type, region: region ?? '' } },
)
}
export function deleteConsoleSession(sessionId: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/console-sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' })
}
/** 控制台数据面 WS 地址:浏览器 WS 无法带自定义头,token 走 query */
export function consoleSessionWsUrl(sessionId: string, token: string, extra?: string): string {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const q = extra ? `&${extra}` : ''
return `${proto}://${location.host}/api/v1/console-sessions/${encodeURIComponent(sessionId)}/ws?token=${encodeURIComponent(token)}${q}`
}
// ---- 控制台连接 ----
export function listConsoleConnections(
cfgId: number,
instanceId: string,
region?: string,
): Promise<ConsoleConnection[]> {
if (mockOn) return mocked(mockConsoleConns)
return request(
`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/console-connections`,
{ query: { region } },
)
}
export function createConsoleConnection(
cfgId: number,
instanceId: string,
sshPublicKey: string,
region?: string,
): Promise<ConsoleConnection> {
if (mockOn) return mocked(mockConsoleConns[0], 1500)
return request(
`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/console-connections`,
{ method: 'POST', body: { region: region ?? '', sshPublicKey } },
)
}
export function deleteConsoleConnection(
cfgId: number,
connectionId: string,
region?: string,
): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${cfgId}/console-connections/${encodeURIComponent(connectionId)}`, {
method: 'DELETE',
query: { region },
})
}
// ---- 引导卷挂载 ----
export function listBootVolumeAttachments(
cfgId: number,
instanceId: string,
region?: string,
): Promise<BootVolumeAttachment[]> {
if (mockOn) return mocked(mockBvAttachments)
return request(
`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/boot-volume-attachments`,
{ query: { region } },
)
}
export function attachBootVolume(
cfgId: number,
instanceId: string,
bootVolumeId: string,
region?: string,
): Promise<BootVolumeAttachment> {
if (mockOn) return mocked(mockBvAttachments[0], 1200)
return request(
`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/boot-volume-attachments`,
{ method: 'POST', body: { region: region ?? '', bootVolumeId } },
)
}
export function replaceBootVolume(
cfgId: number,
instanceId: string,
bootVolumeId: string,
region?: string,
): Promise<BootVolumeAttachment> {
if (mockOn) return mocked(mockBvAttachments[0], 1500)
return request(
`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/replace-boot-volume`,
{ method: 'POST', body: { region: region ?? '', bootVolumeId } },
)
}
export function detachBootVolume(
cfgId: number,
attachmentId: string,
region?: string,
): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${cfgId}/boot-volume-attachments/${encodeURIComponent(attachmentId)}`, {
method: 'DELETE',
query: { region },
})
}
// ---- 块卷挂载 ----
export function listVolumeAttachments(
cfgId: number,
instanceId: string,
region?: string,
): Promise<VolumeAttachment[]> {
if (mockOn) return mocked(mockVolAttachments)
return request(
`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/volume-attachments`,
{ query: { region } },
)
}
export function attachVolume(
cfgId: number,
instanceId: string,
volumeId: string,
readOnly = false,
region?: string,
): Promise<VolumeAttachment> {
if (mockOn) return mocked(mockVolAttachments[0], 1000)
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/volume-attachments`, {
method: 'POST',
body: { region: region ?? '', volumeId, readOnly },
})
}
export function detachVolume(cfgId: number, attachmentId: string, region?: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${cfgId}/volume-attachments/${encodeURIComponent(attachmentId)}`, {
method: 'DELETE',
query: { region },
})
}
+104
View File
@@ -0,0 +1,104 @@
import { mockLogEvents, mockLogRelays, mockLogWebhooks } from './mock'
import { mockOn, mocked, request } from './request'
import type { LogEventPage, LogRelayView, LogWebhookInfo, LogWebhookState } from '@/types/api'
export interface LogEventParams {
page: number
pageSize: number
/** 按租户过滤,缺省为全部 */
cfgId?: number
}
export function listLogEvents(params: LogEventParams): Promise<LogEventPage> {
if (mockOn) {
const filtered = params.cfgId
? mockLogEvents.filter((e) => e.ociConfigId === params.cfgId)
: mockLogEvents
const start = (params.page - 1) * params.pageSize
return mocked({
items: filtered.slice(start, start + params.pageSize),
total: filtered.length,
})
}
return request('/log-events', { query: { ...params } })
}
export function getLogWebhook(cfgId: number): Promise<LogWebhookState> {
if (mockOn) {
const info = mockLogWebhooks.get(cfgId)
return mocked(info ? { exists: true, webhook: { ...info } } : { exists: false })
}
return request(`/oci-configs/${cfgId}/log-webhook`)
}
/** 生成(或幂等返回)回传回调地址 */
export function ensureLogWebhook(cfgId: number): Promise<LogWebhookInfo> {
if (mockOn) {
let info = mockLogWebhooks.get(cfgId)
if (!info) {
const secret = `mock${cfgId}`.padEnd(64, '0')
info = {
path: `/api/v1/webhooks/oci-logs/${secret}`,
secret,
createdAt: new Date().toISOString(),
}
mockLogWebhooks.set(cfgId, info)
}
return mocked({ ...info }, 400)
}
return request(`/oci-configs/${cfgId}/log-webhook`, { method: 'POST' })
}
/** 撤销回调地址,旧 URL 随即 404 */
export function revokeLogWebhook(cfgId: number): Promise<void> {
if (mockOn) {
mockLogWebhooks.delete(cfgId)
return mocked(undefined, 300)
}
return request(`/oci-configs/${cfgId}/log-webhook`, { method: 'DELETE' })
}
/** 查询 OCI 侧链路状态与关键事件清单 */
export function getLogRelay(cfgId: number): Promise<LogRelayView> {
if (mockOn) return mocked(structuredCloneRelay(cfgId))
return request(`/oci-configs/${cfgId}/log-relay`)
}
/** 一键建立回传链路(P1):同步执行,含订阅确认与 Connector 就绪等待,可达 2 分钟 */
export function setupLogRelay(cfgId: number): Promise<LogRelayView> {
if (mockOn) {
mockLogRelays.set(cfgId, true)
return mocked(structuredCloneRelay(cfgId), 1500)
}
return request(`/oci-configs/${cfgId}/log-relay`, { method: 'POST' })
}
/** 销毁 OCI 侧链路并撤销回调地址 */
export function teardownLogRelay(cfgId: number): Promise<void> {
if (mockOn) {
mockLogRelays.delete(cfgId)
mockLogWebhooks.delete(cfgId)
return mocked(undefined, 800)
}
return request(`/oci-configs/${cfgId}/log-relay`, { method: 'DELETE' })
}
/** mock 模式下按已建状态拼一份链路视图 */
function structuredCloneRelay(cfgId: number): LogRelayView {
const built = mockLogRelays.get(cfgId) === true
const res = (suffix: string) => (built ? { id: `ocid1.mock.${suffix}`, state: 'ACTIVE' } : { id: '', state: '' })
return {
endpoint: built ? `https://demo.example.com/api/v1/webhooks/oci-logs/mock${cfgId}` : undefined,
topic: res('topic'),
subscription: res('sub'),
connector: res('conn'),
policy: res('policy'),
ready: built,
events: [
'LaunchInstance', 'TerminateInstance', 'InstanceAction',
'CreateUser', 'DeleteUser', 'UpdateUser',
'CreateApiKey', 'DeleteApiKey', 'UpdateUserCapabilities',
'CreateRegionSubscription', 'CreatePolicy', 'UpdatePolicy', 'DeletePolicy',
],
}
}
+1716
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
import { mockIpv6Report, mockSecurityLists, mockSubnets, mockVcns } from './mock'
import { mockOn, mocked, request } from './request'
import type { CreateVcnRequest, Ipv6Report, SecurityList, Subnet, Vcn } from '@/types/api'
export function listVcns(cfgId: number, region?: string, compartmentId?: string): Promise<Vcn[]> {
if (mockOn) return mocked(compartmentId ? [] : (mockVcns[cfgId] ?? []))
return request(`/oci-configs/${cfgId}/vcns`, { query: { region, compartmentId } })
}
export function getVcn(cfgId: number, vcnId: string, region?: string): Promise<Vcn> {
if (mockOn) {
const found = Object.values(mockVcns)
.flat()
.find((v) => v.id === vcnId)
return found ? mocked(found) : Promise.reject(new Error('VCN 不存在'))
}
return request(`/oci-configs/${cfgId}/vcns/${encodeURIComponent(vcnId)}`, { query: { region } })
}
export function createVcn(cfgId: number, body: CreateVcnRequest): Promise<Vcn> {
if (mockOn) return mocked({ ...mockVcns[1][0], id: 'ocid1.vcn..new', displayName: body.displayName }, 900)
return request(`/oci-configs/${cfgId}/vcns`, { method: 'POST', body })
}
export function renameVcn(
cfgId: number,
vcnId: string,
displayName: string,
region?: string,
): Promise<Vcn> {
if (mockOn) return mocked({ ...mockVcns[1][0], displayName })
return request(`/oci-configs/${cfgId}/vcns/${encodeURIComponent(vcnId)}`, {
method: 'PUT',
body: { region: region ?? '', displayName },
})
}
export function deleteVcn(cfgId: number, vcnId: string, region?: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${cfgId}/vcns/${encodeURIComponent(vcnId)}`, {
method: 'DELETE',
query: { region },
})
}
export function enableVcnIpv6(cfgId: number, vcnId: string, region?: string): Promise<Ipv6Report> {
if (mockOn) return mocked(mockIpv6Report, 1500)
return request(`/oci-configs/${cfgId}/vcns/${encodeURIComponent(vcnId)}/enable-ipv6`, {
method: 'POST',
body: { region: region ?? '' },
})
}
export function listSubnets(cfgId: number, vcnId: string, region?: string): Promise<Subnet[]> {
if (mockOn) return mocked(mockSubnets.filter((s) => s.vcnId === vcnId || vcnId === ''))
return request(`/oci-configs/${cfgId}/subnets`, { query: { vcnId, region } })
}
export function listSecurityLists(
cfgId: number,
vcnId: string,
region?: string,
): Promise<SecurityList[]> {
if (mockOn) return mocked(mockSecurityLists.filter((s) => s.vcnId === vcnId || vcnId === ''))
return request(`/oci-configs/${cfgId}/security-lists`, { query: { vcnId, region } })
}
+90
View File
@@ -0,0 +1,90 @@
import { mockProxies } from './mock'
import { mockOn, mocked, request } from './request'
import type { ProxyImportResult, ProxyInfo, ProxyInput } from '@/types/api'
export function listProxies(): Promise<{ items: ProxyInfo[] }> {
if (mockOn) return mocked({ items: mockProxies })
return request('/proxies')
}
/** mock 端复刻后端自动命名:留空生成 {type}-{host}:{port},冲突加序号 */
function mockAutoName(body: ProxyInput): string {
const base = `${body.type}-${body.host}:${body.port}`
let name = base
for (let i = 2; mockProxies.some((p) => p.name === name); i++) name = `${base}-${i}`
return name
}
function mockCreate(body: ProxyInput): ProxyInfo {
const created: ProxyInfo = {
id: Math.max(0, ...mockProxies.map((p) => p.id)) + 1,
name: body.name?.trim() || mockAutoName(body),
type: body.type,
host: body.host,
port: body.port,
username: body.username ?? '',
passwordSet: !!body.password,
country: '',
city: '',
geoAt: '',
usedBy: 0,
createdAt: new Date().toISOString(),
}
mockProxies.push(created)
return created
}
export function createProxy(body: ProxyInput): Promise<ProxyInfo> {
if (mockOn) return mocked(mockCreate(body))
return request('/proxies', { method: 'POST', body })
}
export function updateProxy(id: number, body: ProxyInput): Promise<ProxyInfo> {
if (mockOn) {
const found = mockProxies.find((p) => p.id === id)
if (!found) return Promise.reject(new Error('代理不存在'))
Object.assign(found, body, {
name: body.name?.trim() || found.name,
passwordSet: body.password ? true : found.passwordSet,
})
return mocked({ ...found })
}
return request(`/proxies/${id}`, { method: 'PUT', body })
}
export function deleteProxy(id: number): Promise<void> {
if (mockOn) {
const i = mockProxies.findIndex((p) => p.id === id)
if (i >= 0) mockProxies.splice(i, 1)
return mocked(undefined)
}
return request(`/proxies/${id}`, { method: 'DELETE' })
}
/** 批量导入:每行 scheme://[user:pass@]host:port 或 host:port[:user:pass] */
export function importProxies(text: string): Promise<ProxyImportResult> {
if (mockOn) {
const created = text
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'))
.map((l) => {
const m = /^(?:(socks5|http|https):\/\/)?(?:[^@]+@)?([^:@/]+):(\d+)/.exec(l)
return m ? mockCreate({ type: m[1] ?? 'socks5', host: m[2], port: Number(m[3]) }) : null
})
.filter((p): p is ProxyInfo => !!p)
return mocked({ created, failed: [] })
}
return request('/proxies/import', { method: 'POST', body: { text } })
}
/** 手动重测出口地区,同步返回最新视图 */
export function probeProxy(id: number): Promise<ProxyInfo> {
if (mockOn) {
const found = mockProxies.find((p) => p.id === id)
if (!found) return Promise.reject(new Error('代理不存在'))
Object.assign(found, { country: '日本', city: '大阪市', geoAt: new Date().toISOString() })
return mocked({ ...found })
}
return request(`/proxies/${id}/probe`, { method: 'POST' })
}
+66
View File
@@ -0,0 +1,66 @@
import { useAuthStore } from '@/stores/auth'
const BASE = '/api/v1'
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
interface RequestOptions {
method?: string
body?: unknown
query?: Record<string, string | number | boolean | undefined>
}
function buildUrl(path: string, query?: RequestOptions['query']): string {
if (!query) return BASE + path
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== '') qs.set(k, String(v))
}
const s = qs.toString()
return s ? `${BASE}${path}?${s}` : BASE + path
}
async function parseError(resp: Response): Promise<never> {
let message = `请求失败(${resp.status}`
try {
const body = (await resp.json()) as { error?: string; hint?: string }
if (body.error) message = body.hint ? `${body.hint}${body.error}` : body.error
} catch {
/* 非 JSON 响应体,保留默认消息 */
}
if (resp.status === 401) useAuthStore().logout()
// 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示
if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') {
location.assign('/blocked')
}
throw new ApiError(resp.status, message)
}
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const auth = useAuthStore()
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
const resp = await fetch(buildUrl(path, opts.query), {
method: opts.method ?? 'GET',
headers,
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
})
if (!resp.ok) await parseError(resp)
if (resp.status === 204) return undefined as T
return (await resp.json()) as T
}
export const mockOn = import.meta.env.VITE_MOCK === '1'
/** mock 数据延时返回,模拟网络延迟 */
export function mocked<T>(data: T, ms = 200): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(structuredClone(data)), ms))
}
+152
View File
@@ -0,0 +1,152 @@
import {
mockNotifyEvents,
mockSecuritySetting,
mockSystemLogs,
mockTaskSetting,
mockTelegramSetting,
} from './mock'
import { mockOn, mocked, request } from './request'
import type {
AboutInfo,
NotifyEventsSetting,
NotifyTemplateItem,
SecuritySetting,
SystemLogPage,
TaskSetting,
TelegramSetting,
} from '@/types/api'
export function getTelegramSetting(): Promise<TelegramSetting> {
if (mockOn) return mocked({ ...mockTelegramSetting })
return request('/settings/telegram')
}
export interface UpdateTelegramBody {
enabled: boolean
/** 缺省表示沿用已保存的 token,空串表示清除 */
botToken?: string
chatId: string
}
export function updateTelegramSetting(body: UpdateTelegramBody): Promise<TelegramSetting> {
if (mockOn) {
mockTelegramSetting.enabled = body.enabled
mockTelegramSetting.chatId = body.chatId
if (body.botToken !== undefined) {
mockTelegramSetting.tokenSet = body.botToken !== ''
mockTelegramSetting.tokenTail = body.botToken.length >= 8 ? body.botToken.slice(-4) : ''
}
return mocked({ ...mockTelegramSetting }, 400)
}
return request('/settings/telegram', { method: 'PUT', body })
}
export function testTelegram(): Promise<void> {
if (mockOn) return mocked(undefined, 800)
return request('/settings/telegram/test', { method: 'POST' })
}
export function getNotifyEvents(): Promise<NotifyEventsSetting> {
if (mockOn) return mocked({ ...mockNotifyEvents })
return request('/settings/notify-events')
}
/** 全量保存五个事件开关,返回最新值 */
export function updateNotifyEvents(body: NotifyEventsSetting): Promise<NotifyEventsSetting> {
if (mockOn) {
Object.assign(mockNotifyEvents, body)
return mocked({ ...mockNotifyEvents }, 400)
}
return request('/settings/notify-events', { method: 'PUT', body })
}
export function listNotifyTemplates(): Promise<NotifyTemplateItem[]> {
if (mockOn)
return mocked([
{
kind: 'task_fail',
label: '任务失败',
vars: ['task_name', 'error'],
defaultTemplate: '❌ 任务失败:{{task_name}}\n{{error}}',
template: '',
},
])
return request<{ items: NotifyTemplateItem[] }>('/settings/notify-templates').then((r) => r.items)
}
/** 保存单个通知模板;空串清除自定义(恢复默认) */
export function updateNotifyTemplate(kind: string, template: string): Promise<void> {
if (mockOn) return mocked(undefined, 300)
return request(`/settings/notify-templates/${kind}`, { method: 'PUT', body: { template } })
}
/** 用示例变量渲染并发送测试消息;template 非空时按编辑中内容渲染(不落库) */
export function testNotifyTemplate(kind: string, template: string): Promise<void> {
if (mockOn) return mocked(undefined, 600)
return request(`/settings/notify-templates/${kind}/test`, { method: 'POST', body: { template } })
}
export function getTaskSetting(): Promise<TaskSetting> {
if (mockOn) return mocked({ ...mockTaskSetting })
return request('/settings/task')
}
/** 保存任务行为设置,返回最新值;阈值越界(1-10 之外)后端返回 400 */
export function updateTaskSetting(body: TaskSetting): Promise<TaskSetting> {
if (mockOn) {
Object.assign(mockTaskSetting, body)
return mocked({ ...mockTaskSetting }, 400)
}
return request('/settings/task', { method: 'PUT', body })
}
export function getSecuritySetting(): Promise<SecuritySetting> {
if (mockOn) return mocked({ ...mockSecuritySetting })
return request('/settings/security')
}
/** 保存安全设置,返回最新值;越界或非法后端返回 400,保存后立即生效 */
export function updateSecuritySetting(body: SecuritySetting): Promise<SecuritySetting> {
if (mockOn) {
Object.assign(mockSecuritySetting, body)
return mocked({ ...mockSecuritySetting }, 400)
}
return request('/settings/security', { method: 'PUT', body })
}
export interface SystemLogParams {
page: number
pageSize: number
/** 关键字,对路径与用户名模糊匹配 */
keyword?: string
}
export function listSystemLogs(params: SystemLogParams): Promise<SystemLogPage> {
if (mockOn) {
const kw = (params.keyword ?? '').toLowerCase()
const filtered = kw
? mockSystemLogs.filter(
(l) => l.path.toLowerCase().includes(kw) || l.username.toLowerCase().includes(kw),
)
: mockSystemLogs
const start = (params.page - 1) * params.pageSize
return mocked({
items: filtered.slice(start, start + params.pageSize),
total: filtered.length,
})
}
return request('/system-logs', { query: { ...params } })
}
// ---- 关于 ----
/** 构建与运行环境信息(设置 · 关于) */
export function getAbout(): Promise<AboutInfo> {
if (mockOn)
return mocked({
version: 'v1.0.0',
buildTime: '2026-07-09T12:00Z',
goVersion: 'go1.26.4',
platform: 'linux/amd64',
})
return request('/about')
}
+75
View File
@@ -0,0 +1,75 @@
import { mockTaskLogs, mockTasks } from './mock'
import { mockOn, mocked, request } from './request'
import type { Task, TaskLog, TaskStatus } from '@/types/api'
export function listTasks(): Promise<Task[]> {
if (mockOn) return mocked(mockTasks)
return request('/tasks')
}
export function getTask(id: number): Promise<Task> {
if (mockOn) {
const found = mockTasks.find((t) => t.id === id)
return found ? mocked(found) : Promise.reject(new Error('任务不存在'))
}
return request(`/tasks/${id}`)
}
export interface CreateTaskRequest {
name: string
type: string
cronExpr: string
payload: Record<string, unknown>
}
export function createTask(body: CreateTaskRequest): Promise<Task> {
if (mockOn) {
const created: Task = {
...mockTasks[0],
id: Math.max(...mockTasks.map((t) => t.id)) + 1,
name: body.name,
type: body.type as Task['type'],
cronExpr: body.cronExpr,
payload: JSON.stringify(body.payload),
status: 'active',
lastRunAt: null,
runCount: 0,
}
mockTasks.push(created)
return mocked(created, 600)
}
return request('/tasks', { method: 'POST', body })
}
export interface UpdateTaskRequest {
name?: string
cronExpr?: string
payload?: Record<string, unknown>
status?: TaskStatus
}
export function updateTask(id: number, body: UpdateTaskRequest): Promise<Task> {
if (mockOn) {
const base = mockTasks.find((t) => t.id === id) ?? mockTasks[0]
return mocked({ ...base, status: body.status ?? base.status })
}
return request(`/tasks/${id}`, { method: 'PUT', body })
}
export function deleteTask(id: number): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/tasks/${id}`, { method: 'DELETE' })
}
export function listTaskLogs(id: number, limit = 50): Promise<TaskLog[]> {
if (mockOn) return mocked(mockTaskLogs[id] ?? [])
return request(`/tasks/${id}/logs`, { query: { limit } })
}
export function runTask(id: number): Promise<TaskLog> {
if (mockOn) {
const logs = mockTaskLogs[id]
return mocked(logs?.[0] ?? { id: 0, taskId: id, success: true, message: 'ok', durationMs: 100, createdAt: '' }, 1000)
}
return request(`/tasks/${id}/run`, { method: 'POST' })
}
+344
View File
@@ -0,0 +1,344 @@
import {
mockAuditEvents,
mockIdentitySetting,
mockIdps,
mockLimits,
mockPolicies,
mockRecipients,
mockSignOnRules,
mockSubscriptions,
mockUserDetails,
mockUsers,
} from './mock'
import { mockOn, mocked, request } from './request'
import type {
AuditEventsResult,
IamUser,
IamUserDetail,
IdentityProvider,
IdentitySetting,
LimitItem,
LimitService,
NotificationRecipients,
PasswordPolicy,
SignOnRule,
SubscriptionDetail,
SubscriptionSummary,
} from '@/types/api'
// ---- 配额 ----
export interface LimitsQuery {
service?: string
region?: string
name?: string
scopeType?: 'GLOBAL' | 'REGION' | 'AD'
availabilityDomain?: string
/** 服务端过滤上限为 0 的配额项,查用量时不占并发名额 */
nonZero?: boolean
withAvailability?: boolean
}
export function listLimits(id: number, query: LimitsQuery = {}): Promise<LimitItem[]> {
if (mockOn) {
if (query.service === 'regions')
return mocked([{ name: 'subscribed-region-count', scopeType: 'GLOBAL', value: 3 }])
const needle = (query.name ?? '').toLowerCase()
return mocked(
mockLimits
.filter((l) => !needle || l.name.toLowerCase().includes(needle))
.filter((l) => !query.scopeType || l.scopeType === query.scopeType)
.filter((l) => !query.nonZero || l.value > 0),
)
}
return request(`/oci-configs/${id}/limits`, { query: { ...query } })
}
export function listLimitServices(id: number): Promise<LimitService[]> {
if (mockOn)
return mocked([
{ name: 'compute', description: 'Compute' },
{ name: 'vcn', description: 'Virtual Cloud Network' },
{ name: 'block-storage', description: 'Block Volume' },
])
return request(`/oci-configs/${id}/limits/services`)
}
// ---- 订阅 ----
export function listSubscriptions(id: number): Promise<SubscriptionSummary[]> {
if (mockOn)
return mocked(
mockSubscriptions.map((d) => ({
id: d.id,
serviceName: d.serviceName,
timeCreated: d.timeCreated,
timeUpdated: d.timeUpdated,
})),
)
return request(`/oci-configs/${id}/subscriptions`)
}
export function getSubscription(id: number, subscriptionId: string): Promise<SubscriptionDetail> {
if (mockOn)
return mocked(mockSubscriptions.find((d) => d.id === subscriptionId) ?? mockSubscriptions[0])
return request(`/oci-configs/${id}/subscriptions/${subscriptionId}`)
}
// ---- 用户 ----
export function listUsers(id: number): Promise<IamUser[]> {
if (mockOn) return mocked(mockUsers)
return request(`/oci-configs/${id}/users`)
}
// 编辑表单预填充用;不在身份域的经典用户返回 inDomain: false
export function getUserDetail(id: number, userId: string): Promise<IamUserDetail> {
if (mockOn)
return mocked(
mockUserDetails[userId] ?? {
inDomain: false,
givenName: '',
familyName: '',
isDomainAdmin: false,
inAdminGroup: false,
},
)
return request(`/oci-configs/${id}/users/${userId}`)
}
export interface CreateUserBody {
name: string
givenName: string
familyName: string
description?: string
email?: string
/** 同时勾选时后端先授身份域管理员,再加入管理员组 */
grantDomainAdmin?: boolean
addToAdminGroup?: boolean
}
export function createUser(id: number, body: CreateUserBody): Promise<IamUser> {
if (mockOn)
return mocked({ ...mockUsers[2], id: 'ocid1.user..new', name: body.name, email: body.email ?? '' })
return request(`/oci-configs/${id}/users`, { method: 'POST', body })
}
export interface UpdateUserBody {
description?: string
email?: string
givenName?: string
familyName?: string
/** 三态:true 授予/加入,false 撤销/移出,缺省不动 */
grantDomainAdmin?: boolean
addToAdminGroup?: boolean
}
export function updateUser(id: number, userId: string, body: UpdateUserBody): Promise<IamUser> {
if (mockOn) {
const u = mockUsers.find((x) => x.id === userId) ?? mockUsers[2]
if (body.description !== undefined) u.description = body.description
if (body.email !== undefined) u.email = body.email
return mocked({ ...u })
}
return request(`/oci-configs/${id}/users/${userId}`, { method: 'PUT', body })
}
export function deleteUser(id: number, userId: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${id}/users/${userId}`, { method: 'DELETE' })
}
export function resetUserPassword(id: number, userId: string): Promise<{ password: string }> {
if (mockOn) return mocked({ password: 'Xy9#kQ2m$Lp8' })
return request(`/oci-configs/${id}/users/${userId}/reset-password`, { method: 'POST' })
}
export function clearUserMfa(id: number, userId: string): Promise<{ deletedTotpDevices: number }> {
if (mockOn) return mocked({ deletedTotpDevices: 1 })
return request(`/oci-configs/${id}/users/${userId}/mfa-devices`, { method: 'DELETE' })
}
export function deleteUserApiKeys(
id: number,
userId: string,
includeCurrent = false,
): Promise<{ deletedApiKeys: number }> {
if (mockOn) return mocked({ deletedApiKeys: 1 })
return request(`/oci-configs/${id}/users/${userId}/api-keys`, {
method: 'DELETE',
query: { includeCurrent },
})
}
// ---- 审计日志 ----
/** 实时查询租户 OCI 审计事件:hours ∈ [1,72] 首查;
* 截断续查传上次响应的 start/end 与 nextPage(绝对窗+游标断点续翻) */
export function listAuditEvents(
id: number,
opts: { hours?: number; region?: string; start?: string; end?: string; page?: string } = {},
): Promise<AuditEventsResult> {
// mock 下选 1 小时窗演示截断提示
if (mockOn)
return mocked(
{
items: mockAuditEvents,
truncated: opts.hours === 1,
start: '2026-07-06T10:00:00Z',
end: '2026-07-07T10:00:00Z',
},
300,
)
const { hours = 24, region, start, end, page } = opts
const query = start && end ? { region, start, end, page } : { hours, region }
return request(`/oci-configs/${id}/audit-events`, { query })
}
/** 取回单条审计事件的原始 JSON;404 表示缓存过期且重查未找回(前端降级展示精简字段) */
export function getAuditEventDetail(
id: number,
q: { eventId: string; eventTime: string; region?: string },
): Promise<{ raw: unknown }> {
if (mockOn) return mocked({ raw: { eventId: q.eventId, mock: true, note: '演示原始事件' } }, 200)
return request(`/oci-configs/${id}/audit-events/detail`, { query: q })
}
// ---- 域设置 ----
export function getNotificationRecipients(id: number): Promise<NotificationRecipients> {
if (mockOn) return mocked(mockRecipients)
return request(`/oci-configs/${id}/notification-recipients`)
}
export function updateNotificationRecipients(
id: number,
recipients: string[],
): Promise<NotificationRecipients> {
if (mockOn) return mocked({ recipients, testModeEnabled: recipients.length > 0 })
return request(`/oci-configs/${id}/notification-recipients`, {
method: 'PUT',
body: { recipients },
})
}
export function listPasswordPolicies(id: number): Promise<PasswordPolicy[]> {
if (mockOn) return mocked(mockPolicies)
return request(`/oci-configs/${id}/password-policies`)
}
export function updatePasswordPolicy(
id: number,
policyId: string,
body: { passwordExpiresAfter?: number; minLength?: number; numPasswordsInHistory?: number },
): Promise<PasswordPolicy> {
if (mockOn) return mocked({ ...mockPolicies[0], ...body })
return request(`/oci-configs/${id}/password-policies/${policyId}`, { method: 'PUT', body })
}
// ---- 身份设置 ----
export function getIdentitySetting(id: number): Promise<IdentitySetting> {
if (mockOn) return mocked({ ...mockIdentitySetting })
return request(`/oci-configs/${id}/identity-settings`)
}
export function updateIdentitySetting(
id: number,
primaryEmailRequired: boolean,
): Promise<IdentitySetting> {
if (mockOn) {
mockIdentitySetting.primaryEmailRequired = primaryEmailRequired
return mocked({ ...mockIdentitySetting })
}
return request(`/oci-configs/${id}/identity-settings`, {
method: 'PUT',
body: { primaryEmailRequired },
})
}
export async function downloadSamlMetadata(id: number): Promise<void> {
if (mockOn) {
const blob = new Blob(['<mock-saml-metadata/>'], { type: 'application/xml' })
triggerDownload(blob, 'oci-domain-saml-metadata.xml')
return
}
const { useAuthStore } = await import('@/stores/auth')
const auth = useAuthStore()
const resp = await fetch(`/api/v1/oci-configs/${id}/saml-metadata`, {
headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : {},
})
if (!resp.ok) throw new Error(`下载失败(${resp.status}`)
triggerDownload(await resp.blob(), 'oci-domain-saml-metadata.xml')
}
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
// ---- Federation ----
export interface CreateIdpBody {
name: string
metadata: string
description?: string
/** logodata URI 或外链 URL */
iconUrl?: string
nameIdFormat?: string
/** 非空表示按断言属性映射;空则按 SAML NameID 映射 */
assertionAttribute?: string
userStoreAttribute?: string
jitEnabled?: boolean
jitCreate?: boolean
jitUpdate?: boolean
jitAssignAdminGroup?: boolean
jitMapEmail?: boolean
}
export function createIdp(id: number, body: CreateIdpBody): Promise<IdentityProvider> {
if (mockOn) {
const created = {
...mockIdps[1],
id: `idp-${mockIdps.length + 1}`,
name: body.name,
enabled: false,
}
mockIdps.push(created)
return mocked(created, 900)
}
return request(`/oci-configs/${id}/identity-providers`, { method: 'POST', body })
}
export function listIdps(id: number): Promise<IdentityProvider[]> {
if (mockOn) return mocked(mockIdps)
return request(`/oci-configs/${id}/identity-providers`)
}
export function activateIdp(id: number, idpId: string, enabled: boolean): Promise<IdentityProvider> {
if (mockOn) return mocked({ ...mockIdps[0], enabled })
return request(`/oci-configs/${id}/identity-providers/${idpId}/activate`, {
method: 'POST',
body: { enabled },
})
}
export function deleteIdp(id: number, idpId: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${id}/identity-providers/${idpId}`, { method: 'DELETE' })
}
export function listSignOnRules(id: number): Promise<SignOnRule[]> {
if (mockOn) return mocked(mockSignOnRules)
return request(`/oci-configs/${id}/sign-on-rules`)
}
export function createSignOnExemption(id: number, identityProviderId: string): Promise<SignOnRule> {
if (mockOn) return mocked(mockSignOnRules[0])
return request(`/oci-configs/${id}/sign-on-exemptions`, {
method: 'POST',
body: { identityProviderId },
})
}
export function deleteSignOnExemption(id: number, ruleId: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${id}/sign-on-exemptions/${ruleId}`, { method: 'DELETE' })
}
+260
View File
@@ -0,0 +1,260 @@
/* 设计 token 唯一 JS 来源在 src/theme/tokens.ts,两处取值保持同步 */
@import 'tailwindcss';
@theme {
--color-bg: #f5f4ed;
--color-card: #faf9f5;
--color-white: #ffffff; /* 语义:输入 / 嵌入面,暗色下为深灰而非纯白 */
--color-ink: #141413;
--color-ink-2: #5e5d59;
--color-ink-3: #87867f;
--color-line: #e8e6dc;
--color-line-soft: #f0eee6;
--color-row-hover: #f1efe8;
--color-wash: #efede4; /* 侧栏 / 图标按钮悬浮底 */
--color-wash-deep: #ece9df; /* 侧栏激活 / 头像底 */
--color-accent: #c96442;
--color-accent-hover: #ad4f2e;
--color-chart: #c15f3c;
--color-ok: #788c5d;
--color-err: #b53333;
--color-info: #6a9bcc;
--color-warn: #a87514;
--font-sans:
-apple-system, 'SF Pro Text', 'Helvetica Neue', 'PingFang SC', 'Noto Sans SC', sans-serif;
--font-serif: Georgia, 'Times New Roman', 'Songti SC', serif;
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
}
/* 暗色主题:html.dark 由 stores/app.ts 挂载,色值与 tokens.ts 的 darkTokens 同步 */
html.dark {
--color-bg: #191817;
--color-card: #201f1e;
--color-white: #262524;
--color-ink: #eceae4;
--color-ink-2: #a8a69e;
--color-ink-3: #807e76;
--color-line: #3a3835;
--color-line-soft: #2c2b29;
--color-row-hover: #292825;
--color-wash: #2b2a27;
--color-wash-deep: #33312d;
--color-accent: #d97757;
--color-accent-hover: #e08b6d;
--color-chart: #d97757;
--color-ok: #8fa574;
--color-err: #d06060;
--color-info: #7fabd6;
--color-warn: #c29135;
color-scheme: dark;
}
html,
body {
background: var(--color-bg);
color: var(--color-ink);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* 全局滚动条瘦身:透明轨道 + 细圆角滑块,明暗主题跟随 ink 变量 */
* {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--color-ink) 22%, transparent) transparent;
}
::-webkit-scrollbar {
width: 7px;
height: 7px;
background: transparent;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--color-ink) 22%, transparent);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--color-ink) 38%, transparent);
}
/* 页面标题统一 serif */
.page-title {
font-family: var(--font-serif);
font-size: 24px;
font-weight: 600;
}
/* 卡片:圆角 8、细边框、whisper 阴影,无渐变;
overflow hidden 防止内部表格等方角内容溢出圆角 */
.panel {
background: var(--color-card);
border: 1px solid var(--color-line);
border-radius: 8px;
box-shadow: 0 1px 2px rgba(20, 20, 19, 0.04);
min-width: 0;
overflow: hidden;
}
.mono {
font-family: var(--font-mono);
font-size: 12.5px;
letter-spacing: -0.2px;
}
/* NDataTable 分页器默认贴表格底边,补出与卡片底部的间隔 */
.n-data-table .n-data-table__pagination {
padding: 4px 18px 14px;
}
/* 租户下拉双行选项:菜单项上下留白拉开行距,避免相邻两租户挤在一起 */
.n-base-select-option .cfg-tenancy-sub {
margin-top: 1px;
}
.n-base-select-option__content:has(.cfg-tenancy-sub) {
padding: 6px 0;
}
/* NSelect 选中框(收起态)只显示别名单行,租户名辅行隐藏 */
.n-base-selection-label .cfg-tenancy-sub {
display: none;
}
/* AppInputNumber(方案 B 内嵌步进):上下箭头收进右侧独立竖栏——
竖栏占满框高、border-left 与框体结构化分隔,上下两格中缝分隔线,
悬停格底 wash;suffix 内 minus / add 恒为最后两个子元素(add 上 minus 下)。 */
.app-input-number .n-input .n-input-wrapper {
padding-right: 34px; /* 26px 竖栏 + 文本安全间距 */
}
.app-input-number .n-input__suffix {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 26px;
margin: 0;
display: grid;
grid-template-rows: 1fr 1fr;
border-left: 1px solid var(--color-line-soft);
}
.app-input-number .n-input__suffix > :last-child {
grid-row: 1; /* add 在上 */
border-bottom: 1px solid var(--color-line-soft);
border-top-right-radius: 5px;
}
.app-input-number .n-input__suffix > :nth-last-child(2) {
grid-row: 2; /* minus 在下 */
border-bottom-right-radius: 5px;
}
.app-input-number .n-input__suffix .n-button {
/* 箭头颜色跟随文本弱色而非按钮主题色:NButton 自带 --n-text-color
内联声明会盖过外层 color,必须直接覆盖变量 */
--n-icon-size: 9px;
--n-text-color: var(--color-ink-3);
--n-text-color-hover: var(--color-ink);
--n-text-color-pressed: var(--color-ink);
--n-text-color-focus: var(--color-ink);
width: 100%;
height: 100%;
border-radius: inherit;
color: var(--color-ink-3);
}
.app-input-number .n-input__suffix .n-button:not(.n-button--disabled):hover {
background: var(--color-wash);
color: var(--color-ink);
}
/* useToast 可展开详情:toast 内嵌代码块(标签 + 复制 + 内滚 pre)。
message 经 teleport 渲染于 body,组件 scoped 样式不可达,故置于全局。 */
.toast-body {
max-width: 420px;
}
.toast-line {
display: flex;
align-items: baseline;
gap: 10px;
}
.toast-toggle {
flex: none;
cursor: pointer;
font-size: 12px;
color: var(--color-accent);
user-select: none;
}
.toast-toggle:hover {
text-decoration: underline;
}
.toast-code {
margin-top: 8px;
overflow: hidden;
border: 1px solid var(--color-line);
border-radius: 8px;
background: var(--color-wash);
}
.toast-code-head {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--color-line-soft);
padding: 4px 8px 4px 10px;
}
.toast-tag {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
}
.toast-tag-error {
color: var(--color-err);
}
.toast-tag-warning {
color: var(--color-warn);
}
.toast-copy {
cursor: pointer;
border: none;
background: none;
padding: 2px 4px;
font-size: 11px;
color: var(--color-ink-3);
}
.toast-copy:hover {
color: var(--color-ink);
}
.toast-code-pre {
margin: 0;
max-height: 180px;
overflow: auto;
padding: 8px 10px;
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-all;
color: var(--color-ink-2);
}
/* 全屏页轨道环(FullPageBackdrop 与登录页品牌区共用):
spin 动画占用 transform,环定位只能用 left/top(calc 同心),不能用 translate。 */
.fpb-ring {
position: absolute;
border-radius: 50%;
border: 1.5px solid var(--color-line);
pointer-events: none;
}
.fpb-ring-dash {
border-style: dashed;
border-color: color-mix(in srgb, var(--color-line) 70%, var(--color-accent) 30%);
}
@media (prefers-reduced-motion: no-preference) {
.fpb-spin {
animation: fpb-spin 52s linear infinite;
}
@keyframes fpb-spin {
to {
transform: rotate(360deg);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import StatusBadge from './StatusBadge.vue'
import type { AccountType } from '@/types/api'
/** 账户类别徽标:升级(暖橙)/试用(蓝)/免费(灰),判定规则见 API 文档 */
const props = defineProps<{ type: AccountType }>()
const view = computed(() => {
switch (props.type) {
case 'paid':
return { kind: 'snatch' as const, label: '升级' }
case 'trial':
return { kind: 'prov' as const, label: '试用' }
case 'free':
return { kind: 'check' as const, label: '免费' }
default:
return { kind: 'check' as const, label: '未知' }
}
})
</script>
<template>
<StatusBadge :kind="view.kind" :label="view.label" :dot="false" />
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { NInputNumber } from 'naive-ui'
/** 数字输入框全局封装(设计稿·数字输入框 方案 B 内嵌步进):
* 上下箭头收进右侧独立竖栏,配合全局样式(main.css 的 .app-input-number 规则)
* 呈现分隔栏 + 悬停底色。其余 props / v-model / class 经 $attrs 原样透传。 */
</script>
<template>
<NInputNumber v-bind="$attrs" button-placement="right" class="app-input-number">
<template #add-icon>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 7.5 18.5 16h-13z" />
</svg>
</template>
<template #minus-icon>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 16.5 5.5 8h13z" />
</svg>
</template>
</NInputNumber>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
// 项目 logo:星爆贴纸(波普风,十二角星爆 + O!)。
// 固定品牌色值不走 CSS 变量——favicon 与深浅主题共用同一套颜色。
withDefaults(defineProps<{ size?: number }>(), { size: 48 })
const BURST =
'M24 5 L27.9 9.5 L33.5 7.5 L34.6 13.4 L40.5 14.5 L38.5 20.1 L43 24 L38.5 27.9 ' +
'L40.5 33.5 L34.6 34.6 L33.5 40.5 L27.9 38.5 L24 43 L20.1 38.5 L14.5 40.5 L13.4 34.6 ' +
'L7.5 33.5 L9.5 27.9 L5 24 L9.5 20.1 L7.5 14.5 L13.4 13.4 L14.5 7.5 L20.1 9.5 Z'
</script>
<template>
<svg :width="size" :height="size" viewBox="0 0 48 48" aria-hidden="true">
<g transform="rotate(-8 24 24)">
<path :d="BURST" fill="#3A2418" transform="translate(1.9,2.4)"></path>
<path :d="BURST" fill="#C96442" stroke="#FFFDF7" stroke-width="2.6" stroke-linejoin="round"></path>
<text
x="24"
y="28.8"
text-anchor="middle"
font-family="Georgia,'Times New Roman',serif"
font-size="13"
font-weight="900"
fill="#FAF3E3"
>
O!
</text>
</g>
</svg>
</template>
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { computed } from 'vue'
import VChart from 'vue-echarts'
import { useAppStore } from '@/stores/app'
import { darkTokens, tokens } from '@/theme/tokens'
use([LineChart, GridComponent, TooltipComponent, CanvasRenderer])
/** 每日成本面积图:terracotta 单色系,无渐变滥用(10% 透明度平涂);
* echarts 不认 CSS 变量,色板按明暗主题从 tokens 取。 */
const props = defineProps<{ labels: string[]; values: number[]; currency?: string }>()
const app = useAppStore()
const t = computed(() => (app.dark ? darkTokens : tokens))
const option = computed(() => ({
grid: { left: 44, right: 16, top: 18, bottom: 26 },
tooltip: {
trigger: 'axis',
backgroundColor: t.value.ink,
borderWidth: 0,
textStyle: { color: t.value.bg, fontSize: 12 },
valueFormatter: (v: number) => `$${v.toFixed(2)}`,
},
xAxis: {
type: 'category',
data: props.labels,
boundaryGap: false,
axisLine: { lineStyle: { color: t.value.line } },
axisTick: { show: false },
axisLabel: { color: t.value.ink3, fontSize: 10.5 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: t.value.lineSoft } },
axisLabel: { color: t.value.ink3, fontSize: 10.5, formatter: '${value}' },
},
series: [
{
type: 'line',
data: props.values,
smooth: false,
symbol: 'circle',
symbolSize: 6,
lineStyle: { color: t.value.chart, width: 2 },
itemStyle: { color: t.value.chart, borderColor: t.value.card, borderWidth: 1.5 },
areaStyle: { color: app.dark ? 'rgba(217,119,87,0.14)' : 'rgba(193,95,60,0.10)' },
},
],
}))
</script>
<template>
<VChart class="w-full" style="height: 200px" :option="option" autoresize />
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
/** 租户失联占位:租户详情各 tab 与实例 / 网络 / 存储等资源页在
* 当前租户失联时整体替换内容区,避免各处「无数据」/ 报错造成误判。 */
defineProps<{ lastError?: string }>()
</script>
<template>
<div class="panel flex flex-col items-center justify-center gap-2 px-6 py-16 text-center">
<svg
class="mb-1 h-9 w-9 text-ink-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18.84 12.25l1.72-1.71a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M5.17 11.75l-1.71 1.71a5 5 0 0 0 7.07 7.07l1.71-1.71" />
<line x1="8" y1="2" x2="8" y2="5" />
<line x1="2" y1="8" x2="5" y2="8" />
<line x1="16" y1="19" x2="16" y2="22" />
<line x1="19" y1="16" x2="22" y2="16" />
</svg>
<div class="text-sm font-semibold text-ink-2">租户已失联</div>
<div v-if="lastError" class="mono max-w-130 text-xs break-all whitespace-pre-wrap text-ink-3">
{{ lastError }}
</div>
<div class="max-w-95 text-[12.5px] text-ink-3">
请检查 API Key 是否有效修复后在租户页重新测活
</div>
</div>
</template>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
/** 详情弹窗的字段列表:默认单列「label 左 value 右」;cols=2 时切两列网格
* (label 上 value 下,设计稿 kv 风格)。tone=err 时值染红(如失败原因)。 */
export interface DetailRow {
label: string
value: string
mono?: boolean
tone?: 'err'
/** cols=2 网格中占满整行(长值字段) */
span?: 2
/** 单行显示,溢出省略号(hover title 出全文) */
ellipsis?: boolean
}
defineProps<{ rows: DetailRow[]; labelWidth?: string; cols?: 1 | 2 }>()
</script>
<template>
<div v-if="cols === 2" class="grid grid-cols-2 gap-x-8 max-md:grid-cols-1">
<div
v-for="row in rows"
:key="row.label"
class="border-b border-line-soft py-2 [&:nth-last-child(-n+2)]:border-b-0"
:class="row.span === 2 ? 'col-span-full' : ''"
>
<div class="text-[11px] tracking-wide text-ink-3">{{ row.label }}</div>
<div
class="mt-0.5 min-w-0 text-[12.5px] font-medium"
:class="[
row.mono ? 'mono' : '',
row.tone === 'err' ? 'text-err' : '',
row.ellipsis ? 'truncate' : 'break-all',
]"
:title="row.ellipsis ? row.value : undefined"
>
{{ row.value || '—' }}
</div>
</div>
</div>
<div v-else class="flex flex-col">
<div
v-for="row in rows"
:key="row.label"
class="flex gap-3 border-b border-line-soft py-2 last:border-b-0"
>
<div class="shrink-0 text-xs leading-5 text-ink-3" :style="{ width: labelWidth ?? '6rem' }">
{{ row.label }}
</div>
<div
class="min-w-0 text-[13px] break-all"
:class="[row.mono ? 'mono text-[12.5px]' : '', row.tone === 'err' ? 'text-err' : '']"
>
{{ row.value || '—' }}
</div>
</div>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
/** 日志详情弹窗的摘要头(放 NModal #header):kind 小标签 + 标题(旁徽章 slot)
* + mono 副行 + chips 行;三个日志详情共用同构头部。 */
export interface HeroChip {
label: string
value: string
mono?: boolean
}
defineProps<{ kind: string; title: string; sub?: string; chips?: HeroChip[]; titleMono?: boolean }>()
</script>
<template>
<div class="min-w-0 pb-1">
<div class="text-[11px] font-semibold tracking-[0.08em] text-ink-3 uppercase">{{ kind }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-base leading-6 font-semibold">
<span class="break-all" :class="titleMono ? 'mono' : ''">{{ title }}</span>
<slot />
</div>
<div v-if="sub" class="mono mt-1 text-[11.5px] font-normal break-all text-ink-3">
{{ sub }}
</div>
<div v-if="chips?.length" class="mt-2.5 flex flex-wrap gap-1.5">
<span
v-for="c in chips"
:key="c.label"
class="inline-flex items-center gap-1.5 rounded-full border border-line bg-white px-2.5 py-[3px] text-[11.5px] font-normal text-ink-2"
>
<span class="text-ink-3">{{ c.label }}</span>
<span :class="c.mono ? 'mono' : ''">{{ c.value }}</span>
</span>
</div>
</div>
</template>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
defineProps<{ title: string; note?: string }>()
</script>
<template>
<div class="flex flex-col items-center justify-center gap-2 px-6 py-13 text-center">
<div class="text-sm font-semibold text-ink-2">{{ title }}</div>
<div v-if="note" class="max-w-95 text-[12.5px] text-ink-3">{{ note }}</div>
<slot />
</div>
</template>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { useMessage } from 'naive-ui'
import { computed, ref } from 'vue'
/** 拖拽或点击上传的文本文件选择区;文件内容仅在浏览器本地读取。
* compact:单行紧凑形态,用于表单内与其他字段并排。 */
const props = defineProps<{ accept?: string; label?: string; hint?: string; compact?: boolean }>()
const emit = defineEmits<{ load: [text: string, name: string] }>()
const message = useMessage()
const inputEl = ref<HTMLInputElement>()
const fileName = ref('')
const dragging = ref(false)
const acceptExts = computed(
() =>
props.accept
?.split(',')
.map((s) => s.trim().toLowerCase())
.filter((s) => s.startsWith('.')) ?? [],
)
function extOk(name: string): boolean {
if (!acceptExts.value.length) return true
const lower = name.toLowerCase()
return acceptExts.value.some((ext) => lower.endsWith(ext))
}
async function take(file: File | undefined) {
if (!file) return
if (!extOk(file.name)) {
message.error(`仅支持 ${acceptExts.value.join(' / ')} 文件`)
return
}
if (file.size > 1024 * 1024) {
message.error('文件超过 1 MB,请确认选择的是文本文件')
return
}
fileName.value = file.name
emit('load', await file.text(), file.name)
}
function onChange(e: Event) {
const input = e.target as HTMLInputElement
void take(input.files?.[0])
input.value = ''
}
function onDrop(e: DragEvent) {
dragging.value = false
void take(e.dataTransfer?.files?.[0])
}
</script>
<template>
<div
v-if="compact"
class="flex h-[34px] cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed px-3 transition-colors"
:class="dragging ? 'border-accent bg-accent/5' : 'border-line hover:border-accent'"
:title="fileName ? '已读取,点击或拖拽可重新选择' : hint"
@click="inputEl?.click()"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDrop"
>
<svg
class="h-3.5 w-3.5 flex-none text-ink-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M12 16V4m0 0 4 4m-4-4-4 4" stroke-linecap="round" stroke-linejoin="round" />
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" />
</svg>
<span
class="truncate text-[13px]"
:class="fileName ? 'mono font-medium text-ink' : 'text-ink-2'"
>
{{ fileName || label || '上传文件' }}
</span>
<input ref="inputEl" type="file" class="hidden" :accept="accept" @change="onChange" />
</div>
<div
v-else
class="cursor-pointer rounded-lg border border-dashed px-5 py-5 text-center transition-colors"
:class="dragging ? 'border-accent bg-accent/5' : 'border-line hover:border-accent'"
@click="inputEl?.click()"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDrop"
>
<div
class="mx-auto flex h-9 w-9 items-center justify-center rounded-md border border-line bg-card text-ink-2"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 16V4m0 0 4 4m-4-4-4 4" stroke-linecap="round" stroke-linejoin="round" />
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" />
</svg>
</div>
<div v-if="fileName" class="mt-2.5">
<div class="mono truncate text-[13px] font-medium text-ink">{{ fileName }}</div>
<div class="mt-1 text-xs text-ink-3">已读取点击或拖拽可重新选择</div>
</div>
<div v-else class="mt-2.5">
<div class="text-[13px] font-medium text-ink-2">{{ label ?? '拖拽或点击上传文件' }}</div>
<div v-if="hint" class="mt-1 text-xs text-ink-3">{{ hint }}</div>
</div>
<input ref="inputEl" type="file" class="hidden" :accept="accept" @change="onChange" />
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
<template>
<div class="border-t border-line-soft px-4.5 py-2.5 text-xs leading-relaxed text-ink-3">
<slot />
</div>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { NTooltip } from 'naive-ui'
/** hint:静态说明,label 后问号图标悬浮显示;error:校验 / 阻塞提示,控件下方红字;
* feedback:中性状态反馈(如「已读取文件」),控件下方灰字,error 存在时让位。 */
defineProps<{
label: string
hint?: string
required?: boolean
error?: string
feedback?: string
}>()
</script>
<template>
<div class="mb-3.5">
<div class="mb-1.5 flex items-center gap-[5px] text-[12.5px] font-medium text-ink-2">
<span>{{ label }}<span v-if="required" class="ml-0.5 text-err">*</span></span>
<NTooltip v-if="hint" trigger="hover" placement="top" :style="{ maxWidth: '320px' }">
<template #trigger>
<span
class="flex h-[15px] w-[15px] flex-none cursor-help items-center justify-center rounded-full border border-ink-3/70 text-[10.5px] font-semibold text-ink-3 hover:border-accent hover:text-accent"
aria-label="字段说明"
>
?
</span>
</template>
<span class="text-xs leading-relaxed">{{ hint }}</span>
</NTooltip>
</div>
<slot />
<div v-if="error" class="mt-1 text-xs leading-relaxed text-err">{{ error }}</div>
<div v-else-if="feedback" class="mt-1 text-xs leading-relaxed text-ink-3">{{ feedback }}</div>
</div>
</template>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { NButton, NModal } from 'naive-ui'
defineProps<{
show: boolean
title: string
submitting?: boolean
submitText?: string
submitDisabled?: boolean
danger?: boolean
width?: number
}>()
const emit = defineEmits<{ 'update:show': [boolean]; submit: [] }>()
</script>
<template>
<NModal
:show="show"
preset="card"
:title="title"
:style="{ width: `${width ?? 480}px`, maxWidth: 'calc(100vw - 24px)' }"
:mask-closable="!submitting"
:close-on-esc="!submitting"
@update:show="emit('update:show', $event)"
>
<!-- 标题插槽:需要在标题旁放徽章等富内容时覆盖 -->
<template v-if="$slots.header" #header>
<slot name="header" />
</template>
<div class="form-modal-body">
<slot />
</div>
<template #footer>
<div class="flex justify-end gap-2.5">
<NButton size="small" :disabled="submitting" @click="emit('update:show', false)">
取消
</NButton>
<NButton
size="small"
:type="danger ? 'error' : 'primary'"
:loading="submitting"
:disabled="submitDisabled"
@click="emit('submit')"
>
{{ submitText ?? '确定' }}
</NButton>
</div>
</template>
</NModal>
</template>
<style scoped>
/* 长表单不撑破视口:内容区限高滚动,页脚按钮常驻;
负 margin + 等量 padding 让滚动条贴弹窗边缘(变量继承自 .n-card) */
.form-modal-body {
max-height: min(62vh, calc(100vh - 220px));
overflow-y: auto;
margin-inline: calc(-1 * var(--n-padding-left, 24px));
padding-inline: var(--n-padding-left, 24px);
}
</style>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
/** 表单分组标题:≥ 5 字段或语义分段时使用,小标题 + 分隔线。 */
defineProps<{ title: string }>()
</script>
<template>
<div class="mt-5 mb-3 flex items-center gap-2.5 first:mt-0">
<span class="flex-none text-xs font-semibold tracking-wider text-ink-3">{{ title }}</span>
<span class="h-px flex-1 bg-line-soft"></span>
</div>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useParticles } from '@/composables/useParticles'
// 全屏页装饰底:细网格 + 双光晕 + 飘浮粒子;错误页(404/429)另带居中双轨道环。
// 登录页用 rings="none",环由品牌区自渲染以便与内容同心。
const props = withDefaults(
defineProps<{
/** 光晕主色调:accent(登录/404)或 warn(429) */
tint?: 'accent' | 'warn'
/** 轨道环:center(错误页,视口中心偏上 10px)或 none(使用方自带) */
rings?: 'center' | 'none'
}>(),
{ tint: 'accent', rings: 'center' },
)
const pcanvas = ref<HTMLCanvasElement | null>(null)
useParticles(pcanvas)
</script>
<template>
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div class="grid-bg"></div>
<div
class="glow"
:style="{
width: '520px', height: '520px', left: '-160px', top: '-180px',
background: props.tint === 'warn' ? 'rgba(168,117,20,.15)' : 'rgba(201,100,66,.16)',
}"
></div>
<div
class="glow"
style="width: 460px; height: 460px; right: -140px; bottom: -200px; background: rgba(106, 155, 204, 0.13)"
></div>
<template v-if="props.rings === 'center'">
<div
v-if="props.tint === 'warn'"
class="fpb-ring fpb-spin"
style="width: 620px; height: 620px; left: calc(50% - 310px); top: calc(50% - 320px); border-color: #e4d9c4"
></div>
<div
v-else
class="fpb-ring fpb-spin"
style="width: 640px; height: 640px; left: calc(50% - 320px); top: calc(50% - 330px)"
></div>
<div
class="fpb-ring fpb-ring-dash"
:style="
props.tint === 'warn'
? 'width:480px;height:480px;left:calc(50% - 240px);top:calc(50% - 250px)'
: 'width:500px;height:500px;left:calc(50% - 250px);top:calc(50% - 260px)'
"
></div>
</template>
<canvas ref="pcanvas" class="absolute inset-0 h-full w-full"></canvas>
</div>
</template>
<style scoped>
.grid-bg {
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--color-line) 1px, transparent 1px),
linear-gradient(90deg, var(--color-line) 1px, transparent 1px);
background-size: 36px 36px;
opacity: 0.42;
-webkit-mask-image: radial-gradient(ellipse 90% 80% at 50% 42%, #000 26%, transparent 76%);
mask-image: radial-gradient(ellipse 90% 80% at 50% 42%, #000 26%, transparent 76%);
}
.glow {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.5;
}
</style>
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { NButton, useMessage } from 'naive-ui'
import { computed } from 'vue'
/** 统一的 JSON 展示块:美化缩进 + 语法高亮 + 复制;三个日志详情共用。
* 传 title 时渲染工具栏头(左标签 + meta + 右复制按钮),否则复制按钮浮动。
* 长行不折行、横向滚动;wide 时负 margin 贴满弹窗(NModal card)内容区左右。
* 高亮在转义后的文本上做,v-html 无注入风险。 */
const props = defineProps<{
value: unknown
maxHeight?: string
title?: string
meta?: string
wide?: boolean
}>()
const message = useMessage()
const pretty = computed(() => {
const v = props.value
if (typeof v === 'string') {
try {
return JSON.stringify(JSON.parse(v), null, 2)
} catch {
return v
}
}
return JSON.stringify(v, null, 2)
})
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
const TOKEN_RE =
/("(?:\\.|[^"\\])*")(\s*:)?|\b(?:true|false)\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g
function tokenClass(match: string, str?: string, colon?: string): string {
if (str) return colon ? 'j-key' : 'j-str'
if (match === 'true' || match === 'false') return 'j-bool'
if (match === 'null') return 'j-null'
return 'j-num'
}
const highlighted = computed(() =>
escapeHtml(pretty.value).replace(TOKEN_RE, (m, str, colon) => {
if (str) return `<span class="${tokenClass(m, str, colon)}">${str}</span>${colon ?? ''}`
return `<span class="${tokenClass(m)}">${m}</span>`
}),
)
async function copy() {
try {
await navigator.clipboard.writeText(pretty.value)
message.success('已复制')
} catch {
message.error('复制失败,请手动选择文本复制')
}
}
</script>
<template>
<div
class="min-w-0"
:class="[
title ? 'overflow-hidden rounded-lg border border-line' : 'relative',
wide ? 'json-wide' : '',
]"
>
<div
v-if="title"
class="flex items-center gap-2.5 border-b border-line-soft bg-white px-3 py-1.5"
>
<span class="text-[11.5px] font-semibold text-ink-2">{{ title }}</span>
<span v-if="meta" class="text-[11px] text-ink-3">{{ meta }}</span>
<NButton size="tiny" quaternary class="!ml-auto" @click="copy">复制</NButton>
</div>
<NButton
v-else
size="tiny"
quaternary
class="!absolute top-1.5 right-1.5 z-10"
@click="copy"
>
复制
</NButton>
<!-- 内容先经 escapeHtml 转义再着色,无注入面 -->
<!-- eslint-disable vue/no-v-html -->
<pre
class="json-block mono overflow-auto bg-wash p-3 text-[12px] leading-relaxed whitespace-pre"
:class="title ? '' : 'rounded-md'"
:style="{ maxHeight: maxHeight ?? '52vh' }"
v-html="highlighted"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
</template>
<style scoped>
/* 贴满 NModal(preset card)内容区左右;padding 变量继承自 .n-card,兜底 24px */
.json-wide {
margin-inline: calc(-1 * var(--n-padding-left, 24px));
border-radius: 0;
border-left: none;
border-right: none;
}
.json-block :deep(.j-key) {
color: var(--color-accent);
}
.json-block :deep(.j-str) {
color: var(--color-ok);
}
.json-block :deep(.j-num) {
color: var(--color-info);
}
.json-block :deep(.j-bool),
.json-block :deep(.j-null) {
color: var(--color-warn);
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import StatusBadge from './StatusBadge.vue'
/** OCI lifecycleState → 徽标形态的统一映射 */
const props = defineProps<{ state: string }>()
const kind = computed(() => {
const s = props.state.toUpperCase()
if (['RUNNING', 'AVAILABLE', 'ACTIVE', 'READY', 'ATTACHED'].includes(s)) return 'run'
if (['STOPPED', 'STOPPING', 'DETACHED', 'PAUSED', 'INACTIVE'].includes(s)) return 'stop'
if (['PROVISIONING', 'STARTING', 'CREATING', 'IN_PROGRESS', 'ATTACHING', 'DETACHING'].includes(s))
return 'prov'
if (['TERMINATED', 'TERMINATING', 'FAILED', 'DELETED'].includes(s)) return 'term'
return 'check'
})
</script>
<template>
<StatusBadge :kind="kind" :label="state" />
</template>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
// OAuth provider 的品牌图标:GitHub octocat / OIDC 盾形(设计稿同源 SVG)
defineProps<{ provider: string }>()
</script>
<template>
<svg v-if="provider === 'github'" class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor">
<path
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"
></path>
</svg>
<svg
v-else
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 2l7 3.2V11c0 4.6-3 8.7-7 10-4-1.3-7-5.4-7-10V5.2L12 2z"></path>
<path d="M9 11.5l2 2 4-4.5"></path>
</svg>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { NButton, NIcon, useMessage } from 'naive-ui'
import { truncOcid } from '@/composables/useFormat'
const props = defineProps<{ ocid: string }>()
const message = useMessage()
async function copy() {
await navigator.clipboard.writeText(props.ocid)
message.success('已复制 OCID')
}
</script>
<template>
<span class="inline-flex min-w-0 items-center gap-1">
<span class="mono truncate whitespace-nowrap" :title="ocid">{{ truncOcid(ocid) }}</span>
<NButton size="tiny" quaternary aria-label="复制 OCID" @click="copy">
<template #icon>
<NIcon size="14">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</NIcon>
</template>
</NButton>
</span>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{ title: string; sub?: string }>()
</script>
<template>
<div class="flex flex-wrap items-baseline justify-between gap-2">
<div class="flex items-baseline gap-3">
<h1 class="page-title">{{ title }}</h1>
<slot name="title-extra" />
</div>
<div class="flex items-center gap-2">
<span v-if="sub" class="text-[13px] text-ink-3">{{ sub }}</span>
<slot name="actions" />
</div>
</div>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { NInput } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
const props = defineProps<{
show: boolean
title: string
current: string
submitting?: boolean
}>()
const emit = defineEmits<{ 'update:show': [boolean]; submit: [string] }>()
const name = ref(props.current)
watch(
() => props.show,
(v) => {
if (v) name.value = props.current
},
)
const invalid = computed(() => !name.value.trim() || name.value.trim() === props.current)
function submit() {
if (!invalid.value) emit('submit', name.value.trim())
}
</script>
<template>
<FormModal
:show="show"
:title="title"
:submitting="submitting"
:submit-disabled="invalid"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField label="新名称" required>
<NInput v-model:value="name" placeholder="输入新名称" @keyup.enter="submit" />
</FormField>
</FormModal>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue'
/**
* 状态徽标:设计稿 badge 体系。
* kind 映射:run(绿)/stop(灰)/prov(蓝)/term(红)/snatch(暖橙)/check(灰)
* 具体色由语义色 color-mix 派生,随明暗主题自动适配。
*/
const props = withDefaults(
defineProps<{
kind: 'run' | 'stop' | 'prov' | 'term' | 'snatch' | 'check' | 'warn'
label: string
dot?: boolean
}>(),
{ dot: true },
)
const SEMANTIC: Record<typeof props.kind, string> = {
run: 'var(--color-ok)',
stop: 'var(--color-ink-3)',
prov: 'var(--color-info)',
term: 'var(--color-err)',
snatch: 'var(--color-accent)',
check: 'var(--color-ink-3)',
warn: 'var(--color-warn)',
}
const style = computed(() => {
const c = SEMANTIC[props.kind]
return {
background: `color-mix(in oklab, ${c} 13%, var(--color-card))`,
borderColor: `color-mix(in oklab, ${c} 38%, var(--color-card))`,
color: `color-mix(in oklab, ${c} 75%, var(--color-ink))`,
}
})
const dotStyle = computed(() => ({ background: SEMANTIC[props.kind] }))
</script>
<template>
<span
class="inline-flex h-[22px] items-center gap-1.5 rounded border px-2 text-xs font-medium whitespace-nowrap"
:style="style"
>
<span v-if="dot" class="h-1.5 w-1.5 rounded-full" :style="dotStyle" />
{{ label }}
</span>
</template>
+198
View File
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { NInput, NPopover, NSpin } from 'naive-ui'
import { computed, nextTick, ref, watch } from 'vue'
import type { OciConfigSummary } from '@/types/api'
/** 租户选择器:Popover 面板(搜索 + 左分组导航 + 右租户列表),替代平铺下拉。
* 单选(v-model:value)点行即选即关;多选(multiple + v-model:values)点行切换勾选。
* 搜索命中时忽略左侧分组限定。 */
const props = withDefaults(
defineProps<{
configs: OciConfigSummary[]
value?: number | null
values?: number[]
multiple?: boolean
clearable?: boolean
placeholder?: string
size?: 'small' | 'medium'
loading?: boolean
placement?: 'bottom-start' | 'bottom-end'
}>(),
{ value: null, values: () => [], placeholder: '选择租户', size: 'medium', placement: 'bottom-start' },
)
const emit = defineEmits<{ 'update:value': [number | null]; 'update:values': [number[]] }>()
/** 「未分组」导航项哨兵(空串已被「全部租户」占用) */
const UNGROUPED = '__ungrouped__'
const show = ref(false)
const query = ref('')
const activeGroup = ref('')
const searchInput = ref<InstanceType<typeof NInput> | null>(null)
watch(show, (v) => {
if (!v) return
query.value = ''
activeGroup.value = ''
void nextTick(() => searchInput.value?.focus())
})
const groups = computed(() => {
const counts = new Map<string, number>()
let ungrouped = 0
for (const c of props.configs) {
if (c.group) counts.set(c.group, (counts.get(c.group) ?? 0) + 1)
else ungrouped++
}
const out = [{ key: '', label: '全部租户', count: props.configs.length }]
for (const g of [...counts.keys()].sort((a, b) => a.localeCompare(b, 'zh-CN')))
out.push({ key: g, label: g, count: counts.get(g) ?? 0 })
if (ungrouped && counts.size) out.push({ key: UNGROUPED, label: '未分组', count: ungrouped })
return out
})
const filtered = computed(() => {
const kw = query.value.trim().toLowerCase()
if (kw)
return props.configs.filter(
(c) => c.alias.toLowerCase().includes(kw) || c.tenancyName.toLowerCase().includes(kw),
)
if (!activeGroup.value) return props.configs
if (activeGroup.value === UNGROUPED) return props.configs.filter((c) => !c.group)
return props.configs.filter((c) => c.group === activeGroup.value)
})
const selectedIds = computed(() => {
if (props.multiple) return new Set(props.values)
return new Set(props.value === null ? [] : [props.value])
})
const displayText = computed(() => {
if (props.multiple) return props.values.length ? `${props.values.length} 个租户` : ''
if (props.value === null) return ''
const cfg = props.configs.find((c) => c.id === props.value)
return cfg?.alias ?? `#${props.value}`
})
function pick(cfg: OciConfigSummary) {
if (!props.multiple) {
emit('update:value', cfg.id)
show.value = false
return
}
const cur = [...props.values]
const i = cur.indexOf(cfg.id)
if (i >= 0) cur.splice(i, 1)
else cur.push(cfg.id)
emit('update:values', cur)
}
function clear(e: MouseEvent) {
e.stopPropagation()
if (props.multiple) emit('update:values', [])
else emit('update:value', null)
}
const showClear = computed(
() => !!props.clearable && (props.multiple ? props.values.length > 0 : props.value !== null),
)
</script>
<template>
<NPopover v-model:show="show" trigger="click" :placement="placement" raw :show-arrow="false">
<template #trigger>
<slot name="trigger">
<button
type="button"
class="group flex w-full cursor-pointer items-center gap-2 rounded-lg border bg-white px-3 text-left transition-colors"
:class="[
size === 'small' ? 'h-7' : 'h-[34px]',
show ? 'border-accent ring-2 ring-accent/15' : 'border-line hover:border-accent/60',
]"
>
<span
class="min-w-0 flex-1 truncate"
:class="[size === 'small' ? 'text-[13px]' : 'text-sm', displayText ? '' : 'text-ink-3']"
>
{{ displayText || placeholder }}
</span>
<svg
v-if="showClear"
class="hidden h-3.5 w-3.5 flex-none text-ink-3 hover:text-ink group-hover:block"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round"
@click="clear"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
<svg
class="h-3.5 w-3.5 flex-none text-ink-3 transition-transform"
:class="[showClear ? 'group-hover:hidden' : '', show ? 'rotate-180' : '']"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</slot>
</template>
<div class="w-[min(560px,92vw)] overflow-hidden rounded-xl border border-line bg-card shadow-[0_18px_44px_rgba(20,20,19,0.16)]">
<div class="px-3.5 pt-3 pb-2.5">
<NInput ref="searchInput" v-model:value="query" size="small" placeholder="搜索租户别名 / 租户名称…" clearable>
<template #prefix>
<svg class="h-3.5 w-3.5 text-ink-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
</template>
</NInput>
</div>
<NSpin :show="!!loading" size="small">
<div class="flex min-h-[248px] border-t border-line-soft">
<div class="flex w-[150px] flex-none flex-col gap-0.5 overflow-y-auto border-r border-line-soft bg-wash p-2 max-md:w-[110px]">
<button
v-for="g in groups"
:key="g.key"
type="button"
class="flex cursor-pointer items-center justify-between gap-1.5 rounded-[7px] px-2.5 py-1.5 text-left text-[12.5px]"
:class="!query && activeGroup === g.key ? 'border border-line bg-card font-semibold' : 'border border-transparent text-ink-2 hover:bg-row-hover'"
@click="activeGroup = g.key"
>
<span class="truncate">{{ g.label }}</span>
<span class="flex-none text-[11px]" :class="!query && activeGroup === g.key ? 'text-accent' : 'text-ink-3'">{{ g.count }}</span>
</button>
</div>
<div class="flex max-h-[300px] min-w-0 flex-1 flex-col gap-[3px] overflow-y-auto p-2">
<button
v-for="c in filtered"
:key="c.id"
type="button"
class="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-left"
:class="selectedIds.has(c.id) ? 'bg-accent/8 outline outline-1 outline-accent/30' : 'hover:bg-row-hover'"
@click="pick(c)"
>
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px] font-semibold">{{ c.alias }}</span>
<span class="mono mt-px block truncate text-[11px] text-ink-3">{{ c.tenancyName }}</span>
</span>
<span class="mono flex-none rounded-[5px] border border-line bg-white px-2 py-0.5 text-[11px] text-ink-2">{{ c.region }}</span>
<span class="w-4 flex-none text-center font-bold text-accent">{{ selectedIds.has(c.id) ? '✓' : '' }}</span>
</button>
<div v-if="!filtered.length" class="py-8 text-center text-xs text-ink-3">
{{ query ? '无匹配租户' : '该分组暂无租户' }}
</div>
</div>
</div>
</NSpin>
<div
v-if="multiple"
class="flex items-center justify-between border-t border-line-soft bg-wash px-3.5 py-2 text-[11.5px] text-ink-3"
>
<span>已选 {{ selectedIds.size }} · 留空表示全部租户</span>
<button type="button" class="cursor-pointer text-accent hover:underline" @click="clear">清空</button>
</div>
</div>
</NPopover>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { NSwitch } from 'naive-ui'
/** 能力开关卡:左 switch、右标题+说明;租户导入 / 修改表单共用 */
defineProps<{ title: string; desc: string; disabled?: boolean }>()
const checked = defineModel<boolean>({ required: true })
</script>
<template>
<div class="flex items-start gap-2.5 rounded-lg border border-line bg-white px-3.5 py-3">
<NSwitch v-model:value="checked" size="small" class="mt-0.5 flex-none" :disabled="disabled" />
<div class="min-w-0">
<div class="text-[13px] font-medium">{{ title }}</div>
<div class="mt-0.5 text-xs leading-snug text-ink-3">{{ desc }}</div>
</div>
</div>
</template>
+250
View File
@@ -0,0 +1,250 @@
<script setup lang="ts">
import { NButton, NDataTable, NInput, NSelect, NTooltip, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
import { computed, h, reactive, ref } from 'vue'
import { createAiChannel, deleteAiChannel, probeAiChannel, syncAiChannelModels, updateAiChannel } from '@/api/aigateway'
import { listCachedRegions } from '@/api/configs'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import TenantPicker from '@/components/TenantPicker.vue'
import { useAsync } from '@/composables/useAsync'
import { fmtRelative } from '@/composables/useFormat'
import { useToast } from '@/composables/useToast'
import { useScopeStore } from '@/stores/scope'
import type { AiChannel, AiProbeStatus } from '@/types/api'
defineProps<{ channels: AiChannel[]; loading: boolean }>()
const emit = defineEmits<{ changed: [] }>()
const message = useMessage()
const toast = useToast()
const dialog = useDialog()
const scope = useScopeStore()
const PROBE_META: Record<Exclude<AiProbeStatus, ''>, { kind: 'run' | 'warn' | 'term' | 'stop'; label: string }> = {
ok: { kind: 'run', label: '可用' },
no_service: { kind: 'warn', label: '无服务' },
no_quota: { kind: 'warn', label: '无配额' },
error: { kind: 'term', label: '不可用' },
}
// ---- 新建 / 编辑弹窗 ----
const showForm = ref(false)
const editing = ref<AiChannel | null>(null)
const submitting = ref(false)
const form = reactive({ ociConfigId: null as number | null, region: '', name: '', group: '', priority: 1, weight: 1 })
const regions = useAsync(
() => (form.ociConfigId === null ? Promise.resolve([]) : listCachedRegions(form.ociConfigId).catch(() => [])),
false,
)
const regionOptions = computed(() =>
(regions.data.value ?? []).map((r) => ({ label: r.name, value: r.name })),
)
function onCfgChange() {
form.region = ''
void regions.run()
}
function openCreate() {
editing.value = null
Object.assign(form, { ociConfigId: null, region: '', name: '', group: '', priority: 1, weight: 1 })
showForm.value = true
}
function openEdit(ch: AiChannel) {
editing.value = ch
Object.assign(form, { ociConfigId: ch.ociConfigId, region: ch.region, name: ch.name, group: ch.group, priority: ch.priority, weight: ch.weight })
showForm.value = true
}
async function submit() {
submitting.value = true
try {
if (editing.value) {
await updateAiChannel(editing.value.id, { name: form.name, group: form.group, priority: form.priority, weight: form.weight })
message.success('已更新渠道')
showForm.value = false
emit('changed')
} else {
const ch = await createAiChannel({
ociConfigId: form.ociConfigId ?? undefined, region: form.region, name: form.name || undefined,
group: form.group, priority: form.priority, weight: form.weight,
})
showForm.value = false
emit('changed')
message.success('渠道已添加,正在探测…')
void doProbe(ch.id)
}
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
submitting.value = false
}
}
const probing = ref(new Set<number>())
async function doProbe(id: number) {
probing.value.add(id)
try {
const ch = await probeAiChannel(id)
const meta = ch.probeStatus ? PROBE_META[ch.probeStatus as Exclude<AiProbeStatus, ''>] : null
if (ch.probeStatus === 'ok') {
message.success('探测完成:可用')
} else {
toast.warning(`探测完成:${meta?.label ?? ch.probeStatus}`, ch.probeError || undefined)
}
} catch (e) {
message.error(e instanceof Error ? e.message : '探测失败')
} finally {
probing.value.delete(id)
emit('changed')
}
}
async function doSync(id: number) {
try {
const models = await syncAiChannelModels(id)
message.success(`已同步 ${models.length} 个模型`)
emit('changed')
} catch (e) {
message.error(e instanceof Error ? e.message : '同步失败')
}
}
async function toggle(ch: AiChannel) {
try {
await updateAiChannel(ch.id, { enabled: !ch.enabled })
message.success(ch.enabled ? '已停用' : '已启用')
emit('changed')
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
}
}
function confirmRemove(ch: AiChannel) {
dialog.warning({
title: '删除渠道',
content: `删除渠道「${ch.name}」及其模型缓存?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await deleteAiChannel(ch.id)
message.success('已删除')
emit('changed')
},
})
}
function probeCell(r: AiChannel) {
const badge = probeBadge(r)
const tip = [r.probeError, r.lastProbeAt ? `探测于 ${fmtRelative(r.lastProbeAt)}` : '']
.filter(Boolean)
.join('\n')
if (!tip) return badge
return h(
NTooltip,
{ style: { maxWidth: '380px' } },
{
trigger: () => h('span', { class: 'inline-flex cursor-help' }, [badge]),
default: () => h('span', { class: 'text-xs leading-relaxed whitespace-pre-wrap break-all' }, tip),
},
)
}
function probeBadge(r: AiChannel) {
if (!r.enabled) return h(StatusBadge, { kind: 'stop', label: '已停用' })
if (r.disabledUntil && new Date(r.disabledUntil) > new Date())
return h(StatusBadge, { kind: 'warn', label: '熔断中' })
const meta = r.probeStatus ? PROBE_META[r.probeStatus as Exclude<AiProbeStatus, ''>] : null
return meta ? h(StatusBadge, meta) : h('span', { class: 'text-xs text-ink-3' }, '未探测')
}
const columns = computed<DataTableColumns<AiChannel>>(() => [
{ title: '名称', key: 'name', minWidth: 200, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'text-[13px] font-medium' }, r.name) },
{ title: '区域', key: 'region', width: 150, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'mono text-xs text-ink-2' }, r.region) },
{
title: '分组', key: 'group', width: 100,
render: (r) => r.group
? h('span', { class: 'rounded-[5px] bg-info/15 px-2 py-0.5 text-xs font-medium text-info' }, r.group)
: h('span', { class: 'text-xs text-ink-3' }, '默认'),
},
{ title: '优先级', key: 'priority', width: 76, render: (r) => h('span', { class: 'mono text-[13px]' }, String(r.priority)) },
{ title: '权重', key: 'weight', width: 66, render: (r) => h('span', { class: 'mono text-[13px]' }, String(r.weight)) },
{ title: '探测状态', key: 'probeStatus', width: 120, render: probeCell },
{
title: '操作', key: 'actions', width: 250,
render: (r) => h('div', { class: 'flex items-center gap-0.5' }, [
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', loading: probing.value.has(r.id), onClick: () => doProbe(r.id) }, { default: () => '探测' }),
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => doSync(r.id) }, { default: () => '同步模型' }),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => openEdit(r) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmRemove(r) }, { default: () => '删除' }),
]),
},
])
</script>
<template>
<div class="panel">
<div class="flex items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3">
<div>
<div class="text-sm font-semibold">渠道(号池)</div>
<div class="mt-0.5 text-xs text-ink-3">
渠道 = 租户 × 区域;请求按优先级分组组内权重随机,429/超时自动换渠道重试,连续失败指数退避熔断
</div>
</div>
<NButton size="small" type="primary" @click="openCreate">添加渠道</NButton>
</div>
<NDataTable :columns="columns" :data="channels" :loading="loading" :bordered="false" size="small" />
<div class="border-t border-line-soft bg-wash px-4.5 py-2.5 text-xs leading-relaxed text-ink-3">
探测三步:列模型(服务可见性) 同步模型缓存 max_tokens=1 试调(配额)· 探测成功自动复位熔断 ·
有渠道时系统自动维护AI渠道探测后台任务(每天 00:00 逐渠道探测,渠道清空自动暂停)
</div>
<FormModal
v-model:show="showForm"
:title="editing ? '编辑渠道' : '添加渠道'"
:submitting="submitting"
:submit-text="editing ? '保存' : '添加并探测'"
:submit-disabled="!editing && (form.ociConfigId === null || !form.region)"
@submit="submit"
>
<template v-if="!editing">
<FormField label="租户配置" required>
<TenantPicker
v-model:value="form.ociConfigId"
:configs="scope.configs.data ?? []"
:loading="scope.configs.loading"
@update:value="onCfgChange"
/>
</FormField>
<FormField label="区域" required hint="建议选 GenAI 提供区域:芝加哥/阿什本/凤凰城/法兰克福/伦敦/大阪等;租户需已订阅该区域">
<NSelect v-model:value="form.region" :options="regionOptions" :loading="regions.loading.value" placeholder="选择区域" filterable tag />
</FormField>
</template>
<FormField v-else label="名称">
<NInput v-model:value="form.name" />
</FormField>
<div class="grid grid-cols-3 gap-3">
<FormField label="分组" hint="分组供密钥定向路由;留空归入默认分组">
<NInput v-model:value="form.group" placeholder="默认" />
</FormField>
<FormField label="优先级" hint="数字小优先">
<AppInputNumber v-model:value="form.priority" :min="1" />
</FormField>
<FormField label="权重" hint="同优先级内按权重分流">
<AppInputNumber v-model:value="form.weight" :min="1" />
</FormField>
</div>
<div v-if="!editing" class="rounded-lg bg-wash px-3 py-2 text-xs leading-relaxed text-ink-3">
创建后自动发起一次探测,并自动维护AI渠道探测后台任务(渠道清空自动暂停)
</div>
</FormModal>
</div>
</template>
+226
View File
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { NButton, NDataTable, NInput, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
import { computed, h, reactive, ref } from 'vue'
import { createAiKey, deleteAiKey, updateAiKey, updateAiKeyContentLog } from '@/api/aigateway'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { fmtTime } from '@/composables/useFormat'
import type { AiKey } from '@/types/api'
const props = defineProps<{ keys: AiKey[]; loading: boolean; groups: string[] }>()
const emit = defineEmits<{ changed: [] }>()
const message = useMessage()
const dialog = useDialog()
// ---- 新建 / 编辑弹窗 ----
const showForm = ref(false)
const editing = ref<AiKey | null>(null)
const submitting = ref(false)
const form = reactive({ name: '', value: '', group: '' })
function openCreate() {
editing.value = null
Object.assign(form, { name: '', value: '', group: '' })
showForm.value = true
}
function openEdit(key: AiKey) {
editing.value = key
Object.assign(form, { name: key.name, value: '', group: key.group })
showForm.value = true
}
// 创建成功后明文只在此弹窗展示一次
const createdKey = ref('')
const showCreated = ref(false)
async function submit() {
submitting.value = true
try {
if (editing.value) {
await updateAiKey(editing.value.id, { name: form.name, group: form.group })
message.success('已更新密钥')
} else {
const { key } = await createAiKey({ name: form.name, value: form.value || undefined, group: form.group || undefined })
createdKey.value = key
showCreated.value = true
}
showForm.value = false
emit('changed')
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
submitting.value = false
}
}
// ---- 内容日志(红线例外:限时开启,到期自动停写) ----
const contentLogHours = ref<number | null>(24)
const togglingContentLog = ref(false)
const contentLogActive = computed(() => {
const until = editing.value?.contentLogUntil
return !!until && new Date(until).getTime() > Date.now()
})
async function setContentLog(hours: number) {
if (!editing.value) return
togglingContentLog.value = true
try {
const fresh = await updateAiKeyContentLog(editing.value.id, hours)
editing.value = fresh
message.success(hours > 0 ? '内容日志已开启,到期自动关闭' : '内容日志已关闭')
emit('changed')
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
togglingContentLog.value = false
}
}
async function copyKey() {
try {
await navigator.clipboard.writeText(createdKey.value)
message.success('已复制')
} catch {
message.error('复制失败,请手动选择复制')
}
}
async function toggle(key: AiKey) {
try {
await updateAiKey(key.id, { enabled: !key.enabled })
message.success(key.enabled ? '已停用' : '已启用')
emit('changed')
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
}
}
function confirmRemove(key: AiKey) {
dialog.warning({
title: '删除密钥',
content: `删除密钥「${key.name}」?使用该密钥的调用将立即失效。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await deleteAiKey(key.id)
message.success('已删除')
emit('changed')
},
})
}
const groupHints = computed(() => props.groups.filter(Boolean))
const columns = computed<DataTableColumns<AiKey>>(() => [
{ title: '名称', key: 'name', width: 220, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'text-[13px] font-medium' }, r.name) },
{ title: '密钥', key: 'tail', width: 120, render: (r) => h('span', { class: 'mono text-[13px] text-ink-2' }, `……${r.tail}`) },
{
title: '分组', key: 'group', width: 110,
render: (r) => r.group
? h('span', { class: 'rounded-[5px] bg-info/15 px-2 py-0.5 text-xs font-medium text-info' }, r.group)
: h('span', { class: 'text-xs text-ink-3' }, '不限'),
},
{
title: '状态', key: 'enabled', width: 100,
render: (r) => h(StatusBadge, r.enabled ? { kind: 'run', label: '启用' } : { kind: 'stop', label: '已禁用' }),
},
{ title: '最近使用', key: 'lastUsedAt', render: (r) => h('span', { class: 'text-[13px] text-ink-2' }, r.lastUsedAt ? fmtTime(r.lastUsedAt) : '从未') },
{ title: '创建时间', key: 'createdAt', render: (r) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(r.createdAt)) },
{
title: '操作', key: 'actions', width: 170,
render: (r) => h('div', { class: 'flex items-center gap-2' }, [
h(NButton, { size: 'tiny', quaternary: true, onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => openEdit(r) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmRemove(r) }, { default: () => '删除' }),
]),
},
])
</script>
<template>
<div class="panel">
<div class="flex items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3">
<div>
<div class="text-sm font-semibold">网关密钥</div>
<div class="mt-0.5 text-xs text-ink-3">
对外发放的访问凭证;明文仅创建时展示一次,库内只存哈希与尾 4 ;选了分组的密钥只在该分组渠道内负载均衡
</div>
</div>
<NButton size="small" type="primary" @click="openCreate">新建密钥</NButton>
</div>
<NDataTable :columns="columns" :data="keys" :loading="loading" :bordered="false" size="small" />
<FormModal
v-model:show="showForm"
:title="editing ? '编辑密钥' : '新建密钥'"
:submitting="submitting"
:submit-text="editing ? '保存' : '创建'"
:submit-disabled="!form.name.trim()"
@submit="submit"
>
<FormField label="名称" required>
<NInput v-model:value="form.name" placeholder="例如:免费-ai-api-key" />
</FormField>
<FormField label="渠道分组" hint="选择分组后,该密钥的调用只在该分组的渠道内负载均衡;留空不限分组">
<NInput v-model:value="form.group" placeholder="留空 = 不限(全部渠道)" />
<div v-if="groupHints.length" class="mt-1.5 flex flex-wrap items-center gap-1.5">
<span class="text-xs text-ink-3">现有分组:</span>
<button
v-for="g in groupHints"
:key="g"
type="button"
class="cursor-pointer rounded-[5px] border border-line bg-card px-2 py-0.5 text-xs text-ink-2 hover:border-accent hover:text-accent"
@click="form.group = g"
>
{{ g }}
</button>
</div>
</FormField>
<FormField v-if="!editing" label="自定义密钥值" hint="至少 8 位;仅测试场景建议自定义">
<NInput v-model:value="form.value" placeholder="留空自动生成 sk- 开头随机密钥(推荐)" />
</FormField>
<FormField
v-if="editing"
label="内容日志"
hint="「不落正文」红线的显式例外:限时记录该密钥的请求正文用于排障,到期自动停写;正文截断 64KB,保留 7 天 / 1 万行,流式响应不记录"
>
<div class="flex items-center gap-2">
<template v-if="!contentLogActive">
<AppInputNumber v-model:value="contentLogHours" :min="1" :max="168" size="small" class="w-28" />
<span class="text-xs text-ink-3">小时(上限 7 )</span>
<NButton
size="tiny"
:loading="togglingContentLog"
:disabled="!contentLogHours"
@click="setContentLog(contentLogHours ?? 0)"
>
开启
</NButton>
</template>
<template v-else>
<span class="text-xs text-ok">已开启 · {{ fmtTime(editing!.contentLogUntil!) }} 自动关闭</span>
<NButton size="tiny" quaternary type="error" :loading="togglingContentLog" @click="setContentLog(0)">
立即关闭
</NButton>
</template>
</div>
</FormField>
</FormModal>
<FormModal v-model:show="showCreated" title="密钥已创建" submit-text="我已保存" @submit="showCreated = false">
<div class="flex items-center gap-2 rounded-lg border border-line bg-wash px-3 py-2.5">
<span class="mono min-w-0 flex-1 text-[13px] break-all">{{ createdKey }}</span>
<NButton size="tiny" quaternary type="primary" @click="copyKey">复制</NButton>
</div>
<div class="mt-3 rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-xs leading-relaxed text-warn">
明文仅此一次展示,关闭后无法再查看;遗失请删除后重建
</div>
</FormModal>
</div>
</template>
+194
View File
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { NInput, NSelect, useMessage } from 'naive-ui'
import { computed, reactive, watch } from 'vue'
import { ref } from 'vue'
import { updateConfig } from '@/api/configs'
import FilePicker from '@/components/FilePicker.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import FormSection from '@/components/FormSection.vue'
import ToggleCard from '@/components/ToggleCard.vue'
import { useProxyOptions } from '@/composables/useProxyOptions'
import { useScopeStore } from '@/stores/scope'
import type { OciConfigSummary } from '@/types/api'
const props = defineProps<{ show: boolean; config: OciConfigSummary | null }>()
const emit = defineEmits<{ 'update:show': [boolean]; updated: [] }>()
const message = useMessage()
const submitting = ref(false)
const form = reactive({
alias: '',
group: '',
region: '',
privateKey: '',
fingerprint: '',
passphrase: '',
multiRegion: false,
multiCompartment: false,
proxyId: null as number | null,
})
// 出站代理选项(含出口地区);编辑时回填现有关联
const { proxies, options: proxyOptions } = useProxyOptions()
// 分组选项:现有租户分组去重,可直接选也可输入新分组名
const scope = useScopeStore()
const groupSelectOptions = computed(() => {
const names = new Set<string>()
for (const c of scope.configs.data ?? []) if (c.group) names.add(c.group)
return [...names].sort((a, b) => a.localeCompare(b, 'zh-CN')).map((g) => ({ label: g, value: g }))
})
watch(
() => props.show,
(v) => {
if (!v || !props.config) return
form.alias = props.config.alias
form.group = props.config.group
form.region = props.config.region
form.privateKey = ''
form.fingerprint = ''
form.passphrase = ''
form.multiRegion = props.config.multiRegion
form.multiCompartment = props.config.multiCompartment
form.proxyId = props.config.proxyId
},
)
const keyLooksValid = computed(() => !form.privateKey || form.privateKey.includes('PRIVATE KEY'))
const canSubmit = computed(() => {
if (!form.alias.trim() || !form.region.trim() || !keyLooksValid.value) return false
if (form.privateKey && !form.fingerprint.trim()) return false
return true
})
async function submit() {
if (!props.config) return
submitting.value = true
try {
const result = await updateConfig(props.config.id, {
alias: form.alias.trim(),
// 空串表示取消分组,后端一经提供整体覆盖
group: form.group.trim(),
region: form.region.trim(),
privateKey: form.privateKey || undefined,
fingerprint: form.fingerprint.trim() || undefined,
// 换私钥时口令整体覆盖(空串清除旧口令),避免旧口令解密新钥失败
passphrase: form.privateKey ? form.passphrase : undefined,
multiRegion: form.multiRegion,
multiCompartment: form.multiCompartment,
// 后端约定:0 解除代理关联,>0 关联到该代理
proxyId: form.proxyId ?? 0,
})
const alive = result.config.aliveStatus === 'alive'
message.success(alive ? '已保存并重新测活通过' : '已保存,但重新测活失败,请检查凭据')
emit('update:show', false)
emit('updated')
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
:title="`修改租户「${config?.alias ?? ''}」`"
:width="560"
:submitting="submitting"
:submit-disabled="!canSubmit"
submit-text="保存并测活"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="别名" required>
<NInput v-model:value="form.alias" placeholder="面板内展示名" />
</FormField>
<FormField label="默认区域" required hint="资源接口未显式传 region 时使用">
<NInput v-model:value="form.region" class="mono" placeholder="eu-frankfurt-1" />
</FormField>
</div>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="分组" hint="可选已有分组或直接输入新分组名;清空表示取消分组">
<NSelect
v-model:value="form.group"
:options="groupSelectOptions"
placeholder="如「生产」「教育」,可选"
filterable
tag
clearable
/>
</FormField>
<FormField
label="出站代理"
hint="关联后该租户全部 OCI SDK 请求经代理发出,选项含实测出口地区,代理在侧边栏「代理」页维护"
>
<NSelect
v-model:value="form.proxyId"
:options="proxyOptions"
:loading="proxies.loading.value"
/>
</FormField>
</div>
<FormSection title="能力开关" />
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<ToggleCard
v-model="form.multiRegion"
title="多区域支持"
desc="订阅区域随本次测活入库,筛选器可切换;关闭则清空缓存并锁定默认区域"
/>
<ToggleCard
v-model="form.multiCompartment"
title="多区间支持"
desc="区间随本次测活入库,筛选器可切换;关闭则清空缓存并锁定根区间"
/>
</div>
<FormSection title="凭据轮换(可选)" />
<FormField
label="替换 API 私钥"
hint="密钥轮换或过期时上传新私钥;留空则保持现有私钥不变"
:error="
form.privateKey && !keyLooksValid
? '文件内容不含 PRIVATE KEY 段,请确认选择的是私钥 .pem'
: undefined
"
:feedback="
form.privateKey && keyLooksValid ? '已读取新私钥,保存后替换并重新测活' : undefined
"
>
<FilePicker
:key="`edit-pem-${show}`"
compact
label="上传新的 api-key.pem"
hint="支持 .pem / .key,文件内容仅本地读取"
accept=".pem,.key"
@load="(text) => (form.privateKey = text)"
/>
</FormField>
<template v-if="form.privateKey">
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="新指纹" required hint="OCI 控制台该 API Key 的指纹">
<NInput v-model:value="form.fingerprint" class="mono" placeholder="xx:xx:xx:…" />
</FormField>
<FormField label="新私钥口令" hint="未加口令则留空;口令整体覆盖,空串清除旧口令">
<NInput
v-model:value="form.passphrase"
type="password"
show-password-on="click"
placeholder="可选"
/>
</FormField>
</div>
</template>
</FormModal>
</template>
+213
View File
@@ -0,0 +1,213 @@
<script setup lang="ts">
import { NInput, NSelect, NTabPane, NTabs, useMessage } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { importConfig } from '@/api/configs'
import FilePicker from '@/components/FilePicker.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import FormSection from '@/components/FormSection.vue'
import ToggleCard from '@/components/ToggleCard.vue'
import { useProxyOptions } from '@/composables/useProxyOptions'
import { useScopeStore } from '@/stores/scope'
import type { ImportConfigRequest } from '@/types/api'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ 'update:show': [boolean]; imported: [] }>()
const message = useMessage()
const mode = ref<'ini' | 'fields'>('ini')
const submitting = ref(false)
const blank = {
alias: '',
group: '',
privateKey: '',
passphrase: '',
configIni: '',
tenancyOcid: '',
userOcid: '',
region: '',
fingerprint: '',
multiRegion: false,
multiCompartment: false,
proxyId: null as number | null,
}
const form = reactive({ ...blank })
// 出站代理选项:该租户全部 SDK 请求经所选代理发出,选项含出口地区
const { proxies, options: proxyOptions } = useProxyOptions()
// 分组选项:现有租户分组去重,可直接选也可输入新分组名
const scope = useScopeStore()
const groupSelectOptions = computed(() => {
const names = new Set<string>()
for (const c of scope.configs.data ?? []) if (c.group) names.add(c.group)
return [...names].sort((a, b) => a.localeCompare(b, 'zh-CN')).map((g) => ({ label: g, value: g }))
})
watch(
() => props.show,
(v) => {
if (v) Object.assign(form, blank)
},
)
const keyLooksValid = computed(() => form.privateKey.includes('PRIVATE KEY'))
const canSubmit = computed(() => {
if (!form.alias.trim() || !keyLooksValid.value) return false
if (mode.value === 'ini') return !!form.configIni.trim()
return [form.tenancyOcid, form.userOcid, form.region, form.fingerprint].every((v) => v.trim())
})
function buildBody(): ImportConfigRequest {
const base = {
alias: form.alias.trim(),
group: form.group.trim() || undefined,
privateKey: form.privateKey,
passphrase: form.passphrase || undefined,
multiRegion: form.multiRegion,
multiCompartment: form.multiCompartment,
proxyId: form.proxyId ?? undefined,
}
if (mode.value === 'ini') return { ...base, configIni: form.configIni }
return {
...base,
tenancyOcid: form.tenancyOcid.trim(),
userOcid: form.userOcid.trim(),
region: form.region.trim(),
fingerprint: form.fingerprint.trim(),
}
}
async function submit() {
submitting.value = true
try {
const created = await importConfig(buildBody())
message.success(`已导入「${created.alias}」,测活与账户类别判定完成`)
emit('update:show', false)
emit('imported')
} catch (e) {
message.error(e instanceof Error ? e.message : '导入失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="导入租户"
:width="560"
:submitting="submitting"
:submit-disabled="!canSubmit"
submit-text="导入并测活"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="别名" required hint="面板内展示用,如「法兰克福主号」">
<NInput v-model:value="form.alias" placeholder="给这个租户起个名字" />
</FormField>
<FormField label="分组" hint="可选已有分组或直接输入新分组名;列表与全局选择器可按组筛选">
<NSelect
v-model:value="form.group"
:options="groupSelectOptions"
placeholder="留空表示不分组"
filterable
tag
clearable
/>
</FormField>
</div>
<FormSection title="凭据来源" />
<NTabs v-model:value="mode" type="segment" size="small" class="mb-1" animated>
<NTabPane name="ini" tab="粘贴 config">
<FormField
label="config 内容"
required
hint="OCI 控制台生成 API Key 时提供的 config 片段([DEFAULT] 段含 user / fingerprint / tenancy / region),四个字段自动解析"
>
<NInput
v-model:value="form.configIni"
type="textarea"
class="mono"
:rows="6"
placeholder="[DEFAULT]&#10;user=ocid1.user.oc1..…&#10;fingerprint=xx:xx:…&#10;tenancy=ocid1.tenancy.oc1..…&#10;region=eu-frankfurt-1"
/>
</FormField>
</NTabPane>
<NTabPane name="fields" tab="手动填写">
<FormField label="Tenancy OCID" required>
<NInput v-model:value="form.tenancyOcid" class="mono" placeholder="ocid1.tenancy.oc1..…" />
</FormField>
<FormField label="User OCID" required>
<NInput v-model:value="form.userOcid" class="mono" placeholder="ocid1.user.oc1..…" />
</FormField>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="区域" required>
<NInput v-model:value="form.region" class="mono" placeholder="eu-frankfurt-1" />
</FormField>
<FormField label="指纹" required>
<NInput v-model:value="form.fingerprint" class="mono" placeholder="xx:xx:xx:…" />
</FormField>
</div>
</NTabPane>
</NTabs>
<FormSection title="凭据" />
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField
label="API 私钥"
required
hint="OCI 控制台生成 API Key 时下载的私钥;支持 .pem / .key,文件内容仅本地读取,经 AES-GCM 加密后入库"
:error="
form.privateKey && !keyLooksValid
? '文件内容不含 PRIVATE KEY 段,请确认选择的是私钥 .pem'
: undefined
"
:feedback="form.privateKey && keyLooksValid ? '已读取私钥,加密入库不存明文' : undefined"
>
<FilePicker
:key="`pem-${show}`"
compact
label="上传 api-key.pem"
hint="支持 .pem / .key,文件内容仅本地读取"
accept=".pem,.key"
@load="(text) => (form.privateKey = text)"
/>
</FormField>
<FormField label="私钥口令" hint="仅私钥带 passphrase 时填写">
<NInput
v-model:value="form.passphrase"
type="password"
show-password-on="click"
placeholder="未加口令则留空"
/>
</FormField>
</div>
<FormSection title="关联与开关" />
<FormField
label="出站代理"
hint="可选;关联后该租户全部 OCI SDK 请求经代理发出,选项含实测出口地区,代理在侧边栏「代理」页维护"
>
<NSelect v-model:value="form.proxyId" :options="proxyOptions" :loading="proxies.loading.value" />
</FormField>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<ToggleCard
v-model="form.multiRegion"
title="多区域支持"
desc="订阅区域随测活入库,筛选器可切换;否则锁定默认区域"
/>
<ToggleCard
v-model="form.multiCompartment"
title="多区间支持"
desc="区间随测活入库,筛选器可切换;否则锁定根区间"
/>
</div>
</FormModal>
</template>
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { NInput, NSelect, useMessage, type SelectOption } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { attachVnic } from '@/api/instances'
import { listSubnets } from '@/api/networks'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import ToggleCard from '@/components/ToggleCard.vue'
import { useAsync } from '@/composables/useAsync'
import { shortAd } from '@/composables/useFormat'
const props = defineProps<{
show: boolean
cfgId: number
instanceId: string
region?: string
/** 已附加网卡数,仅用于配额提示文案 */
attachedCount: number
}>()
const emit = defineEmits<{ 'update:show': [boolean]; attached: [] }>()
const message = useMessage()
const submitting = ref(false)
const subnetId = ref<string | null>(null)
const displayName = ref('')
const privateIp = ref('')
const assignPublicIp = ref(false)
const assignIpv6 = ref(false)
/** vcnId 传空列全部子网:次要网卡允许跨 VCN */
const subnets = useAsync(() => listSubnets(props.cfgId, '', props.region), false)
watch(
() => props.show,
(v) => {
if (v) {
subnetId.value = null
displayName.value = ''
privateIp.value = ''
assignPublicIp.value = false
assignIpv6.value = false
void subnets.run()
}
},
)
const options = computed<SelectOption[]>(() =>
(subnets.data.value ?? [])
.filter((s) => s.lifecycleState === 'AVAILABLE')
.map((s) => ({
label: `${s.displayName}${s.cidrBlock}${s.availabilityDomain ? ` · ${shortAd(s.availabilityDomain)}` : ''}`,
value: s.id,
})),
)
const selected = computed(() => (subnets.data.value ?? []).find((s) => s.id === subnetId.value))
/** 私有子网禁分公网 IP;未启用 IPv6 的子网禁自动分配 */
const publicIpBlocked = computed(() => !!selected.value?.prohibitPublicIp)
const ipv6Blocked = computed(() => !!selected.value && !selected.value.ipv6CidrBlock)
watch(selected, (s) => {
if (s?.prohibitPublicIp) assignPublicIp.value = false
if (s && !s.ipv6CidrBlock) assignIpv6.value = false
})
async function submit() {
if (!subnetId.value) return
submitting.value = true
try {
await attachVnic(props.cfgId, props.instanceId, {
region: props.region,
subnetId: subnetId.value,
displayName: displayName.value.trim(),
privateIp: privateIp.value.trim(),
assignPublicIp: assignPublicIp.value,
assignIpv6: assignIpv6.value,
})
message.success('附加请求已提交,OS 内需自行配置新网卡 IP')
emit('update:show', false)
emit('attached')
} catch (e) {
message.error(e instanceof Error ? e.message : '附加失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="附加 VNIC"
:submitting="submitting"
:submit-disabled="!subnetId"
submit-text="附加"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField
label="子网"
required
hint="与实例同 AD 的子网;跨 VCN 子网亦可选"
:error="
!subnets.loading.value && !options.length ? '该区域没有可用子网,请先创建' : undefined
"
>
<NSelect
v-model:value="subnetId"
:options="options"
:loading="subnets.loading.value"
filterable
placeholder="选择子网"
/>
</FormField>
<FormField label="显示名称">
<NInput v-model:value="displayName" placeholder="留空自动生成" />
</FormField>
<FormField label="私网 IP">
<NInput v-model:value="privateIp" placeholder="留空自动分配(推荐)" />
</FormField>
<div class="flex flex-col gap-2.5">
<ToggleCard
v-model="assignPublicIp"
title="分配公网 IP"
:desc="publicIpBlocked ? '所选子网为私有子网,不可分配公网 IP' : '仅公有子网可用;临时地址,分离即释放'"
:disabled="publicIpBlocked"
/>
<ToggleCard
v-model="assignIpv6"
title="自动分配 IPv6"
:desc="
ipv6Blocked
? '所选子网未启用 IPv6(可在 VCN 详情一键启用)'
: '附加时同时分一个 IPv6;后续可在网卡列表继续添加'
"
:disabled="ipv6Blocked"
/>
</div>
<div class="mt-2 rounded-md bg-wash px-3 py-2 text-xs leading-relaxed text-ink-3">
当前已附加 {{ attachedCount }} 张网卡可附加数量由 shape 决定 A1.Flex OCPU 1
超出配额时 OCI 会拒绝请求
</div>
</FormModal>
</template>
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { NCheckbox, NSelect, useMessage, type SelectGroupOption } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { listBootVolumes } from '@/api/bootVolumes'
import { attachVolume, listVolumes } from '@/api/instances'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useAsync } from '@/composables/useAsync'
const props = defineProps<{
show: boolean
cfgId: number
instanceId: string
region?: string
availabilityDomain: string
attachedVolumeIds: string[]
}>()
const emit = defineEmits<{ 'update:show': [boolean]; attached: [] }>()
const message = useMessage()
const submitting = ref(false)
const volumeId = ref<string | null>(null)
const readOnly = ref(false)
const volumes = useAsync(
() => listVolumes(props.cfgId, props.availabilityDomain, undefined, props.region),
false,
)
/** 未挂载的引导卷可作为数据卷附加(OCI attachVolume 直接接受引导卷 OCID */
const bootVolumes = useAsync(
() =>
listBootVolumes(props.cfgId, props.availabilityDomain, undefined, props.region).catch(() => []),
false,
)
watch(
() => props.show,
(v) => {
if (v) {
volumeId.value = null
readOnly.value = false
void volumes.run()
void bootVolumes.run()
}
},
)
const options = computed<SelectGroupOption[]>(() => {
const vols = (volumes.data.value ?? [])
.filter((v) => v.lifecycleState === 'AVAILABLE' && !props.attachedVolumeIds.includes(v.id))
.map((v) => ({ label: `${v.displayName}${v.sizeInGBs} GB`, value: v.id }))
const bvs = (bootVolumes.data.value ?? [])
.filter(
(b) =>
b.lifecycleState === 'AVAILABLE' &&
!b.attachedInstanceId &&
!props.attachedVolumeIds.includes(b.id),
)
.map((b) => ({ label: `${b.displayName}${b.sizeInGBs} GB`, value: b.id }))
const groups: SelectGroupOption[] = []
if (vols.length) groups.push({ type: 'group', label: '块卷', key: 'vol', children: vols })
if (bvs.length) groups.push({ type: 'group', label: '引导卷(作数据卷)', key: 'bv', children: bvs })
return groups
})
const loading = computed(() => volumes.loading.value || bootVolumes.loading.value)
async function submit() {
if (!volumeId.value) return
submitting.value = true
try {
await attachVolume(props.cfgId, props.instanceId, volumeId.value, readOnly.value, props.region)
message.success('附加请求已提交,稍候在 OS 内可见新磁盘')
emit('update:show', false)
emit('attached')
} catch (e) {
message.error(e instanceof Error ? e.message : '附加失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="附加块卷"
:submitting="submitting"
:submit-disabled="!volumeId"
submit-text="附加"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField
label="块卷 / 引导卷"
required
hint="同可用域的块卷,或未挂载的引导卷(作为数据卷附加)"
:error="!loading && !options.length ? '同可用域内没有可附加的块卷或引导卷' : undefined"
>
<NSelect
v-model:value="volumeId"
:options="options"
:loading="loading"
placeholder="选择块卷或引导卷"
/>
</FormField>
<NCheckbox v-model:checked="readOnly">只读附加</NCheckbox>
<div class="mt-2 text-xs leading-relaxed text-ink-3">
半虚拟化附加运行中实例可热插拔附加后仍需在实例 OS 内分区挂载文件系统
</div>
</FormModal>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { NInput, useMessage } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { createConsoleConnection } from '@/api/instances'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
const props = defineProps<{ show: boolean; cfgId: number; instanceId: string; region?: string }>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const message = useMessage()
const submitting = ref(false)
const publicKey = ref('')
watch(
() => props.show,
(v) => {
if (v) publicKey.value = ''
},
)
const isRsa = computed(() => publicKey.value.trim().startsWith('ssh-rsa '))
async function submit() {
submitting.value = true
try {
await createConsoleConnection(props.cfgId, props.instanceId, publicKey.value.trim(), props.region)
message.success('控制台连接已创建,稍候列表刷新出连接串')
emit('update:show', false)
emit('created')
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="创建控制台连接"
:width="560"
:submitting="submitting"
:submit-disabled="!isRsa"
submit-text="创建"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField
label="SSH 公钥(仅 RSA"
required
hint="生成示例:ssh-keygen -t rsa -b 4096 -f console_key"
:error="
publicKey.trim() && !isRsa
? '必须以 ssh-rsa 开头 —— OCI 控制台连接不接受 ed25519 等其他算法'
: undefined
"
>
<NInput
v-model:value="publicKey"
type="textarea"
:rows="4"
class="mono"
placeholder="ssh-rsa AAAAB3…"
/>
</FormField>
<div class="text-xs leading-relaxed text-ink-3">
创建后用对应私钥执行连接串串口直接执行 connectionStringVNC 执行 vncConnectionString
建立隧道后 VNC 客户端连 localhost:5900
</div>
</FormModal>
</template>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { NInput, useMessage } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { createInstances } from '@/api/instances'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import InstanceSpecForm from '@/components/instance/InstanceSpecForm.vue'
import { defaultResourceName } from '@/composables/useFormat'
/** 租户 / 区间 / 区域直接使用父页面当前值,不在表单中出现 */
const props = defineProps<{
show: boolean
cfgId: number | null
compartmentId?: string
region?: string
}>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const message = useMessage()
const submitting = ref(false)
const specForm = ref<InstanceType<typeof InstanceSpecForm> | null>(null)
const form = reactive({ displayName: '', count: 1 })
watch(
() => props.show,
(v) => {
if (!v) return
form.displayName = defaultResourceName('instance')
form.count = 1
specForm.value?.reset()
},
)
const canSubmit = computed(
() =>
props.cfgId !== null &&
Boolean(form.displayName.trim()) &&
(specForm.value?.valid ?? false),
)
async function submit() {
if (props.cfgId === null || !specForm.value) return
submitting.value = true
try {
const result = await createInstances(props.cfgId, {
region: props.region || undefined,
compartmentId: props.compartmentId || undefined,
displayName: form.displayName.trim(),
count: form.count,
...specForm.value.buildSpec(),
})
// 后端 errors / instances 为空时序列化为 null,判空防炸
const ok = result.instances?.length ?? 0
const failed = result.errors?.length ?? 0
if (failed) message.warning(`${ok} 台创建成功,${failed} 台失败:${result.errors![0]}`)
else message.success(`已创建 ${ok} 台实例`)
emit('update:show', false)
emit('created')
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="创建实例"
:width="600"
:submitting="submitting"
:submit-disabled="!canSubmit"
submit-text="创建"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="实例名" required>
<NInput v-model:value="form.displayName" placeholder="instance-20260703-2136" />
</FormField>
<FormField label="数量" hint="批量创建,名称自动追加序号">
<AppInputNumber v-model:value="form.count" :min="1" :max="10" class="w-full" />
</FormField>
</div>
<InstanceSpecForm
ref="specForm"
sectioned
:cfg-id="cfgId"
:region="region"
:compartment-id="compartmentId"
/>
<div
class="mt-3 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-line-soft bg-wash px-3 py-2 text-xs leading-relaxed text-ink-3"
>
<span v-if="specForm?.summary" class="font-medium text-ink-2">
预估规格:{{ form.count }} · {{ specForm.summary }}
</span>
<span>容量不足Out of host capacity时可改用抢机任务反复尝试</span>
</div>
</FormModal>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { useMessage } from 'naive-ui'
import AppInputNumber from '@/components/AppInputNumber.vue'
import { reactive, ref, watch } from 'vue'
import { updateBootVolume } from '@/api/bootVolumes'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import type { BootVolume } from '@/types/api'
const props = defineProps<{
show: boolean
cfgId: number
region?: string
bootVolume: BootVolume | null
}>()
const emit = defineEmits<{ 'update:show': [boolean]; updated: [] }>()
const message = useMessage()
const submitting = ref(false)
const form = reactive({ sizeInGBs: 50, vpusPerGB: 10 })
watch(
() => props.show,
(v) => {
if (!v || !props.bootVolume) return
form.sizeInGBs = props.bootVolume.sizeInGBs
form.vpusPerGB = props.bootVolume.vpusPerGB
},
)
async function submit() {
const bv = props.bootVolume
if (!bv) return
if (form.sizeInGBs < bv.sizeInGBs) {
message.error('引导卷仅支持扩容,不能缩小')
return
}
submitting.value = true
try {
await updateBootVolume(
props.cfgId,
bv.id,
{
sizeInGBs: form.sizeInGBs === bv.sizeInGBs ? 0 : form.sizeInGBs,
vpusPerGB: form.vpusPerGB === bv.vpusPerGB ? 0 : form.vpusPerGB,
},
props.region,
)
message.success('修改已提交;扩容后需在实例 OS 内扩展文件系统')
emit('update:show', false)
emit('updated')
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="调整引导卷"
:submitting="submitting"
submit-text="保存"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="大小(GB" hint="仅支持扩容;扩容后需在 OS 内扩展文件系统">
<AppInputNumber
v-model:value="form.sizeInGBs"
:min="bootVolume?.sizeInGBs ?? 47"
:max="32768"
class="w-full"
/>
</FormField>
<FormField label="性能(VPU/GB" hint="10 均衡 120 超高性能,可在线调整">
<AppInputNumber v-model:value="form.vpusPerGB" :min="10" :max="120" :step="10" class="w-full" />
</FormField>
</div>
</FormModal>
</template>
@@ -0,0 +1,535 @@
<script setup lang="ts">
import { NCheckbox, NInput, NSelect } from 'naive-ui'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { listAvailabilityDomains, listImages, listShapes } from '@/api/instances'
import { listSubnets, listVcns } from '@/api/networks'
import { listLimits } from '@/api/tenant'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormSection from '@/components/FormSection.vue'
import { shortAd } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
/** 实例规格表单(创建实例弹窗与抢机任务共用):
* Shape / 可用域 / 规格 / 镜像 / VCN 子网 / 引导卷 / 登录方式 / IP 分配。
* 实例名、数量、租户 / 区域 / 区间由父表单提供,经 props 传入作数据源;
* sectioned 为 true 时渲染「规格 / 网络与存储 / 登录与 IP」分区标题。 */
const props = defineProps<{
cfgId: number | null
region?: string
compartmentId?: string
sectioned?: boolean
/** 可用域字段说明;抢机任务留空为轮询语义,与手动创建(固定 AD-1)不同 */
adHint?: string
}>()
/** VCN 选择的「自动创建」哨兵:提交时不传 subnetId,由后端建 VCN 与子网 */
const AUTO_VCN = '__auto__'
const blank = {
availabilityDomain: null as string | null,
shape: 'VM.Standard.A1.Flex',
ocpus: 4,
memoryInGBs: 24,
imageId: null as string | null,
vcnId: AUTO_VCN,
subnetId: null as string | null,
bootVolumeSizeGBs: null as number | null,
bootVolumeVpusPerGB: 10,
assignPublicIp: true,
assignIpv6: true,
authMode: 'password' as 'password' | 'ssh',
randomPassword: true,
rootPassword: '',
sshPublicKey: '',
}
const form = reactive({ ...blank })
const images = useAsync(
() =>
props.cfgId === null
? Promise.resolve([])
: listImages(props.cfgId, { region: props.region || undefined, shape: form.shape }),
false,
)
const ads = useAsync(
() =>
props.cfgId === null
? Promise.resolve([])
: listAvailabilityDomains(props.cfgId, props.region || undefined),
false,
)
const vcns = useAsync(
() =>
props.cfgId === null
? Promise.resolve([])
: listVcns(props.cfgId, props.region || undefined, props.compartmentId || undefined).catch(
() => [],
),
false,
)
/** 租户当前区域实际提供的 shape 清单(服务端带缓存),失败回退基础清单 */
const shapes = useAsync(
() =>
props.cfgId === null
? Promise.resolve([])
: listShapes(props.cfgId, props.region || undefined).catch(() => []),
false,
)
/** compute 配额一次全量拉取(服务端单页 1000 条),本地按映射统计各 shape 核数配额 */
const limits = useAsync(
() =>
props.cfgId === null
? Promise.resolve([])
: listLimits(props.cfgId, { service: 'compute', region: props.region || undefined }).catch(
() => [],
),
false,
)
const subnets = useAsync(
() =>
props.cfgId === null || form.vcnId === AUTO_VCN
? Promise.resolve([])
: listSubnets(props.cfgId, form.vcnId, props.region || undefined).catch(() => []),
false,
)
function reloadAll() {
if (props.cfgId === null) return
void images.run()
void ads.run()
void vcns.run()
void shapes.run()
void limits.run()
}
/** 重置表单并按当前租户 / 区域重新加载数据源,父表单打开时调用 */
function reset() {
Object.assign(form, blank)
pendingSubnetId.value = null
reloadAll()
}
// 弹窗 / tab 均为按需挂载(display-directive=if),挂载即自加载一次
onMounted(reset)
// 租户 / 区域 / 区间切换(抢机表单内联选择场景):失效选择并重载
watch(
() => [props.cfgId, props.region, props.compartmentId],
() => {
form.imageId = null
form.vcnId = AUTO_VCN
form.subnetId = null
form.availabilityDomain = null
reloadAll()
},
)
watch(
() => form.shape,
() => {
form.imageId = null
if (props.cfgId !== null) void images.run()
},
)
watch(
() => form.vcnId,
() => {
form.subnetId = null
void subnets.run()
},
)
// 子网加载后:优先落位编辑回填的子网,否则默认选第一个
watch(
() => subnets.data.value,
(list) => {
if (!list?.length) return
if (pendingSubnetId.value && list.some((s) => s.id === pendingSubnetId.value)) {
form.subnetId = pendingSubnetId.value
pendingSubnetId.value = null
return
}
if (!form.subnetId) form.subnetId = list[0].id
},
)
/** 编辑任务回填:spec 为 buildSpec 产出的字段集合(payload.instance */
const pendingSubnetId = ref<string | null>(null)
function applySpec(spec: Record<string, unknown>) {
const s = spec as Partial<ReturnType<typeof buildSpec>> & Record<string, unknown>
if (typeof s.shape === 'string') form.shape = s.shape
if (typeof s.ocpus === 'number') form.ocpus = s.ocpus
if (typeof s.memoryInGBs === 'number') form.memoryInGBs = s.memoryInGBs
if (typeof s.availabilityDomain === 'string') form.availabilityDomain = s.availabilityDomain
if (typeof s.imageId === 'string') form.imageId = s.imageId
if (typeof s.bootVolumeSizeGBs === 'number') form.bootVolumeSizeGBs = s.bootVolumeSizeGBs
if (typeof s.bootVolumeVpusPerGB === 'number') form.bootVolumeVpusPerGB = s.bootVolumeVpusPerGB
form.assignPublicIp = s.assignPublicIp !== false
form.assignIpv6 = s.assignIpv6 !== false
form.authMode = typeof s.sshPublicKey === 'string' && s.sshPublicKey ? 'ssh' : 'password'
form.sshPublicKey = typeof s.sshPublicKey === 'string' ? s.sshPublicKey : ''
form.randomPassword = s.generateRootPassword === true || !s.rootPassword
form.rootPassword = typeof s.rootPassword === 'string' ? s.rootPassword : ''
pendingSubnetId.value = typeof s.subnetId === 'string' ? s.subnetId : null
}
// 回填的子网归属未知:VCN 列表加载后逐个查找包含它的 VCN 并落位
watch(
() => vcns.data.value,
async (list) => {
const target = pendingSubnetId.value
if (!target || !list?.length || props.cfgId === null) return
for (const v of list) {
const subs = await listSubnets(props.cfgId, v.id, props.region || undefined).catch(() => [])
if (subs.some((s) => s.id === target)) {
form.vcnId = v.id // 触发 subnets.run,落位在 subnets 加载 watch 中完成
return
}
}
pendingSubnetId.value = null
},
)
/** shape → OCI compute 配额名(官方 Limits by Service 命名);已知映射用于配额标注,
* 清单外的 shape 不判定配额。AD 维度多行求和 > 0 才认为有配额。 */
const SHAPE_QUOTAS: Record<string, string> = {
'VM.Standard.A1.Flex': 'standard-a1-core-count',
'VM.Standard.A2.Flex': 'standard-a2-core-count',
'VM.Standard.A4.Flex': 'standard-a4-core-count',
'VM.Standard.E2.1.Micro': 'standard-e2-micro-core-count',
'VM.Standard.E3.Flex': 'standard-e3-core-ad-count',
'VM.Standard.E4.Flex': 'standard-e4-core-count',
'VM.Standard.E5.Flex': 'standard-e5-core-count',
'VM.Standard.E6.Flex': 'standard-e6-core-count',
'VM.Standard3.Flex': 'standard3-core-count',
}
const BILLING_LABEL: Record<string, string> = {
ALWAYS_FREE: '永久免费',
LIMITED_FREE: '免费额度',
}
/** 已知映射 shape 的核数配额合计;未知映射或配额未加载返回 null(不判定) */
function quotaOf(shape: string): number | null {
const quota = SHAPE_QUOTAS[shape]
const items = limits.data.value ?? []
if (!quota || !items.length) return null
return items.filter((l) => l.name === quota).reduce((sum, l) => sum + (l.value > 0 ? l.value : 0), 0)
}
/** 排序:免费 shape 优先 → VM.Standard 系列 → 其他 VM → 裸金属等 */
function shapeRank(s: { billingType: string; name: string }): number {
if (BILLING_LABEL[s.billingType]) return 0
if (s.name.startsWith('VM.Standard')) return 1
if (s.name.startsWith('VM.')) return 2
return 3
}
const shapeOptions = computed(() => {
// 清单全部来自 shapes 接口,不做本地预设;接口不可用时仍可手输(tag 模式)
const list = shapes.data.value ?? []
return [...list]
.sort((a, b) => shapeRank(a) - shapeRank(b) || a.name.localeCompare(b.name))
.map((s) => {
const free = BILLING_LABEL[s.billingType]
const quota = quotaOf(s.name)
const suffix = (free ? `${free}` : '') + (quota === 0 ? ' · 无配额' : '')
return { label: s.name + suffix, value: s.name, disabled: quota === 0 }
})
})
// 清单 / 配额加载后,当前 shape 不在清单或无配额时自动切到第一个可用项
watch(
() => shapeOptions.value,
(opts) => {
const cur = opts.find((o) => o.value === form.shape)
if (!opts.length || (cur && !cur.disabled)) return
const first = opts.find((o) => !o.disabled)
if (first) form.shape = String(first.value)
},
)
const selectedShape = computed(
() => shapes.data.value?.find((s) => s.name === form.shape) ?? null,
)
/** 清单外(手输 / 未加载)的 shape 按名称后缀判定弹性 */
const isFlex = computed(() => selectedShape.value?.isFlexible ?? form.shape.endsWith('.Flex'))
const ocpuRange = computed(() => ({
min: selectedShape.value?.ocpusMin || 1,
max: selectedShape.value?.ocpusMax || 128,
}))
const memRange = computed(() => ({
min: selectedShape.value?.memoryMinGBs || 1,
max: selectedShape.value?.memoryMaxGBs || 1024,
}))
// 切换 shape 后把已填规格收敛到该 shape 的合法范围
watch(selectedShape, (s) => {
if (!s?.isFlexible) return
form.ocpus = Math.min(Math.max(form.ocpus, ocpuRange.value.min), ocpuRange.value.max)
form.memoryInGBs = Math.min(Math.max(form.memoryInGBs, memRange.value.min), memRange.value.max)
})
/** 同名镜像只保留日期最新的一版:去掉尾部 -2026.05.09-0 作分组 key */
const imageOptions = computed(() => {
const groups = new Map<string, { id: string; displayName: string; timeCreated: string }>()
for (const img of images.data.value ?? []) {
const key = img.displayName.replace(/-\d{4}\.\d{2}\.\d{2}-\d+$/, '')
const prev = groups.get(key)
if (!prev || img.timeCreated > prev.timeCreated) groups.set(key, img)
}
return [...groups.values()]
.sort((a, b) => a.displayName.localeCompare(b.displayName))
.map((i) => ({ label: i.displayName, value: i.id }))
})
const adOptions = computed(
() => ads.data.value?.map((ad) => ({ label: shortAd(ad), value: ad })) ?? [],
)
const vcnOptions = computed(() => [
{ label: '自动创建(oci-portal-auto-vcn', value: AUTO_VCN },
...(vcns.data.value ?? []).map((v) => ({ label: v.displayName, value: v.id })),
])
const subnetOptions = computed(
() =>
subnets.data.value?.map((s) => ({
label: `${s.displayName}${s.cidrBlock}${s.ipv6CidrBlock ? ' · IPv6' : ''}`,
value: s.id,
})) ?? [],
)
const selectedSubnet = computed(
() => subnets.data.value?.find((s) => s.id === form.subnetId) ?? null,
)
/** 自动创建的 VCN 默认启用 IPv6;选现有子网时看其是否有 IPv6 CIDR */
const ipv6Supported = computed(
() => form.vcnId === AUTO_VCN || !!selectedSubnet.value?.ipv6CidrBlock,
)
// 按子网支持程度自动勾选 / 取消 IPv6
watch(ipv6Supported, (v) => {
form.assignIpv6 = v
})
const valid = computed(() => {
if (!form.imageId) return false
if (form.vcnId !== AUTO_VCN && !form.subnetId) return false
return !isFlex.value || form.ocpus > 0
})
/** 规格摘要(父表单底部预估条用):shape 短名 + 弹性核内存 + 免费标注 */
const summary = computed(() => {
const parts = [form.shape.replace(/^VM\.Standard\.?/, '') || form.shape]
if (isFlex.value) parts.push(`${form.ocpus} OCPU · ${form.memoryInGBs} GB`)
const free = selectedShape.value && BILLING_LABEL[selectedShape.value.billingType]
if (free) parts.push(free)
return parts.join(' · ')
})
/** 产出规格字段(不含 displayName / count / region / compartmentId,由父表单拼接) */
function buildSpec(): Record<string, unknown> {
const usePwd = form.authMode === 'password'
return {
availabilityDomain: form.availabilityDomain ?? undefined,
shape: form.shape,
ocpus: isFlex.value ? form.ocpus : undefined,
memoryInGBs: isFlex.value ? form.memoryInGBs : undefined,
imageId: form.imageId ?? undefined,
subnetId: form.vcnId === AUTO_VCN ? undefined : (form.subnetId ?? undefined),
bootVolumeSizeGBs: form.bootVolumeSizeGBs ?? undefined,
// 性能独立于大小:留空按镜像默认大小时同样生效(后端仅镜像启动源使用)
bootVolumeVpusPerGB: form.bootVolumeVpusPerGB || undefined,
assignPublicIp: form.assignPublicIp,
assignIpv6: form.assignIpv6,
generateRootPassword: usePwd && form.randomPassword ? true : undefined,
rootPassword:
usePwd && !form.randomPassword && form.rootPassword ? form.rootPassword : undefined,
sshPublicKey:
form.authMode === 'ssh' && form.sshPublicKey.trim() ? form.sshPublicKey.trim() : undefined,
}
}
defineExpose({ valid, buildSpec, reset, applySpec, summary })
</script>
<template>
<FormSection v-if="props.sectioned" title="规格" />
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField
label="Shape"
required
hint="清单来自租户当前区域实际提供的 shape(免费与无配额已标注),可手输其他"
>
<NSelect
v-model:value="form.shape"
filterable
tag
:options="shapeOptions"
:loading="shapes.loading.value || limits.loading.value"
/>
</FormField>
<FormField label="可用域" :hint="props.adHint ?? '留空默认 AD-1'">
<NSelect
v-model:value="form.availabilityDomain"
clearable
:options="adOptions"
:loading="ads.loading.value"
placeholder="自动"
/>
</FormField>
</div>
<div v-if="isFlex" class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="OCPU" required :hint="`该 shape 可选 ${ocpuRange.min}${ocpuRange.max}`">
<AppInputNumber
v-model:value="form.ocpus"
:min="ocpuRange.min"
:max="ocpuRange.max"
class="w-full"
/>
</FormField>
<FormField label="内存(GB" :hint="`该 shape 可选 ${memRange.min}${memRange.max} GB`">
<AppInputNumber
v-model:value="form.memoryInGBs"
:min="memRange.min"
:max="memRange.max"
class="w-full"
/>
</FormField>
</div>
<FormField label="镜像" required hint="同名镜像只显示日期最新的一版,已按 shape 过滤">
<NSelect
v-model:value="form.imageId"
filterable
:options="imageOptions"
:loading="images.loading.value"
placeholder="选择启动镜像"
/>
</FormField>
<FormSection v-if="props.sectioned" title="网络与存储" />
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="VCN" hint="「自动创建」复用或创建 oci-portal-auto-vcn 公网子网,含 IPv4/IPv6 默认路由">
<NSelect v-model:value="form.vcnId" :options="vcnOptions" :loading="vcns.loading.value" />
</FormField>
<FormField v-if="form.vcnId !== AUTO_VCN" label="子网" required>
<NSelect
v-model:value="form.subnetId"
:options="subnetOptions"
:loading="subnets.loading.value"
placeholder="选择子网"
/>
</FormField>
</div>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="引导卷(GB" hint="留空按镜像默认(约 47 GB">
<AppInputNumber
v-model:value="form.bootVolumeSizeGBs"
:min="47"
:max="32768"
clearable
class="w-full"
placeholder="默认"
/>
</FormField>
<FormField label="引导卷性能(VPU/GB" hint="10 均衡 120 超高性能">
<AppInputNumber
v-model:value="form.bootVolumeVpusPerGB"
:min="10"
:max="120"
:step="10"
class="w-full"
/>
</FormField>
</div>
<FormSection v-if="props.sectioned" title="登录与 IP" />
<FormField
label="登录方式"
hint="root 密码经 cloud-init 开启 root 密码登录,随机密码生成后写入实例标签 RootPassword"
>
<div class="grid grid-cols-2 gap-2.5 max-md:grid-cols-1">
<button
type="button"
class="flex cursor-pointer items-start gap-2.5 rounded-lg border bg-white px-3 py-2.5 text-left"
:class="
form.authMode === 'password'
? 'border-accent shadow-[0_0_0_1px_var(--color-accent)]'
: 'border-line hover:border-ink-3'
"
@click="form.authMode = 'password'"
>
<span
class="mt-0.5 flex h-[15px] w-[15px] flex-none items-center justify-center rounded-full border-[1.5px]"
:class="form.authMode === 'password' ? 'border-accent' : 'border-line'"
>
<span v-if="form.authMode === 'password'" class="h-[7px] w-[7px] rounded-full bg-accent" />
</span>
<span class="min-w-0">
<span class="block text-[12.5px] font-semibold">root 密码</span>
<span class="mt-0.5 block text-[11px] leading-snug text-ink-3">
随机或手动设置, cloud-init 开启 root 登录
</span>
</span>
</button>
<button
type="button"
class="flex cursor-pointer items-start gap-2.5 rounded-lg border bg-white px-3 py-2.5 text-left"
:class="
form.authMode === 'ssh'
? 'border-accent shadow-[0_0_0_1px_var(--color-accent)]'
: 'border-line hover:border-ink-3'
"
@click="form.authMode = 'ssh'"
>
<span
class="mt-0.5 flex h-[15px] w-[15px] flex-none items-center justify-center rounded-full border-[1.5px]"
:class="form.authMode === 'ssh' ? 'border-accent' : 'border-line'"
>
<span v-if="form.authMode === 'ssh'" class="h-[7px] w-[7px] rounded-full bg-accent" />
</span>
<span class="min-w-0">
<span class="block text-[12.5px] font-semibold">SSH 公钥</span>
<span class="mt-0.5 block text-[11px] leading-snug text-ink-3">
粘贴 ssh-ed25519 / ssh-rsa 公钥,不开启密码登录
</span>
</span>
</button>
</div>
</FormField>
<template v-if="form.authMode === 'password'">
<div class="mb-3.5">
<NCheckbox v-model:checked="form.randomPassword">随机密码</NCheckbox>
</div>
<FormField v-if="!form.randomPassword" label="root 密码">
<NInput
v-model:value="form.rootPassword"
type="password"
show-password-on="click"
placeholder="设置 root 登录密码"
/>
</FormField>
</template>
<FormField v-else label="SSH 公钥">
<NInput
v-model:value="form.sshPublicKey"
type="textarea"
:rows="2"
class="mono"
placeholder="ssh-ed25519 AAAA…"
/>
</FormField>
<div class="flex flex-wrap gap-5">
<NCheckbox v-model:checked="form.assignPublicIp">分配公网 IPv4</NCheckbox>
<NCheckbox v-model:checked="form.assignIpv6" :disabled="!ipv6Supported">
分配 IPv6{{ ipv6Supported ? '' : '(所选子网未启用)' }}
</NCheckbox>
</div>
</template>
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { LineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { NSelect, NSpin } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import VChart from 'vue-echarts'
import { getInstanceTraffic } from '@/api/analytics'
import EmptyCard from '@/components/EmptyCard.vue'
import FootNote from '@/components/FootNote.vue'
import { fmtBytes } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import { useAppStore } from '@/stores/app'
import { darkTokens, tokens } from '@/theme/tokens'
use([LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
/** echarts 不认 CSS 变量,色板按明暗主题从 tokens 取 */
const app = useAppStore()
const t = computed(() => (app.dark ? darkTokens : tokens))
const props = defineProps<{ cfgId: number; instanceId: string; region?: string }>()
const days = ref(7)
const traffic = useAsync(() =>
getInstanceTraffic(props.cfgId, props.instanceId, days.value, props.region),
)
watch(days, () => void traffic.run())
const daysOptions = [
{ label: '近 7 天', value: 7 },
{ label: '近 30 天', value: 30 },
]
const series = computed(() => {
const vnic = traffic.data.value?.vnics[0]
if (!vnic) return null
const labels = vnic.outbound.map((p) => p.timestamp.slice(5, 10))
return {
labels,
outbound: vnic.outbound.map((p) => +(p.bytes / 1024 ** 3).toFixed(2)),
inbound: vnic.inbound.map((p) => +(p.bytes / 1024 ** 3).toFixed(2)),
}
})
const option = computed(() => ({
grid: { left: 44, right: 16, top: 36, bottom: 26 },
legend: { top: 4, left: 8, textStyle: { color: t.value.ink2, fontSize: 12 }, itemWidth: 14 },
tooltip: {
trigger: 'axis',
backgroundColor: t.value.ink,
borderWidth: 0,
textStyle: { color: t.value.bg, fontSize: 12 },
valueFormatter: (v: number) => `${v} GB`,
},
xAxis: {
type: 'category',
data: series.value?.labels ?? [],
boundaryGap: false,
axisLine: { lineStyle: { color: t.value.line } },
axisTick: { show: false },
axisLabel: { color: t.value.ink3, fontSize: 10.5 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: t.value.lineSoft } },
axisLabel: { color: t.value.ink3, fontSize: 10.5, formatter: '{value}G' },
},
series: [
{
name: '流出',
type: 'line',
data: series.value?.outbound ?? [],
lineStyle: { color: t.value.chart, width: 2 },
itemStyle: { color: t.value.chart },
symbol: 'circle',
symbolSize: 5,
},
{
name: '流入',
type: 'line',
data: series.value?.inbound ?? [],
lineStyle: { color: t.value.info, width: 2 },
itemStyle: { color: t.value.info },
symbol: 'circle',
symbolSize: 5,
},
],
}))
</script>
<template>
<div class="panel">
<div class="flex flex-wrap items-center gap-2.5 border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">流量</div>
<div class="flex-1" />
<div class="w-28">
<NSelect
v-model:value="days"
size="small"
:options="daysOptions"
:loading="traffic.loading.value"
/>
</div>
</div>
<div
v-if="traffic.loading.value && !traffic.data.value"
class="flex items-center justify-center py-14"
>
<NSpin size="small" />
</div>
<template v-else-if="traffic.data.value">
<div class="flex flex-wrap gap-5 px-4.5 pt-3 text-[12.5px] text-ink-2">
<span>
<span class="mr-1.5 inline-block h-2 w-2 rounded-full" style="background: var(--color-chart)" />
流出 · 合计 {{ fmtBytes(traffic.data.value.outboundBytes) }}
</span>
<span>
<span class="mr-1.5 inline-block h-2 w-2 rounded-full" style="background: var(--color-info)" />
流入 · 合计 {{ fmtBytes(traffic.data.value.inboundBytes) }}
</span>
</div>
<div class="px-3 pb-2">
<VChart class="w-full" style="height: 210px" :option="option" autoresize />
</div>
</template>
<EmptyCard
v-else
title="暂无流量数据"
note="该实例暂无 Monitoring 数据点(新建实例有数分钟延迟,终止实例不再产生数据)"
/>
<FootNote>
数据来自 Monitoring 指标VnicToNetworkBytes 发送 / VnicFromNetworkBytes 接收按日聚合
</FootNote>
</div>
</template>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { NSelect, useMessage } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { listBootVolumes } from '@/api/bootVolumes'
import { replaceBootVolume } from '@/api/instances'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useAsync } from '@/composables/useAsync'
const props = defineProps<{
show: boolean
cfgId: number
instanceId: string
region?: string
availabilityDomain: string
}>()
const emit = defineEmits<{ 'update:show': [boolean]; replaced: [] }>()
const message = useMessage()
const submitting = ref(false)
const bootVolumeId = ref<string | null>(null)
const volumes = useAsync(
() => listBootVolumes(props.cfgId, props.availabilityDomain, undefined, props.region),
false,
)
watch(
() => props.show,
(v) => {
if (v) {
bootVolumeId.value = null
void volumes.run()
}
},
)
const options = computed(
() =>
volumes.data.value
?.filter((v) => !v.attachedInstanceId)
.map((v) => ({ label: `${v.displayName}${v.sizeInGBs} GB`, value: v.id })) ?? [],
)
async function submit() {
if (!bootVolumeId.value) return
submitting.value = true
try {
await replaceBootVolume(props.cfgId, props.instanceId, bootVolumeId.value, props.region)
message.success('替换请求已提交:分离旧引导卷并挂载所选引导卷')
emit('update:show', false)
emit('replaced')
} catch (e) {
message.error(e instanceof Error ? e.message : '替换失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="替换引导卷"
:submitting="submitting"
:submit-disabled="!bootVolumeId"
submit-text="替换"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField
label="目标引导卷"
required
hint="仅列出同可用域、未挂载的引导卷"
:error="
!volumes.loading.value && !options.length
? '同可用域内没有未挂载的引导卷,无法替换'
: undefined
"
>
<NSelect
v-model:value="bootVolumeId"
:options="options"
:loading="volumes.loading.value"
placeholder="选择未挂载的引导卷"
/>
</FormField>
<div class="text-xs leading-relaxed text-ink-3">
原引导卷分离后保留不会删除实例保持 STOPPED替换完成后可启动
</div>
</FormModal>
</template>
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { NSelect, useMessage } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { listShapes, updateInstance } from '@/api/instances'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useAsync } from '@/composables/useAsync'
const props = defineProps<{
show: boolean
cfgId: number
instanceId: string
region?: string
shape: string
ocpus: number
memoryInGBs: number
}>()
const emit = defineEmits<{ 'update:show': [boolean]; resized: [] }>()
const message = useMessage()
const submitting = ref(false)
const form = reactive({ shape: props.shape, ocpus: props.ocpus, memoryInGBs: props.memoryInGBs })
const shapes = useAsync(() => listShapes(props.cfgId, props.region).catch(() => []), false)
watch(
() => props.show,
(v) => {
if (v) {
Object.assign(form, { shape: props.shape, ocpus: props.ocpus, memoryInGBs: props.memoryInGBs })
void shapes.run()
}
},
)
const BILLING_LABEL: Record<string, string> = {
ALWAYS_FREE: '永久免费',
LIMITED_FREE: '免费额度',
}
const shapeOptions = computed(() => {
const list = shapes.data.value ?? []
if (!list.length) return [{ label: form.shape, value: form.shape }]
return list.map((s) => {
const free = BILLING_LABEL[s.billingType]
return { label: s.name + (free ? `${free}` : ''), value: s.name }
})
})
const selectedShape = computed(
() => shapes.data.value?.find((s) => s.name === form.shape) ?? null,
)
const isFlex = computed(() => selectedShape.value?.isFlexible ?? form.shape.endsWith('.Flex'))
const ocpuRange = computed(() => ({
min: selectedShape.value?.ocpusMin || 1,
max: selectedShape.value?.ocpusMax || 128,
}))
const memRange = computed(() => ({
min: selectedShape.value?.memoryMinGBs || 1,
max: selectedShape.value?.memoryMaxGBs || 1024,
}))
// 换 shape 后把规格收敛到该 shape 的合法范围
watch(selectedShape, (s) => {
if (!s?.isFlexible) return
form.ocpus = Math.min(Math.max(form.ocpus, ocpuRange.value.min), ocpuRange.value.max)
form.memoryInGBs = Math.min(Math.max(form.memoryInGBs, memRange.value.min), memRange.value.max)
})
const changed = computed(
() =>
form.shape !== props.shape ||
(isFlex.value && (form.ocpus !== props.ocpus || form.memoryInGBs !== props.memoryInGBs)),
)
async function submit() {
submitting.value = true
try {
await updateInstance(
props.cfgId,
props.instanceId,
{
shape: form.shape !== props.shape ? form.shape : undefined,
ocpus: isFlex.value ? form.ocpus : undefined,
memoryInGBs: isFlex.value ? form.memoryInGBs : undefined,
},
props.region,
)
message.success('规格调整已提交,运行中实例将自动重启')
emit('update:show', false)
emit('resized')
} catch (e) {
message.error(e instanceof Error ? e.message : '调整失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="调整规格"
:submitting="submitting"
:submit-disabled="!changed"
submit-text="提交调整"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField label="Shape" required hint="可更换为租户当前区域提供的其他 shape,需目标容量可用">
<NSelect
v-model:value="form.shape"
filterable
tag
:options="shapeOptions"
:loading="shapes.loading.value"
/>
</FormField>
<div v-if="isFlex" class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="OCPU" required :hint="`可选 ${ocpuRange.min}${ocpuRange.max}`">
<AppInputNumber
v-model:value="form.ocpus"
:min="ocpuRange.min"
:max="ocpuRange.max"
class="w-full"
/>
</FormField>
<FormField label="内存(GB" required :hint="`可选 ${memRange.min}${memRange.max} GB`">
<AppInputNumber
v-model:value="form.memoryInGBs"
:min="memRange.min"
:max="memRange.max"
class="w-full"
/>
</FormField>
</div>
<div class="text-xs leading-relaxed text-ink-3">
运行中实例 OCI 会自动重启完成变更更换 shape 要求镜像与目标 shape 架构兼容 aarch64
镜像不能换到 x86 shapeA1 免费额度上限 4 OCPU / 24 GB
</div>
</FormModal>
</template>
@@ -0,0 +1,332 @@
<script setup lang="ts">
import { FitAddon } from '@xterm/addon-fit'
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
import { NSpin, useMessage } from 'naive-ui'
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { consoleSessionWsUrl, createConsoleSession, deleteConsoleSession } from '@/api/instances'
import { useAuthStore } from '@/stores/auth'
const props = defineProps<{
show: boolean
cfgId: number
instanceId: string
region?: string
instanceName?: string
rootPassword?: string
}>()
const emit = defineEmits<{ 'update:show': [boolean] }>()
const message = useMessage()
const auth = useAuthStore()
const termEl = ref<HTMLDivElement | null>(null)
const phase = ref<'idle' | 'creating' | 'connecting' | 'connected' | 'closed'>('idle')
const error = ref('')
const minimized = ref(false)
const fullscreen = ref(false)
const height = ref(360)
let term: Terminal | null = null
let fit: FitAddon | null = null
let ws: WebSocket | null = null
let ro: ResizeObserver | null = null
let sessionId = ''
const encoder = new TextEncoder()
async function open() {
phase.value = 'creating'
error.value = ''
height.value = Math.max(280, Math.round(window.innerHeight * 0.42))
try {
const res = await createConsoleSession(props.cfgId, props.instanceId, 'serial', props.region)
sessionId = res.sessionId
} catch (e) {
phase.value = 'closed'
error.value = e instanceof Error ? e.message : '创建串行会话失败'
return
}
phase.value = 'connecting'
await nextTick()
mountTerm()
connectWs()
}
/** 深色终端主题固定不随明暗切换(对齐 Cloud Shell 观感) */
function newTerminal(): Terminal {
return new Terminal({
fontFamily: "ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
fontSize: 13,
lineHeight: 1.2,
cursorBlink: true,
scrollback: 5000,
theme: {
background: '#1c1b1a',
foreground: '#eceae4',
cursor: '#d97757',
selectionBackground: 'rgba(217, 119, 87, 0.35)',
},
})
}
function mountTerm() {
if (term || !termEl.value) return
term = newTerminal()
fit = new FitAddon()
term.loadAddon(fit)
term.open(termEl.value)
fit.fit()
term.onData((d) => wsSendText(d))
term.onResize(({ cols, rows }) => wsSendCtl({ type: 'resize', cols, rows }))
// 选中即复制:终端里 Ctrl+C 保留 SIGINT 语义,复制交给选区
term.onSelectionChange(() => {
const sel = term?.getSelection()
if (sel) void navigator.clipboard.writeText(sel).catch(() => undefined)
})
term.attachCustomKeyEventHandler(copyKeyHandler)
ro = new ResizeObserver(() => fit?.fit())
ro.observe(termEl.value)
}
/** Cmd+C / Ctrl+Shift+C 在有选区时作复制,其余按键交还终端 */
function copyKeyHandler(ev: KeyboardEvent): boolean {
if (ev.type !== 'keydown') return true
const combo = (ev.metaKey && !ev.ctrlKey) || (ev.ctrlKey && ev.shiftKey)
if (combo && ev.key.toLowerCase() === 'c' && term?.hasSelection()) {
void navigator.clipboard.writeText(term.getSelection()).catch(() => undefined)
return false
}
return true
}
function connectWs() {
if (!term) return
const url = consoleSessionWsUrl(sessionId, auth.token, `rows=${term.rows}&cols=${term.cols}`)
ws = new WebSocket(url)
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
phase.value = 'connected'
term?.focus()
}
ws.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) term?.write(new Uint8Array(ev.data))
}
ws.onclose = (ev) => {
if (phase.value === 'idle' || phase.value === 'closed') return
phase.value = 'closed'
if (!error.value) error.value = mapCloseReason(ev.reason)
}
}
function mapCloseReason(reason: string): string {
if (!reason) return '连接已断开'
if (reason.includes('administratively prohibited'))
return '串行控制台已被占用:同一时刻仅允许一路连接(可能有本地 ssh 或其他会话)'
return reason
}
function wsSendText(data: string) {
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data))
}
function wsSendCtl(ctl: { type: string; cols: number; rows: number }) {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(ctl))
}
async function pasteFromClipboard() {
try {
const text = await navigator.clipboard.readText()
if (text) term?.paste(text)
} catch {
message.warning('无法读取剪贴板,请直接按 Cmd/Ctrl+V 粘贴')
}
term?.focus()
}
/** 串口 tty 收不到 SIGWINCH,向 shell 注入 stty 同步行列数(需处于提示符) */
function syncWinsize() {
if (!term) return
wsSendText(`stty rows ${term.rows} cols ${term.cols}\r`)
message.info('已发送 stty 同步命令(需处于 shell 提示符)')
term.focus()
}
async function copyRootPassword() {
await navigator.clipboard.writeText(props.rootPassword ?? '')
message.success('已复制 root 密码')
term?.focus()
}
function reconnect() {
teardown()
void open()
}
function toggleFullscreen() {
fullscreen.value = !fullscreen.value
minimized.value = false
void nextTick(() => fit?.fit())
}
function toggleMinimize() {
minimized.value = !minimized.value
if (!minimized.value) void nextTick(() => fit?.fit())
}
function startDrag(e: MouseEvent) {
const startY = e.clientY
const startH = height.value
const move = (ev: MouseEvent) => {
height.value = Math.min(Math.max(startH + (startY - ev.clientY), 200), window.innerHeight - 72)
}
const up = () => {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', up)
}
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', up)
e.preventDefault()
}
function teardown() {
ro?.disconnect()
ro = null
if (ws) {
ws.onclose = null
ws.close()
ws = null
}
if (sessionId) {
void deleteConsoleSession(sessionId).catch(() => undefined)
sessionId = ''
}
term?.dispose()
term = null
fit = null
phase.value = 'idle'
error.value = ''
}
function close() {
teardown()
minimized.value = false
fullscreen.value = false
emit('update:show', false)
}
watch(
() => props.show,
(v) => {
if (v) void open()
else teardown()
},
)
onBeforeUnmount(teardown)
</script>
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-x-0 bottom-0 z-[1500] flex flex-col border-t border-[#3a3835] shadow-[0_-6px_24px_rgba(0,0,0,0.35)]"
:class="fullscreen && !minimized ? 'top-0' : ''"
>
<div
v-if="!fullscreen && !minimized"
class="h-[3px] shrink-0 cursor-ns-resize bg-[#141413] transition-colors hover:bg-[#d97757]"
@mousedown="startDrag"
/>
<!-- 标题栏Cloud Shell 风格 -->
<div class="flex h-9 shrink-0 items-center gap-2 bg-[#141413] px-3 text-[12.5px] text-[#a8a69e]">
<span
class="inline-block h-2 w-2 rounded-full"
:style="{ background: phase === 'connected' ? '#8fa574' : phase === 'closed' ? '#d06060' : '#c29135' }"
/>
<span class="truncate font-medium text-[#eceae4]">
{{ instanceName || instanceId }} · 串行控制台
</span>
<span v-if="phase === 'creating'" class="text-xs">准备连接中</span>
<div class="flex-1" />
<button v-if="rootPassword" class="ser-btn" @click="copyRootPassword">复制 root 密码</button>
<button class="ser-btn" :disabled="phase !== 'connected'" @click="pasteFromClipboard">
粘贴
</button>
<button
class="ser-btn"
:disabled="phase !== 'connected'"
title="向 shell 发送 stty 命令同步终端行列数"
@click="syncWinsize"
>
同步窗口
</button>
<button v-if="phase === 'closed'" class="ser-btn text-[#d97757]" @click="reconnect">
重连
</button>
<div class="mx-1 h-4 w-px bg-[#3a3835]" />
<button class="ser-btn" :title="minimized ? '还原' : '最小化'" @click="toggleMinimize">
{{ minimized ? '' : '' }}
</button>
<button class="ser-btn" :title="fullscreen ? '退出全屏' : '全屏'" @click="toggleFullscreen">
</button>
<button class="ser-btn" title="关闭并结束会话" @click="close"></button>
</div>
<!-- 终端区 -->
<div
v-show="!minimized"
class="relative min-h-0 bg-[#1c1b1a]"
:class="fullscreen ? 'flex-1' : ''"
:style="fullscreen ? {} : { height: height + 'px' }"
>
<div ref="termEl" class="absolute inset-0 py-1 pl-2" />
<div
v-if="phase !== 'connected'"
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-[#1c1b1a]/92 text-[13px] text-[#a8a69e]"
>
<template v-if="phase === 'creating' || phase === 'connecting'">
<NSpin size="small" />
<div>
{{
phase === 'creating'
? '正在清理旧连接并创建控制台连接(约 30-90 秒)…'
: '正在建立串行隧道…'
}}
</div>
</template>
<template v-else-if="phase === 'closed'">
<div class="max-w-[80%] break-all text-center">{{ error || '连接已关闭' }}</div>
<div class="max-w-[80%] text-center text-xs leading-relaxed">
串口登录需实例本地账户密码SSH 公钥无效创建实例时若启用随机 root
密码可从顶栏复制无密码也可查看启动日志或经 GRUB 单用户模式救援
</div>
<button class="ser-btn !border !border-[#3a3835] !px-3 !py-1" @click="reconnect">
重新连接
</button>
</template>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.ser-btn {
border-radius: 4px;
padding: 2px 7px;
font-size: 12px;
line-height: 1.4;
color: inherit;
transition: background-color 0.15s;
}
.ser-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: #eceae4;
}
.ser-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
+235
View File
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { NButton, NPopconfirm, NSpin, useMessage } from 'naive-ui'
import { computed, ref } from 'vue'
import { addVnicIpv6, detachVnic, listVnics, removeIpv6 } from '@/api/instances'
import FootNote from '@/components/FootNote.vue'
import AttachVnicModal from '@/components/instance/AttachVnicModal.vue'
import LifecycleBadge from '@/components/LifecycleBadge.vue'
import OcidText from '@/components/OcidText.vue'
import { useAsync } from '@/composables/useAsync'
import { ATTACHMENT_TRANSIENT_STATES, useTransientPoll } from '@/composables/useTransientPoll'
/** 网卡(VNIC)区块:每卡一主行 + IPv6 副行;IPv6 增删按卡操作。
* 删除复用实例级接口(后端按地址遍历全部网卡),添加走 per-VNIC 接口。 */
const props = defineProps<{
cfgId: number
instanceId: string
region?: string
/** 实例已终止 / 终止中:全部变更操作不可用 */
disabled?: boolean
}>()
const emit = defineEmits<{ changed: [] }>()
const message = useMessage()
const vnics = useAsync(() => listVnics(props.cfgId, props.instanceId, props.region))
useTransientPoll(
() => vnics.data.value,
(list) => !!list?.some((v) => ATTACHMENT_TRANSIENT_STATES.includes(v.lifecycleState)),
() => void vnics.run({ silent: true }),
)
const showAttach = ref(false)
const detaching = ref('')
const addingIpv6 = ref('')
const removingIpv6 = ref('')
const busy = computed(() => !!detaching.value || !!addingIpv6.value || !!removingIpv6.value)
function refresh() {
void vnics.run()
emit('changed')
}
async function doDetach(attachmentId: string) {
detaching.value = attachmentId
try {
await detachVnic(props.cfgId, attachmentId, props.region)
message.success('分离请求已提交')
refresh()
} catch (e) {
message.error(e instanceof Error ? e.message : '分离失败')
} finally {
detaching.value = ''
}
}
async function doAddIpv6(vnicId: string) {
addingIpv6.value = vnicId
try {
await addVnicIpv6(props.cfgId, vnicId, '', props.region)
message.success('IPv6 已添加')
refresh()
} catch (e) {
message.error(e instanceof Error ? e.message : '添加失败,请确认子网已启用 IPv6')
} finally {
addingIpv6.value = ''
}
}
async function doRemoveIpv6(addr: string) {
removingIpv6.value = addr
try {
await removeIpv6(props.cfgId, props.instanceId, addr, props.region)
message.success('IPv6 已取消分配')
refresh()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
} finally {
removingIpv6.value = ''
}
}
defineExpose({ refresh: () => vnics.run() })
</script>
<template>
<div class="panel">
<div class="flex items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5">
<div class="min-w-0">
<div class="text-sm font-semibold">网卡VNIC</div>
<div class="mt-0.5 text-xs text-ink-3">
实例的全部虚拟网卡每张网卡可各自附加多个 IPv6
</div>
</div>
<NButton size="small" type="primary" :disabled="disabled" @click="showAttach = true">
附加 VNIC
</NButton>
</div>
<div
v-if="vnics.loading.value && !vnics.data.value"
class="flex items-center justify-center py-8"
>
<NSpin size="small" />
</div>
<div v-else class="overflow-x-auto">
<table class="w-full min-w-[760px] border-collapse text-[13px]">
<thead>
<tr class="text-left text-xs text-ink-3">
<th class="w-[26%] border-b border-line-soft py-2 pr-3 pl-4.5 font-medium">名称</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">私网 IP</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">公网 IP</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">子网</th>
<th class="w-28 border-b border-line-soft px-3 py-2 font-medium">状态</th>
<th class="w-18 border-b border-line-soft py-2 pr-4.5 pl-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
<template v-for="v in vnics.data.value ?? []" :key="v.attachmentId">
<tr>
<td class="py-2.5 pr-3 pb-1.5 pl-4.5">
<div class="flex min-w-0 items-center gap-2">
<span class="truncate font-medium" :title="v.displayName">
{{ v.displayName || '(未命名)' }}
</span>
<span
v-if="v.isPrimary"
class="flex-none rounded bg-accent/10 px-1.5 py-px text-[10.5px] font-semibold text-accent"
>
主网卡
</span>
</div>
</td>
<td class="mono px-3 py-2.5 pb-1.5">{{ v.privateIp || '—' }}</td>
<td class="mono px-3 py-2.5 pb-1.5">{{ v.publicIp || '—' }}</td>
<td class="px-3 py-2.5 pb-1.5">
<span v-if="v.subnetName" class="truncate" :title="v.subnetName">
{{ v.subnetName }}
</span>
<OcidText v-else-if="v.subnetId" :ocid="v.subnetId" />
<span v-else></span>
</td>
<td class="px-3 py-2.5 pb-1.5"><LifecycleBadge :state="v.lifecycleState" /></td>
<td class="py-2.5 pr-4.5 pb-1.5 pl-3">
<NButton
v-if="v.isPrimary"
size="tiny"
quaternary
disabled
title="主网卡不可分离"
>
分离
</NButton>
<NPopconfirm v-else @positive-click="doDetach(v.attachmentId)">
<template #trigger>
<NButton
size="tiny"
quaternary
type="error"
:loading="detaching === v.attachmentId"
:disabled="disabled || v.lifecycleState !== 'ATTACHED' || busy"
>
分离
</NButton>
</template>
分离该网卡其上全部 IP IPv6将一并释放
</NPopconfirm>
</td>
</tr>
<tr class="border-b border-line-soft last:border-b-0">
<td colspan="6" class="px-4.5 pt-0 pb-2.5">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-[10.5px] font-medium tracking-wider text-ink-3">IPV6</span>
<template v-if="v.vnicId">
<span
v-for="addr in v.ipv6Addresses ?? []"
:key="addr"
class="mono inline-flex max-w-full items-center gap-1 rounded border border-line bg-white px-1.5 py-0.5 text-xs"
>
<span class="truncate">{{ addr }}</span>
<button
class="flex-none cursor-pointer text-ink-3 hover:text-err disabled:cursor-wait"
:aria-label="`删除 ${addr}`"
:disabled="disabled || busy"
@click="doRemoveIpv6(addr)"
>
{{ removingIpv6 === addr ? '…' : '×' }}
</button>
</span>
<span v-if="!(v.ipv6Addresses ?? []).length" class="text-xs text-ink-3">
</span>
<NButton
size="tiny"
quaternary
type="primary"
:loading="addingIpv6 === v.vnicId"
:disabled="disabled || busy"
@click="doAddIpv6(v.vnicId)"
>
+ IPv6
</NButton>
</template>
<span v-else class="text-xs text-ink-3"> · 附加完成后可添加</span>
</div>
</td>
</tr>
</template>
</tbody>
</table>
<div
v-if="!(vnics.data.value ?? []).length && !vnics.loading.value"
class="py-6 text-center text-[13px] text-ink-3"
>
{{ vnics.error.value || '无网卡记录' }}
</div>
</div>
<FootNote>
附加须实例运行中且 shape 有空余 VNIC 配额 · 主网卡不可分离 · 每卡 IPv6
可多个要求所在子网已启用 IPv6· 次要网卡在 OS 内需自行配置 IPdhclient / netplan
</FootNote>
<AttachVnicModal
v-model:show="showAttach"
:cfg-id="cfgId"
:instance-id="instanceId"
:region="region"
:attached-count="(vnics.data.value ?? []).length"
@attached="refresh"
/>
</div>
</template>
+293
View File
@@ -0,0 +1,293 @@
<script setup lang="ts">
import { NDataTable, NModal, NTooltip, useMessage, type DataTableColumns } from 'naive-ui'
import { computed, h, onMounted, reactive, ref } from 'vue'
import { listAiCallLogs, listAiContentLogs } from '@/api/aigateway'
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
import { fmtTime } from '@/composables/useFormat'
import type { AiCallLog, AiContentLog } from '@/types/api'
const message = useMessage()
const rows = ref<AiCallLog[]>([])
const loading = ref(false)
const pagination = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
showSizePicker: true,
pageSizes: [20, 50, 100],
onUpdatePage: (p: number) => {
pagination.page = p
void load()
},
onUpdatePageSize: (ps: number) => {
pagination.pageSize = ps
pagination.page = 1
void load()
},
})
async function load(silent = false) {
if (!silent) loading.value = true
try {
const data = await listAiCallLogs({ page: pagination.page, size: pagination.pageSize })
rows.value = data.items
pagination.itemCount = data.total
} catch {
// 自动刷新失败静默,手动路径由全局兜底
} finally {
if (!silent) loading.value = false
}
}
onMounted(() => void load())
useAutoRefresh(() => load(true))
function fmtLatency(ms: number) {
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`
}
function statusCell(row: AiCallLog) {
const ok = row.status >= 200 && row.status < 300
const badge = h(StatusBadge, { kind: ok ? 'run' : 'term', label: String(row.status) })
if (ok || !row.errMsg) return badge
return h(
NTooltip,
{ style: { maxWidth: '380px' } },
{
trigger: () => h('span', { class: 'inline-flex cursor-help' }, [badge]),
default: () => h('span', { class: 'text-xs leading-relaxed whitespace-pre-wrap break-all' }, row.errMsg),
},
)
}
function tokensCell(row: AiCallLog) {
if (row.errMsg && !row.totalTokens) return h('span', { class: 'text-[13px] text-ink-3' }, '—')
const flow = row.stream ? ' · 流' : ''
return h('span', { class: 'mono text-[13px]' }, `${row.promptTokens} / ${row.completionTokens}${flow}`)
}
const columns: DataTableColumns<AiCallLog> = [
{ title: '时间', key: 'createdAt', width: 150, render: (r) => h('span', { class: 'text-[13px] whitespace-nowrap text-ink-2' }, fmtTime(r.createdAt)) },
{ title: '密钥', key: 'keyName', width: 140, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'text-[13px]' }, r.keyName || '—') },
{ title: '请求 IP', key: 'clientIp', width: 130, render: (r) => (r.clientIp ? h('span', { class: 'mono text-[12.5px] text-ink-2' }, r.clientIp) : h('span', { class: 'text-[13px] text-ink-3' }, '—')) },
{ title: '模型', key: 'model', minWidth: 220, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'mono text-xs' }, r.model) },
{ title: '渠道', key: 'channelName', minWidth: 180, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'text-[13px]' }, r.channelName || '—') },
{ title: '状态', key: 'status', width: 80, render: statusCell },
{ title: 'Tokens(入/出)', key: 'tokens', width: 150, render: tokensCell },
{ title: '耗时', key: 'latencyMs', width: 80, render: (r) => h('span', { class: 'mono text-[13px]' }, fmtLatency(r.latencyMs)) },
{ title: '重试', key: 'retries', width: 60, render: (r) => h('span', { class: 'mono text-[13px]' }, String(r.retries)) },
]
// ---- 行点击详情 ----
const EP_PATH: Record<string, string> = {
openai: '/ai/v1/chat/completions',
anthropic: '/ai/v1/messages',
responses: '/ai/v1/responses',
}
const detail = ref<AiCallLog | null>(null)
const showDetail = ref(false)
// ---- 内容日志正文(仅密钥内容日志窗口期内的调用存在) ----
const contentLog = ref<AiContentLog | null>(null)
async function loadContent(callLogId: number) {
contentLog.value = null
try {
const { items } = await listAiContentLogs({ page: 1, size: 1, callLogId })
contentLog.value = items[0] ?? null
} catch {
// 正文加载失败不阻塞元数据详情
}
}
/** 展示层深截断:base64 data URI 只留协议头与少量前缀,多轮对话里的
* 长文本(历史回答/长 prompt)留前 500 字符;存储仍是完整快照,全文走「复制完整」 */
function truncateText(s: string): string {
const b64 = /^(data:[\w/+.-]+;base64,)/.exec(s)
if (b64) {
const keep = b64[1].length + 24
return s.length > keep ? `${s.slice(0, keep)}…(已截断,共 ${s.length} 字符)` : s
}
if (s.length > 600) return `${s.slice(0, 500)}…(已截断,共 ${s.length} 字符)`
return s
}
function truncateDeep(v: unknown): unknown {
if (typeof v === 'string') return truncateText(v)
if (Array.isArray(v)) return v.map(truncateDeep)
if (v && typeof v === 'object')
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, truncateDeep(x)]))
return v
}
/** 正文按 JSON 美化并深截断超长值;非 JSON(如 64KB 尾部截断)按文本截断展示 */
function fmtBody(s: string) {
try {
return JSON.stringify(truncateDeep(JSON.parse(s)), null, 2)
} catch {
return s.length > 2000 ? `${s.slice(0, 2000)}…(内容过长,完整正文请复制查看)` : s
}
}
async function copyBody(text: string) {
try {
await navigator.clipboard.writeText(text)
message.success('已复制完整正文')
} catch {
message.error('复制失败,请手动选择复制')
}
}
const rowProps = (row: AiCallLog) => ({
style: 'cursor: pointer',
onClick: () => {
detail.value = row
showDetail.value = true
void loadContent(row.id)
},
})
const detailOk = computed(() => !!detail.value && detail.value.status >= 200 && detail.value.status < 300)
const heroSub = computed(() => {
const d = detail.value
if (!d) return ''
const path = EP_PATH[d.endpoint] ?? d.endpoint
return `POST ${path} · ${d.stream ? '流式(SSE)' : '非流式'} · ${fmtLatency(d.latencyMs)}`
})
const heroChips = computed<HeroChip[]>(() => {
const d = detail.value
if (!d) return []
const chips: HeroChip[] = [{ label: '密钥', value: d.keyName || '—' }]
if (d.clientIp) chips.push({ label: '来源 IP', value: d.clientIp, mono: true })
chips.push({ label: '时间', value: fmtTime(d.createdAt) })
return chips
})
const detailRows = computed<DetailRow[]>(() => {
const d = detail.value
if (!d) return []
const list: DetailRow[] = []
if (d.errMsg) list.push({ label: '失败原因', value: d.errMsg, tone: 'err', span: 2 })
list.push({ label: '渠道', value: d.channelName || '—(未命中渠道)' })
list.push({ label: '重试次数', value: String(d.retries), mono: true })
return list
})
/** 失败且无用量时隐藏 Token 区(无意义的 0/0) */
const showTokens = computed(() => detailOk.value || (detail.value?.totalTokens ?? 0) > 0)
const footerText = computed(() => {
if (contentLog.value) return '该调用命中密钥内容日志窗口,正文为截断快照(上限 64KB,保留 7 天 / 1 万行)'
return detailOk.value ? '无计费信息,OCI 成本见总览成本卡' : '仅记录元数据与 token 用量,不含任何对话内容'
})
</script>
<template>
<div class="panel">
<div class="border-b border-line-soft px-4.5 py-3">
<div class="text-sm font-semibold">AI 调用日志</div>
<div class="mt-0.5 text-xs text-ink-3">仅元数据与 token 用量,不记录对话内容;保留 90 / 5 万行 · 5 秒自动刷新</div>
</div>
<NDataTable
remote
:columns="columns"
:data="rows"
:loading="loading"
:pagination="pagination"
:bordered="false"
:row-props="rowProps"
size="small"
/>
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '600px', maxWidth: 'calc(100vw - 24px)' }">
<template #header>
<DetailHero
v-if="detail"
:kind="`AI 调用 · ${detail.endpoint.toUpperCase()} 协议`"
:title="detail.model"
title-mono
:sub="heroSub"
:chips="heroChips"
>
<StatusBadge
:kind="detailOk ? 'run' : 'term'"
:label="`${detailOk ? '成功' : '失败'} ${detail.status}`"
:dot="false"
/>
</DetailHero>
</template>
<div v-if="detail" class="flex flex-col gap-3.5">
<DetailFieldList :rows="detailRows" :cols="2" />
<div v-if="showTokens" class="overflow-hidden rounded-[10px] border border-line-soft">
<div class="flex items-baseline gap-2 border-b border-line-soft bg-wash px-3.5 py-2">
<span class="text-xs font-semibold text-ink-2">Token 明细</span>
<span class="text-[11px] text-ink-3">仅用量计数 · 不含对话内容</span>
</div>
<div class="grid" :class="detail.cachedTokens > 0 ? 'grid-cols-4' : 'grid-cols-3'">
<div class="px-3.5 py-3 text-center">
<div class="mono text-[21px] font-semibold">{{ detail.promptTokens }}</div>
<div class="mt-0.5 text-[11px] text-ink-3">输入 Token</div>
</div>
<div v-if="detail.cachedTokens > 0" class="border-l border-line-soft px-3.5 py-3 text-center">
<div class="mono text-[21px] font-semibold text-ok">{{ detail.cachedTokens }}</div>
<div class="mt-0.5 text-[11px] text-ink-3">缓存读取</div>
</div>
<div class="border-l border-line-soft px-3.5 py-3 text-center">
<div class="mono text-[21px] font-semibold">{{ detail.completionTokens }}</div>
<div class="mt-0.5 text-[11px] text-ink-3">输出 Token</div>
</div>
<div class="border-l border-line-soft px-3.5 py-3 text-center">
<div class="mono text-[21px] font-semibold">{{ detail.totalTokens }}</div>
<div class="mt-0.5 text-[11px] text-ink-3">合计</div>
</div>
</div>
</div>
<template v-if="contentLog">
<div class="overflow-hidden rounded-[10px] border border-line-soft">
<div class="flex items-baseline gap-2 border-b border-line-soft bg-wash px-3.5 py-2">
<span class="text-xs font-semibold text-ink-2">请求正文</span>
<span class="text-[11px] text-ink-3">内容日志 · 超长值已截断展示</span>
<span class="flex-1"></span>
<button
type="button"
class="cursor-pointer text-[11px] font-medium text-accent hover:text-accent-hover"
@click="copyBody(contentLog.requestBody)"
>
复制完整
</button>
</div>
<pre class="mono max-h-56 overflow-auto px-3.5 py-2.5 text-[11.5px] leading-relaxed break-all whitespace-pre-wrap">{{ fmtBody(contentLog.requestBody) }}</pre>
</div>
<div v-if="contentLog.responseBody" class="overflow-hidden rounded-[10px] border border-line-soft">
<div class="flex items-baseline gap-2 border-b border-line-soft bg-wash px-3.5 py-2">
<span class="text-xs font-semibold text-ink-2">响应正文</span>
<span class="text-[11px] text-ink-3">内容日志 · 超长值已截断展示</span>
<span class="flex-1"></span>
<button
type="button"
class="cursor-pointer text-[11px] font-medium text-accent hover:text-accent-hover"
@click="copyBody(contentLog.responseBody)"
>
复制完整
</button>
</div>
<pre class="mono max-h-56 overflow-auto px-3.5 py-2.5 text-[11.5px] leading-relaxed break-all whitespace-pre-wrap">{{ fmtBody(contentLog.responseBody) }}</pre>
</div>
<div v-else class="text-[11px] text-ink-3">无响应正文:流式响应向量结果与失败调用只记请求</div>
</template>
<div class="text-xs leading-relaxed text-ink-3">
{{ footerText }}
</div>
</div>
</NModal>
</div>
</template>
+280
View File
@@ -0,0 +1,280 @@
<script setup lang="ts">
import { NDataTable, NModal, NPopover, useMessage, type DataTableColumns } from 'naive-ui'
import { computed, h, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { listConfigs } from '@/api/configs'
import { listLogEvents } from '@/api/logevents'
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
import FootNote from '@/components/FootNote.vue'
import JsonBlock from '@/components/JsonBlock.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import TenantPicker from '@/components/TenantPicker.vue'
import { eventLabel, eventTail } from '@/composables/useEventLabel'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
import { fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import { useRegionAlias } from '@/composables/useRegionAlias'
import { useScopeStore } from '@/stores/scope'
import type { LogEvent, OciConfigSummary } from '@/types/api'
const message = useMessage()
const router = useRouter()
const scope = useScopeStore()
const { alias: regionAlias } = useRegionAlias()
const rows = ref<LogEvent[]>([])
const loading = ref(false)
/** 租户筛选;null 为全部 */
const cfgFilter = ref<number | null>(null)
/** 租户摘要:hover 信息(别名/租户名/主区域)的数据源 */
const configs = useAsync(listConfigs)
const cfgById = computed(() => {
const map = new Map<number, OciConfigSummary>()
for (const c of configs.data.value ?? []) map.set(c.id, c)
return map
})
const detail = ref<LogEvent | null>(null)
const showDetail = ref(false)
/** 受控远程分页:字面量对象会在重渲染时被重建导致状态丢失 */
const pagination = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
showSizePicker: true,
pageSizes: [20, 50, 100],
onUpdatePage: (p: number) => {
pagination.page = p
void load()
},
onUpdatePageSize: (ps: number) => {
pagination.pageSize = ps
pagination.page = 1
void load()
},
})
async function load(silent = false) {
if (!silent) loading.value = true
try {
const data = await listLogEvents({
page: pagination.page,
pageSize: pagination.pageSize,
cfgId: cfgFilter.value ?? undefined,
})
rows.value = data.items
pagination.itemCount = data.total
} catch (e) {
if (!silent) message.error(e instanceof Error ? e.message : '加载回传日志失败')
} finally {
if (!silent) loading.value = false
}
}
function onFilter() {
pagination.page = 1
void load()
}
const aliasById = computed(() => {
const map = new Map<number, string>()
for (const o of scope.cfgOptions) map.set(Number(o.value), String(o.label))
return map
})
/** 租户单元格:点击跳租户详情(阻止冒泡到行详情);hover 只展示值不加标签 */
function renderTenant(row: LogEvent) {
const label = aliasById.value.get(row.ociConfigId) ?? `#${row.ociConfigId}`
const cfg = cfgById.value.get(row.ociConfigId)
const link = h(
'span',
{
class: 'cursor-pointer border-b border-dashed border-ink-3/50 text-[13px] hover:text-accent',
onClick: (e: MouseEvent) => {
e.stopPropagation()
void router.push({ name: 'tenant-detail', params: { id: row.ociConfigId } })
},
},
label,
)
if (!cfg) return link
return h(
NPopover,
{ trigger: 'hover', placement: 'right' },
{
trigger: () => link,
default: () =>
h('div', { class: 'flex flex-col gap-1 text-xs' }, [
h('div', { class: 'font-medium text-[13px]' }, cfg.alias),
h('div', { class: 'mono text-ink-2' }, cfg.tenancyName),
h('div', { class: 'text-ink-2' }, regionAlias(cfg.region)),
]),
},
)
}
/** 摘要头标题:事件中文名 → 类型尾段 → 解析状态兜底 */
const heroTitle = computed(() => {
const d = detail.value
if (!d) return ''
if (d.eventType) return eventLabel(d.eventType) || eventTail(d.eventType)
return d.processed ? '未识别事件' : '解析中…'
})
/** 摘要头 chips:接收时间 / 事件时间 / 来源 IP */
const heroChips = computed<HeroChip[]>(() => {
const d = detail.value
if (!d) return []
return [
{ label: '接收', value: fmtTime(d.receivedAt) },
{ label: '事件时间', value: d.eventTime ? fmtTime(d.eventTime) : '—' },
{ label: '来源 IP', value: d.sourceIp || '—', mono: true },
]
})
/** 字段网格:头部未覆盖的补充字段;payload 原文由 JsonBlock 高亮展示 */
const detailRows = computed<DetailRow[]>(() => {
const d = detail.value
if (!d) return []
return [
{ label: 'MessageId', value: d.messageId, mono: true, span: 2 as const, ellipsis: true },
{ label: '事件类型', value: d.eventType, mono: true, span: 2 as const, ellipsis: true },
]
})
function openDetail(row: LogEvent) {
detail.value = row
showDetail.value = true
}
const columns: DataTableColumns<LogEvent> = [
{
title: '接收时间',
key: 'receivedAt',
width: 150,
render: (row) =>
h('span', { class: 'text-[13px] whitespace-nowrap text-ink-2' }, fmtTime(row.receivedAt)),
},
{
title: '租户',
key: 'ociConfigId',
width: 130,
render: renderTenant,
},
{
title: '事件类型',
key: 'eventType',
minWidth: 280,
ellipsis: { tooltip: true },
render: (row) =>
row.eventType
? h('span', { class: 'mono text-[12.5px]' }, row.eventType)
: h('span', { class: 'text-[12.5px] text-ink-3' }, row.processed ? '未识别' : '解析中…'),
},
{
title: 'IP',
key: 'sourceIp',
width: 140,
render: (row) =>
row.sourceIp
? h('span', { class: 'mono text-[12.5px] text-ink-2' }, row.sourceIp)
: h('span', { class: 'text-[13px] text-ink-3' }, '—'),
},
{
title: '来源',
key: 'source',
width: 160,
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'text-[13px] text-ink-2' }, row.source || '—'),
},
{
title: '标记',
key: 'truncated',
width: 90,
render: (row) =>
row.truncated ? h(StatusBadge, { kind: 'term', label: '已截断', dot: false }) : null,
},
]
function rowProps(row: LogEvent) {
return { style: 'cursor: pointer', onClick: () => openDetail(row) }
}
void load()
useAutoRefresh(() => load(true))
</script>
<template>
<div class="panel">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
>
<div class="min-w-0">
<div class="text-sm font-semibold">回传日志</div>
<div class="mt-0.5 text-xs text-ink-3">
OCI 侧推送的关键审计事件(Connector Hub Notifications 面板 webhook)
</div>
</div>
<TenantPicker
v-model:value="cfgFilter"
size="small"
clearable
placeholder="全部租户"
class="w-52"
:configs="scope.configs.data ?? []"
:loading="scope.configs.loading"
@update:value="onFilter"
/>
</div>
<NDataTable
remote
:columns="columns"
:data="rows"
:loading="loading"
:pagination="pagination"
:scroll-x="880"
:max-height="480"
:row-key="(r: LogEvent) => r.id"
:row-props="rowProps"
/>
<FootNote>
在租户详情「其他」tab 一键创建链路后,实例生命周期(Launch/Terminate/InstanceAction)、
用户与凭据(Create/Delete/UpdateUser、Create/DeleteApiKey、UpdateUserCapabilities)、
区域订阅与策略变更等关键审计事件将自动回传;消息按 MessageId 幂等去重,保留 90
天、总量超 2 万条时删除最旧,关键事件另经「通知管理 → 云端事件」推送告警
</FootNote>
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '720px', maxWidth: 'calc(100vw - 24px)' }">
<template #header>
<DetailHero
v-if="detail"
kind="回传日志 · Connector Webhook"
:title="heroTitle"
:sub="`${aliasById.get(detail.ociConfigId) ?? `#${detail.ociConfigId}`} · ${detail.source || '来源未知'}`"
:chips="heroChips"
>
<StatusBadge
v-if="detail.eventType"
kind="prov"
:label="eventTail(detail.eventType)"
:dot="false"
/>
</DetailHero>
</template>
<div v-if="detail" class="flex flex-col gap-3.5">
<DetailFieldList :rows="detailRows" :cols="2" />
<JsonBlock
:value="detail.payload"
title="Payload 原文"
:meta="`ONS 投递${detail.truncated ? ' · 超限已截断' : ''}`"
max-height="42vh"
wide
/>
</div>
</NModal>
</div>
</template>
+219
View File
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { NDataTable, NInput, NModal, useMessage, type DataTableColumns } from 'naive-ui'
import { computed, h, reactive, ref } from 'vue'
import { listSystemLogs } from '@/api/settings'
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
import FootNote from '@/components/FootNote.vue'
import JsonBlock from '@/components/JsonBlock.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { actionLabel } from '@/composables/useActionLabel'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
import { fmtTime } from '@/composables/useFormat'
import type { SystemLog } from '@/types/api'
const message = useMessage()
const rows = ref<SystemLog[]>([])
const loading = ref(false)
const keyword = ref('')
// 已生效的关键字:回车 / 清除时更新,翻页沿用
const applied = ref('')
/** 受控远程分页:字面量对象会在重渲染时被重建导致状态丢失 */
const pagination = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
showSizePicker: true,
pageSizes: [20, 50, 100],
onUpdatePage: (p: number) => {
pagination.page = p
void load()
},
onUpdatePageSize: (ps: number) => {
pagination.pageSize = ps
pagination.page = 1
void load()
},
})
async function load(silent = false) {
if (!silent) loading.value = true
try {
const data = await listSystemLogs({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: applied.value || undefined,
})
rows.value = data.items
pagination.itemCount = data.total
} catch (e) {
if (!silent) message.error(e instanceof Error ? e.message : '加载系统日志失败')
} finally {
if (!silent) loading.value = false
}
}
function search() {
applied.value = keyword.value.trim()
pagination.page = 1
void load()
}
function onClear() {
keyword.value = ''
search()
}
// 行点击详情:与回传/审计日志一致的「字段 + 原始 JSON」风格
const detail = ref<SystemLog | null>(null)
const showDetail = ref(false)
function rowProps(row: SystemLog) {
return {
style: 'cursor: pointer',
onClick: () => {
detail.value = row
showDetail.value = true
},
}
}
/** 摘要头 chips:用户 / IP / 时间 */
const heroChips = computed<HeroChip[]>(() => {
const d = detail.value
if (!d) return []
return [
{ label: '用户', value: d.username || '—' },
{ label: 'IP', value: d.clientIp, mono: true },
{ label: '时间', value: fmtTime(d.createdAt) },
]
})
/** 字段网格:头部未覆盖的补充字段;失败原因红字置顶 */
const detailRows = computed<DetailRow[]>(() => {
const d = detail.value
if (!d) return []
return [
...(d.errMsg ? [{ label: '失败原因', value: d.errMsg, tone: 'err' as const, span: 2 as const }] : []),
{ label: 'User-Agent', value: d.userAgent, mono: true, span: 2 as const, ellipsis: true },
]
})
const columns: DataTableColumns<SystemLog> = [
{
title: '时间',
key: 'createdAt',
width: 150,
render: (row) =>
h('span', { class: 'text-[13px] whitespace-nowrap text-ink-2' }, fmtTime(row.createdAt)),
},
{
title: '用户',
key: 'username',
width: 110,
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'text-[13px]' }, row.username || '—'),
},
{
// 主行中文动作、副行原始接口,可读性与可检索性兼顾
title: '动作',
key: 'path',
minWidth: 280,
render: (row) =>
h('div', { class: 'min-w-0' }, [
h('div', { class: 'truncate text-[13px]' }, actionLabel(row.method, row.path) || '—'),
h('div', { class: 'mono mt-px truncate text-xs text-ink-3' }, `${row.method} ${row.path}`),
]),
},
{
title: '状态',
key: 'status',
width: 70,
render: (row) =>
h(
'span',
{ class: ['text-[13px] tabular-nums', row.status >= 400 ? 'text-err' : ''] },
String(row.status),
),
},
{
title: '耗时',
key: 'durationMs',
width: 90,
render: (row) =>
h('span', { class: 'text-[13px] tabular-nums text-ink-2' }, `${row.durationMs} ms`),
},
{
title: 'IP',
key: 'clientIp',
width: 140,
render: (row) => h('span', { class: 'mono text-[12.5px] text-ink-2' }, row.clientIp),
},
]
void load()
useAutoRefresh(() => load(true))
</script>
<template>
<div class="panel">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
>
<div class="min-w-0">
<div class="text-sm font-semibold">系统日志</div>
<div class="mt-0.5 text-xs text-ink-3">面板写操作与登录成败留痕用于审计与排障</div>
</div>
<NInput
v-model:value="keyword"
size="small"
clearable
placeholder="搜索路径 / 用户名,回车确认"
class="max-w-60"
@keyup.enter="search"
@clear="onClear"
/>
</div>
<NDataTable
remote
:columns="columns"
:data="rows"
:loading="loading"
:pagination="pagination"
:scroll-x="880"
:max-height="480"
:row-key="(r: SystemLog) => r.id"
:row-props="rowProps"
/>
<FootNote>
自动记录 secured 接口的全部写请求(POST / PUT /
DELETE)与登录成败,只存方法与路径等元数据、绝不记录请求体;失败请求另存响应错误文案与
User-Agent;日志保留 90 天,总量超 5 万条时自动删除最旧记录 · 点击行查看详情
</FootNote>
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '640px', maxWidth: 'calc(100vw - 24px)' }">
<template #header>
<DetailHero
v-if="detail"
kind="系统日志 · 面板操作"
:title="actionLabel(detail.method, detail.path) || `${detail.method} ${detail.path}`"
:sub="`${detail.method} ${detail.path} · ${detail.durationMs} ms`"
:chips="heroChips"
>
<StatusBadge
:kind="detail.status >= 400 ? 'term' : 'run'"
:label="`${detail.status >= 400 ? '失败' : '成功'} ${detail.status}`"
:dot="false"
/>
</DetailHero>
</template>
<div v-if="detail" class="flex flex-col gap-3.5">
<DetailFieldList :rows="detailRows" :cols="2" />
<JsonBlock :value="detail" title="原始记录" meta="仅元数据,不含请求体" max-height="32vh" wide />
</div>
</NModal>
</div>
</template>
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { NCheckbox, NInput, useMessage } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { createVcn } from '@/api/networks'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { defaultResourceName } from '@/composables/useFormat'
/** 租户 / 区间 / 区域直接使用父页面当前值,不在表单中出现 */
const props = defineProps<{
show: boolean
cfgId: number | null
compartmentId?: string
region?: string
}>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const message = useMessage()
const submitting = ref(false)
const blank = {
displayName: '',
cidrBlock: '10.0.0.0/16',
dnsLabel: '',
enableIpv6: true,
}
const form = reactive({ ...blank })
watch(
() => props.show,
(v) => {
if (!v) return
Object.assign(form, blank)
form.displayName = defaultResourceName('vcn')
},
)
const cidrOk = computed(() => /^\d{1,3}(\.\d{1,3}){3}\/\d{1,2}$/.test(form.cidrBlock.trim()))
const canSubmit = computed(() => props.cfgId !== null && !!form.displayName.trim() && cidrOk.value)
async function submit() {
if (props.cfgId === null) return
submitting.value = true
try {
const created = await createVcn(props.cfgId, {
region: props.region || undefined,
compartmentId: props.compartmentId || undefined,
displayName: form.displayName.trim(),
cidrBlock: form.cidrBlock.trim(),
dnsLabel: form.dnsLabel.trim() || undefined,
enableIpv6: form.enableIpv6,
})
message.success(`VCN「${created.displayName}」已创建`)
emit('update:show', false)
emit('created')
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="创建 VCN"
:submitting="submitting"
:submit-disabled="!canSubmit"
submit-text="创建"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="名称" required>
<NInput v-model:value="form.displayName" placeholder="vcn-20260703-2136" />
</FormField>
<FormField label="IPv4 CIDR" required :error="cidrOk ? undefined : '格式如 10.0.0.0/16'">
<NInput v-model:value="form.cidrBlock" class="mono" placeholder="10.0.0.0/16" />
</FormField>
</div>
<FormField label="DNS 标签" hint="留空不启用 VCN 内 DNS;仅小写字母数字,15 字符内">
<NInput v-model:value="form.dnsLabel" class="mono" placeholder="可选" />
</FormField>
<NCheckbox v-model:checked="form.enableIpv6">同时分配 Oracle IPv6 前缀/56</NCheckbox>
</FormModal>
</template>
+386
View File
@@ -0,0 +1,386 @@
<script setup lang="ts">
import {
NButton,
NDataTable,
NInput,
NModal,
NPopconfirm,
NRadioButton,
NRadioGroup,
useMessage,
type DataTableColumns,
} from 'naive-ui'
import { computed, h, onUnmounted, ref } from 'vue'
import {
createProxy,
deleteProxy,
importProxies,
listProxies,
probeProxy,
updateProxy,
} from '@/api/proxies'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FootNote from '@/components/FootNote.vue'
import FormField from '@/components/FormField.vue'
import { useAsync } from '@/composables/useAsync'
import type { ProxyImportResult, ProxyInfo } from '@/types/api'
const message = useMessage()
const proxies = useAsync(listProxies)
const rows = computed(() => proxies.data.value?.items ?? [])
// 创建 / 导入后服务端异步探测出口地区,延迟静默刷新把结果带回来
let geoTimer: ReturnType<typeof setTimeout> | null = null
function scheduleGeoRefresh() {
if (geoTimer) clearTimeout(geoTimer)
geoTimer = setTimeout(() => void proxies.run({ silent: true }), 3000)
}
onUnmounted(() => {
if (geoTimer) clearTimeout(geoTimer)
})
// ---- 新增 / 编辑弹窗 ----
const showForm = ref(false)
const editing = ref<ProxyInfo | null>(null)
const saving = ref(false)
const form = ref({ name: '', type: 'socks5', host: '', port: 1080 as number | null, username: '', password: '' })
const typeOptions = [
{ label: 'SOCKS5', value: 'socks5' },
{ label: 'HTTP', value: 'http' },
{ label: 'HTTPS', value: 'https' },
]
/** 名称留空时的自动命名预览,与后端 autoProxyName 规则一致 */
const autoNamePreview = computed(() => {
const f = form.value
if (f.name.trim() || !f.host.trim() || !f.port) return ''
return `${f.type}-${f.host.trim()}:${f.port}`
})
function openCreate() {
editing.value = null
form.value = { name: '', type: 'socks5', host: '', port: 1080, username: '', password: '' }
showForm.value = true
}
function openEdit(row: ProxyInfo) {
editing.value = row
form.value = { name: row.name, type: row.type, host: row.host, port: row.port, username: row.username, password: '' }
showForm.value = true
}
const passwordPlaceholder = computed(() =>
editing.value?.passwordSet ? '已配置,留空沿用;输入新值覆盖' : '无认证可留空;服务端加密存储',
)
async function save() {
const f = form.value
if (!f.host.trim() || !f.port) {
message.error('请填写主机与端口')
return
}
saving.value = true
try {
const body = {
name: f.name.trim(),
type: f.type,
host: f.host.trim(),
port: f.port,
username: f.username.trim(),
// 编辑时留空表示沿用已存密码;新建时留空即无密码
...(f.password || !editing.value ? { password: f.password } : {}),
}
if (editing.value) {
await updateProxy(editing.value.id, body)
message.success('代理已更新,关联租户的后续请求即用新配置')
} else {
await createProxy(body)
message.success('代理已创建,正在检测出口地区')
}
showForm.value = false
void proxies.run({ silent: true })
scheduleGeoRefresh()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
async function remove(row: ProxyInfo) {
try {
await deleteProxy(row.id)
message.success('代理已删除')
void proxies.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
// ---- 手动重测出口地区 ----
const probingIds = ref(new Set<number>())
async function probe(row: ProxyInfo) {
probingIds.value.add(row.id)
try {
const view = await probeProxy(row.id)
message.success(view.country ? `出口地区:${view.country}${view.city ? '·' + view.city : ''}` : '探测失败,出口地区未知')
void proxies.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '重测失败')
} finally {
probingIds.value.delete(row.id)
}
}
// ---- 批量导入 ----
const showImport = ref(false)
const importing = ref(false)
const importText = ref('')
const importResult = ref<ProxyImportResult | null>(null)
function openImport() {
importText.value = ''
importResult.value = null
showImport.value = true
}
async function doImport() {
if (!importText.value.trim()) {
message.error('请粘贴代理列表')
return
}
importing.value = true
try {
const res = await importProxies(importText.value)
importResult.value = res
void proxies.run({ silent: true })
scheduleGeoRefresh()
if (!res.failed.length) {
message.success(`已导入 ${res.created.length} 条,正在检测出口地区`)
showImport.value = false
} else {
message.warning(`成功 ${res.created.length} 条,失败 ${res.failed.length} 条,详见下方`)
}
} catch (e) {
message.error(e instanceof Error ? e.message : '导入失败')
} finally {
importing.value = false
}
}
/** 地区单元格:未探测显示「检测中」,探测失败显示「未知」 */
function renderGeo(row: ProxyInfo) {
if (!row.geoAt) return h('span', { class: 'text-[12.5px] text-ink-3' }, '检测中…')
if (!row.country) return h('span', { class: 'text-[12.5px] text-ink-3' }, '未知')
return h('span', { class: 'text-[13px]' }, row.city ? `${row.country}·${row.city}` : row.country)
}
// 新增 / 批量导入入口由页面标题行触发(ProxyListView),这里暴露打开方法
defineExpose({ openCreate, openImport })
const columns: DataTableColumns<ProxyInfo> = [
{
title: '名称',
key: 'name',
minWidth: 140,
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'text-[13px] font-medium' }, row.name),
},
{
title: '类型',
key: 'type',
width: 84,
render: (row) => h('span', { class: 'mono text-[12.5px] uppercase' }, row.type),
},
{
title: '地址',
key: 'host',
minWidth: 180,
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'mono text-[12.5px] text-ink-2' }, `${row.host}:${row.port}`),
},
{ title: '地区', key: 'geo', minWidth: 120, render: renderGeo },
{
title: '认证',
key: 'username',
width: 110,
render: (row) =>
h(
'span',
{ class: 'text-[12.5px] text-ink-2' },
row.username ? `${row.username}${row.passwordSet ? ' / ●●●' : ''}` : row.passwordSet ? '●●●' : '无',
),
},
{
title: '关联租户',
key: 'usedBy',
width: 88,
render: (row) =>
h('span', { class: 'text-[13px] tabular-nums' }, row.usedBy ? `${row.usedBy}` : '—'),
},
{
title: '操作',
key: 'actions',
width: 168,
render: (row) =>
h('div', { class: 'flex items-center gap-1' }, [
h(
NButton,
{ size: 'tiny', quaternary: true, loading: probingIds.value.has(row.id), onClick: () => probe(row) },
() => '重测',
),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, () => '编辑'),
h(
NPopconfirm,
{ onPositiveClick: () => remove(row) },
{
trigger: () =>
h(NButton, { size: 'tiny', type: 'error', quaternary: true, disabled: row.usedBy > 0 }, () => '删除'),
default: () => '删除后不可恢复,确认?',
},
),
]),
},
]
</script>
<template>
<div class="panel">
<NDataTable
:columns="columns"
:data="rows"
:loading="proxies.loading.value"
:scroll-x="900"
:row-key="(r: ProxyInfo) => r.id"
/>
<FootNote>
支持 SOCKS5 / HTTP / HTTPS;密码 AES 加密存储、不回显明文;地区为经代理实测的出口位置(ip-api.com),创建后自动检测;被租户关联的代理不可删除,请先解除关联
</FootNote>
<NModal
v-model:show="showForm"
preset="card"
:title="editing ? '编辑代理' : '新增代理'"
:style="{ width: '480px', maxWidth: 'calc(100vw - 24px)' }"
>
<FormField label="类型">
<NRadioGroup v-model:value="form.type" class="w-full" size="small">
<NRadioButton
v-for="t in typeOptions"
:key="t.value"
:value="t.value"
class="w-1/3 text-center"
>
{{ t.label }}
</NRadioButton>
</NRadioGroup>
</FormField>
<div class="grid grid-cols-3 gap-x-3">
<FormField label="主机" required class="col-span-2">
<NInput v-model:value="form.host" placeholder="203.0.113.50" />
</FormField>
<FormField label="端口" required>
<AppInputNumber v-model:value="form.port" :min="1" :max="65535" class="!w-full" />
</FormField>
</div>
<FormField
label="名称"
hint="选填;留空按类型-主机:端口自动生成,重名自动追加序号"
:feedback="autoNamePreview ? `将生成 ${autoNamePreview}` : undefined"
>
<NInput v-model:value="form.name" placeholder="留空自动生成" />
</FormField>
<div class="grid grid-cols-2 gap-x-3">
<FormField label="用户名">
<NInput v-model:value="form.username" placeholder="无认证可留空" />
</FormField>
<FormField label="密码" :hint="passwordPlaceholder">
<NInput
v-model:value="form.password"
type="password"
show-password-on="click"
placeholder="可选"
/>
</FormField>
</div>
<div class="mb-3.5 flex items-start gap-2.5 rounded-lg border border-ok/30 bg-ok/10 px-3 py-2.5">
<svg
class="mt-0.5 h-[15px] w-[15px] flex-none text-ok"
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>
<div class="min-w-0 text-xs leading-relaxed">
<div class="font-medium">保存后自动检测出口地区</div>
<div class="mt-0.5 text-ink-3">
经该代理请求 ip-api.com 实测出口位置(非主机字段),结果显示在「地区」列,可随时重测
</div>
</div>
</div>
<div class="mt-1 flex items-center gap-2">
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
<NButton size="small" quaternary @click="showForm = false">取消</NButton>
</div>
</NModal>
<NModal
v-model:show="showImport"
preset="card"
title="批量导入代理"
:style="{ width: '560px', maxWidth: 'calc(100vw - 24px)' }"
>
<FormField label="代理列表">
<NInput
v-model:value="importText"
type="textarea"
:autosize="{ minRows: 6, maxRows: 12 }"
placeholder="socks5://user:pass@203.0.113.50:1080&#10;https://198.51.100.2:8443&#10;203.0.113.9:1080:user:pass"
class="mono"
/>
</FormField>
<div class="mb-3 flex flex-col gap-1.5 rounded-lg border border-line-soft bg-wash px-3 py-2.5 text-xs text-ink-2">
<div class="flex flex-wrap items-center gap-2">
<code class="mono rounded border border-line bg-white px-1.5 py-px text-[11px]">scheme://[user:pass@]host:port</code>
<span>scheme socks5 / http / https</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<code class="mono rounded border border-line bg-white px-1.5 py-px text-[11px]">host:port[:user:pass]</code>
<span>无协议头,缺省按 SOCKS5;# 开头为注释</span>
</div>
<div class="text-ink-3">失败行回显已脱敏(凭据段替换为 ***),不阻断其余行;名称自动生成</div>
</div>
<div v-if="importResult" class="mb-3 overflow-hidden rounded-lg border border-line">
<div class="flex items-center gap-4 border-b border-line-soft bg-white px-3 py-2 text-[12.5px] font-semibold">
<span><span class="text-ok">{{ importResult.created.length }}</span> 条导入成功</span>
<span v-if="importResult.failed.length">
<span class="text-err">{{ importResult.failed.length }}</span> 条失败
</span>
<span v-if="importResult.created.length" class="ml-auto text-[11px] font-medium text-ink-3">
正在检测出口地区
</span>
</div>
<div
v-for="f in importResult.failed"
:key="f.lineNo"
class="mono flex items-center gap-2.5 bg-err/8 px-3 py-1.5 text-[12px] leading-relaxed"
>
<span class="flex-none font-semibold text-err"> {{ f.lineNo }} </span>
<span class="truncate text-ink">{{ f.line }}</span>
<span class="ml-auto flex-none text-[11.5px] text-ink-2">{{ f.reason }}</span>
</div>
</div>
<div class="mt-1 flex items-center gap-2">
<NButton size="small" type="primary" :loading="importing" @click="doImport">导入</NButton>
<NButton size="small" quaternary @click="showImport = false">关闭</NButton>
</div>
</NModal>
</div>
</template>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { NButton, NSpin } from 'naive-ui'
import { onMounted, ref } from 'vue'
import { getAbout } from '@/api/settings'
import AppLogo from '@/components/AppLogo.vue'
import { useParticles } from '@/composables/useParticles'
import type { AboutInfo } from '@/types/api'
// GitHub 仓库地址:开源发布后填充;空串时按钮禁用并显示占位
const REPO_URL = ''
const info = ref<AboutInfo | null>(null)
const loading = ref(true)
const pcanvas = ref<HTMLCanvasElement | null>(null)
useParticles(pcanvas, { count: 26 })
onMounted(async () => {
try {
info.value = await getAbout()
} catch {
/* 构建信息加载失败不阻塞页面,chips 显示占位 */
} finally {
loading.value = false
}
})
function fmtBuild(t: string) {
return t ? t.replace('T', ' ').replace('Z', ' UTC') : '—'
}
function openRepo() {
if (REPO_URL) window.open(REPO_URL, '_blank', 'noopener')
}
</script>
<template>
<div class="flex max-w-[860px] flex-col gap-4">
<!-- Hero:印章 + 名称 + 版本 chips,粒子与轨道环装饰 -->
<div class="panel relative overflow-hidden px-6 py-12 text-center">
<div class="pointer-events-none absolute inset-0" aria-hidden="true">
<div class="glow" style="width: 420px; height: 420px; left: 50%; top: -180px; transform: translateX(-50%)"></div>
<div class="ring-dash spin" style="width: 380px; height: 380px; left: calc(50% - 190px); top: -60px"></div>
<canvas ref="pcanvas" class="absolute inset-0 h-full w-full"></canvas>
</div>
<div class="relative">
<span class="floaty mx-auto block w-fit"><AppLogo :size="84" /></span>
<div class="mt-5 font-serif text-[34px] leading-tight font-semibold">OCI Portal</div>
<div class="mt-1 text-[13px] text-ink-3">OCI 账号批量管理面板 · 自托管</div>
<div v-if="loading" class="mt-6 flex justify-center"><NSpin size="small" /></div>
<div v-else class="mt-6 flex flex-wrap justify-center gap-2">
<span class="chip mono">{{ info?.version || 'dev' }}</span>
<span class="chip mono">构建 {{ fmtBuild(info?.buildTime ?? '') }}</span>
<span class="chip mono">{{ info?.platform || '—' }} · {{ info?.goVersion || '' }}</span>
</div>
</div>
</div>
<!-- GitHub 仓库 -->
<div class="panel flex items-center gap-3.5 px-5 py-4">
<span class="flex h-10 w-10 flex-none items-center justify-center rounded-[9px] bg-wash">
<svg class="h-5 w-5" viewBox="0 0 16 16" fill="currentColor">
<path
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"
></path>
</svg>
</span>
<span class="min-w-0 flex-1">
<span class="block text-[13px] font-semibold">GitHub 仓库</span>
<span class="mono mt-0.5 block truncate text-xs text-ink-3">{{ REPO_URL || 'github.com/…(待填充)' }}</span>
</span>
<NButton size="small" :disabled="!REPO_URL" @click="openRepo">前往仓库 </NButton>
</div>
</div>
</template>
<style scoped>
.chip {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-line);
border-radius: 999px;
background: var(--color-card);
padding: 6px 13px;
font-size: 12px;
color: var(--color-ink-2);
box-shadow: 0 2px 8px rgba(20, 20, 19, 0.06);
}
.glow {
position: absolute;
border-radius: 50%;
background: rgba(201, 100, 66, 0.13);
filter: blur(80px);
}
.ring-dash {
position: absolute;
border-radius: 50%;
border: 1.5px dashed color-mix(in srgb, var(--color-line) 70%, var(--color-accent) 30%);
}
@media (prefers-reduced-motion: no-preference) {
.spin {
animation: about-sp 52s linear infinite;
}
.floaty {
animation: about-fl 7s ease-in-out infinite;
}
@keyframes about-sp {
to {
transform: rotate(360deg);
}
}
@keyframes about-fl {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
}
</style>
@@ -0,0 +1,739 @@
<script setup lang="ts">
import { NButton, NInput, NModal, NPopconfirm, NQrCode, NSpin, NSwitch, useMessage } from 'naive-ui'
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
activateTotp,
disableTotp,
getCredentials,
getOAuthSettings,
getOauthAuthorizeUrl,
getTotpStatus,
listIdentities,
setupTotp,
unbindIdentity,
updateCredentials,
updateOAuthSettings,
updatePasswordLogin,
} from '@/api/auth'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import FootNote from '@/components/FootNote.vue'
import { useAsync } from '@/composables/useAsync'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { fmtTime } from '@/composables/useFormat'
import type { TotpSetup, UpdateOAuthRequest } from '@/types/api'
const message = useMessage()
const router = useRouter()
const auth = useAuthStore()
const app = useAppStore()
const providerLabels: Record<string, string> = { github: 'GitHub', oidc: 'OIDC 单点登录' }
/** provider 的展示名:配置显示名 → 默认名 → 原串 */
function displayNameOf(p: string): string {
const cfg = oauthCfg.data.value
const custom = p === 'oidc' ? cfg?.oidcDisplayName : cfg?.githubDisplayName
return custom || providerLabels[p] || p
}
// ---- 两步验证(TOTP) ----
const totp = useAsync(getTotpStatus)
const showTotpSetup = ref(false)
const showTotpDisable = ref(false)
const totpPending = ref<TotpSetup | null>(null)
const totpActivateCode = ref('')
const totpBusy = ref(false)
const totpDisableInput = ref('')
async function startTotpSetup() {
totpBusy.value = true
try {
totpPending.value = await setupTotp()
totpActivateCode.value = ''
showTotpSetup.value = true
} catch (e) {
message.error(e instanceof Error ? e.message : '发起失败')
} finally {
totpBusy.value = false
}
}
async function confirmTotpActivate() {
if (totpActivateCode.value.length !== 6) {
message.error('请输入 6 位验证码')
return
}
totpBusy.value = true
try {
await activateTotp(totpActivateCode.value)
message.success('两步验证已启用,下次登录需输入动态验证码')
showTotpSetup.value = false
totpPending.value = null
void totp.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '激活失败')
} finally {
totpBusy.value = false
}
}
async function confirmTotpDisable() {
const input = totpDisableInput.value.trim()
totpBusy.value = true
try {
// 6 位纯数字按验证码提交,其余按密码
await disableTotp(/^\d{6}$/.test(input) ? { code: input } : { password: input })
message.success('两步验证已停用')
totpDisableInput.value = ''
showTotpDisable.value = false
void totp.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '停用失败')
} finally {
totpBusy.value = false
}
}
async function copyTotpSecret() {
if (!totpPending.value) return
try {
await navigator.clipboard.writeText(totpPending.value.secret)
message.success('已复制')
} catch {
message.error('复制失败,请手动选择文本复制')
}
}
// ---- 外部身份绑定 ----
const identities = useAsync(listIdentities)
const oauthCfg = useAsync(getOAuthSettings)
const binding = ref('')
const hasIdentity = computed(() => (identities.data.value?.items ?? []).length > 0)
// ---- 登录凭据:用户名 / 密码修改与密码登录开关 ----
const creds = useAsync(getCredentials)
const showCredModal = ref(false)
const savingCred = ref(false)
const togglingPwLogin = ref(false)
const credForm = reactive({ newUsername: '', newPassword: '', confirm: '', currentPassword: '' })
function openCredModal() {
Object.assign(credForm, {
newUsername: creds.data.value?.username ?? '',
newPassword: '',
confirm: '',
currentPassword: '',
})
showCredModal.value = true
}
/** 是否有实际变更(改名或改密),提交按钮据此启用 */
const credChanges = computed(() => ({
nameChanged:
!!credForm.newUsername.trim() && credForm.newUsername.trim() !== creds.data.value?.username,
pwChanged: !!credForm.newPassword,
}))
const credValid = computed(() => {
const { nameChanged, pwChanged } = credChanges.value
if (!credForm.currentPassword || (!nameChanged && !pwChanged)) return false
if (pwChanged && (credForm.newPassword.length < 8 || credForm.newPassword !== credForm.confirm))
return false
return true
})
async function saveCredentials() {
const { nameChanged, pwChanged } = credChanges.value
savingCred.value = true
try {
await updateCredentials({
...(nameChanged ? { newUsername: credForm.newUsername.trim() } : {}),
...(pwChanged ? { newPassword: credForm.newPassword } : {}),
currentPassword: credForm.currentPassword,
})
message.success('登录凭据已更新,请使用新凭据重新登录')
showCredModal.value = false
auth.logout()
void router.push('/login')
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
savingCred.value = false
}
}
/** 切换密码登录禁用;开启由后端校验至少绑定一个外部身份 */
async function togglePasswordLogin(disabled: boolean) {
togglingPwLogin.value = true
try {
await updatePasswordLogin(disabled)
message.success(disabled ? '已禁用密码登录,仅外部身份可登录' : '已恢复密码登录')
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
togglingPwLogin.value = false
void creds.run({ silent: true })
}
}
/** 已配置 clientID 的 provider 才显示绑定入口 */
const bindableProviders = computed(() => {
const cfg = oauthCfg.data.value
if (!cfg) return []
const out: string[] = []
if (cfg.oidcClientId) out.push('oidc')
if (cfg.githubClientId) out.push('github')
return out
})
async function bindProvider(provider: string) {
binding.value = provider
try {
const { url } = await getOauthAuthorizeUrl(provider, 'bind')
window.location.href = url
} catch (e) {
message.error(e instanceof Error ? e.message : '发起绑定失败')
binding.value = ''
}
}
async function removeIdentity(id: number) {
try {
await unbindIdentity(id)
message.success('已解绑;该身份后续无法登录面板')
void identities.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '解绑失败')
}
}
// ---- 登录方式配置(OAuth provider):表格 + 弹窗增改;禁用仅隐藏登录入口,删除清全部配置 ----
type ProviderType = 'github' | 'oidc'
const PROVIDER_TYPES: { label: string; value: ProviderType }[] = [
{ label: 'GitHub', value: 'github' },
{ label: 'OIDC(自动 discovery)', value: 'oidc' },
]
const savingOauth = ref(false)
const showProviderModal = ref(false)
/** 编辑中的 provider;null 表示新增(类型可选) */
const editingProvider = ref<ProviderType | null>(null)
const pForm = reactive({
provider: 'github' as ProviderType,
displayName: '',
issuer: '',
clientId: '',
secret: '',
})
/** 已配置的登录方式表格行 */
const providerRows = computed(() => {
const cfg = oauthCfg.data.value
if (!cfg) return []
const rows: { provider: ProviderType; name: string; clientId: string; disabled: boolean }[] = []
if (cfg.githubClientId)
rows.push({
provider: 'github',
name: displayNameOf('github'),
clientId: cfg.githubClientId,
disabled: cfg.githubDisabled,
})
if (cfg.oidcClientId)
rows.push({
provider: 'oidc',
name: displayNameOf('oidc'),
clientId: cfg.oidcClientId,
disabled: cfg.oidcDisabled,
})
return rows
})
const callbackBase = window.location.origin
function openCreateProvider() {
editingProvider.value = null
Object.assign(pForm, { provider: 'github', displayName: '', issuer: '', clientId: '', secret: '' })
showProviderModal.value = true
}
function openEditProvider(p: ProviderType) {
const cfg = oauthCfg.data.value
editingProvider.value = p
Object.assign(pForm, {
provider: p,
displayName: p === 'oidc' ? (cfg?.oidcDisplayName ?? '') : (cfg?.githubDisplayName ?? ''),
issuer: p === 'oidc' ? (cfg?.oidcIssuer ?? '') : '',
clientId: p === 'oidc' ? (cfg?.oidcClientId ?? '') : (cfg?.githubClientId ?? ''),
secret: '',
})
showProviderModal.value = true
}
const pFormValid = computed(
() => Boolean(pForm.clientId.trim()) && (pForm.provider !== 'oidc' || Boolean(pForm.issuer.trim())),
)
const secretConfigured = computed(() => {
const cfg = oauthCfg.data.value
return pForm.provider === 'oidc' ? !!cfg?.oidcSecretSet : !!cfg?.githubSecretSet
})
/** 服务端接口为两 provider 整体保存:以现值为底、只覆写 patch 内字段,防误清 */
function oauthPayloadWith(patch: Partial<UpdateOAuthRequest>): UpdateOAuthRequest {
const cfg = oauthCfg.data.value
return {
oidcIssuer: cfg?.oidcIssuer ?? '',
oidcClientId: cfg?.oidcClientId ?? '',
oidcDisplayName: cfg?.oidcDisplayName ?? '',
oidcDisabled: cfg?.oidcDisabled ?? false,
githubClientId: cfg?.githubClientId ?? '',
githubDisplayName: cfg?.githubDisplayName ?? '',
githubDisabled: cfg?.githubDisabled ?? false,
...patch,
}
}
/** 表单字段折叠为目标 provider 的 patch;secret 留空沿用 */
function providerFormPatch(): Partial<UpdateOAuthRequest> {
const name = pForm.displayName.trim()
const id = pForm.clientId.trim()
if (pForm.provider === 'oidc')
return {
oidcIssuer: pForm.issuer.trim(),
oidcClientId: id,
oidcDisplayName: name,
...(pForm.secret ? { oidcClientSecret: pForm.secret } : {}),
}
return {
githubClientId: id,
githubDisplayName: name,
...(pForm.secret ? { githubClientSecret: pForm.secret } : {}),
}
}
async function saveProvider() {
savingOauth.value = true
try {
await updateOAuthSettings(oauthPayloadWith(providerFormPatch()))
message.success(`登录方式「${pForm.displayName.trim() || providerLabels[pForm.provider]}」已保存`)
showProviderModal.value = false
void oauthCfg.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
savingOauth.value = false
}
}
/** 禁用/启用:仅控制登录页展示与 login 授权,绑定入口与已绑身份不受影响 */
async function toggleProviderDisabled(p: ProviderType, disabled: boolean) {
savingOauth.value = true
try {
const patch = p === 'oidc' ? { oidcDisabled: disabled } : { githubDisabled: disabled }
await updateOAuthSettings(oauthPayloadWith(patch))
message.success(`${displayNameOf(p)}」已${disabled ? '禁用,登录页不再展示' : '启用'}`)
void oauthCfg.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
savingOauth.value = false
}
}
/** 删除 = 清空该 provider 全部配置(含 secret 与显示名称),不可恢复 */
async function deleteProvider(p: ProviderType) {
savingOauth.value = true
const name = displayNameOf(p)
try {
const patch: Partial<UpdateOAuthRequest> =
p === 'oidc'
? { oidcIssuer: '', oidcClientId: '', oidcClientSecret: '', oidcDisplayName: '', oidcDisabled: false }
: { githubClientId: '', githubClientSecret: '', githubDisplayName: '', githubDisabled: false }
await updateOAuthSettings(oauthPayloadWith(patch))
message.success(`登录方式「${name}」已删除`)
void oauthCfg.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
savingOauth.value = false
}
}
</script>
<template>
<!-- 账号安全:两步验证 + 外部身份 -->
<div class="panel">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
>
<div class="min-w-0">
<div class="text-sm font-semibold">账号安全</div>
<div class="mt-0.5 text-xs text-ink-3">两步验证与外部身份</div>
</div>
<span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-[3px] text-[11.5px] text-ink-2"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="totp.data.value?.enabled ? 'bg-ok' : 'bg-ink-3/50'"
/>
两步验证{{ totp.data.value?.enabled ? '已启用' : '未启用' }}
</span>
</div>
<div class="px-4.5 py-1">
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
<div class="min-w-0">
<div class="text-[13px] font-medium">两步验证(TOTP)</div>
<div class="mt-0.5 text-xs text-ink-3">
登录需额外输入验证器动态码;兼容 Google Authenticator / 1Password
</div>
</div>
<NSpin v-if="totp.loading.value" size="small" />
<NButton
v-else-if="totp.data.value?.enabled"
size="tiny"
type="error"
quaternary
@click="showTotpDisable = true"
>
停用
</NButton>
<NButton v-else size="tiny" type="primary" :loading="totpBusy" @click="startTotpSetup">
启用
</NButton>
</div>
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
<div class="min-w-0">
<div class="text-[13px] font-medium">登录凭据</div>
<div class="mt-0.5 text-xs text-ink-3">
用户名 {{ creds.data.value?.username ?? '…' }};修改用户名或密码后需重新登录
</div>
</div>
<NButton size="tiny" quaternary @click="openCredModal">修改</NButton>
</div>
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
<div class="min-w-0">
<div class="text-[13px] font-medium">禁用密码登录</div>
<div class="mt-0.5 text-xs text-ink-3">
{{
hasIdentity || creds.data.value?.passwordLoginDisabled
? '开启后仅外部身份可登录;关闭随时恢复密码登录'
: '至少绑定一个外部身份(OAuth2)后才能开启'
}}
</div>
</div>
<NSwitch
:value="creds.data.value?.passwordLoginDisabled ?? false"
size="small"
:loading="togglingPwLogin"
:disabled="!hasIdentity && !creds.data.value?.passwordLoginDisabled"
@update:value="togglePasswordLogin"
/>
</div>
<div
v-for="it in identities.data.value?.items ?? []"
:key="it.id"
class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5"
>
<div class="min-w-0">
<div class="text-[13px] font-medium">
{{ displayNameOf(it.provider) }} · {{ it.display }}
</div>
<div class="mt-0.5 text-xs text-ink-3">绑定于 {{ fmtTime(it.createdAt) }}</div>
</div>
<NPopconfirm @positive-click="removeIdentity(it.id)">
<template #trigger>
<NButton size="tiny" type="error" quaternary>解绑</NButton>
</template>
解绑后该身份无法再登录面板,确认?
</NPopconfirm>
</div>
<div
v-if="bindableProviders.length"
class="flex flex-wrap items-center gap-2 border-b border-line-soft py-2.5"
>
<NButton
v-for="p in bindableProviders"
:key="p"
size="small"
:loading="binding === p"
@click="bindProvider(p)"
>
绑定 {{ displayNameOf(p) }}
</NButton>
<span class="text-xs text-ink-3">将跳转到对应平台授权</span>
</div>
<div class="py-2.5 text-xs text-ink-3">
外部身份绑定后可直接登录;未绑定的外部身份一律无法登录(不开放注册)
</div>
</div>
</div>
<!-- 登录方式配置:表格化管理,可新增 / 编辑 / 禁用 / 删除 -->
<div class="panel">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
>
<div class="min-w-0">
<div class="text-sm font-semibold">登录方式配置</div>
<div class="mt-0.5 text-xs text-ink-3">配置后登录页与上方绑定入口即出现对应方式</div>
</div>
<NButton size="tiny" @click="openCreateProvider">新增登录方式</NButton>
</div>
<div v-if="providerRows.length" class="overflow-x-auto">
<table class="w-full text-[12.5px]">
<thead>
<tr class="text-left text-xs text-ink-3">
<th class="border-b border-line-soft px-4.5 py-2 font-medium">名称</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">类型</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">Client ID</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">状态</th>
<th class="w-40 border-b border-line-soft px-3 py-2 font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in providerRows" :key="row.provider" class="[&:last-child>td]:border-b-0">
<td class="border-b border-line-soft px-4.5 py-2.5 font-medium">{{ row.name }}</td>
<td class="border-b border-line-soft px-3 py-2.5">
<span class="rounded-full bg-wash px-2 py-px text-[11.5px] text-ink-2">
{{ row.provider }}
</span>
</td>
<td class="border-b border-line-soft px-3 py-2.5">
<span class="mono block max-w-40 truncate text-ink-2">{{ row.clientId }}</span>
</td>
<td class="border-b border-line-soft px-3 py-2.5">
<span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] text-ink-2"
>
<span class="h-1.5 w-1.5 rounded-full" :class="row.disabled ? 'bg-ink-3' : 'bg-ok'" />
{{ row.disabled ? '已禁用' : '启用中' }}
</span>
</td>
<td class="border-b border-line-soft px-3 py-2.5 whitespace-nowrap">
<NButton size="tiny" quaternary @click="openEditProvider(row.provider)">编辑</NButton>
<NButton
size="tiny"
quaternary
@click="toggleProviderDisabled(row.provider, !row.disabled)"
>
{{ row.disabled ? '启用' : '禁用' }}
</NButton>
<NPopconfirm @positive-click="deleteProvider(row.provider)">
<template #trigger>
<NButton size="tiny" type="error" quaternary>删除</NButton>
</template>
删除将清空该方式全部配置( Secret),不可恢复;确认?
</NPopconfirm>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="px-4.5 py-6 text-center text-xs text-ink-3">
尚未配置任何登录方式;点击右上新增登录方式配置 GitHub OIDC
</div>
<FootNote>
IdP / GitHub 应用中填写的授权回调地址(Redirect URI):{{ callbackBase
}}/api/v1/auth/oauth/&lt;github|oidc&gt;/callback;secret ·
需先在网络与地址保存面板地址
</FootNote>
</div>
<!-- 新增 / 编辑登录方式弹窗 -->
<FormModal
:show="showProviderModal"
:title="editingProvider ? '编辑登录方式' : '新增登录方式'"
:width="460"
:submitting="savingOauth"
:submit-disabled="!pFormValid"
submit-text="保存"
@update:show="showProviderModal = $event"
@submit="saveProvider"
>
<FormField label="类型" required>
<div class="flex flex-wrap gap-2">
<button
v-for="t in PROVIDER_TYPES"
:key="t.value"
type="button"
class="rounded-md border px-3 py-1.5 text-xs"
:class="[
pForm.provider === t.value
? 'border-accent bg-accent/10 font-semibold text-accent'
: 'border-line bg-white text-ink-2',
editingProvider ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-ink-3',
]"
:disabled="!!editingProvider"
@click="pForm.provider = t.value"
>
{{ t.label }}
</button>
</div>
</FormField>
<FormField label="显示名称" :hint="`登录页按钮与绑定入口的名称;留空用默认「${providerLabels[pForm.provider]}」`">
<NInput
v-model:value="pForm.displayName"
:maxlength="32"
:placeholder="providerLabels[pForm.provider]"
/>
</FormField>
<FormField
v-if="pForm.provider === 'oidc'"
label="Issuer"
required
hint="IdP 的 issuer 地址,自动 discovery(如 https://accounts.google.com)"
>
<NInput v-model:value="pForm.issuer" placeholder="https://idp.example.com/realms/main" />
</FormField>
<FormField
label="Client ID"
required
:hint="pForm.provider === 'github' ? 'GitHub → Settings → Developer settings → OAuth Apps' : undefined"
>
<NInput
v-model:value="pForm.clientId"
class="mono"
:placeholder="pForm.provider === 'github' ? 'Iv1.xxxxxxxx' : 'oci-portal'"
/>
</FormField>
<FormField
label="Client Secret"
:hint="secretConfigured ? '已配置,留空沿用;服务端加密存储' : '服务端加密存储,保存后不回显'"
>
<NInput v-model:value="pForm.secret" type="password" show-password-on="click" />
</FormField>
<div class="rounded-md bg-wash px-3 py-2 text-xs leading-relaxed break-all text-ink-3">
在平台侧填写回调地址:
<span class="mono">{{ callbackBase }}/api/v1/auth/oauth/{{ pForm.provider }}/callback</span>
</div>
</FormModal>
<!-- 修改登录凭据弹窗 -->
<FormModal
:show="showCredModal"
title="修改登录凭据"
:width="420"
:submitting="savingCred"
:submit-disabled="!credValid"
submit-text="保存并重新登录"
@update:show="showCredModal = $event"
@submit="saveCredentials"
>
<FormField label="用户名" hint="最长 64 字符;保持原值表示不修改">
<NInput v-model:value="credForm.newUsername" :maxlength="64" />
</FormField>
<FormField label="新密码" hint="8-72 字符;留空表示不修改密码">
<NInput
v-model:value="credForm.newPassword"
type="password"
show-password-on="click"
placeholder="留空不修改"
/>
</FormField>
<FormField
v-if="credForm.newPassword"
label="确认新密码"
:feedback="
credForm.confirm && credForm.confirm !== credForm.newPassword ? '两次输入不一致' : undefined
"
>
<NInput v-model:value="credForm.confirm" type="password" show-password-on="click" />
</FormField>
<FormField label="当前密码" required hint="身份确认;保存成功后会退出登录">
<NInput
v-model:value="credForm.currentPassword"
type="password"
show-password-on="click"
placeholder="输入当前登录密码"
/>
</FormField>
</FormModal>
<!-- 停用两步验证弹窗 -->
<FormModal
:show="showTotpDisable"
title="停用两步验证"
:width="380"
danger
submit-text="停用"
:submitting="totpBusy"
:submit-disabled="!totpDisableInput.trim()"
@update:show="showTotpDisable = $event"
@submit="confirmTotpDisable"
>
<FormField label="身份确认" hint="输入验证器当前 6 位验证码,或输入登录密码">
<NInput
v-model:value="totpDisableInput"
type="password"
show-password-on="click"
placeholder="验证码或登录密码"
@keyup.enter="confirmTotpDisable"
/>
</FormField>
<div class="text-xs text-ink-3">停用后登录不再要求动态验证码</div>
</FormModal>
<!-- TOTP 启用引导弹窗 -->
<NModal
v-model:show="showTotpSetup"
preset="card"
title="启用两步验证"
closable
:style="{ width: '360px', maxWidth: 'calc(100vw - 24px)' }"
>
<div v-if="totpPending" class="flex flex-col items-center gap-3">
<!-- 亮色下透明融入弹窗底;暗色下保留浅底(黑模块二维码的可扫性要求) -->
<!-- 暗色下反色渲染(浅模块+透明底),不再垫任何色块 -->
<NQrCode
:value="totpPending.otpauthUri"
:size="160"
:padding="0"
:color="app.dark ? '#f5f4ed' : '#141413'"
background-color="transparent"
error-correction-level="M"
/>
<div class="text-center text-xs text-ink-3">
用验证器 App 扫码添加;无法扫码时手动输入密钥
</div>
<div class="flex w-full items-center gap-2 rounded-md bg-wash px-2.5 py-2">
<span class="mono min-w-0 flex-1 text-[12px] tracking-wide break-all select-all">
{{ totpPending.secret }}
</span>
<NButton size="tiny" quaternary class="flex-none" @click="copyTotpSecret">复制</NButton>
</div>
<NInput
v-model:value="totpActivateCode"
placeholder="输入 6 位验证码"
:maxlength="6"
class="totp-code !w-full"
@keyup.enter="confirmTotpActivate"
/>
<div class="flex items-center gap-2">
<NButton size="small" type="primary" :loading="totpBusy" @click="confirmTotpActivate">
验证并启用
</NButton>
<NButton size="small" quaternary @click="showTotpSetup = false">取消</NButton>
</div>
<div class="text-xs text-ink-3">密钥 10 分钟内有效,关闭弹窗后需重新发起</div>
</div>
</NModal>
</template>
<style scoped>
.totp-code :deep(.n-input__input-el) {
text-align: center;
letter-spacing: 0.3em;
}
</style>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { NButton, NInput, NModal, useMessage } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { testNotifyTemplate, updateNotifyTemplate } from '@/api/settings'
import type { NotifyTemplateItem } from '@/types/api'
const props = defineProps<{
show: boolean
item: NotifyTemplateItem | null
/** 事件行的说明文案,展示在弹窗副标题 */
hint?: string
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
/** 保存成功后携带最新自定义模板(空串=已恢复默认) */
saved: [kind: string, template: string]
}>()
const message = useMessage()
const draft = ref('')
const saving = ref(false)
const testing = ref(false)
const inputRef = ref<InstanceType<typeof NInput> | null>(null)
watch(
() => props.show,
(show) => {
if (show && props.item) draft.value = props.item.template || props.item.defaultTemplate
},
)
const visible = computed({
get: () => props.show,
set: (v: boolean) => emit('update:show', v),
})
/** 变量 chips:事件专属变量 + 公共变量 time */
const varChips = computed(() => [...(props.item?.vars ?? []), 'time'])
/** 包装为占位符文本;直接写在模板插值里会被 Vue 编译器误判提前闭合 */
function varToken(name: string) {
return '{{' + name + '}}'
}
/** 在光标处插入变量占位符;拿不到底层 textarea 时退化为追加 */
function insertVar(name: string) {
const token = `{{${name}}}`
const el = inputRef.value?.textareaElRef
if (!el) {
draft.value += token
return
}
const start = el.selectionStart ?? draft.value.length
const end = el.selectionEnd ?? start
draft.value = draft.value.slice(0, start) + token + draft.value.slice(end)
requestAnimationFrame(() => {
el.focus()
el.selectionStart = el.selectionEnd = start + token.length
})
}
/** 与默认模板一致或留空 → 按空串保存(恢复默认) */
function normalized() {
const text = draft.value.trim()
if (!props.item || text === props.item.defaultTemplate.trim()) return ''
return text
}
async function save() {
if (!props.item) return
saving.value = true
try {
const tpl = normalized()
await updateNotifyTemplate(props.item.kind, tpl)
message.success(tpl ? '模板已保存' : '已恢复默认模板')
emit('saved', props.item.kind, tpl)
visible.value = false
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
async function sendTest() {
if (!props.item) return
testing.value = true
try {
await testNotifyTemplate(props.item.kind, draft.value.trim())
message.success('测试消息已发送,请在 Telegram 查收')
} catch (e) {
message.error(e instanceof Error ? e.message : '测试发送失败')
} finally {
testing.value = false
}
}
</script>
<template>
<NModal
v-model:show="visible"
preset="card"
closable
:style="{ width: '600px', maxWidth: 'calc(100vw - 24px)' }"
>
<template #header>
<div class="flex flex-col gap-1">
<div class="text-[11px] tracking-wider text-ink-3 uppercase">
通知模板 · {{ item?.label }}
</div>
<div class="text-[17px] font-semibold">编辑推送模板</div>
<div class="text-[12.5px] font-normal text-ink-2">
{{ hint ? hint + ' · ' : '' }}Markdown 语法(加粗 / 斜体 / 代码块) Telegram 语义渲染
</div>
</div>
</template>
<div v-if="item" class="flex flex-col gap-3.5">
<div>
<div class="mb-1.5 flex items-baseline gap-2 text-[12.5px] font-medium text-ink-2">
模板内容
<span class="text-[11px] font-normal text-ink-3">留空保存 = 恢复默认模板</span>
</div>
<NInput
ref="inputRef"
v-model:value="draft"
type="textarea"
:rows="6"
class="mono"
:placeholder="item.defaultTemplate"
/>
</div>
<div>
<div class="mb-1.5 flex items-baseline gap-2 text-[12.5px] font-medium text-ink-2">
可用变量
<span class="text-[11px] font-normal text-ink-3">点击插入光标处</span>
</div>
<div class="flex flex-wrap gap-1.5">
<button
v-for="v in varChips"
:key="v"
type="button"
class="mono cursor-pointer rounded-md border border-line bg-white px-2 py-0.5 text-[11.5px] text-ink-2 hover:border-accent hover:text-accent dark:bg-transparent"
@click="insertVar(v)"
>
{{ varToken(v) }}
</button>
</div>
</div>
<div class="text-[11.5px] leading-relaxed text-ink-3">
变量在发送时替换为实际值,未知变量原样保留 · 正文超长自动截断
</div>
<div class="flex items-center gap-2 pt-1">
<NButton size="small" :loading="testing" @click="sendTest">发送测试消息</NButton>
<div class="flex-1" />
<NButton size="small" @click="visible = false">取消</NButton>
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
</div>
</div>
</NModal>
</template>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { NSelect, useMessage } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { attachBootVolume, listInstances } from '@/api/instances'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { shortAd } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { BootVolume } from '@/types/api'
const props = defineProps<{
show: boolean
cfgId: number | null
volume: BootVolume | null
region?: string
}>()
const emit = defineEmits<{ 'update:show': [boolean]; attached: [] }>()
const message = useMessage()
const submitting = ref(false)
const instanceId = ref<string | null>(null)
const instances = useAsync(
() => (props.cfgId === null ? Promise.resolve([]) : listInstances(props.cfgId, props.region)),
false,
)
watch(
() => props.show,
(v) => {
if (v) {
instanceId.value = null
void instances.run()
}
},
)
/** 仅同可用域且 STOPPED 的实例可选,其余给出禁用原因 */
const instanceOptions = computed(() =>
(instances.data.value ?? []).map((i) => {
const sameAd = i.availabilityDomain === props.volume?.availabilityDomain
const stopped = i.lifecycleState === 'STOPPED'
const reason = !sameAd ? ` · ${shortAd(i.availabilityDomain)} 可用域不同` : !stopped ? ` · ${i.lifecycleState}` : ''
return {
label: `${i.displayName}${reason}`,
value: i.id,
disabled: !sameAd || !stopped,
}
}),
)
async function submit() {
if (props.cfgId === null || !props.volume || !instanceId.value) return
submitting.value = true
try {
await attachBootVolume(props.cfgId, instanceId.value, props.volume.id, props.region)
message.success('挂载已提交,稍后自动刷新')
emit('update:show', false)
emit('attached')
} catch (e) {
message.error(e instanceof Error ? e.message : '挂载失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="挂载引导卷"
:width="440"
:submitting="submitting"
:submit-disabled="!instanceId"
submit-text="挂载"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<div class="mb-3 text-[13px] text-ink-2">
<span class="font-medium">{{ volume?.displayName }}</span> 挂载为实例引导盘
</div>
<FormField
label="目标实例"
required
hint="仅可挂到同可用域、处于 STOPPED 的实例;实例开机后从该卷启动"
>
<NSelect
v-model:value="instanceId"
filterable
:options="instanceOptions"
:loading="instances.loading.value"
placeholder="选择实例"
/>
</FormField>
</FormModal>
</template>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { NInput, useMessage } from 'naive-ui'
import AppInputNumber from '@/components/AppInputNumber.vue'
import { computed, reactive, ref, watch } from 'vue'
import { updateBootVolume } from '@/api/bootVolumes'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import type { BootVolume, UpdateBootVolumeRequest } from '@/types/api'
const props = defineProps<{
show: boolean
cfgId: number | null
volume: BootVolume | null
region?: string
}>()
const emit = defineEmits<{ 'update:show': [boolean]; updated: [] }>()
const message = useMessage()
const submitting = ref(false)
const form = reactive({ displayName: '', sizeInGBs: 50, vpusPerGB: 10 })
watch(
() => props.show,
(v) => {
if (v && props.volume)
Object.assign(form, {
displayName: props.volume.displayName,
sizeInGBs: props.volume.sizeInGBs,
vpusPerGB: props.volume.vpusPerGB,
})
},
)
const sizeError = computed(() =>
props.volume && form.sizeInGBs < props.volume.sizeInGBs ? '仅支持扩容,不能缩小' : '',
)
const canSubmit = computed(() => Boolean(form.displayName.trim()) && !sizeError.value)
/** 零值字段不更新:只提交与当前值不同的字段 */
function buildBody(v: BootVolume): UpdateBootVolumeRequest {
const body: UpdateBootVolumeRequest = {}
if (form.displayName.trim() !== v.displayName) body.displayName = form.displayName.trim()
if (form.sizeInGBs !== v.sizeInGBs) body.sizeInGBs = form.sizeInGBs
if (form.vpusPerGB !== v.vpusPerGB) body.vpusPerGB = form.vpusPerGB
return body
}
async function submit() {
if (props.cfgId === null || !props.volume) return
const body = buildBody(props.volume)
if (!Object.keys(body).length) {
emit('update:show', false)
return
}
submitting.value = true
try {
await updateBootVolume(props.cfgId, props.volume.id, body, props.region)
message.success(body.sizeInGBs ? '修改已提交;扩容后需在实例 OS 内扩展文件系统' : '修改已提交')
emit('update:show', false)
emit('updated')
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="编辑引导卷"
:width="440"
:submitting="submitting"
:submit-disabled="!canSubmit"
submit-text="保存修改"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField label="显示名" required>
<NInput v-model:value="form.displayName" placeholder="引导卷显示名" />
</FormField>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField
label="大小(GB"
hint="仅支持扩容,不能缩小;扩容后需在实例 OS 内扩展文件系统"
:error="sizeError"
>
<AppInputNumber
v-model:value="form.sizeInGBs"
:min="volume?.sizeInGBs ?? 47"
:max="32768"
class="w-full"
/>
</FormField>
<FormField label="性能(VPU/GB" hint="10 均衡 / 20 高性能 / 30120 超高性能,步进 10">
<AppInputNumber
v-model:value="form.vpusPerGB"
:min="10"
:max="120"
:step="10"
class="w-full"
/>
</FormField>
</div>
</FormModal>
</template>
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { NInput, NSelect } from 'naive-ui'
import AppInputNumber from '@/components/AppInputNumber.vue'
import { nextCronRunLabel } from '@/composables/cron'
import { computed, reactive, watch } from 'vue'
/** cron 可视化编辑器:常用模式 chips 点选生成表达式,双向同步 v-model:value
* 外部传入的表达式能识别为四种常用形状时回显参数,否则落到自定义模式。 */
const props = defineProps<{ value: string }>()
const emit = defineEmits<{ 'update:value': [string] }>()
type Mode = 'minutes' | 'hourly' | 'daily' | 'weekly' | 'custom'
const MODE_OPTIONS = [
{ label: '每 N 分钟', value: 'minutes' },
{ label: '每小时', value: 'hourly' },
{ label: '每天', value: 'daily' },
{ label: '每周', value: 'weekly' },
{ label: '自定义 cron', value: 'custom' },
]
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const WEEKDAY_OPTIONS = WEEKDAYS.map((label, value) => ({ label, value }))
const state = reactive({
mode: 'minutes' as Mode,
n: 5,
minute: 0,
hour: 3,
weekday: 1,
custom: '',
})
function build(): string {
switch (state.mode) {
case 'minutes':
return `*/${state.n} * * * *`
case 'hourly':
return `${state.minute} * * * *`
case 'daily':
return `${state.minute} ${state.hour} * * *`
case 'weekly':
return `${state.minute} ${state.hour} * * ${state.weekday}`
default:
return state.custom.trim()
}
}
/** 尝试把表达式识别为四种常用形状,命中则回填参数 */
function parse(expr: string) {
const p = expr.trim().split(/\s+/)
const num = (s: string) => /^\d+$/.test(s)
const every = (n: number) => p.slice(n).every((s) => s === '*')
if (p.length !== 5) {
Object.assign(state, { mode: 'custom', custom: expr })
return
}
const m = /^\*\/(\d+)$/.exec(p[0])
if (m && every(1)) Object.assign(state, { mode: 'minutes', n: Number(m[1]) })
else if (num(p[0]) && every(1)) Object.assign(state, { mode: 'hourly', minute: Number(p[0]) })
else if (num(p[0]) && num(p[1]) && every(2))
Object.assign(state, { mode: 'daily', minute: Number(p[0]), hour: Number(p[1]) })
else if (num(p[0]) && num(p[1]) && p[2] === '*' && p[3] === '*' && num(p[4]))
Object.assign(state, {
mode: 'weekly',
minute: Number(p[0]),
hour: Number(p[1]),
weekday: Number(p[4]),
})
else Object.assign(state, { mode: 'custom', custom: expr })
}
const pad = (n: number) => String(n).padStart(2, '0')
/** 生成表达式的中文描述;自定义且不可识别时提示格式 */
const description = computed(() => {
switch (state.mode) {
case 'minutes':
return `${state.n} 分钟执行一次`
case 'hourly':
return `每小时第 ${state.minute} 分执行`
case 'daily':
return `每天 ${pad(state.hour)}:${pad(state.minute)} 执行`
case 'weekly':
return `${WEEKDAYS[state.weekday]} ${pad(state.hour)}:${pad(state.minute)} 执行`
default:
return customValid.value ? '按自定义表达式执行' : '5 段格式:分 时 日 月 周'
}
})
const customValid = computed(
() => state.mode !== 'custom' || state.custom.trim().split(/\s+/).length === 5,
)
/** 下次执行时间预览;表达式非法时为空 */
const nextRun = computed(() => (customValid.value ? nextCronRunLabel(build()) : ''))
// 参数变化 → 同步 v-model;外部值变化(回显)→ 反解析参数
watch(
() => build(),
(v) => {
if (v !== props.value) emit('update:value', v)
},
{ immediate: true },
)
watch(
() => props.value,
(v) => {
// 空值不反解析:初始 emit 尚未回流 / 表单重置时保持当前模式
if (v && v !== build()) parse(v)
},
{ immediate: true },
)
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<button
v-for="m in MODE_OPTIONS"
:key="m.value"
type="button"
class="cursor-pointer rounded-full border px-3 py-[3.5px] text-xs"
:class="
state.mode === m.value
? 'border-ink bg-ink font-medium text-white'
: 'border-line bg-white text-ink-2 hover:border-ink-3'
"
@click="state.mode = m.value as Mode"
>
{{ m.label }}
</button>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<template v-if="state.mode === 'minutes'">
<span class="text-[12.5px] text-ink-3">间隔</span>
<AppInputNumber v-model:value="state.n" :min="1" :max="59" size="small" class="!w-24" />
<span class="text-[12.5px] text-ink-3">分钟</span>
</template>
<template v-else-if="state.mode === 'hourly'">
<span class="text-[12.5px] text-ink-3"></span>
<AppInputNumber v-model:value="state.minute" :min="0" :max="59" size="small" class="!w-24" />
<span class="text-[12.5px] text-ink-3"></span>
</template>
<template v-else-if="state.mode === 'daily' || state.mode === 'weekly'">
<NSelect
v-if="state.mode === 'weekly'"
v-model:value="state.weekday"
:options="WEEKDAY_OPTIONS"
size="small"
class="!w-24"
/>
<AppInputNumber v-model:value="state.hour" :min="0" :max="23" size="small" class="!w-24" />
<span class="text-[12.5px] text-ink-3"></span>
<AppInputNumber v-model:value="state.minute" :min="0" :max="59" size="small" class="!w-24" />
<span class="text-[12.5px] text-ink-3"></span>
</template>
<NInput
v-else
v-model:value="state.custom"
size="small"
class="mono !w-40"
placeholder="*/5 * * * *"
/>
<span
class="flex items-center gap-2 rounded-md border border-line-soft bg-wash px-2.5 py-1 text-xs"
:class="customValid ? 'text-ink-3' : 'text-err'"
>
<span class="mono rounded border border-line bg-white px-1.5 py-px font-medium text-ink-2">
{{ build() || '—' }}
</span>
<span v-if="nextRun">下次执行 {{ nextRun }}</span>
<span v-else>{{ description }}</span>
</span>
</div>
</div>
</template>
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { NButton, NDropdown, NTooltip } from 'naive-ui'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import StatusBadge from '@/components/StatusBadge.vue'
import TaskTypeIcon from '@/components/task/TaskTypeIcon.vue'
import { cronText, parseSnatch, scopeCfgIds, STATUS_META, TYPE_LABEL } from '@/components/task/taskDisplay'
import { fmtRelative } from '@/composables/useFormat'
import type { Task } from '@/types/api'
const props = defineProps<{ task: Task; cfgAlias?: string }>()
const emit = defineEmits<{ run: []; toggle: []; edit: []; remove: [] }>()
const router = useRouter()
const snatch = computed(() => parseSnatch(props.task))
const status = computed(() => STATUS_META[props.task.status])
/** 副标题:抢机显示 租户 · shape · 规格;测活 / 成本显示类型与租户范围 */
const subtitle = computed(() => {
const s = snatch.value
if (s) {
const spec = s.ocpus ? `${s.ocpus} OCPU / ${s.memoryInGBs} GB` : ''
const boot = s.bootGb ? ` / ${s.bootGb} GB` : ''
return [props.cfgAlias || `租户 #${s.cfgId}`, s.shape, spec + boot].filter(Boolean).join(' · ')
}
if (props.task.type === 'ai_probe')
return 'AI探测 · 跟随号池渠道(系统自动管理,渠道清空自动暂停)'
const ids = scopeCfgIds(props.task)
return `${TYPE_LABEL[props.task.type]} · ${ids.length ? `${ids.length} 个租户` : '全部租户'}`
})
const pct = computed(() => {
const s = snatch.value
if (!s || !s.total) return 0
return Math.min(100, Math.round((s.done / s.total) * 100))
})
/** 调度展示:常见 cron 给中文描述并附原表达式,冷门形态只显示表达式 */
const scheduleText = computed(() => cronText(props.task.cronExpr))
/** AI 探测任务由系统随渠道增删自动建/删,不提供任何手动管理入口 */
const moreOptions = computed(() =>
props.task.type === 'ai_probe'
? []
: [
{ label: '编辑', key: 'edit' },
{ label: '删除', key: 'remove' },
],
)
function onMore(key: string) {
if (key === 'edit') emit('edit')
if (key === 'remove') emit('remove')
}
function goDetail() {
void router.push({ name: 'task-detail', params: { taskId: props.task.id } })
}
</script>
<template>
<div
class="panel cursor-pointer px-5 py-4 transition-colors hover:bg-row-hover/40"
@click="goDetail"
>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-none items-center justify-center rounded-[9px] bg-wash">
<TaskTypeIcon :type="task.type" class="h-5 w-5 text-accent" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2.5">
<span class="truncate text-[15px] font-semibold">{{ task.name }}</span>
<StatusBadge :kind="status.kind" :label="status.label" />
</div>
<div class="mono mt-0.5 truncate text-xs text-ink-3">{{ subtitle }}</div>
</div>
<div class="flex flex-none items-center gap-0.5" @click.stop>
<NTooltip v-if="task.status !== 'succeeded'">
<template #trigger>
<NButton size="small" quaternary circle @click="emit('toggle')">
<svg
class="h-[15px] w-[15px]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path v-if="task.status === 'active'" d="M10 5v14M14 5v14" />
<path v-else d="M6 4l14 8-14 8z" />
</svg>
</NButton>
</template>
{{ task.status === 'active' ? '暂停调度' : task.status === 'failed' ? '重新启用' : '恢复调度' }}
</NTooltip>
<NTooltip>
<template #trigger>
<NButton size="small" quaternary circle @click="emit('run')">
<svg
class="h-[15px] w-[15px]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
</NButton>
</template>
立即执行一次
</NTooltip>
<NTooltip>
<template #trigger>
<NButton size="small" quaternary circle @click="goDetail">
<svg
class="h-[15px] w-[15px]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path d="M2 12s3.5-6.5 10-6.5S22 12 22 12s-3.5 6.5-10 6.5S2 12 2 12z" />
<circle cx="12" cy="12" r="2.6" />
</svg>
</NButton>
</template>
详情与事件流
</NTooltip>
<NDropdown v-if="moreOptions.length" :options="moreOptions" trigger="click" @select="onMore">
<NButton size="small" quaternary circle>
<svg class="h-[15px] w-[15px]" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="12" cy="19" r="1.6" />
</svg>
</NButton>
</NDropdown>
</div>
</div>
<div class="mt-3.5 flex flex-wrap items-center gap-x-10 gap-y-2.5">
<div v-if="snatch" class="flex w-full max-w-105 min-w-70 items-center gap-3">
<span class="flex-none text-xs text-ink-3">创建进度</span>
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-line-soft">
<div
class="h-full rounded-full transition-all"
:style="{
width: pct + '%',
background: task.status === 'succeeded' ? 'var(--color-ok)' : 'var(--color-accent)',
}"
/>
</div>
<span class="mono flex-none text-[13px]">
<b :style="{ color: task.status === 'succeeded' ? 'var(--color-ok)' : 'var(--color-accent)' }">{{ snatch.done }}</b>
/ {{ snatch.total }}
</span>
</div>
<div class="w-20">
<div class="text-xs text-ink-3">{{ snatch ? '已尝试' : '已运行' }}</div>
<div class="mono mt-0.5 text-[13px] text-ink-2">{{ task.runCount }} </div>
</div>
<div class="min-w-28">
<div class="text-xs text-ink-3">调度</div>
<div class="mt-0.5 flex items-baseline gap-1.5 text-[13px] text-ink-2">
<span v-if="scheduleText">{{ scheduleText }}</span>
<span class="mono" :class="scheduleText ? 'text-xs text-ink-3' : ''">{{ task.cronExpr }}</span>
</div>
</div>
<div class="w-20">
<div class="text-xs text-ink-3">上次运行</div>
<div class="mt-0.5 text-[13px] text-ink-2">{{ fmtRelative(task.lastRunAt) }}</div>
</div>
</div>
<div
v-if="task.lastError"
class="mt-3 flex items-center gap-2.5 rounded-md border border-err/30 bg-err/10 px-3 py-2"
:title="task.lastError"
>
<span class="flex-none text-xs font-medium text-err">最近错误</span>
<span class="mono line-clamp-2 min-w-0 flex-1 text-xs leading-relaxed text-err">
{{ task.lastError }}
</span>
</div>
</div>
</template>
+345
View File
@@ -0,0 +1,345 @@
<script setup lang="ts">
import { NInput, NSelect, useMessage } from 'naive-ui'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { listCachedCompartments, listCachedRegions, listConfigs } from '@/api/configs'
import { createTask, updateTask } from '@/api/tasks'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import FormSection from '@/components/FormSection.vue'
import InstanceSpecForm from '@/components/instance/InstanceSpecForm.vue'
import CronEditor from '@/components/task/CronEditor.vue'
import TaskTypeIcon from '@/components/task/TaskTypeIcon.vue'
import TenantPicker from '@/components/TenantPicker.vue'
import { defaultResourceName } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { Task, TaskType } from '@/types/api'
/** task 传入即为编辑模式:回填全部字段,任务类型锁定不可切换 */
const props = defineProps<{ show: boolean; task?: Task | null }>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const isEdit = computed(() => !!props.task)
/** 任务类型选择卡:新建时三选一,编辑时锁定当前类型 */
const TYPE_CARDS: { value: TaskType; title: string; desc: string }[] = [
{ value: 'health_check', title: '定时测活', desc: '周期性验证租户凭据,失效推送通知' },
{ value: 'cost', title: '每日成本同步', desc: '拉取 Usage 账单汇总到总览' },
{ value: 'snatch', title: '抢机', desc: '容量不足时反复尝试创建,抢满自停' },
]
const message = useMessage()
const submitting = ref(false)
const specForm = ref<InstanceType<typeof InstanceSpecForm> | null>(null)
const blank = {
type: 'health_check' as TaskType,
name: '',
cronExpr: '',
checkCfgIds: [] as number[],
snatchCfgId: null as number | null,
snatchRegion: '',
snatchCompartmentId: '',
count: 1,
displayName: '',
}
const form = reactive({ ...blank })
/** 编辑回填期间抑制 snatchCfgId watch 的级联清空 */
let applying = false
watch(
() => props.show,
async (v) => {
if (!v) return
if (!props.task) {
Object.assign(form, blank, { checkCfgIds: [], displayName: defaultResourceName('snatch') })
return
}
const t = props.task
const p = JSON.parse(t.payload || '{}') as Record<string, unknown>
applying = true
Object.assign(form, blank, {
type: t.type,
name: t.name,
cronExpr: t.cronExpr,
checkCfgIds: Array.isArray(p.ociConfigIds) ? p.ociConfigIds : [],
})
if (t.type === 'snatch') {
const inst = (p.instance ?? {}) as Record<string, unknown>
form.snatchCfgId = typeof p.ociConfigId === 'number' ? p.ociConfigId : null
form.count = typeof p.count === 'number' ? p.count : 1
form.displayName = String(inst.displayName ?? '')
form.snatchRegion = typeof inst.region === 'string' ? inst.region : ''
form.snatchCompartmentId = typeof inst.compartmentId === 'string' ? inst.compartmentId : ''
await nextTick() // 等 snatch tab 与 SpecForm 挂载
await nextTick()
specForm.value?.applySpec(inst)
}
await nextTick()
applying = false
},
)
const configs = useAsync(listConfigs)
const typeLabel = computed(
() => TYPE_CARDS.find((c) => c.value === form.type)?.title ?? form.type,
)
/** 类型卡点击:编辑态锁定;切换时保持已填的名称与计划 */
function pickType(t: TaskType) {
if (isEdit.value || form.type === t) return
form.type = t
}
const snatchCfg = computed(
() => configs.data.value?.find((c) => c.id === form.snatchCfgId) ?? null,
)
/** 抢机目标租户的区域 / 区间数据源(与全局作用域选择器同一套缓存接口) */
const regions = useAsync(
() =>
form.snatchCfgId === null
? Promise.resolve([])
: listCachedRegions(form.snatchCfgId).catch(() => []),
false,
)
const compartments = useAsync(
() =>
form.snatchCfgId === null
? Promise.resolve([])
: listCachedCompartments(form.snatchCfgId).catch(() => []),
false,
)
watch(
() => form.snatchCfgId,
() => {
if (!applying) {
form.snatchRegion = ''
form.snatchCompartmentId = ''
}
void regions.run()
void compartments.run()
},
)
// 区域加载后默认主区域;未开启多区域的租户锁定唯一默认区域
watch(
() => regions.data.value,
(subs) => {
if (!subs?.length) return
if (!form.snatchRegion || !subs.some((s) => s.name === form.snatchRegion))
form.snatchRegion = subs.find((s) => s.isHomeRegion)?.name ?? subs[0].name
},
)
const regionDisabled = computed(() => !snatchCfg.value?.multiRegion)
const compDisabled = computed(() => !snatchCfg.value?.multiCompartment)
const regionOptions = computed(() =>
(regions.data.value ?? []).map((s) => ({
label: s.isHomeRegion ? `${s.alias} · 主` : s.alias,
value: s.name,
})),
)
const compOptions = computed(() => [
{ label: `${snatchCfg.value?.tenancyName || '租户'}(根)`, value: '' },
...(compartments.data.value ?? []).map((c) => ({ label: c.name, value: c.id })),
])
const canSubmit = computed(() => {
if (!form.name.trim() || !form.cronExpr.trim()) return false
if (form.type !== 'snatch') return true
if (form.snatchCfgId === null || !form.displayName.trim()) return false
return specForm.value?.valid ?? false
})
function buildPayload(): Record<string, unknown> {
if (form.type !== 'snatch') return { ociConfigIds: form.checkCfgIds }
return {
ociConfigId: form.snatchCfgId,
count: form.count,
instance: {
displayName: form.displayName.trim(),
region: form.snatchRegion || undefined,
compartmentId: form.snatchCompartmentId || undefined,
...(specForm.value?.buildSpec() ?? {}),
},
}
}
async function submit() {
submitting.value = true
try {
if (props.task) {
await updateTask(props.task.id, {
name: form.name.trim(),
cronExpr: form.cronExpr.trim(),
payload: buildPayload(),
})
message.success(`任务「${form.name.trim()}」已更新`)
} else {
const created = await createTask({
name: form.name.trim(),
type: form.type,
cronExpr: form.cronExpr.trim(),
payload: buildPayload(),
})
message.success(`任务「${created.name}」已创建并进入调度`)
}
emit('update:show', false)
emit('created')
} catch (e) {
message.error(e instanceof Error ? e.message : isEdit.value ? '更新失败' : '创建失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
:title="isEdit ? '编辑后台任务' : '新建后台任务'"
:width="600"
:submitting="submitting"
:submit-disabled="!canSubmit"
:submit-text="isEdit ? '保存' : '创建并调度'"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<template v-if="isEdit" #header>
<div class="flex items-center gap-2.5">
<span>编辑后台任务</span>
<span
class="flex items-center gap-1 rounded-full border border-line bg-wash px-2.5 py-0.5 text-[11px] font-medium text-ink-2"
>
<svg class="h-2.5 w-2.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
{{ typeLabel }} · 类型不可改
</span>
</div>
</template>
<FormField v-if="!isEdit" label="任务类型" required>
<div class="grid grid-cols-3 gap-2.5 max-md:grid-cols-1">
<button
v-for="c in TYPE_CARDS"
:key="c.value"
type="button"
class="cursor-pointer rounded-lg border bg-white p-3 text-left"
:class="
form.type === c.value
? 'border-accent shadow-[0_0_0_1px_var(--color-accent)]'
: 'border-line hover:border-ink-3'
"
@click="pickType(c.value)"
>
<span
class="mb-2 flex h-7 w-7 items-center justify-center rounded-md"
:class="form.type === c.value ? 'bg-accent/12 text-accent' : 'bg-wash text-ink-2'"
>
<TaskTypeIcon :type="c.value" class="h-3.5 w-3.5" />
</span>
<div class="text-[12.5px] font-semibold">{{ c.title }}</div>
<div class="mt-0.5 text-[11px] leading-snug text-ink-3">{{ c.desc }}</div>
</button>
</div>
</FormField>
<FormField label="任务名" required>
<NInput v-model:value="form.name" placeholder="如「每日全量测活」" />
</FormField>
<FormField
label="执行计划"
required
hint="按服务器本地时区调度;选「自定义 cron」可直接书写 5 段表达式"
>
<CronEditor v-model:value="form.cronExpr" />
</FormField>
<template v-if="form.type === 'health_check'">
<FormField label="测活范围" hint="留空表示全部租户;搜索同时匹配别名与租户名称">
<TenantPicker
v-model:values="form.checkCfgIds"
multiple
clearable
:configs="configs.data.value ?? []"
:loading="configs.loading.value"
placeholder="全部租户"
/>
</FormField>
</template>
<template v-else-if="form.type === 'cost'">
<FormField
label="同步范围"
hint="留空表示全部租户;免费类别租户执行时自动跳过,不发起 Usage API 请求"
>
<TenantPicker
v-model:values="form.checkCfgIds"
multiple
clearable
:configs="configs.data.value ?? []"
:loading="configs.loading.value"
placeholder="全部租户"
/>
</FormField>
</template>
<template v-else>
<FormSection title="目标" />
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="目标租户" required>
<TenantPicker
v-model:value="form.snatchCfgId"
:configs="configs.data.value ?? []"
:loading="configs.loading.value"
/>
</FormField>
<FormField label="目标台数" hint="每次调度按剩余台数尝试,抢满后任务自动停止">
<AppInputNumber v-model:value="form.count" :min="1" :max="10" class="w-full" />
</FormField>
</div>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="区域" hint="未开启多区域的租户锁定默认区域">
<NSelect
v-model:value="form.snatchRegion"
:options="regionOptions"
:loading="regions.loading.value"
:disabled="regionDisabled"
:consistent-menu-width="false"
/>
</FormField>
<FormField label="区间" hint="未开启多区间的租户锁定根 compartment">
<NSelect
v-model:value="form.snatchCompartmentId"
:options="compOptions"
:loading="compartments.loading.value"
:disabled="compDisabled"
/>
</FormField>
</div>
<FormSection title="实例规格(与创建实例一致)" />
<FormField label="实例名" required>
<NInput v-model:value="form.displayName" placeholder="my-a1" />
</FormField>
<InstanceSpecForm
ref="specForm"
:cfg-id="form.snatchCfgId"
:region="form.snatchRegion || undefined"
:compartment-id="form.snatchCompartmentId || undefined"
ad-hint="留空 = 自动轮询区域全部可用域,每次执行换下一个(ad-1 ad-2 循环)"
/>
<div class="mt-2 text-xs leading-relaxed text-ink-3">
VCN 自动创建时复用或创建oci-portal-auto-vcn 公网子网 ·
每次调度按剩余台数尝试创建容量不足自动等待下次执行
</div>
</template>
</FormModal>
</template>
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { TaskType } from '@/types/api'
defineProps<{ type: TaskType }>()
</script>
<template>
<!-- 抢机同心圆靶标 -->
<svg
v-if="type === 'snatch'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="4.5" />
<circle cx="12" cy="12" r="1" fill="currentColor" />
</svg>
<!-- 测活心电波 -->
<svg
v-else-if="type === 'health_check'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 12h4l2.2-5.4L14.4 18 16.6 12H20" />
</svg>
<!-- AI 探测四芒星火花 -->
<svg
v-else-if="type === 'ai_probe'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>
<!-- 成本折线图 -->
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M4 19V5M4 19h16" stroke-linecap="round" />
<path d="M7 15l3.5-4 3 2.5L18 8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</template>
+91
View File
@@ -0,0 +1,91 @@
import type { Task, TaskStatus, TaskType } from '@/types/api'
/** 抢机任务 payload 的展示视图(count 为剩余台数,totalCount 为目标台数) */
export interface SnatchInfo {
cfgId: number
total: number
remaining: number
done: number
shape: string
ocpus?: number
memoryInGBs?: number
bootGb?: number
region?: string
displayName?: string
}
export function parseSnatch(task: Task): SnatchInfo | null {
if (task.type !== 'snatch') return null
try {
const p = JSON.parse(task.payload || '{}') as Record<string, unknown>
const inst = (p.instance ?? {}) as Record<string, unknown>
const remaining = Math.max(0, Number(p.count ?? 0))
// 旧任务无 totalCount:已完成的按剩余 0 处理,进行中的退化为 remaining
const total = Number(p.totalCount ?? 0) || (task.status === 'succeeded' ? remaining || 1 : remaining || 1)
const done = task.status === 'succeeded' ? total : Math.max(0, total - remaining)
return {
cfgId: Number(p.ociConfigId ?? 0),
total,
remaining,
done,
shape: String(inst.shape ?? '—'),
ocpus: inst.ocpus as number | undefined,
memoryInGBs: inst.memoryInGBs as number | undefined,
bootGb: inst.bootVolumeSizeGBs as number | undefined,
region: inst.region as string | undefined,
displayName: inst.displayName as string | undefined,
}
} catch {
return null
}
}
/** 测活 / 成本任务的租户范围(空 = 全部租户) */
export function scopeCfgIds(task: Task): number[] {
try {
const p = JSON.parse(task.payload || '{}') as Record<string, unknown>
return Array.isArray(p.ociConfigIds) ? (p.ociConfigIds as number[]) : []
} catch {
return []
}
}
/** 常见 cron 形态的中文描述;识别不了返回空串(界面回退只显示表达式) */
export function cronText(expr: string): string {
const p = expr.trim().split(/\s+/)
if (p.length !== 5) return ''
const [min, hour, dom, mon, dow] = p
if (dom !== '*' || mon !== '*') return ''
const at = (h: string, m: string) => `${h.padStart(2, '0')}:${m.padStart(2, '0')}`
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
if (dow !== '*') {
const d = Number(dow)
if (!/^\d$/.test(dow) || d > 6 || !/^\d+$/.test(hour) || !/^\d+$/.test(min)) return ''
return `每周${weekdays[d]} ${at(hour, min)}`
}
const minStep = /^\*\/(\d+)$/.exec(min)
if (minStep && hour === '*') return Number(minStep[1]) === 1 ? '每分钟' : `${minStep[1]} 分钟`
if (min === '*' && hour === '*') return '每分钟'
const hourStep = /^\*\/(\d+)$/.exec(hour)
if (hourStep && /^\d+$/.test(min)) return `${hourStep[1]} 小时`
if (/^\d+$/.test(min) && hour === '*') return `每小时第 ${Number(min)}`
if (/^\d+$/.test(min) && /^\d+$/.test(hour)) return `每天 ${at(hour, min)}`
return ''
}
export const TYPE_LABEL: Record<TaskType, string> = {
snatch: '抢机',
health_check: '测活',
cost: '成本',
ai_probe: 'AI探测',
}
export const STATUS_META: Record<
TaskStatus,
{ label: string; kind: 'run' | 'warn' | 'prov' | 'term' }
> = {
active: { label: '运行中', kind: 'run' },
paused: { label: '已暂停', kind: 'warn' },
succeeded: { label: '已完成', kind: 'prov' },
failed: { label: '已熔断', kind: 'term' },
}
+277
View File
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { NButton, NDataTable, NModal, NSelect, useMessage, type DataTableColumns } from 'naive-ui'
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
import { getAuditEventDetail, listAuditEvents } from '@/api/tenant'
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
import FootNote from '@/components/FootNote.vue'
import JsonBlock from '@/components/JsonBlock.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { eventLabel } from '@/composables/useEventLabel'
import { fmtTime } from '@/composables/useFormat'
import type { AuditEvent } from '@/types/api'
const props = defineProps<{ cfgId: number }>()
const message = useMessage()
const hours = ref(24)
const hourOptions = [
{ label: '近 1 小时', value: 1 },
{ label: '近 6 小时', value: 6 },
{ label: '近 24 小时', value: 24 },
{ label: '近 72 小时', value: 72 },
]
/** 受控分页:字面量对象会在重渲染时被重建导致页大小切换失效 */
const pagination = reactive({
page: 1,
pageSize: 20,
showSizePicker: true,
pageSizes: [20, 50, 100],
onUpdatePage: (p: number) => {
pagination.page = p
},
onUpdatePageSize: (ps: number) => {
pagination.pageSize = ps
pagination.page = 1
},
})
const loading = ref(false)
const loadingMore = ref(false)
const error = ref('')
const rows = ref<AuditEvent[]>([])
const truncated = ref(false)
const nextPage = ref('')
// 本次查询的绝对时间窗:续查游标绑定查询参数,必须原样带回
const win = ref<{ start: string; end: string } | null>(null)
async function load() {
loading.value = true
error.value = ''
try {
const r = await listAuditEvents(props.cfgId, { hours: hours.value })
rows.value = r.items
truncated.value = r.truncated
nextPage.value = r.nextPage ?? ''
win.value = { start: r.start, end: r.end }
pagination.page = 1
} catch (e) {
error.value = e instanceof Error ? e.message : '查询失败'
} finally {
loading.value = false
}
}
/** 截断续查:同一绝对窗带游标断点续翻,追加去重后按时间重排 */
async function loadMore() {
if (!win.value || !nextPage.value || loadingMore.value) return
loadingMore.value = true
try {
const r = await listAuditEvents(props.cfgId, { ...win.value, page: nextPage.value })
const seen = new Set(rows.value.map((e) => e.eventId))
rows.value = [...rows.value, ...r.items.filter((e) => !seen.has(e.eventId))].sort((a, b) =>
(b.eventTime ?? '').localeCompare(a.eventTime ?? ''),
)
truncated.value = r.truncated
nextPage.value = r.nextPage ?? ''
} catch (e) {
// 追加失败不清空已有列表;游标过期时提示重新查询
message.error(e instanceof Error ? e.message : '继续加载失败,请刷新重新查询')
} finally {
loadingMore.value = false
}
}
onMounted(() => void load())
// 切换时间窗自动重查并回到第一页
watch(hours, () => void load())
// ---- 行点击详情:原始 JSON 懒取(命中服务端缓存秒开,过期走小窗重查) ----
const detail = ref<AuditEvent | null>(null)
const showDetail = ref(false)
const detailRaw = ref<unknown>(null)
const rawLoading = ref(false)
const rawGone = ref(false)
function rowProps(row: AuditEvent) {
return {
style: 'cursor: pointer',
onClick: () => {
detail.value = row
showDetail.value = true
void loadRaw(row)
},
}
}
async function loadRaw(row: AuditEvent) {
detailRaw.value = null
rawGone.value = false
if (!row.eventId || !row.eventTime) {
rawGone.value = true
return
}
rawLoading.value = true
try {
const { raw } = await getAuditEventDetail(props.cfgId, {
eventId: row.eventId,
eventTime: row.eventTime,
})
detailRaw.value = raw
} catch {
rawGone.value = true
} finally {
rawLoading.value = false
}
}
/** 摘要头 chips:操作者 / IP / 时间;完整长值仍在字段网格与原始 JSON 中 */
const heroChips = computed<HeroChip[]>(() => {
const d = detail.value
if (!d) return []
return [
{ label: '操作者', value: d.principalName || '—' },
{ label: 'IP', value: d.ipAddress || '—', mono: true },
{ label: '时间', value: fmtTime(d.eventTime) },
]
})
/** 字段网格:头部未覆盖的补充字段;空值以「—」占位 */
const detailRows = computed<DetailRow[]>(() => {
const d = detail.value
if (!d) return []
return [
{ label: '资源', value: d.resourceName, mono: true },
{ label: '区间', value: d.compartmentName },
{ label: '请求', value: `${d.requestAction} ${d.requestPath}`.trim(), mono: true },
{ label: '来源', value: d.source },
]
})
/** 主文本 + 次行小字的两行单元格;空值以「—」占位 */
function twoLine(main: string, sub: string) {
return h('div', { class: 'min-w-0' }, [
h('div', { class: 'truncate text-[13px]' }, main || '—'),
h('div', { class: 'mt-px truncate text-xs text-ink-3' }, sub || '—'),
])
}
const columns: DataTableColumns<AuditEvent> = [
{
title: '时间',
key: 'eventTime',
width: 145,
render: (row) =>
h('span', { class: 'text-[13px] text-ink-2 tabular-nums whitespace-nowrap' }, fmtTime(row.eventTime)),
},
{
title: '操作',
key: 'eventName',
minWidth: 200,
render: (row) => twoLine(row.eventName, row.source),
},
{
// 固定列宽让 truncate 生效:资源路径可长达数百字符,完整值点行查看
title: '资源',
key: 'resourceName',
width: 200,
render: (row) => twoLine(row.resourceName, row.compartmentName),
},
{
title: '操作者',
key: 'principalName',
width: 180,
render: (row) => twoLine(row.principalName, row.ipAddress),
},
{
title: '结果',
key: 'status',
width: 90,
render: (row) => {
if (!row.status) return h('span', { class: 'text-[13px] text-ink-3' }, '—')
const failed = Number(row.status) >= 400
return h(StatusBadge, { kind: failed ? 'term' : 'run', label: row.status, dot: false })
},
},
]
</script>
<template>
<div class="panel">
<div class="flex flex-wrap items-center gap-2.5 border-b border-line-soft px-4.5 py-3">
<div class="text-sm font-semibold">审计日志</div>
<template v-if="truncated">
<StatusBadge kind="warn" label="结果已截断" />
<NButton v-if="nextPage" size="tiny" :loading="loadingMore" @click="loadMore">
继续加载更早
</NButton>
</template>
<div class="flex-1" />
<div class="w-32 max-md:w-full">
<NSelect v-model:value="hours" size="small" :options="hourOptions" />
</div>
<NButton size="small" :loading="loading" @click="load()">刷新</NButton>
</div>
<div
v-if="error"
class="px-5 py-10 text-center text-[13px] text-ink-3"
>
{{ error }}
</div>
<NDataTable
v-else
:columns="columns"
:data="rows"
:loading="loading"
:pagination="pagination"
:scroll-x="820"
:max-height="480"
:row-props="rowProps"
/>
<FootNote>
OCI Audit 免费且默认开启事件保留 365 无需在云端做任何配置 ·
实时查询不入库事件通常延迟数分钟可见 ·
已默认过滤遥测噪声SummarizeMetricsData与内网地址发起的服务互调 ·
单次最多翻 10 截断时点继续加载更早断点续查 · 点击行查看完整详情
</FootNote>
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '720px', maxWidth: 'calc(100vw - 24px)' }">
<template #header>
<DetailHero
v-if="detail"
kind="审计事件 · OCI Audit"
:title="eventLabel(detail.eventName) || detail.eventName"
:sub="`${detail.eventName} · ${detail.source}`"
:chips="heroChips"
>
<StatusBadge
v-if="detail.status"
:kind="Number(detail.status) >= 400 ? 'term' : 'run'"
:label="`${Number(detail.status) >= 400 ? '失败' : '成功'} ${detail.status}`"
:dot="false"
/>
</DetailHero>
</template>
<div v-if="detail" class="flex flex-col gap-3.5">
<DetailFieldList :rows="detailRows" :cols="2" />
<JsonBlock
v-if="detailRaw"
:value="detailRaw"
title="原始 JSON"
meta="来自 Audit SDK"
max-height="36vh"
wide
/>
<div v-else-if="rawLoading" class="rounded-[10px] border border-line-soft px-3.5 py-3 text-xs text-ink-3">
正在取回原始事件
</div>
<div v-else-if="rawGone" class="rounded-[10px] border border-line-soft px-3.5 py-3 text-xs text-ink-3">
原始事件已不可取回超出缓存与重查窗口以上为列表精简字段
</div>
</div>
</NModal>
</div>
</template>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { NSelect, NSpin } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { getCosts } from '@/api/analytics'
import CostChart from '@/components/CostChart.vue'
import EmptyCard from '@/components/EmptyCard.vue'
import FootNote from '@/components/FootNote.vue'
import { useAsync } from '@/composables/useAsync'
const props = defineProps<{ cfgId: number }>()
const rangeDays = ref(14)
const rangeOptions = [
{ label: '近 14 天', value: 14 },
{ label: '近 30 天', value: 30 },
]
const costs = useAsync(() => getCosts(props.cfgId, { granularity: 'DAILY', groupBy: 'service' }))
watch(rangeDays, () => void costs.run())
const daily = computed(() => {
const byDay = new Map<string, number>()
for (const item of costs.data.value ?? []) {
const day = item.timeStart.slice(5, 10)
byDay.set(day, (byDay.get(day) ?? 0) + item.computedAmount)
}
const labels = [...byDay.keys()].sort()
return { labels, values: labels.map((l) => +(byDay.get(l) ?? 0).toFixed(2)) }
})
const total = computed(() => daily.value.values.reduce((s, v) => s + v, 0))
const byService = computed(() => {
const map = new Map<string, number>()
for (const item of costs.data.value ?? []) {
map.set(item.groupValue, (map.get(item.groupValue) ?? 0) + item.computedAmount)
}
const rows = [...map.entries()].sort((a, b) => b[1] - a[1])
const max = rows[0]?.[1] ?? 1
return rows.map(([name, amount]) => ({
name,
amount,
pct: total.value ? Math.round((amount / total.value) * 100) : 0,
barWidth: `${Math.round((amount / max) * 100)}%`,
}))
})
</script>
<template>
<div class="panel">
<div class="flex flex-wrap items-center gap-2.5 border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">成本分析</div>
<div class="flex-1" />
<div class="w-28">
<NSelect v-model:value="rangeDays" size="small" :options="rangeOptions" />
</div>
</div>
<div v-if="costs.loading.value" class="flex items-center justify-center py-14">
<NSpin size="small" />
</div>
<template v-else-if="costs.data.value?.length">
<div class="flex flex-wrap items-baseline gap-2.5 px-4.5 pt-3.5">
<div class="text-[26px] font-semibold tabular-nums">${{ total.toFixed(2) }}</div>
<div class="text-[12.5px] text-ink-3">
{{ daily.labels[0] }} {{ daily.labels.at(-1) }} 合计
</div>
</div>
<div class="px-3 pb-1">
<CostChart :labels="daily.labels" :values="daily.values" />
</div>
<div class="border-t border-line-soft px-4.5 py-3">
<div class="mb-2 flex items-center justify-between">
<div class="text-[13px] font-semibold">按服务拆分</div>
<div class="text-[12.5px] text-ink-3">本期合计 ${{ total.toFixed(2) }}</div>
</div>
<div
v-for="svc in byService"
:key="svc.name"
class="flex items-center gap-3 py-1.5 max-md:gap-2"
>
<div class="w-32 flex-none truncate text-[13px] max-md:w-24" :title="svc.name">
{{ svc.name }}
</div>
<div class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-line-soft">
<div class="h-full rounded-full bg-chart" :style="{ width: svc.barWidth }" />
</div>
<div class="w-17 flex-none text-right text-[13px] tabular-nums">
${{ svc.amount.toFixed(2) }}
</div>
<div class="w-10 flex-none text-right text-xs tabular-nums text-ink-3">{{ svc.pct }}%</div>
</div>
</div>
</template>
<EmptyCard
v-else
title="暂无成本数据"
note="试用租户无消费时成本为空;Usage API 数据通常延迟数小时至一天"
/>
<FootNote>
每日 03:30 自动同步 Usage API · 本地快照 · 免费类别租户不参与成本同步 · 点击刷新可即时拉取
</FootNote>
</div>
</template>
+218
View File
@@ -0,0 +1,218 @@
<script setup lang="ts">
import { NCheckbox, NInput, NSelect, NSwitch, useMessage } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { createIdp, getIdentitySetting, type CreateIdpBody } from '@/api/tenant'
import FilePicker from '@/components/FilePicker.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
const props = defineProps<{ show: boolean; cfgId: number }>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const message = useMessage()
const submitting = ref(false)
// 「用户需要提供主电子邮件地址」开启时 JIT 必须映射邮箱
const primaryEmailRequired = ref(false)
const nameIdFormatOptions = [
{ label: '无', value: 'saml-none' },
{ label: '电子邮件地址', value: 'saml-emailaddress' },
{ label: '持久标识符', value: 'saml-persistent' },
{ label: '临时标识符', value: 'saml-transient' },
{ label: '未指定', value: 'saml-unspecified' },
]
const idpAttrOptions = [
{ label: 'SAML 断言名称 IDNameID', value: 'nameid' },
{ label: '断言属性', value: 'attribute' },
]
const userAttrOptions = [
{ label: '用户名(userName', value: 'userName' },
{ label: '主电子邮件(emails.primary.value', value: 'emails.primary.value' },
]
// 默认值与控制台一致:名称 ID 格式「无」、NameID 映射用户名、
// JIT 开启建用户不更新、分配 Administrators 组
const blank = {
name: '',
metadata: '',
description: '',
iconUrl: '',
nameIdFormat: 'saml-none',
idpUserAttr: 'nameid',
assertionAttr: '',
userStoreAttribute: 'userName',
jitEnabled: true,
jitCreate: true,
jitUpdate: false,
jitAssignAdminGroup: true,
jitMapEmail: false,
}
const form = reactive({ ...blank })
watch(
() => props.show,
(v) => {
if (!v) return
Object.assign(form, blank)
void loadIdentitySetting()
},
)
async function loadIdentitySetting() {
try {
const s = await getIdentitySetting(props.cfgId)
primaryEmailRequired.value = s.primaryEmailRequired
if (s.primaryEmailRequired) form.jitMapEmail = true
} catch {
primaryEmailRequired.value = false
}
}
// IDCS 只接受 http(s) 外链图标,data URI 会被拒(error.saml.identityprovider.invalidIconUrl
const iconUrlValid = computed(() => {
const v = form.iconUrl.trim()
return !v || /^https?:\/\//.test(v)
})
const canSubmit = computed(
() =>
!!form.name.trim() &&
form.metadata.includes('EntityDescriptor') &&
iconUrlValid.value &&
(form.idpUserAttr !== 'attribute' || !!form.assertionAttr.trim()),
)
function buildBody(): CreateIdpBody {
return {
name: form.name.trim(),
metadata: form.metadata,
description: form.description.trim() || undefined,
iconUrl: form.iconUrl.trim() || undefined,
nameIdFormat: form.nameIdFormat,
assertionAttribute: form.idpUserAttr === 'attribute' ? form.assertionAttr.trim() : undefined,
userStoreAttribute: form.userStoreAttribute,
jitEnabled: form.jitEnabled,
jitCreate: form.jitCreate,
jitUpdate: form.jitUpdate,
jitAssignAdminGroup: form.jitAssignAdminGroup,
jitMapEmail: form.jitEnabled && form.jitMapEmail,
}
}
async function submit() {
submitting.value = true
try {
const created = await createIdp(props.cfgId, buildBody())
message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`)
emit('update:show', false)
emit('created')
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="创建 SAML IdP"
:width="640"
:submitting="submitting"
:submit-disabled="!canSubmit"
submit-text="创建禁用态"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField label="名称" required>
<NInput v-model:value="form.name" placeholder="my-idp" />
</FormField>
<FormField
label="IdP SAML metadata XML 文件"
required
hint="IdP 侧导出的 metadata XML;先在本页「下载 SP 元数据」导入 IdP 侧再创建"
:error="
form.metadata && !form.metadata.includes('EntityDescriptor')
? '文件内容不含 EntityDescriptor,请确认选择的是 SAML metadata XML'
: undefined
"
:feedback="
form.metadata.includes('EntityDescriptor')
? `已读取 ${(form.metadata.length / 1024).toFixed(1)} KB`
: undefined
"
>
<FilePicker
:key="`md-${show}`"
label="拖拽或点击上传 SAML metadata 文件"
hint="支持 .xml,文件内容仅本地读取"
accept=".xml"
@load="(text) => (form.metadata = text)"
/>
</FormField>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="备注">
<NInput v-model:value="form.description" placeholder="可选" />
</FormField>
<FormField
label="Logo(登录页图标)"
hint="展示在登录页 IdP 按钮上;身份域只接受 http(s) 外链图片 URL,不支持上传"
:error="iconUrlValid ? undefined : '需为 http(s):// 开头的图片外链 URL'"
>
<div class="flex items-center gap-2">
<img
v-if="form.iconUrl && iconUrlValid"
:src="form.iconUrl"
alt=""
class="h-7 w-7 flex-none rounded border border-line-soft object-contain"
/>
<NInput v-model:value="form.iconUrl" placeholder="https://…/logo.png" />
</div>
</FormField>
</div>
<div class="mt-1 mb-2 border-t border-line-soft pt-3 text-[12.5px] font-semibold">
映射用户身份
</div>
<FormField label="请求的名称 ID 格式" hint="向 IdP 请求断言时期望的 NameID 格式">
<NSelect v-model:value="form.nameIdFormat" :options="nameIdFormatOptions" />
</FormField>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="IdP 用户属性" hint="从 SAML 断言中取哪个值来识别用户">
<NSelect v-model:value="form.idpUserAttr" :options="idpAttrOptions" />
</FormField>
<FormField label="域用户属性" hint="用取到的值匹配身份域中用户的哪个属性">
<NSelect v-model:value="form.userStoreAttribute" :options="userAttrOptions" />
</FormField>
</div>
<FormField v-if="form.idpUserAttr === 'attribute'" label="断言属性名" required>
<NInput v-model:value="form.assertionAttr" placeholder="如 mail、uid" />
</FormField>
<div class="mt-1 mb-2 flex items-center justify-between border-t border-line-soft pt-3">
<span class="text-[12.5px] font-semibold">JIT 预配</span>
<NSwitch v-model:value="form.jitEnabled" size="small" />
</div>
<div v-if="form.jitEnabled" class="mb-2 flex flex-col gap-1.5">
<NCheckbox v-model:checked="form.jitCreate">首次联邦登录时自动创建用户</NCheckbox>
<NCheckbox v-model:checked="form.jitUpdate">每次登录时按断言更新既有用户</NCheckbox>
<NCheckbox v-model:checked="form.jitAssignAdminGroup">
JIT 用户加入 Administrators
</NCheckbox>
<NCheckbox
v-model:checked="form.jitMapEmail"
:disabled="primaryEmailRequired"
>
映射邮箱属性断言 email 主电子邮件
<span v-if="primaryEmailRequired" class="text-xs text-ink-3">
域已开启用户需要提供主电子邮件地址必须映射
</span>
</NCheckbox>
</div>
<div class="text-xs leading-relaxed text-ink-3">
创建后为禁用态需手动激活映射与 JIT 均按上方配置写入身份域
</div>
</FormModal>
</template>
+216
View File
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { NButton, NDataTable, NPopconfirm, NSwitch, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
import { h, ref } from 'vue'
import {
activateIdp,
createSignOnExemption,
deleteIdp,
deleteSignOnExemption,
downloadSamlMetadata,
listIdps,
listSignOnRules,
} from '@/api/tenant'
import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import IdpFormModal from '@/components/tenant/IdpFormModal.vue'
import { useAsync } from '@/composables/useAsync'
import type { IdentityProvider, SignOnRule } from '@/types/api'
const props = defineProps<{ cfgId: number }>()
const message = useMessage()
const dialog = useDialog()
const idps = useAsync(() => listIdps(props.cfgId))
const rules = useAsync(() => listSignOnRules(props.cfgId))
const showCreate = ref(false)
async function act(fn: () => Promise<unknown>, ok: string) {
try {
await fn()
message.success(ok)
void idps.run()
void rules.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
}
}
const idpColumns: DataTableColumns<IdentityProvider> = [
{
title: 'IdP',
key: 'name',
minWidth: 220,
render: (row) =>
h('div', [
h('div', { class: 'font-medium' }, row.name),
h('div', { class: 'mono text-xs text-ink-3 mt-px break-all' }, row.partnerProviderId),
]),
},
{ title: '协议', key: 'type', width: 80 },
{
title: '状态',
key: 'enabled',
width: 100,
render: (row) =>
h(StatusBadge, {
kind: row.enabled ? 'run' : 'stop',
label: row.enabled ? '已启用' : '已停用',
}),
},
{
title: 'JIT',
key: 'jitEnabled',
width: 70,
render: (row) => h('span', { class: 'text-[13px]' }, row.jitEnabled ? '开启' : '关闭'),
},
{
title: '操作',
key: 'actions',
width: 160,
render: (row) =>
h('div', { class: 'flex items-center gap-3' }, [
h(NSwitch, {
size: 'small',
value: row.enabled,
onUpdateValue: (v: boolean) =>
act(() => activateIdp(props.cfgId, row.id, v), v ? '已激活并显示到登录页' : '已停用'),
}),
h(
NButton,
{ size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDeleteIdp(row) },
{ default: () => '删除' },
),
]),
},
]
const ruleColumns: DataTableColumns<SignOnRule> = [
{ title: '#', key: 'sequence', width: 50 },
{
title: '规则',
key: 'name',
minWidth: 180,
render: (row) =>
h('div', [
h('div', { class: 'mono text-[12.5px]' }, row.name),
h(
'div',
{ class: 'text-xs text-ink-3 mt-px' },
row.conditionAttribute ? `${row.conditionAttribute} in ${row.conditionValue}` : '默认兜底规则',
),
]),
},
{
title: '认证因子',
key: 'authenticationFactor',
width: 120,
render: (row) =>
h(StatusBadge, {
kind: row.authenticationFactor === 'IDP' ? 'prov' : 'check',
label: row.authenticationFactor === 'IDP' ? '仅 IdP' : 'IdP + 2FA',
dot: false,
}),
},
{
title: '操作',
key: 'actions',
width: 110,
render: (row) =>
row.builtIn
? h('span', { class: 'text-xs text-ink-3' }, '内置')
: h(
NPopconfirm,
{
onPositiveClick: () =>
act(() => deleteSignOnExemption(props.cfgId, row.id), '已删除免 MFA 规则并恢复优先级'),
},
{
trigger: () =>
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
default: () => '删除该免 MFA 规则?删除后经此 IdP 登录将重新要求 MFA',
},
),
},
]
async function doDownloadMetadata() {
try {
await downloadSamlMetadata(props.cfgId)
message.success('SAML 元数据已下载')
} catch (e) {
message.error(e instanceof Error ? e.message : '下载失败')
}
}
function confirmDeleteIdp(row: IdentityProvider) {
dialog.warning({
title: '删除身份提供商',
content: () =>
h('div', { class: 'text-[13px]' }, [
h('p', `确定删除 IdP「${row.name}」?此操作不可恢复。`),
h('p', { class: 'mt-1.5 text-ink-3' }, '将自动清理关联的免 MFA 规则,并从登录页移除'),
]),
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: () =>
act(() => deleteIdp(props.cfgId, row.id), `已删除 IdP「${row.name}」及其关联规则`),
})
}
function addExemption() {
const enabled = idps.data.value?.find((i) => i.enabled)
if (!enabled) {
message.warning('没有已启用的 IdP')
return
}
void act(
() => createSignOnExemption(props.cfgId, enabled.id),
`已为 ${enabled.name} 创建免 MFA 规则并置顶`,
)
}
</script>
<template>
<div class="flex flex-col gap-4">
<div class="panel">
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">SAML 身份提供商</div>
<div class="flex items-center gap-2">
<NButton size="small" @click="doDownloadMetadata">下载 SP 元数据</NButton>
<NButton size="small" type="primary" @click="showCreate = true">创建 IdP</NButton>
</div>
</div>
<NDataTable
:columns="idpColumns"
:data="idps.data.value ?? []"
:loading="idps.loading.value"
:scroll-x="620"
:row-key="(r: IdentityProvider) => r.id"
/>
<FootNote>
推荐流程:下载 SP SAML 元数据导入 IdP 侧 → 创建 IdP → 激活(自动显示到登录页)→ 创建免 MFA
规则;停用与删除会先将 IdP 移出登录页
</FootNote>
</div>
<div class="panel">
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">Sign-on 策略(免 MFA 规则)</div>
<NButton size="small" @click="addExemption">为已启用 IdP 创建免 MFA 规则</NButton>
</div>
<NDataTable
:columns="ruleColumns"
:data="rules.data.value ?? []"
:loading="rules.loading.value"
:scroll-x="560"
:row-key="(r: SignOnRule) => r.id"
/>
<FootNote>
sign-on 策略管控全租户控制台登录;Oracle 预置规则不可删除,请勿手工改动预置规则顺序
</FootNote>
</div>
<IdpFormModal v-model:show="showCreate" :cfg-id="cfgId" @created="idps.run()" />
</div>
</template>
+409
View File
@@ -0,0 +1,409 @@
<script setup lang="ts">
import { NButton, NDynamicTags, NPopconfirm, NSpin, NSwitch, useMessage } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import {
ensureLogWebhook,
getLogRelay,
getLogWebhook,
revokeLogWebhook,
setupLogRelay,
teardownLogRelay,
} from '@/api/logevents'
import {
getIdentitySetting,
getNotificationRecipients,
listPasswordPolicies,
updateIdentitySetting,
updateNotificationRecipients,
} from '@/api/tenant'
import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import PolicyEditModal from '@/components/tenant/PolicyEditModal.vue'
import { useAsync } from '@/composables/useAsync'
import type { PasswordPolicy } from '@/types/api'
const props = defineProps<{ cfgId: number }>()
const message = useMessage()
const policies = useAsync(() => listPasswordPolicies(props.cfgId))
const recipients = useAsync(() => getNotificationRecipients(props.cfgId))
const identity = useAsync(() => getIdentitySetting(props.cfgId))
const webhook = useAsync(() => getLogWebhook(props.cfgId))
const relay = useAsync(() => getLogRelay(props.cfgId))
const savingIdentity = ref(false)
const savingWebhook = ref(false)
const settingUpRelay = ref(false)
const tearingRelay = ref(false)
// OCI 侧链路四资源,按数据流向排成管线;任一资源存在即视为「已建(或残留)」
const relayRows = computed(() => {
const d = relay.data.value
if (!d) return []
return [
{ label: 'Topic', res: d.topic },
{ label: '订阅 · HTTPS', res: d.subscription },
{ label: 'Policy', res: d.policy },
{ label: 'Connector', res: d.connector },
]
})
const relayBuilt = computed(() => relayRows.value.some((r) => r.res.id !== ''))
/** 主卡状态 chip:运行中 / 仅回调地址 / 未启用 */
const relayChip = computed(() => {
if (relay.data.value?.ready) return { label: '链路运行中', dot: 'bg-ok' }
if (webhook.data.value?.exists) return { label: '回调地址已生成', dot: 'bg-warn' }
return { label: '未启用', dot: 'bg-ink-3/50' }
})
function relayDotClass(res: { id: string; state: string }): string {
if (!res.id) return 'bg-ink-3/40'
if (res.state === 'ACTIVE' || !res.state) return 'bg-ok'
if (['PENDING', 'CREATING', 'UPDATING'].includes(res.state)) return 'bg-warn'
return 'bg-ink-3/40'
}
async function createRelay() {
settingUpRelay.value = true
try {
await setupLogRelay(props.cfgId)
message.success('回传链路已建立,关键事件将自动回传')
void relay.run()
void webhook.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
void relay.run({ silent: true })
} finally {
settingUpRelay.value = false
}
}
async function destroyRelay() {
tearingRelay.value = true
try {
await teardownLogRelay(props.cfgId)
message.success('回传链路已销毁,回调地址已撤销')
void relay.run()
void webhook.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '销毁失败')
void relay.run({ silent: true })
} finally {
tearingRelay.value = false
}
}
const emails = ref<string[]>([])
const saving = ref(false)
const showPolicyEdit = ref(false)
const editingPolicy = ref<PasswordPolicy | null>(null)
function openPolicyEdit(p: PasswordPolicy) {
editingPolicy.value = p
showPolicyEdit.value = true
}
watch(
() => recipients.data.value,
(data) => {
if (data) emails.value = [...data.recipients]
},
)
async function saveRecipients() {
saving.value = true
try {
await updateNotificationRecipients(props.cfgId, emails.value)
message.success(emails.value.length ? '通知收件人已保存' : '已关闭 test mode,恢复默认发送')
void recipients.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
async function toggleEmailRequired(v: boolean) {
savingIdentity.value = true
try {
await updateIdentitySetting(props.cfgId, v)
message.success(v ? '已开启:添加用户 / JIT 预配必须提供邮箱' : '已关闭:邮箱变为可选')
void identity.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
savingIdentity.value = false
}
}
async function createWebhook() {
savingWebhook.value = true
try {
await ensureLogWebhook(props.cfgId)
message.success('回调地址已生成')
void webhook.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '生成失败')
} finally {
savingWebhook.value = false
}
}
async function removeWebhook() {
savingWebhook.value = true
try {
await revokeLogWebhook(props.cfgId)
message.success('回调地址已撤销,旧地址随即失效')
void webhook.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '撤销失败')
} finally {
savingWebhook.value = false
}
}
async function copyWebhookPath() {
const path = webhook.data.value?.webhook?.path
if (!path) return
try {
await navigator.clipboard.writeText(path)
message.success('已复制回调路径,请以面板公网域名拼接完整 URL')
} catch {
message.error('复制失败,请手动选择文本复制')
}
}
function policyDesc(p: PasswordPolicy): string {
const parts = [`最短 ${p.minLength ?? 0}`]
parts.push(p.passwordExpiresAfter ? `${p.passwordExpiresAfter} 天过期` : '永不过期')
parts.push(
p.numPasswordsInHistory ? `不可复用最近 ${p.numPasswordsInHistory} 个密码` : '可复用以前的密码',
)
return `${p.passwordStrength} · ${parts.join(' · ')}`
}
</script>
<template>
<div class="flex flex-col gap-4">
<!-- 日志回传:全宽主卡,管线四节点 + 回调地址 + 事件清单 -->
<div class="panel">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
>
<div class="min-w-0">
<div class="text-sm font-semibold">日志回传</div>
<div class="mt-0.5 text-xs text-ink-3">
OCI 审计关键事件推送到面板:Connector Hub Notifications 面板 webhook
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-[3px] text-[11.5px] text-ink-2"
>
<span class="h-1.5 w-1.5 rounded-full" :class="relayChip.dot" />
{{ relayChip.label }}
</span>
<NPopconfirm v-if="relayBuilt" @positive-click="destroyRelay">
<template #trigger>
<NButton size="tiny" type="error" quaternary :loading="tearingRelay">
销毁链路
</NButton>
</template>
将删除租户内的 ConnectorPolicy订阅与 Topic 并撤销回调地址,确认销毁?
</NPopconfirm>
<NButton
v-else
size="small"
type="primary"
:loading="settingUpRelay"
@click="createRelay"
>
一键创建链路
</NButton>
</div>
</div>
<div v-if="webhook.loading.value" class="flex items-center justify-center py-10">
<NSpin size="small" />
</div>
<template v-else>
<div v-if="settingUpRelay" class="px-4.5 pt-3 text-xs text-warn">
正在创建 Topic 订阅(等待自动确认) Policy Connector,约需 1-2 分钟,请勿离开
</div>
<div v-else-if="relay.error.value" class="px-4.5 pt-3 text-xs text-warn">
{{ relay.error.value }}
</div>
<div
v-else-if="relayBuilt"
class="grid grid-cols-4 gap-2.5 px-4.5 pt-3.5 max-md:grid-cols-2"
>
<div
v-for="row in relayRows"
:key="row.label"
class="relative rounded-lg bg-wash px-3 py-2.5 after:absolute after:top-1/2 after:-right-[9px] after:-translate-y-1/2 after:text-xs after:text-ink-3 after:content-['→'] last:after:hidden max-md:after:hidden"
>
<div class="text-[11px] tracking-wider text-ink-3 uppercase">{{ row.label }}</div>
<div class="mt-1 flex items-center gap-1.5 text-[12.5px] font-semibold">
<span class="h-[7px] w-[7px] flex-none rounded-full" :class="relayDotClass(row.res)" />
{{ row.res.id ? row.res.state || '存在' : '未创建' }}
</div>
</div>
</div>
<div v-else class="px-4.5 pt-3.5 text-xs leading-relaxed text-ink-3">
在租户内自动创建 TopicCUSTOM_HTTPS 订阅IAM Policy Service Connector(_Audit
审计日志,含子区间),订阅确认由面板自动完成,全程约 1-2 分钟
</div>
<div
v-if="webhook.data.value?.exists"
class="mx-4.5 mt-3 flex items-center gap-2.5 rounded-md bg-wash px-3 py-2"
>
<span class="flex-none text-xs text-ink-3">回调地址</span>
<span class="mono min-w-0 flex-1 text-[12px] break-all text-ink-2">
{{ webhook.data.value.webhook?.path }}
</span>
<NButton size="tiny" quaternary class="flex-none" @click="copyWebhookPath">复制</NButton>
<NPopconfirm @positive-click="removeWebhook">
<template #trigger>
<NButton size="tiny" type="error" quaternary class="flex-none" :loading="savingWebhook">
撤销
</NButton>
</template>
撤销后旧回调地址立即 404,OCI 侧订阅将投递失败,确认撤销?
</NPopconfirm>
</div>
<div v-else class="mx-4.5 mt-3 flex flex-wrap items-center gap-2.5 rounded-md bg-wash px-3 py-2">
<span class="min-w-0 flex-1 text-xs text-ink-3">
推荐直接一键创建链路;仅需回调地址手动配置 OCI 侧时,可单独生成
</span>
<NButton size="tiny" :loading="savingWebhook" @click="createWebhook">
生成回调地址
</NButton>
</div>
<div class="px-4.5 pt-3.5 pb-4">
<div class="text-xs font-semibold text-ink-2">
回传事件清单(OCI Log Filter 收窄,仅以下关键事件回传,不产生噪声流量)
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="ev in relay.data.value?.events ?? []"
:key="ev"
class="mono rounded bg-wash px-1.5 py-0.5 text-[11px] text-ink-2"
>
{{ ev }}
</span>
</div>
</div>
</template>
<FootNote>
关键事件入库后另经通知管理 云端事件按类推送,可在设置中关闭;回调地址内含随机凭据,
请勿外传,复制路径后以面板公网域名拼接完整 URL
</FootNote>
</div>
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
<!-- 密码策略与身份 -->
<div class="panel">
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">密码策略与身份</div>
<div class="text-[12.5px] text-ink-3">Default 身份域 · IAM 规则</div>
</div>
<div v-if="policies.loading.value || identity.loading.value" class="flex items-center justify-center py-10">
<NSpin size="small" />
</div>
<div v-else class="px-4.5 py-1.5">
<div
v-for="p in policies.data.value ?? []"
:key="p.id"
class="flex items-center gap-2.5 border-b border-line-soft py-2.5"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="mono text-[12.5px] text-ink">{{ p.name }}</span>
<StatusBadge
:kind="p.passwordStrength === 'Custom' ? 'snatch' : 'check'"
:label="p.passwordStrength === 'Custom' ? 'Custom' : '内置'"
:dot="false"
/>
</div>
<div class="mt-0.5 text-xs text-ink-3">
{{ policyDesc(p) }}
</div>
</div>
<NButton
v-if="p.passwordStrength === 'Custom'"
size="tiny"
quaternary
@click="openPolicyEdit(p)"
>
编辑
</NButton>
<span v-else class="text-[12.5px] text-ink-3">只读</span>
</div>
<div class="flex items-center gap-3 py-2.5">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium">用户需要提供主电子邮件地址</div>
<div class="mt-0.5 text-xs text-ink-3">
开启后添加用户必须填写邮箱,JIT 预配必须映射邮箱属性
</div>
</div>
<NSwitch
:value="identity.data.value?.primaryEmailRequired ?? false"
:loading="savingIdentity"
size="small"
@update:value="toggleEmailRequired"
/>
</div>
</div>
<FootNote>
Custom 强度的策略可修改,Simple / Standard Oracle
只读内置策略;关闭邮箱强制后找回密码与欢迎邮件不可用
</FootNote>
</div>
<!-- 通知收件人 -->
<div class="panel">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
>
<div class="min-w-0">
<div class="text-sm font-semibold">通知收件人</div>
<div class="mt-0.5 text-xs text-ink-3">
域通知 test mode:开启后全部通知只发给指定收件人
</div>
</div>
<span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-[3px] text-[11.5px] text-ink-2"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="recipients.data.value?.testModeEnabled ? 'bg-ok' : 'bg-ink-3/50'"
/>
test mode {{ recipients.data.value?.testModeEnabled ? '已开启' : '未开启' }}
</span>
</div>
<div v-if="recipients.loading.value" class="flex items-center justify-center py-10">
<NSpin size="small" />
</div>
<div v-else class="flex flex-col gap-3 px-4.5 py-3.5">
<NDynamicTags v-model:value="emails" />
<div class="flex items-center gap-2">
<NButton size="small" type="primary" :loading="saving" @click="saveRecipients">
保存
</NButton>
<span class="text-xs text-ink-3">传空列表将关闭 test mode恢复域默认发送</span>
</div>
</div>
<FootNote>收件人为本租户级配置;适合把告警邮件集中到运维邮箱</FootNote>
</div>
</div>
<PolicyEditModal
v-model:show="showPolicyEdit"
:cfg-id="cfgId"
:policy="editingPolicy"
@updated="policies.run()"
/>
</div>
</template>
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { NSwitch, useMessage } from 'naive-ui'
import { reactive, ref, watch } from 'vue'
import { updatePasswordPolicy } from '@/api/tenant'
import FormModal from '@/components/FormModal.vue'
import type { PasswordPolicy } from '@/types/api'
const props = defineProps<{ show: boolean; cfgId: number; policy: PasswordPolicy | null }>()
const emit = defineEmits<{ 'update:show': [boolean]; updated: [] }>()
// 关闭开关时写回的控制台默认值
const DEFAULT_EXPIRES_DAYS = 120
const DEFAULT_HISTORY_COUNT = 4
const message = useMessage()
const submitting = ref(false)
const form = reactive({ neverExpires: true, allowReuse: true })
watch(
() => props.show,
(v) => {
if (!v || !props.policy) return
form.neverExpires = !props.policy.passwordExpiresAfter
form.allowReuse = !props.policy.numPasswordsInHistory
},
)
// 仅提交状态发生变化的开关,避免覆盖策略里已有的自定义天数 / 个数
function buildBody(p: PasswordPolicy) {
const body: { passwordExpiresAfter?: number; numPasswordsInHistory?: number } = {}
if (form.neverExpires !== !p.passwordExpiresAfter)
body.passwordExpiresAfter = form.neverExpires ? 0 : DEFAULT_EXPIRES_DAYS
if (form.allowReuse !== !p.numPasswordsInHistory)
body.numPasswordsInHistory = form.allowReuse ? 0 : DEFAULT_HISTORY_COUNT
return body
}
async function submit() {
if (!props.policy) return
const body = buildBody(props.policy)
if (!Object.keys(body).length) {
emit('update:show', false)
return
}
submitting.value = true
try {
await updatePasswordPolicy(props.cfgId, props.policy.id, body)
message.success('密码策略已更新')
emit('update:show', false)
emit('updated')
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败(仅 Custom 强度策略可修改)')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
:title="`编辑密码策略 · ${policy?.name ?? ''}`"
:submitting="submitting"
submit-text="保存"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between rounded border border-line-soft px-3.5 py-3">
<div class="min-w-0">
<div class="text-[13px] font-medium">密码永不过期</div>
<div class="mt-0.5 text-xs text-ink-3">
关闭后按 {{ policy?.passwordExpiresAfter || DEFAULT_EXPIRES_DAYS }} 天过期
</div>
</div>
<NSwitch v-model:value="form.neverExpires" />
</div>
<div class="flex items-center justify-between rounded border border-line-soft px-3.5 py-3">
<div class="min-w-0">
<div class="text-[13px] font-medium">可复用以前的密码</div>
<div class="mt-0.5 text-xs text-ink-3">
关闭后不可复用最近
{{ policy?.numPasswordsInHistory || DEFAULT_HISTORY_COUNT }} 个历史密码
</div>
</div>
<NSwitch v-model:value="form.allowReuse" />
</div>
</div>
<div class="mt-3 text-xs leading-relaxed text-ink-3">
Custom 强度的策略可修改Simple / Standard Oracle 只读内置策略
</div>
</FormModal>
</template>
+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
import {
NCheckbox,
NDataTable,
NInput,
NInputGroup,
NInputGroupLabel,
NSelect,
type DataTableColumns,
} from 'naive-ui'
import { computed, h, reactive, ref, watch } from 'vue'
import { listCachedRegions } from '@/api/configs'
import { listLimitServices, listLimits } from '@/api/tenant'
import FootNote from '@/components/FootNote.vue'
import { regionCity, shortAd } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { LimitItem } from '@/types/api'
const props = defineProps<{ cfgId: number }>()
const service = ref('compute')
const name = ref('')
const region = ref('')
const scopeType = ref<'' | 'GLOBAL' | 'REGION' | 'AD'>('')
const withAvail = ref(false)
const hideZero = ref(false)
const scopeOptions = [
{ label: '全部作用域', value: '' },
{ label: 'GLOBAL', value: 'GLOBAL' },
{ label: 'REGION', value: 'REGION' },
{ label: 'AD', value: 'AD' },
]
const services = useAsync(() => listLimitServices(props.cfgId))
/** 区域选项来自服务端缓存(未开启多区域的租户只返回默认区域) */
const regions = useAsync(() => listCachedRegions(props.cfgId).catch(() => []))
const limits = useAsync(() =>
listLimits(props.cfgId, {
service: service.value,
name: name.value,
region: region.value || undefined,
scopeType: scopeType.value || undefined,
// 下推服务端过滤:0 配额不占「查用量」的 100 条并发名额
nonZero: hideZero.value || undefined,
withAvailability: withAvail.value,
}),
)
// 区域列表加载后默认选中主区域;已选值失效(退订)时同样回退
watch(
() => regions.data.value,
(subs) => {
if (!subs?.length) return
if (!region.value || !subs.some((s) => s.name === region.value))
region.value = subs.find((s) => s.isHomeRegion)?.name ?? subs[0].name
},
)
/** 选项用括号内城市短名(与全局选择器一致),避免选中框被长名截断 */
const regionOptions = computed(() =>
(regions.data.value ?? []).map((s) => ({
label: s.isHomeRegion ? `${regionCity(s.alias)} · 主` : regionCity(s.alias),
value: s.name,
})),
)
watch([service, withAvail, region, scopeType, hideZero], () => void limits.run())
const rows = computed(() =>
hideZero.value ? (limits.data.value ?? []).filter((r) => r.value !== 0) : (limits.data.value ?? []),
)
const serviceOptions = computed(
() =>
services.data.value?.map((s) => ({ label: s.description || s.name, value: s.name })) ?? [
{ label: 'Compute', value: 'compute' },
],
)
function usagePct(row: LimitItem): number | null {
if (row.used === undefined || row.value === 0) return null
return Math.round((row.used / row.value) * 100)
}
/** 受控分页:字面量对象会在重渲染时被重建导致页大小切换失效 */
const pagination = reactive({
page: 1,
pageSize: 10,
showSizePicker: true,
pageSizes: [10, 20, 50],
onUpdatePage: (p: number) => {
pagination.page = p
},
onUpdatePageSize: (ps: number) => {
pagination.pageSize = ps
pagination.page = 1
},
})
const baseColumns: DataTableColumns<LimitItem> = [
{
title: '配额项',
key: 'name',
minWidth: 230,
render: (row) =>
h('div', [
h('div', { class: 'mono text-[12.5px] text-ink' }, row.name),
h(
'div',
{ class: 'text-xs text-ink-3 mt-px' },
row.availabilityDomain ? shortAd(row.availabilityDomain) : row.scopeType,
),
]),
},
{
title: '上限',
key: 'value',
width: 130,
render: (r) =>
h(
'span',
{ class: 'tabular-nums whitespace-nowrap' },
r.value.toLocaleString('en-US'),
),
},
]
const availColumns: DataTableColumns<LimitItem> = [
{
title: '已用',
key: 'used',
width: 80,
render: (r) => h('span', { class: 'tabular-nums' }, r.used === undefined ? '—' : String(r.used)),
},
{
title: '可用',
key: 'available',
width: 80,
render: (r) =>
h('span', { class: 'tabular-nums' }, r.available === undefined ? '—' : String(r.available)),
},
{
title: '用量',
key: 'pct',
minWidth: 160,
render: (row) => {
const pct = usagePct(row)
if (pct === null) return h('span', { class: 'text-xs text-ink-3' }, '无用量数据')
const full = pct >= 100
return h('div', { class: 'flex items-center gap-2' }, [
h('div', { class: 'h-1.5 w-24 overflow-hidden rounded-full bg-line-soft' }, [
h('div', {
class: 'h-full rounded-full',
style: { width: `${Math.min(pct, 100)}%`, background: full ? 'var(--color-err)' : 'var(--color-ok)' },
}),
]),
h(
'span',
{
class: 'text-[12.5px] tabular-nums',
style: full ? 'color:var(--color-err);font-weight:600' : 'color:var(--color-ink-2)',
},
`${pct}%`,
),
])
},
},
]
const columns = computed(() => (withAvail.value ? [...baseColumns, ...availColumns] : baseColumns))
</script>
<template>
<div class="panel">
<div class="flex flex-wrap items-center gap-2 px-4.5 py-3">
<div class="text-sm font-semibold">服务配额</div>
<div class="flex-1" />
<NInputGroup class="!w-60 max-md:!w-full">
<NInputGroupLabel size="small">区域</NInputGroupLabel>
<NSelect
v-model:value="region"
size="small"
filterable
:options="regionOptions"
:loading="regions.loading.value"
:consistent-menu-width="false"
/>
</NInputGroup>
<NInputGroup class="!w-72 max-lg:!w-60 max-md:!w-full">
<NInputGroupLabel size="small">服务</NInputGroupLabel>
<NSelect
v-model:value="service"
size="small"
filterable
:options="serviceOptions"
:loading="services.loading.value"
/>
</NInputGroup>
<NInputGroup class="!w-52 max-lg:!w-44 max-md:!w-full">
<NInputGroupLabel size="small">作用域</NInputGroupLabel>
<NSelect v-model:value="scopeType" size="small" :options="scopeOptions" />
</NInputGroup>
<NInputGroup class="!w-56 max-lg:!w-44 max-md:!w-full">
<NInputGroupLabel size="small">配额名</NInputGroupLabel>
<NInput
v-model:value="name"
size="small"
placeholder="如 core-count"
clearable
@keyup.enter="limits.run()"
@clear="limits.run()"
/>
</NInputGroup>
<NCheckbox v-model:checked="hideZero" size="small">忽略 0 配额</NCheckbox>
<NCheckbox v-model:checked="withAvail" size="small">查用量</NCheckbox>
</div>
<NDataTable
:columns="columns"
:data="rows"
:loading="limits.loading.value"
:pagination="pagination"
:scroll-x="withAvail ? 660 : 380"
:max-height="440"
:row-key="(r: LimitItem) => r.name + (r.availabilityDomain ?? '')"
/>
<FootNote>
勾选查用量后单次限 100 超出请用配额名 / 作用域过滤收窄配合忽略 0
配额服务端过滤可大幅释放名额不支持用量查询的配额自动跳过并显示无用量数据
</FootNote>
</div>
</template>
+297
View File
@@ -0,0 +1,297 @@
<script setup lang="ts">
import { NButton, NDataTable, NSpin, type DataTableColumns } from 'naive-ui'
import { computed, h, ref } from 'vue'
import { listRegionSubscriptions, listRegions } from '@/api/configs'
import { getSubscription, listLimits, listSubscriptions } from '@/api/tenant'
import AccountTypeBadge from '@/components/AccountTypeBadge.vue'
import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import SubscribeRegionModal from '@/components/tenant/SubscribeRegionModal.vue'
import { fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import { useScopeStore } from '@/stores/scope'
import type { AccountType, Promotion, RegionInfo, SubscriptionDetail } from '@/types/api'
const props = defineProps<{ cfgId: number; accountType: AccountType }>()
const scope = useScopeStore()
/** 订阅成功后除本页列表外,同步刷新全局作用域的区域选项 */
function onSubscribed() {
void regionSubs.run()
void scope.refreshConfigs()
}
const showSubscribe = ref(false)
const presetKey = ref<string | null>(null)
/** 租户可能有多个订阅,逐个取详情;IDCS 身份域订阅无账单字段,不展示 */
const subs = useAsync(async () => {
const summaries = await listSubscriptions(props.cfgId)
const details = await Promise.all(summaries.map((s) => getSubscription(props.cfgId, s.id)))
return details.filter((d) => d.serviceName !== 'IDCS')
})
const regionSubs = useAsync(() => listRegionSubscriptions(props.cfgId))
const allRegions = useAsync(listRegions)
/** 租户可订阅区域上限(OCI Limitsregions / subscribed-region-countTenancy 级) */
const regionQuota = useAsync<number | null>(() =>
listLimits(props.cfgId, { service: 'regions', name: 'subscribed-region-count' })
.then((items) => items[0]?.value ?? null)
.catch(() => null),
)
const subscribedCount = computed(() => regionSubs.data.value?.length ?? 0)
const quotaFull = computed(() => {
const quota = regionQuota.data.value
return quota !== null && quota !== undefined && subscribedCount.value >= quota
})
interface RegionRow extends RegionInfo {
status: string
isHome: boolean
}
const regionRows = computed<RegionRow[]>(() => {
const byKey = new Map((regionSubs.data.value ?? []).map((s) => [s.key, s]))
const rows = (allRegions.data.value ?? []).map((r) => {
const sub = byKey.get(r.key)
return { ...r, status: sub?.status ?? '', isHome: sub?.isHomeRegion ?? false }
})
const rank = (r: RegionRow) => (r.isHome ? 0 : r.status ? 1 : 2)
return rows.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name))
})
function subscribe(row: RegionRow) {
presetKey.value = row.key
showSubscribe.value = true
}
function remainDays(expire: string | null): number | null {
if (!expire) return null
const days = Math.ceil((new Date(expire).getTime() - Date.now()) / 86400000)
return days > 0 ? days : 0
}
/** 促销到期:ACTIVE 促销 OCI 常不回填 timeExpired,用开始时间 + 时长推算 */
function promoExpire(r: Promotion): string | null {
if (r.timeExpired) return r.timeExpired
if (!r.timeStarted || !r.duration || !r.durationUnit.startsWith('DAY')) return null
return new Date(new Date(r.timeStarted).getTime() + r.duration * 86400000).toISOString()
}
const promoColumns: DataTableColumns<Promotion> = [
{
title: '时长',
key: 'duration',
width: 88,
render: (r) =>
h('span', { class: 'tabular-nums' }, `${r.duration} ${r.durationUnit.startsWith('DAY') ? '天' : r.durationUnit}`),
},
{
title: '额度',
key: 'amount',
width: 100,
render: (r) =>
h('span', { class: 'tabular-nums' }, `${r.currencyUnit === 'EUR' ? '€' : ''}${r.amount.toFixed(2)}`),
},
{
title: '状态',
key: 'status',
width: 96,
render: (r) => h(StatusBadge, { kind: r.status === 'ACTIVE' ? 'run' : 'stop', label: r.status }),
},
{
title: '开始',
key: 'timeStarted',
width: 108,
render: (r) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(r.timeStarted).slice(0, 10)),
},
{
title: '到期',
key: 'timeExpired',
width: 108,
render: (r) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(promoExpire(r)).slice(0, 10)),
},
{
title: '剩余',
key: 'remain',
width: 80,
render: (r) => {
const days = remainDays(promoExpire(r))
return days === null
? h('span', '—')
: h('span', { class: 'tabular-nums font-semibold', style: 'color:var(--color-warn)' }, `${days}`)
},
},
]
const regionColumns = computed<DataTableColumns<RegionRow>>(() => [
{
title: '区域',
key: 'name',
minWidth: 190,
render: (row) =>
h('div', [
h('div', { class: 'flex items-center gap-1.5' }, [
h('span', { class: 'text-[13px] font-medium' }, row.alias || row.name),
row.isHome
? h(
'span',
{ class: 'rounded bg-info/15 px-1 py-px text-[11px] font-medium text-info' },
'Home',
)
: null,
]),
h('div', { class: 'mono text-xs text-ink-3 mt-px' }, row.name),
]),
},
{ title: 'Key', key: 'key', width: 64, render: (r) => h('span', { class: 'mono' }, r.key) },
{
title: '状态',
key: 'status',
width: 100,
render: (r) =>
r.status
? h(StatusBadge, {
kind: r.status === 'READY' ? 'run' : 'prov',
label: r.status === 'READY' ? '已订阅' : '订阅中',
})
: h(StatusBadge, { kind: 'check', label: '未订阅', dot: false }),
},
{
title: '操作',
key: 'actions',
width: 76,
render: (row) =>
row.status
? h('span', { class: 'text-ink-3' }, '—')
: h(
NButton,
{
size: 'tiny',
quaternary: true,
type: 'primary',
disabled: quotaFull.value,
title: quotaFull.value
? `区域配额已满(${subscribedCount.value}/${regionQuota.data.value}),请先向 OCI 申请提额`
: undefined,
onClick: () => subscribe(row),
},
{ default: () => '订阅' },
),
},
])
function subLabel(d: SubscriptionDetail): string {
return d.serviceName || d.classicSubscriptionId || d.id
}
</script>
<template>
<div class="grid grid-cols-2 items-start gap-4 max-xl:grid-cols-1">
<div class="flex min-w-0 flex-col gap-4">
<div v-if="subs.loading.value" class="panel flex items-center justify-center py-14">
<NSpin size="small" />
</div>
<div
v-else-if="!subs.data.value?.length"
class="panel px-4.5 py-10 text-center text-[13px] text-ink-3"
>
未获取到订阅信息Organizations API 可能对该租户不可用
</div>
<div v-for="d in subs.data.value" v-else :key="d.id" class="panel">
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">云订阅</div>
<div class="text-[12.5px] text-ink-3">{{ subLabel(d) }} · 账户类别数据来源</div>
</div>
<div class="grid grid-cols-3 gap-4 px-4.5 py-3.5 max-md:grid-cols-2">
<div>
<div class="text-xs text-ink-3">付费模式</div>
<div class="mono mt-0.5 text-[13px]">{{ d.paymentModel || '—' }}</div>
</div>
<div>
<div class="text-xs text-ink-3">订阅层级</div>
<div class="mono mt-0.5 text-[13px]">{{ d.subscriptionTier || '—' }}</div>
</div>
<div>
<div class="text-xs text-ink-3">状态</div>
<div class="mt-0.5">
<StatusBadge
:kind="d.lifecycleState === 'ACTIVE' ? 'run' : 'stop'"
:label="d.lifecycleState || '—'"
/>
</div>
</div>
<div>
<div class="text-xs text-ink-3">账户判定</div>
<div class="mt-0.5"><AccountTypeBadge :type="accountType" /></div>
</div>
<div>
<div class="text-xs text-ink-3">币种</div>
<div class="mt-0.5 text-[13px]">{{ d.cloudAmountCurrency || '—' }}</div>
</div>
<div>
<div class="text-xs text-ink-3">Program</div>
<div class="mono mt-0.5 text-[13px]">{{ d.programType || '—' }}</div>
</div>
</div>
<template v-if="d.promotions.length">
<div class="flex items-center justify-between border-t border-line-soft px-4.5 py-2.5">
<div class="text-[13px] font-semibold">试用促销</div>
<div class="text-[12.5px] text-ink-3">{{ d.promotions.length }} </div>
</div>
<NDataTable
:columns="promoColumns"
:data="d.promotions"
:scroll-x="580"
:row-key="(r: Promotion) => r.timeStarted ?? ''"
/>
</template>
<FootNote>
判定规则:付费模式非 FREE_TRIAL → 升级;FREE_TRIAL 且促销 ACTIVE → 试用;促销 EXPIRED
或层级 FREE → 免费
</FootNote>
</div>
</div>
<div class="panel min-w-0">
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">区域订阅</div>
<div class="text-[12.5px] text-ink-3">
已订阅 {{ subscribedCount }} ·
{{
regionQuota.loading.value
? '配额查询中…'
: regionQuota.data.value === null
? '配额未知'
: `配额 ${regionQuota.data.value}`
}}
</div>
</div>
<NDataTable
:columns="regionColumns"
:data="regionRows"
:loading="regionSubs.loading.value || allRegions.loading.value"
:scroll-x="460"
:max-height="480"
:row-key="(r: RegionRow) => r.key"
/>
<FootNote>
区域订阅一经创建不可取消,永久占用配额(regions / subscribed-region-count)· 新订阅生效需数分钟
</FootNote>
</div>
<SubscribeRegionModal
v-model:show="showSubscribe"
:cfg-id="cfgId"
:preset-key="presetKey"
:subscribed-keys="(regionSubs.data.value ?? []).map((r) => r.key)"
@subscribed="onSubscribed()"
/>
</div>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { NCheckbox, NSelect, useMessage } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { listRegions, subscribeRegion } from '@/api/configs'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useAsync } from '@/composables/useAsync'
const props = defineProps<{
show: boolean
cfgId: number
subscribedKeys: string[]
/** 从区域列表行内发起时预选并锁定该区域 */
presetKey?: string | null
}>()
const emit = defineEmits<{ 'update:show': [boolean]; subscribed: [] }>()
const message = useMessage()
const submitting = ref(false)
const regionKey = ref<string | null>(null)
const acknowledged = ref(false)
const regions = useAsync(listRegions, false)
watch(
() => props.show,
(v) => {
if (v) {
regionKey.value = props.presetKey ?? null
acknowledged.value = false
void regions.run()
}
},
)
const options = computed(
() =>
regions.data.value
?.filter((r) => !props.subscribedKeys.includes(r.key))
.map((r) => ({ label: `${r.alias}${r.name}`, value: r.key })) ?? [],
)
async function submit() {
if (!regionKey.value) return
submitting.value = true
try {
await subscribeRegion(props.cfgId, regionKey.value)
message.success('订阅请求已提交,生效需数分钟(状态 IN_PROGRESS')
emit('update:show', false)
emit('subscribed')
} catch (e) {
message.error(e instanceof Error ? e.message : '订阅失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="订阅新区域"
:submitting="submitting"
:submit-disabled="!regionKey || !acknowledged"
submit-text="确认订阅"
danger
@update:show="emit('update:show', $event)"
@submit="submit"
>
<div
class="mb-3.5 rounded-md border border-err/30 bg-err/10 px-3.5 py-2.5 text-[12.5px] leading-relaxed text-err"
>
区域订阅一经创建<strong>永久不可取消</strong>会永久占用该租户的区域配额并可能产生跨区域数据驻留影响请确认业务确实需要后再操作
</div>
<FormField label="目标区域" required :hint="presetKey ? undefined : '已订阅的区域不在列表中'">
<NSelect
v-model:value="regionKey"
filterable
:disabled="!!presetKey"
:options="options"
:loading="regions.loading.value"
placeholder="选择要订阅的区域"
/>
</FormField>
<NCheckbox v-model:checked="acknowledged">我已知晓区域订阅不可取消</NCheckbox>
</FormModal>
</template>
+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { NCheckbox, NInput, useMessage } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import {
createUser,
getIdentitySetting,
getUserDetail,
updateUser,
type UpdateUserBody,
} from '@/api/tenant'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import type { IamUser, IamUserDetail } from '@/types/api'
// user 非空即编辑模式:打开时拉取域档案预填充,提交只发送与原值不同的字段
const props = defineProps<{ show: boolean; cfgId: number; user?: IamUser | null }>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const message = useMessage()
const submitting = ref(false)
// 域开启「用户需要提供主电子邮件地址」时邮箱必填
const emailRequired = ref(false)
// 编辑模式的域档案;加载失败保持 null,降级为「留空不修改、勾选仅添加」
const detail = ref<IamUserDetail | null>(null)
const detailLoading = ref(false)
const blank = {
name: '',
givenName: '',
familyName: '',
email: '',
description: '',
grantDomainAdmin: false,
addToAdminGroup: false,
}
const form = reactive({ ...blank })
const editing = computed(() => !!props.user)
// 经典 IAM 用户不在身份域中,姓名与管理员状态均不可操作
const classicUser = computed(() => editing.value && !!detail.value && !detail.value.inDomain)
watch(
() => props.show,
(v) => {
if (!v) return
Object.assign(form, blank)
detail.value = null
if (props.user) {
form.name = props.user.name
form.email = props.user.email
form.description = props.user.description
void loadDetail(props.user.id)
}
void loadIdentitySetting()
},
)
async function loadIdentitySetting() {
try {
emailRequired.value = (await getIdentitySetting(props.cfgId)).primaryEmailRequired
} catch {
emailRequired.value = false
}
}
// 拉取域档案并回填表单;返回时弹窗已切到别的用户则丢弃结果
async function loadDetail(userId: string) {
detailLoading.value = true
try {
const d = await getUserDetail(props.cfgId, userId)
if (props.user?.id !== userId) return
detail.value = d
if (!d.inDomain) return
form.givenName = d.givenName
form.familyName = d.familyName
form.grantDomainAdmin = d.isDomainAdmin
form.addToAdminGroup = d.inAdminGroup
} catch {
if (props.user?.id === userId)
message.warning('用户详情加载失败:姓名留空表示不修改,管理员选项仅执行添加')
} finally {
if (props.user?.id === userId) detailLoading.value = false
}
}
const nameRequired = computed(() => !editing.value || !!detail.value?.inDomain)
const nameHint = computed(() => {
if (!editing.value || detailLoading.value) return undefined
if (classicUser.value) return '经典 IAM 用户不支持修改姓名'
if (!detail.value) return '详情加载失败:留空表示不修改,仅身份域用户可改姓名'
return undefined
})
const adminHint = computed(() => {
if (!editing.value)
return form.grantDomainAdmin && form.addToAdminGroup
? '同时勾选时先授予身份域管理员,再加入管理员组'
: undefined
if (detailLoading.value) return '正在加载当前管理员状态…'
if (classicUser.value) return '经典 IAM 用户不在身份域中,无法调整管理员角色与组'
if (!detail.value) return '详情加载失败:仅执行添加操作,不会撤销已有的管理员角色或组成员'
return '已按当前状态勾选;取消勾选保存后将撤销对应的管理员角色 / 组成员'
})
const canSubmit = computed(() => {
const emailOk = !emailRequired.value || !!form.email.trim()
if (!editing.value)
return !!form.name.trim() && !!form.givenName.trim() && !!form.familyName.trim() && emailOk
if (detailLoading.value) return false
if (detail.value?.inDomain && (!form.givenName.trim() || !form.familyName.trim())) return false
return emailOk
})
// 编辑只提交与原值不同的字段;域档案在手时管理员勾选与当前状态比对,取消勾选即撤销
function buildUpdateBody(u: IamUser): UpdateUserBody {
const body: UpdateUserBody = {}
if (form.email.trim() !== u.email) body.email = form.email.trim()
if (form.description.trim() !== u.description) body.description = form.description.trim()
if (classicUser.value) return body
const d = detail.value
if (d?.inDomain) {
if (form.givenName.trim() !== d.givenName) body.givenName = form.givenName.trim()
if (form.familyName.trim() !== d.familyName) body.familyName = form.familyName.trim()
if (form.grantDomainAdmin !== d.isDomainAdmin) body.grantDomainAdmin = form.grantDomainAdmin
if (form.addToAdminGroup !== d.inAdminGroup) body.addToAdminGroup = form.addToAdminGroup
return body
}
if (form.givenName.trim()) body.givenName = form.givenName.trim()
if (form.familyName.trim()) body.familyName = form.familyName.trim()
if (form.grantDomainAdmin) body.grantDomainAdmin = true
if (form.addToAdminGroup) body.addToAdminGroup = true
return body
}
async function submitEdit(u: IamUser) {
const body = buildUpdateBody(u)
if (!Object.keys(body).length) {
emit('update:show', false)
return
}
await updateUser(props.cfgId, u.id, body)
message.success(`用户「${u.name}」已更新`)
}
async function submitCreate() {
const created = await createUser(props.cfgId, {
name: form.name.trim(),
givenName: form.givenName.trim(),
familyName: form.familyName.trim(),
email: form.email.trim() || undefined,
description: form.description.trim() || undefined,
grantDomainAdmin: form.grantDomainAdmin,
addToAdminGroup: form.addToAdminGroup,
})
message.success(`用户「${created.name}」已创建,可再执行「重置密码」下发初始密码`)
}
async function submit() {
if (!canSubmit.value) return
submitting.value = true
try {
if (props.user) await submitEdit(props.user)
else await submitCreate()
emit('update:show', false)
emit('created')
} catch (e) {
message.error(e instanceof Error ? e.message : editing.value ? '更新失败' : '创建失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
:title="editing ? `修改用户 · ${user?.name ?? ''}` : '添加用户'"
:submitting="submitting"
:submit-disabled="!canSubmit"
:submit-text="editing ? '保存' : '创建'"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField v-if="!editing" label="用户名" required>
<NInput v-model:value="form.name" placeholder="new-user" @keyup.enter="submit" />
</FormField>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="名字" :required="nameRequired" :hint="nameHint">
<NInput
v-model:value="form.givenName"
placeholder="First name"
:disabled="classicUser"
:loading="detailLoading"
/>
</FormField>
<FormField label="姓氏" :required="nameRequired">
<NInput
v-model:value="form.familyName"
placeholder="Last name"
:disabled="classicUser"
:loading="detailLoading"
/>
</FormField>
</div>
<FormField
label="邮箱"
:required="emailRequired"
:hint="
emailRequired
? '域已开启「用户需要提供主电子邮件地址」,必须填写'
: '用于接收欢迎邮件与找回密码'
"
>
<NInput v-model:value="form.email" placeholder="user@example.com" />
</FormField>
<FormField label="备注" :hint="editing ? undefined : '留空默认取「名字 姓氏」'">
<NInput v-model:value="form.description" placeholder="可选" />
</FormField>
<div class="mb-2 flex flex-col gap-1.5">
<NCheckbox
v-model:checked="form.grantDomainAdmin"
:disabled="classicUser || detailLoading"
>
{{ editing ? '身份域管理员' : '添加到身份域管理员' }}
</NCheckbox>
<NCheckbox v-model:checked="form.addToAdminGroup" :disabled="classicUser || detailLoading">
{{ editing ? '管理员组(Administrators)成员' : '添加到管理员组(Administrators' }}
</NCheckbox>
<div v-if="adminHint" class="text-xs text-ink-3">{{ adminHint }}</div>
</div>
</FormModal>
</template>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { NButton, NDataTable, NPopconfirm, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
import { h, ref } from 'vue'
import { clearUserMfa, deleteUser, deleteUserApiKeys, listUsers, resetUserPassword } from '@/api/tenant'
import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import UserFormModal from '@/components/tenant/UserFormModal.vue'
import { fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { IamUser } from '@/types/api'
const props = defineProps<{ cfgId: number }>()
const message = useMessage()
const dialog = useDialog()
const users = useAsync(() => listUsers(props.cfgId))
const showForm = ref(false)
const editingUser = ref<IamUser | null>(null)
function openCreate() {
editingUser.value = null
showForm.value = true
}
function openEdit(row: IamUser) {
editingUser.value = row
showForm.value = true
}
async function act(fn: () => Promise<unknown>, ok: string) {
try {
await fn()
message.success(ok)
void users.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
}
}
function confirmDelete(row: IamUser) {
dialog.warning({
title: '删除用户',
content: () =>
h('div', { class: 'text-[13px]' }, [
h('p', `确定删除用户「${row.name}」?此操作不可恢复。`),
h('p', { class: 'mt-1.5 text-ink-3' }, '删除后该用户的全部 API Key 将一并吊销'),
]),
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: () => act(() => deleteUser(props.cfgId, row.id), `已删除用户 ${row.name}`),
})
}
async function resetPwd(row: IamUser) {
try {
const { password } = await resetUserPassword(props.cfgId, row.id)
dialog.success({
title: '一次性密码',
content: () =>
h('div', [
h('p', { class: 'mb-2 text-[13px]' }, `${row.name} 的新密码(首次登录须修改):`),
h('code', { class: 'mono rounded bg-line-soft px-2 py-1 select-all' }, password),
]),
positiveText: '已保存',
})
} catch (e) {
message.error(e instanceof Error ? e.message : '重置失败')
}
}
const columns: DataTableColumns<IamUser> = [
{
title: '用户',
key: 'name',
minWidth: 220,
render: (row) =>
h('div', [
h('div', { class: 'flex items-center gap-2' }, [
h('span', { class: 'font-medium' }, row.name),
row.isCurrentUser
? h(StatusBadge, { kind: 'snatch', label: '当前签名用户', dot: false })
: null,
]),
h('div', { class: 'text-xs text-ink-3 mt-px' }, row.email || row.description || '—'),
]),
},
{
title: '状态',
key: 'lifecycleState',
width: 90,
render: (row) =>
h(StatusBadge, {
kind: row.lifecycleState === 'ACTIVE' ? 'run' : 'stop',
label: row.lifecycleState === 'ACTIVE' ? '活跃' : row.lifecycleState,
}),
},
{
title: 'MFA',
key: 'mfaActivated',
width: 70,
render: (row) => h('span', { class: 'text-[13px] tabular-nums' }, row.mfaActivated ? '已启用' : '未启用'),
},
{
title: '创建时间',
key: 'timeCreated',
width: 150,
render: (row) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(row.timeCreated)),
},
{
title: '操作',
key: 'actions',
width: 290,
render: (row) =>
h('div', { class: 'flex items-center gap-1' }, [
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, { default: () => '修改' }),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => resetPwd(row) }, { default: () => '重置密码' }),
h(
NPopconfirm,
{
onPositiveClick: () =>
act(() => clearUserMfa(props.cfgId, row.id), `已清除 ${row.name} 的全部 MFA`),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '清 MFA' }),
default: () => '清除该用户全部 MFA 因子?',
},
),
h(
NPopconfirm,
{
onPositiveClick: () =>
act(() => deleteUserApiKeys(props.cfgId, row.id), `已删除 ${row.name} 的 API Key`),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '删 Key' }),
default: () => '删除该用户全部 API Key(跳过当前配置使用中的指纹)?',
},
),
row.isCurrentUser
? null
: h(
NButton,
{ size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDelete(row) },
{ default: () => '删除' },
),
]),
},
]
</script>
<template>
<div class="panel">
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">IAM 用户</div>
<NButton size="small" type="primary" @click="openCreate">添加用户</NButton>
</div>
<NDataTable
:columns="columns"
:data="users.data.value ?? []"
:loading="users.loading.value"
:scroll-x="800"
:row-key="(r: IamUser) => r.id"
/>
<FootNote>
重置密码生成一次性密码,用户首次登录须修改;当前配置签名用户不可删除,避免面板失联
</FootNote>
<UserFormModal
v-model:show="showForm"
:cfg-id="cfgId"
:user="editingUser"
@created="users.run()"
/>
</div>
</template>
+41
View File
@@ -0,0 +1,41 @@
/** 5 段 cron 单字段匹配:支持 *、n、*\/k、a-b、逗号组合;超出子集返回 false */
function fieldMatch(field: string, v: number): boolean {
return field.split(',').some((seg) => {
const step = /^\*\/(\d+)$/.exec(seg)
if (step) return Number(step[1]) > 0 && v % Number(step[1]) === 0
const range = /^(\d+)-(\d+)$/.exec(seg)
if (range) return v >= Number(range[1]) && v <= Number(range[2])
return seg === '*' || seg === String(v)
})
}
/** 计算 5 段 cron 的下次执行时间(本地时区):
* 从下一分钟起逐分钟扫描 8 天,表达式非法或窗口内无匹配返回 null。
* 日与周同时受限时按 AND 近似(面板生成的表达式不会同时限制)。 */
export function nextCronRun(expr: string): Date | null {
const p = expr.trim().split(/\s+/)
if (p.length !== 5) return null
const t = new Date()
t.setSeconds(0, 0)
for (let i = 0; i < 8 * 24 * 60; i++) {
t.setMinutes(t.getMinutes() + 1)
if (
fieldMatch(p[0], t.getMinutes()) &&
fieldMatch(p[1], t.getHours()) &&
fieldMatch(p[2], t.getDate()) &&
fieldMatch(p[3], t.getMonth() + 1) &&
fieldMatch(p[4], t.getDay())
)
return new Date(t)
}
return null
}
const pad2 = (n: number) => String(n).padStart(2, '0')
/** 下次执行的短文案(MM-DD HH:mm);无法计算返回空串 */
export function nextCronRunLabel(expr: string): string {
const d = nextCronRun(expr)
if (!d) return ''
return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
}
+83
View File
@@ -0,0 +1,83 @@
/**
* 系统日志「动作」中文化:路由模板 + 方法 → 人话描述。
* 精确匹配优先,未命中回退资源段兜底;新增路由不强求登记,兜底可读。
*/
const exact: Record<string, string> = {
'POST /api/v1/auth/login': '登录面板',
'POST /api/v1/auth/totp/setup': '发起两步验证绑定',
'POST /api/v1/auth/totp/activate': '启用两步验证',
'POST /api/v1/auth/totp/disable': '停用两步验证',
'DELETE /api/v1/auth/identities/:id': '解绑外部身份',
'PUT /api/v1/settings/telegram': '保存 Telegram 通知配置',
'POST /api/v1/settings/telegram/test': '发送通知测试消息',
'PUT /api/v1/settings/notify-events': '保存通知管理开关',
'PUT /api/v1/settings/task': '保存任务设置',
'PUT /api/v1/settings/security': '保存安全设置',
'PUT /api/v1/settings/oauth': '保存登录方式配置',
'POST /api/v1/oci-configs': '导入租户 API Key',
'PUT /api/v1/oci-configs/:id': '修改租户配置',
'DELETE /api/v1/oci-configs/:id': '删除租户配置',
'POST /api/v1/oci-configs/:id/verify': '租户测活',
'POST /api/v1/oci-configs/:id/region-subscriptions': '订阅区域',
'POST /api/v1/oci-configs/:id/instances': '创建实例',
'PUT /api/v1/oci-configs/:id/instances/:instanceId': '修改实例配置',
'DELETE /api/v1/oci-configs/:id/instances/:instanceId': '终止实例',
'POST /api/v1/oci-configs/:id/instances/:instanceId/action': '实例电源操作',
'POST /api/v1/oci-configs/:id/instances/:instanceId/change-public-ip': '更换公网 IP',
'POST /api/v1/oci-configs/:id/instances/:instanceId/ipv6-addresses': '添加 IPv6 地址',
'DELETE /api/v1/oci-configs/:id/instances/:instanceId/ipv6-addresses': '删除 IPv6 地址',
'POST /api/v1/oci-configs/:id/instances/:instanceId/replace-boot-volume': '更换引导卷',
'POST /api/v1/oci-configs/:id/users': '创建租户用户',
'PUT /api/v1/oci-configs/:id/users/:userId': '修改租户用户',
'DELETE /api/v1/oci-configs/:id/users/:userId': '删除租户用户',
'POST /api/v1/oci-configs/:id/users/:userId/reset-password': '重置用户密码',
'DELETE /api/v1/oci-configs/:id/users/:userId/mfa-devices': '移除用户 MFA',
'DELETE /api/v1/oci-configs/:id/users/:userId/api-keys': '清除用户 API Key',
'POST /api/v1/oci-configs/:id/log-webhook': '生成回传回调地址',
'DELETE /api/v1/oci-configs/:id/log-webhook': '撤销回传回调地址',
'POST /api/v1/oci-configs/:id/log-relay': '一键创建回传链路',
'DELETE /api/v1/oci-configs/:id/log-relay': '销毁回传链路',
'POST /api/v1/tasks': '创建后台任务',
'PUT /api/v1/tasks/:id': '修改后台任务',
'DELETE /api/v1/tasks/:id': '删除后台任务',
'POST /api/v1/tasks/:id/run': '手动执行任务',
'POST /api/v1/webhooks/oci-logs/:secret': '回传消息投递',
}
/** 资源段兜底词典:精确表未命中时以「动词 + 资源」组合 */
const nouns: Record<string, string> = {
vcns: 'VCN',
subnets: '子网',
'security-lists': '安全列表',
'boot-volumes': '引导卷',
'boot-volume-attachments': '引导卷挂载',
'volume-attachments': '块卷挂载',
'console-sessions': '控制台会话',
'console-connections': '控制台连接',
'notification-recipients': '通知收件人',
'password-policies': '密码策略',
'identity-settings': '身份设置',
'identity-providers': '身份提供方',
'sign-on-exemptions': 'MFA 豁免',
proxies: '代理',
}
const verbs: Record<string, string> = { POST: '创建', PUT: '修改', DELETE: '删除' }
/** 取路径中最后一个可翻译的资源段(跳过 :param 与动作尾段) */
function lastNoun(path: string): string {
const segs = path.split('/').filter((s) => s && !s.startsWith(':'))
for (let i = segs.length - 1; i >= 0; i--) {
const zh = nouns[segs[i]]
if (zh) return zh
}
return ''
}
export function actionLabel(method: string, path: string): string {
const hit = exact[`${method} ${path}`]
if (hit) return hit
const noun = lastNoun(path)
if (noun) return `${verbs[method] ?? method}${noun}`
return ''
}
+36
View File
@@ -0,0 +1,36 @@
import { ref, type Ref } from 'vue'
interface AsyncState<T> {
data: Ref<T | null>
loading: Ref<boolean>
error: Ref<string>
/** silent 静默刷新(不改 loading),用于过渡态轮询等后台刷新,避免闪加载态 */
run: (opts?: { silent?: boolean }) => Promise<void>
}
/** 加载一份异步数据:统一 loading / error / 重试语义;并发 run 时只保留最后一次的结果 */
export function useAsync<T>(fetcher: () => Promise<T>, immediate = true): AsyncState<T> {
const data = ref<T | null>(null) as Ref<T | null>
const loading = ref(false)
const error = ref('')
let seq = 0
async function run(opts?: { silent?: boolean }) {
const cur = ++seq
if (!opts?.silent) loading.value = true
error.value = ''
try {
const result = await fetcher()
if (cur !== seq) return
data.value = result
} catch (e) {
if (cur !== seq) return
error.value = e instanceof Error ? e.message : String(e)
} finally {
if (cur === seq) loading.value = false
}
}
if (immediate) void run()
return { data, loading, error, run }
}
+20
View File
@@ -0,0 +1,20 @@
import { onMounted, onUnmounted } from 'vue'
/**
* 周期静默刷新:每 ms 毫秒调用一次 fn;页面隐藏(标签页切走)或上一轮
* 尚未完成时跳过本轮,组件卸载自动清理。fn 自行保证静默(不置 loading、不弹错)。
*/
export function useAutoRefresh(fn: () => Promise<void>, ms = 5000) {
let timer: ReturnType<typeof setInterval> | undefined
let busy = false
onMounted(() => {
timer = setInterval(() => {
if (busy || document.hidden) return
busy = true
void fn().finally(() => {
busy = false
})
}, ms)
})
onUnmounted(() => clearInterval(timer))
}
+37
View File
@@ -0,0 +1,37 @@
/** OCI 审计 / 回传事件名的中文标签。
* 审计事件 eventName 为尾名(LaunchInstance);回传 eventType 为全限定名
* (com.oraclecloud.ComputeApi.TerminateInstance),先取尾段再匹配,大小写不敏感。 */
const EVENT_LABELS: Record<string, string> = {
launchinstance: '创建实例',
terminateinstance: '终止实例',
instanceaction: '实例电源操作',
updateinstance: '修改实例',
changeinstancecompartment: '移动实例',
createuser: '创建用户',
deleteuser: '删除用户',
updateuser: '修改用户',
createapikey: '创建 API 密钥',
deleteapikey: '删除 API 密钥',
uploadapikey: '上传 API 密钥',
updateusercapabilities: '修改用户能力',
createregionsubscription: '订阅区域',
createpolicy: '创建策略',
updatepolicy: '修改策略',
deletepolicy: '删除策略',
interactivelogin: '控制台登录',
createvcn: '创建 VCN',
deletevcn: '删除 VCN',
createsubnet: '创建子网',
deletesubnet: '删除子网',
}
/** 全限定事件类型的尾段;已是尾名时原样返回 */
export function eventTail(nameOrType: string): string {
const i = nameOrType.lastIndexOf('.')
return i >= 0 ? nameOrType.slice(i + 1) : nameOrType
}
/** 事件中文名;未收录返回空串,调用方回退原名 */
export function eventLabel(nameOrType: string): string {
return EVENT_LABELS[eventTail(nameOrType).toLowerCase()] ?? ''
}
+62
View File
@@ -0,0 +1,62 @@
/** 展示格式化工具:时间、字节、OCID 截断 */
export function fmtTime(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export function fmtRelative(iso: string | null | undefined): string {
if (!iso) return '—'
const diff = Date.now() - new Date(iso).getTime()
if (Number.isNaN(diff)) return '—'
const min = Math.floor(diff / 60000)
if (min < 1) return '刚刚'
if (min < 60) return `${min} 分钟前`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr} 小时前`
return `${Math.floor(hr / 24)} 天前`
}
export function fmtBytes(n: number): string {
if (n <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.min(Math.floor(Math.log2(n) / 10), units.length - 1)
return `${(n / 2 ** (10 * i)).toFixed(1)} ${units[i]}`
}
/** OCID 中段省略:ocid1.instance.oc1..aaaa…mc25a */
export function truncOcid(ocid: string, head = 24, tail = 6): string {
if (!ocid || ocid.length <= head + tail + 1) return ocid
return `${ocid.slice(0, head)}${ocid.slice(-tail)}`
}
/** 可用域展示:RssO:EU-FRANKFURT-1-AD-1 → AD-1 */
export function shortAd(ad: string): string {
const m = ad.match(/AD-\d+$/)
return m ? m[0] : ad
}
/** 区域友好名中括号内的城市短名:Germany Central (Frankfurt) → Frankfurt */
export function regionCity(label: string): string {
const m = /\(([^)]+)\)/.exec(label)
return m ? m[1] : label
}
/** 块存储 VPU/GB 档位:0 低成本 / 10 均衡 / 20 高性能 / 30-120 超高性能 */
export function vpusLabel(vpus: number): string {
if (vpus >= 30) return `${vpus} · 超高性能`
if (vpus >= 20) return `${vpus} · 高性能`
if (vpus >= 10) return `${vpus} · 均衡`
return `${vpus} · 低成本`
}
/** Oracle 官方风格默认资源名:instance-20260703-2136 / vcn-20260509-0252 */
export function defaultResourceName(prefix: string): string {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
return `${prefix}-${date}-${pad(now.getHours())}${pad(now.getMinutes())}`
}
+51
View File
@@ -0,0 +1,51 @@
import { onMounted, onUnmounted, type Ref } from 'vue'
interface ParticleOptions {
/** 粒子数;缺省按画布面积自适应(约每 8000px² 一粒) */
count?: number
}
const PALETTE = ['rgba(201,100,66,', 'rgba(135,134,127,', 'rgba(106,155,204,']
/** 背景飘浮粒子:缓慢上浮 + 透明度呼吸;遵循 prefers-reduced-motion,卸载即停 */
export function useParticles(canvasRef: Ref<HTMLCanvasElement | null>, opts: ParticleOptions = {}) {
let raf = 0
onMounted(() => {
const cv = canvasRef.value
if (!cv || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const ctx = cv.getContext('2d')
if (!ctx) return
cv.width = cv.offsetWidth
cv.height = cv.offsetHeight
const W = cv.width
const H = cv.height
const count = opts.count ?? Math.round((W * H) / 8000)
const ps = Array.from({ length: count }, (_, i) => ({
x: Math.random() * W,
y: Math.random() * H,
r: 0.7 + Math.random() * 1.9,
v: 0.08 + Math.random() * 0.34,
a: 0.12 + Math.random() * 0.38,
c: PALETTE[i % PALETTE.length],
p: Math.random() * 6.28,
}))
const tick = (t: number) => {
ctx.clearRect(0, 0, W, H)
for (const p of ps) {
p.y -= p.v
if (p.y < -4) {
p.y = H + 4
p.x = Math.random() * W
}
const alpha = p.a * (0.6 + 0.4 * Math.sin(t / 1300 + p.p))
ctx.beginPath()
ctx.arc(p.x, p.y, p.r, 0, 6.28318)
ctx.fillStyle = `${p.c}${alpha})`
ctx.fill()
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
})
onUnmounted(() => cancelAnimationFrame(raf))
}
+26
View File
@@ -0,0 +1,26 @@
import { computed } from 'vue'
import { listProxies } from '@/api/proxies'
import { useAsync } from '@/composables/useAsync'
import type { ProxyInfo } from '@/types/api'
/** 代理出口地区短文案:未探测=检测中、失败=未知 */
export function proxyGeoLabel(p: ProxyInfo): string {
if (!p.geoAt) return '检测中'
if (!p.country) return '未知'
return p.city ? `${p.country}·${p.city}` : p.country
}
/** 出站代理下拉数据源(租户导入 / 修改表单共用):
* 选项 label 为「名称 · 出口地区」,首项为直连哨兵 null。 */
export function useProxyOptions() {
const proxies = useAsync(listProxies)
const options = computed(() => [
{ label: '直连(不使用代理)', value: null as number | null },
...(proxies.data.value?.items ?? []).map((p) => ({
label: `${p.name} · ${proxyGeoLabel(p)}`,
value: p.id as number | null,
})),
])
return { proxies, options }
}
+33
View File
@@ -0,0 +1,33 @@
import { computed, ref, type Ref } from 'vue'
import { listRegions } from '@/api/configs'
import type { RegionInfo } from '@/types/api'
/** 模块级缓存:本地静态区域表全站只拉一次 */
const regions: Ref<RegionInfo[] | null> = ref(null)
let pending: Promise<void> | null = null
function ensureLoaded() {
if (regions.value || pending) return
pending = listRegions()
.then((list) => {
regions.value = list
})
.catch(() => {
pending = null
})
}
/** 区域友好名:eu-frankfurt-1 / iad(短名 key)→ 友好名,未收录时回退原名 */
export function useRegionAlias() {
ensureLoaded()
const byName = computed(() => new Map((regions.value ?? []).map((r) => [r.name, r.alias])))
const byKey = computed(
() => new Map((regions.value ?? []).map((r) => [r.key.toLowerCase(), r.alias])),
)
function alias(name: string | null | undefined): string {
if (!name) return '—'
return byName.value.get(name) ?? byKey.value.get(name.toLowerCase()) ?? name
}
return { alias, regions }
}
+82
View File
@@ -0,0 +1,82 @@
import { useMessage } from 'naive-ui'
import { h, ref, type Ref, type VNodeChild } from 'vue'
type ToastKind = 'error' | 'warning'
const AUTO_CLOSE_MS = 8000
function renderDetail(kind: ToastKind, detail: string, copied: Ref<boolean>): VNodeChild {
return h('div', { class: 'toast-code' }, [
h('div', { class: 'toast-code-head' }, [
h('span', { class: `toast-tag toast-tag-${kind}` }, kind === 'error' ? 'ERROR' : 'WARN'),
h(
'button',
{
class: 'toast-copy',
onClick: () => {
void navigator.clipboard.writeText(detail)
copied.value = true
},
},
copied.value ? '已复制' : '⎘ 复制',
),
]),
h('pre', { class: 'toast-code-pre' }, detail),
])
}
function renderToast(
kind: ToastKind,
text: string,
detail: string,
expanded: Ref<boolean>,
copied: Ref<boolean>,
toggle: () => void,
): VNodeChild {
const rows: VNodeChild[] = [
h('div', { class: 'toast-line' }, [
h('span', text),
h('a', { class: 'toast-toggle', onClick: toggle }, expanded.value ? '收起 ▴' : '展开详情 ▾'),
]),
]
if (expanded.value) rows.push(renderDetail(kind, detail, copied))
return h('div', { class: 'toast-body' }, rows)
}
/**
* 带可展开详情的消息封装:error/warning 传 detail 时,toast 内嵌
* 「展开详情」代码块(带复制);展开期间暂停 8s 自动关闭,收起后重新计时。
*/
export function useToast() {
const message = useMessage()
function show(kind: ToastKind, text: string, detail?: string) {
if (!detail) {
message[kind](text)
return
}
const expanded = ref(false)
const copied = ref(false)
let timer = 0
const inst = message[kind](
() => renderToast(kind, text, detail, expanded, copied, toggle),
{ duration: 0, closable: true },
)
const arm = () => {
timer = window.setTimeout(() => inst.destroy(), AUTO_CLOSE_MS)
}
function toggle() {
expanded.value = !expanded.value
window.clearTimeout(timer)
if (!expanded.value) arm()
}
arm()
}
return {
success: (text: string) => message.success(text),
info: (text: string) => message.info(text),
error: (text: string, detail?: string) => show('error', text, detail),
warning: (text: string, detail?: string) => show('warning', text, detail),
}
}
+33
View File
@@ -0,0 +1,33 @@
import { onUnmounted, watch } from 'vue'
/** OCI 实例的过渡状态:处于这些状态时数据会在短时间内继续变化 */
export const INSTANCE_TRANSIENT_STATES = [
'PROVISIONING',
'STARTING',
'STOPPING',
'TERMINATING',
'CREATING_IMAGE',
'MOVING',
]
/** 挂载关系的过渡状态 */
export const ATTACHMENT_TRANSIENT_STATES = ['ATTACHING', 'DETACHING']
/**
* 过渡态自动轮询:每次数据源更新后,若仍处于过渡态则延时触发一次 refresh,
* 进入稳态自动停止;组件卸载时清理定时器。
* 依赖 useAsync 每次 run 成功都会产生新的数据引用来驱动 watch。
*/
export function useTransientPoll<T>(
source: () => T,
isTransient: (v: T) => boolean,
refresh: () => void,
intervalMs = 5000,
) {
let timer: ReturnType<typeof setTimeout> | undefined
watch(source, (v) => {
clearTimeout(timer)
if (isTransient(v)) timer = setTimeout(refresh, intervalMs)
})
onUnmounted(() => clearTimeout(timer))
}
+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>
+12
View File
@@ -0,0 +1,12 @@
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+103
View File
@@ -0,0 +1,103 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{
// 网页 VNC 独立页:从实例详情新标签页打开,无侧栏布局
path: '/console/vnc/:cfgId/:instanceId',
name: 'vnc-console',
component: () => import('@/views/VncConsoleView.vue'),
},
{
path: '/',
component: () => import('@/layouts/AppLayout.vue'),
children: [
{ path: '', name: 'overview', component: () => import('@/views/OverviewView.vue') },
{ path: 'tenants', name: 'tenants', component: () => import('@/views/TenantListView.vue') },
{
path: 'tenants/:id',
name: 'tenant-detail',
component: () => import('@/views/TenantDetailView.vue'),
},
{
path: 'instances',
name: 'instances',
component: () => import('@/views/InstanceListView.vue'),
},
{
path: 'instances/:cfgId/:instanceId',
name: 'instance-detail',
component: () => import('@/views/InstanceDetailView.vue'),
},
{ path: 'network', name: 'network', component: () => import('@/views/NetworkListView.vue') },
{
path: 'network/:cfgId/vcns/:vcnId',
name: 'vcn-detail',
component: () => import('@/views/VcnDetailView.vue'),
},
{
path: 'storage/boot-volumes',
name: 'boot-volumes',
component: () => import('@/views/BootVolumeListView.vue'),
},
{ path: 'tasks', name: 'tasks', component: () => import('@/views/TaskListView.vue') },
{
path: 'ai-gateway',
name: 'ai-gateway',
component: () => import('@/views/AiGatewayView.vue'),
},
{
path: 'tasks/:taskId',
name: 'task-detail',
component: () => import('@/views/TaskDetailView.vue'),
},
{
path: 'proxies',
name: 'proxies',
component: () => import('@/views/ProxyListView.vue'),
},
{
path: 'logs',
name: 'logs',
component: () => import('@/views/LogsView.vue'),
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
},
],
},
{
// 全局限速拦截页:request 层收到非登录接口的 429 时跳转
path: '/blocked',
name: 'blocked',
component: () => import('@/views/errors/BlockedView.vue'),
meta: { public: true },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/errors/NotFoundView.vue'),
meta: { public: true },
},
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (!to.meta.public && !auth.isAuthed) return { name: 'login', query: { redirect: to.fullPath } }
if (to.name === 'login' && auth.isAuthed) return { name: 'overview' }
return true
})
export default router
+37
View File
@@ -0,0 +1,37 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
const DARK_KEY = 'oci-portal:dark'
function initialDark(): boolean {
const saved = localStorage.getItem(DARK_KEY)
if (saved !== null) return saved === '1'
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
/** 跨页面共享的界面状态:侧栏收缩、明暗主题 */
export const useAppStore = defineStore('app', () => {
const sidebarCollapsed = ref(false)
/** 暗色主题:默认跟随系统,手动切换后按选择记忆 */
const dark = ref(initialDark())
// html.dark 驱动 main.css 的变量覆盖,Naive 主题在 App.vue 跟随
watch(
dark,
(v) => {
document.documentElement.classList.toggle('dark', v)
localStorage.setItem(DARK_KEY, v ? '1' : '0')
},
{ immediate: true },
)
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
function toggleDark() {
dark.value = !dark.value
}
return { sidebarCollapsed, toggleSidebar, dark, toggleDark }
})
+32
View File
@@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
const TOKEN_KEY = 'oci-portal.token'
const EXPIRES_KEY = 'oci-portal.expiresAt'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem(TOKEN_KEY) ?? '')
const expiresAt = ref(localStorage.getItem(EXPIRES_KEY) ?? '')
const isAuthed = computed(() => {
if (!token.value) return false
if (!expiresAt.value) return true
return new Date(expiresAt.value).getTime() > Date.now()
})
function setSession(newToken: string, newExpiresAt: string) {
token.value = newToken
expiresAt.value = newExpiresAt
localStorage.setItem(TOKEN_KEY, newToken)
localStorage.setItem(EXPIRES_KEY, newExpiresAt)
}
function logout() {
token.value = ''
expiresAt.value = ''
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(EXPIRES_KEY)
}
return { token, expiresAt, isAuthed, setSession, logout }
})
+41
View File
@@ -0,0 +1,41 @@
import { defineStore } from 'pinia'
import { nextTick, ref } from 'vue'
interface SerialTarget {
cfgId: number
instanceId: string
region?: string
instanceName?: string
rootPassword?: string
}
/** 全局串行控制台面板状态:面板挂在 AppLayout,切换路由不销毁会话 */
export const useConsoleStore = defineStore('console', () => {
const show = ref(false)
const cfgId = ref(0)
const instanceId = ref('')
const region = ref<string | undefined>(undefined)
const instanceName = ref('')
const rootPassword = ref('')
/** 打开串行终端;已开着且换了实例时先关旧会话再开新 */
async function openSerial(target: SerialTarget) {
if (show.value) {
if (instanceId.value === target.instanceId && cfgId.value === target.cfgId) return
show.value = false
await nextTick()
}
cfgId.value = target.cfgId
instanceId.value = target.instanceId
region.value = target.region
instanceName.value = target.instanceName ?? ''
rootPassword.value = target.rootPassword ?? ''
show.value = true
}
function close() {
show.value = false
}
return { show, cfgId, instanceId, region, instanceName, rootPassword, openSerial, close }
})
+186
View File
@@ -0,0 +1,186 @@
import { defineStore } from 'pinia'
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue'
import { listCachedCompartments, listCachedRegions, listConfigs } from '@/api/configs'
import { useAsync } from '@/composables/useAsync'
import { useRegionAlias } from '@/composables/useRegionAlias'
import type { OciConfigSummary } from '@/types/api'
const CFG_KEY = 'oci-portal:scope:cfg'
const regionKey = (id: number) => `oci-portal:scope:region:${id}`
const compKey = (id: number) => `oci-portal:scope:comp:${id}`
interface ScopeState {
cfgId: Ref<number>
region: Ref<string>
compartmentId: Ref<string>
currentConfig: ComputedRef<OciConfigSummary | null>
}
/** 切换租户:持久化选择并恢复该租户记忆的区域 / 区间(未开启对应开关时锁定默认值) */
function restoreScope(state: ScopeState) {
const cfg = state.currentConfig.value
if (!cfg) return
localStorage.setItem(CFG_KEY, String(cfg.id))
state.region.value = (cfg.multiRegion && localStorage.getItem(regionKey(cfg.id))) || cfg.region
state.compartmentId.value =
(cfg.multiCompartment && localStorage.getItem(compKey(cfg.id))) || ''
}
/** 区域 / 区间变化时按租户记忆到 localStorage */
function bindPersistence(state: ScopeState) {
watch(state.region, (v) => {
const cfg = state.currentConfig.value
if (cfg && v) localStorage.setItem(regionKey(cfg.id), v)
})
watch(state.compartmentId, (v) => {
const cfg = state.currentConfig.value
if (!cfg) return
if (v) localStorage.setItem(compKey(cfg.id), v)
else localStorage.removeItem(compKey(cfg.id))
})
}
/**
* 全局资源作用域:租户 / 区域在侧栏选择,区间在资源页选择,
* 实例、网络、存储等资源页共享同一份状态,切页无需重选。
*/
export const useScopeStore = defineStore('scope', () => {
const cfgId = ref<number>(Number(localStorage.getItem(CFG_KEY) ?? 0))
const region = ref('')
const compartmentId = ref('')
// 全局分组段已由租户选择器面板内的分组导航取代,清理旧持久化键
localStorage.removeItem('oci-portal:scope:group')
const configs = useAsync(listConfigs)
const currentConfig = computed(
() => configs.data.value?.find((c) => c.id === cfgId.value) ?? null,
)
const state: ScopeState = { cfgId, region, compartmentId, currentConfig }
/** 区域 / 区间选项来自服务端缓存(未开启对应开关的租户返回锁定值) */
const regionSubs = useAsync(
() =>
currentConfig.value
? listCachedRegions(currentConfig.value.id).catch(() => [])
: Promise.resolve([]),
false,
)
const compartments = useAsync(
() =>
currentConfig.value
? listCachedCompartments(currentConfig.value.id).catch(() => [])
: Promise.resolve([]),
false,
)
// 配置加载后校正失效租户(首次进入或租户已删除)为第一个
watch(
() => configs.data.value,
(list) => {
if (!list?.length) return
if (!list.some((c) => c.id === cfgId.value)) cfgId.value = list[0].id
},
)
watch(
() => currentConfig.value?.id,
() => {
if (!currentConfig.value) return
restoreScope(state)
void regionSubs.run()
void compartments.run()
},
{ immediate: true },
)
bindPersistence(state)
// 订阅列表加载后,记住的区域已退订时回退主区域
watch(
() => regionSubs.data.value,
(subs) => {
if (!subs?.length) return
if (!subs.some((s) => s.name === region.value))
region.value = currentConfig.value?.region ?? ''
},
)
// 区间列表加载后,记住的区间已删除 / 关闭多区间时回退根
watch(
() => compartments.data.value,
(list) => {
if (compartmentId.value && list && !list.some((c) => c.id === compartmentId.value))
compartmentId.value = ''
},
)
const { alias } = useRegionAlias()
const cfgOptions = computed(
() =>
configs.data.value?.map((c) => ({
label: c.alias,
value: c.id,
tenancyName: c.tenancyName,
})) ?? [],
)
const regionOptions = computed(() => {
const subs = regionSubs.data.value ?? []
if (!subs.length && currentConfig.value)
return [{ label: alias(currentConfig.value.region), value: currentConfig.value.region }]
return subs.map((s) => ({
label: s.isHomeRegion ? `${alias(s.name)} · 主` : alias(s.name),
value: s.name,
}))
})
const rootLabel = computed(() =>
currentConfig.value?.tenancyName
? `${currentConfig.value.tenancyName}(根)`
: '根 compartment',
)
const compOptions = computed(() => [
{ label: rootLabel.value, value: '' },
...(compartments.data.value ?? []).map((c) => ({ label: c.name, value: c.id })),
])
/** 未开启多区域 / 多区间支持:对应选择器禁用,锁定默认值 */
const regionDisabled = computed(() => !currentConfig.value?.multiRegion)
const compDisabled = computed(() => !currentConfig.value?.multiCompartment)
/** 租户增删改 / 测活成功后由管理页调用:重载配置并刷新当前租户的
* 区域 / 区间选项;失效值由上方各校正 watch 自动回退。 */
async function refreshConfigs() {
await configs.run()
if (!currentConfig.value) return
void regionSubs.run()
void compartments.run()
}
/** 把指定租户与其主区域设为全局作用域:先把区域记忆写成主区域,
* 再切租户,restoreScope 读到的记忆即主区域(覆盖此前的区域选择)。 */
function setGlobalScope(id: number, homeRegion: string) {
localStorage.setItem(regionKey(id), homeRegion)
if (cfgId.value === id) region.value = homeRegion
else cfgId.value = id
}
return {
refreshConfigs,
setGlobalScope,
cfgId,
region,
compartmentId,
configs,
currentConfig,
regionSubs,
compartments,
cfgOptions,
regionOptions,
compOptions,
regionDisabled,
compDisabled,
}
})
+97
View File
@@ -0,0 +1,97 @@
import type { GlobalThemeOverrides } from 'naive-ui'
import { darkTokens, tokens, type ThemeTokens } from './tokens'
/** Naive UI 主题定制:所有取值来自 tokens.ts,禁止散落硬编码;
* 亮暗两套经同一工厂生成,保证结构一致。 */
function makeOverrides(t: ThemeTokens): GlobalThemeOverrides {
return {
common: {
primaryColor: t.accent,
primaryColorHover: t.accentHover,
primaryColorPressed: t.accentHover,
primaryColorSuppl: t.accent,
errorColor: t.err,
successColor: t.ok,
infoColor: t.info,
warningColor: t.warn,
textColorBase: t.ink,
textColor1: t.ink,
textColor2: t.ink2,
textColor3: t.ink3,
bodyColor: t.bg,
cardColor: t.card,
modalColor: t.card,
popoverColor: t.white,
inputColor: t.white,
borderColor: t.line,
dividerColor: t.lineSoft,
borderRadius: '6px',
borderRadiusSmall: '4px',
fontFamily: t.fontSans,
fontFamilyMono: t.fontMono,
fontSize: '14px',
hoverColor: t.rowHover,
},
Button: {
borderRadiusMedium: '6px',
fontWeight: '500',
},
// 开关表达「启用状态」而非品牌主色:取状态绿,与 StatusBadge 的
// 运行/已启用一致,也避免与相邻红色危险按钮混淆
Switch: {
railColorActive: t.ok,
loadingColor: t.ok,
},
Card: {
borderRadius: '8px',
colorEmbedded: t.card,
},
DataTable: {
thColor: t.card,
thTextColor: t.ink3,
thFontWeight: '500',
// 表头不做 hover 变色(含排序列)
thColorHover: t.card,
thColorHoverSorting: t.card,
tdColor: t.card,
tdColorHover: t.rowHover,
// 排序激活列不再整列变色,与普通列保持一致
tdColorSorting: t.card,
thColorSorting: t.card,
tdColorHoverSorting: t.rowHover,
borderColor: t.lineSoft,
thPaddingMedium: '9px 12px',
tdPaddingMedium: '11px 12px',
},
Input: {
color: t.white,
borderRadius: '6px',
},
Select: {
peers: {
InternalSelection: { color: t.white, borderRadius: '6px' },
},
},
Tabs: {
tabTextColorLine: t.ink2,
tabTextColorActiveLine: t.ink,
barColor: t.accent,
},
// Tooltip 统一为白底卡片风,与 NPopover 一致(默认黑底与整体风格冲突);
// NDataTable ellipsis 提示同走此主题
Tooltip: {
color: t.white,
textColor: t.ink,
borderRadius: '6px',
boxShadow: `0 3px 14px -2px rgba(0, 0, 0, 0.14), 0 0 0 1px ${t.lineSoft}`,
padding: '8px 12px',
},
Dialog: { borderRadius: '8px' },
Tag: { borderRadius: '4px' },
Message: { borderRadius: '6px' },
}
}
export const themeOverrides = makeOverrides(tokens)
export const darkThemeOverrides = makeOverrides(darkTokens)

Some files were not shown because too many files have changed in this diff Show More