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

This commit is contained in:
Wang Defa
2026-07-09 15:31:29 +08:00
commit db45a669e3
135 changed files with 25220 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { NInput, NPopover, NSpin } from 'naive-ui'
import { computed, nextTick, ref, watch } from 'vue'
import type { OciConfigSummary } from '@/types/api'
/** 租户选择器:Popover 面板(搜索 + 左分组导航 + 右租户列表),替代平铺下拉。
* 单选(v-model:value)点行即选即关;多选(multiple + v-model:values)点行切换勾选。
* 搜索命中时忽略左侧分组限定。 */
const props = withDefaults(
defineProps<{
configs: OciConfigSummary[]
value?: number | null
values?: number[]
multiple?: boolean
clearable?: boolean
placeholder?: string
size?: 'small' | 'medium'
loading?: boolean
placement?: 'bottom-start' | 'bottom-end'
}>(),
{ value: null, values: () => [], placeholder: '选择租户', size: 'medium', placement: 'bottom-start' },
)
const emit = defineEmits<{ 'update:value': [number | null]; 'update:values': [number[]] }>()
/** 「未分组」导航项哨兵(空串已被「全部租户」占用) */
const UNGROUPED = '__ungrouped__'
const show = ref(false)
const query = ref('')
const activeGroup = ref('')
const searchInput = ref<InstanceType<typeof NInput> | null>(null)
watch(show, (v) => {
if (!v) return
query.value = ''
activeGroup.value = ''
void nextTick(() => searchInput.value?.focus())
})
const groups = computed(() => {
const counts = new Map<string, number>()
let ungrouped = 0
for (const c of props.configs) {
if (c.group) counts.set(c.group, (counts.get(c.group) ?? 0) + 1)
else ungrouped++
}
const out = [{ key: '', label: '全部租户', count: props.configs.length }]
for (const g of [...counts.keys()].sort((a, b) => a.localeCompare(b, 'zh-CN')))
out.push({ key: g, label: g, count: counts.get(g) ?? 0 })
if (ungrouped && counts.size) out.push({ key: UNGROUPED, label: '未分组', count: ungrouped })
return out
})
const filtered = computed(() => {
const kw = query.value.trim().toLowerCase()
if (kw)
return props.configs.filter(
(c) => c.alias.toLowerCase().includes(kw) || c.tenancyName.toLowerCase().includes(kw),
)
if (!activeGroup.value) return props.configs
if (activeGroup.value === UNGROUPED) return props.configs.filter((c) => !c.group)
return props.configs.filter((c) => c.group === activeGroup.value)
})
const selectedIds = computed(() => {
if (props.multiple) return new Set(props.values)
return new Set(props.value === null ? [] : [props.value])
})
const displayText = computed(() => {
if (props.multiple) return props.values.length ? `${props.values.length} 个租户` : ''
if (props.value === null) return ''
const cfg = props.configs.find((c) => c.id === props.value)
return cfg?.alias ?? `#${props.value}`
})
function pick(cfg: OciConfigSummary) {
if (!props.multiple) {
emit('update:value', cfg.id)
show.value = false
return
}
const cur = [...props.values]
const i = cur.indexOf(cfg.id)
if (i >= 0) cur.splice(i, 1)
else cur.push(cfg.id)
emit('update:values', cur)
}
function clear(e: MouseEvent) {
e.stopPropagation()
if (props.multiple) emit('update:values', [])
else emit('update:value', null)
}
const showClear = computed(
() => !!props.clearable && (props.multiple ? props.values.length > 0 : props.value !== null),
)
</script>
<template>
<NPopover v-model:show="show" trigger="click" :placement="placement" raw :show-arrow="false">
<template #trigger>
<slot name="trigger">
<button
type="button"
class="group flex w-full cursor-pointer items-center gap-2 rounded-lg border bg-white px-3 text-left transition-colors"
:class="[
size === 'small' ? 'h-7' : 'h-[34px]',
show ? 'border-accent ring-2 ring-accent/15' : 'border-line hover:border-accent/60',
]"
>
<span
class="min-w-0 flex-1 truncate"
:class="[size === 'small' ? 'text-[13px]' : 'text-sm', displayText ? '' : 'text-ink-3']"
>
{{ displayText || placeholder }}
</span>
<svg
v-if="showClear"
class="hidden h-3.5 w-3.5 flex-none text-ink-3 hover:text-ink group-hover:block"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round"
@click="clear"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
<svg
class="h-3.5 w-3.5 flex-none text-ink-3 transition-transform"
:class="[showClear ? 'group-hover:hidden' : '', show ? 'rotate-180' : '']"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</slot>
</template>
<div class="w-[min(560px,92vw)] overflow-hidden rounded-xl border border-line bg-card shadow-[0_18px_44px_rgba(20,20,19,0.16)]">
<div class="px-3.5 pt-3 pb-2.5">
<NInput ref="searchInput" v-model:value="query" size="small" placeholder="搜索租户别名 / 租户名称…" clearable>
<template #prefix>
<svg class="h-3.5 w-3.5 text-ink-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
</template>
</NInput>
</div>
<NSpin :show="!!loading" size="small">
<div class="flex min-h-[248px] border-t border-line-soft">
<div class="flex w-[150px] flex-none flex-col gap-0.5 overflow-y-auto border-r border-line-soft bg-wash p-2 max-md:w-[110px]">
<button
v-for="g in groups"
:key="g.key"
type="button"
class="flex cursor-pointer items-center justify-between gap-1.5 rounded-[7px] px-2.5 py-1.5 text-left text-[12.5px]"
:class="!query && activeGroup === g.key ? 'border border-line bg-card font-semibold' : 'border border-transparent text-ink-2 hover:bg-row-hover'"
@click="activeGroup = g.key"
>
<span class="truncate">{{ g.label }}</span>
<span class="flex-none text-[11px]" :class="!query && activeGroup === g.key ? 'text-accent' : 'text-ink-3'">{{ g.count }}</span>
</button>
</div>
<div class="flex max-h-[300px] min-w-0 flex-1 flex-col gap-[3px] overflow-y-auto p-2">
<button
v-for="c in filtered"
:key="c.id"
type="button"
class="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-left"
:class="selectedIds.has(c.id) ? 'bg-accent/8 outline outline-1 outline-accent/30' : 'hover:bg-row-hover'"
@click="pick(c)"
>
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px] font-semibold">{{ c.alias }}</span>
<span class="mono mt-px block truncate text-[11px] text-ink-3">{{ c.tenancyName }}</span>
</span>
<span class="mono flex-none rounded-[5px] border border-line bg-white px-2 py-0.5 text-[11px] text-ink-2">{{ c.region }}</span>
<span class="w-4 flex-none text-center font-bold text-accent">{{ selectedIds.has(c.id) ? '✓' : '' }}</span>
</button>
<div v-if="!filtered.length" class="py-8 text-center text-xs text-ink-3">
{{ query ? '无匹配租户' : '该分组暂无租户' }}
</div>
</div>
</div>
</NSpin>
<div
v-if="multiple"
class="flex items-center justify-between border-t border-line-soft bg-wash px-3.5 py-2 text-[11.5px] text-ink-3"
>
<span>已选 {{ selectedIds.size }} · 留空表示全部租户</span>
<button type="button" class="cursor-pointer text-accent hover:underline" @click="clear">清空</button>
</div>
</div>
</NPopover>
</template>