初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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] user=ocid1.user.oc1..… fingerprint=xx:xx:… tenancy=ocid1.tenancy.oc1..… 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>
|
||||
@@ -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">
|
||||
创建后用对应私钥执行连接串:串口直接执行 connectionString;VNC 执行 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>
|
||||
@@ -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 shape)。A1 免费额度上限 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>
|
||||
@@ -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 内需自行配置 IP(dhclient / 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 https://198.51.100.2:8443 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>
|
||||
@@ -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/<github|oidc>/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 高性能 / 30–120 超高性能,步进 10">
|
||||
<AppInputNumber
|
||||
v-model:value="form.vpusPerGB"
|
||||
:min="10"
|
||||
:max="120"
|
||||
:step="10"
|
||||
class="w-full"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormModal>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 断言名称 ID(NameID)', 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
将删除租户内的 Connector、Policy、订阅与 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">
|
||||
在租户内自动创建 Topic、CUSTOM_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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 Limits:regions / subscribed-region-count,Tenancy 级) */
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user