199 lines
7.9 KiB
Vue
199 lines
7.9 KiB
Vue
<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>
|