实现了实体选择框可以直接选择可见范围内的租户/公司

This commit is contained in:
TermiNexus committed 2026-08-04 17:17:26 +08:00
1 parent 6100dc114c
commit 703dea2655
2 files changed
+353 -62

No files matched your search

+343 -62
View File
@@ -5,6 +5,13 @@
* 基于 el-select 的远程搜索选择器,替代原生 entityPicker 控件。
* 配合 entityPickerPresets 预设或自定义 EntityPickerConfig 使用。
*
* 交互:
* 1. 主输入框(el-select remote):输入关键字远程搜索选择。
* 2. 旁边"浏览"按钮:点击弹出独立面板(el-dialog),面板内:
* - 顶部搜索框(可选输入,不输入即列出全部)
* - 列表展示当前角色可见范围内的所有实体(分页,底部"加载更多")
* - 点击行选中,回填到主输入框
*
* 用法:
* <EntityPicker v-model="row.UserUUID" :config="entityPickerPresets.User" :existing-label="row.UserUUID_label" />
*/
@@ -48,7 +55,7 @@ const emit = defineEmits<{
(e: 'update:label', label: string): void
}>()
/** 下拉选项列表 */
/** 主下拉选项列表(搜索用) */
const options = ref<EntityOption[]>([])
/** 搜索加载态 */
const loading = ref(false)
@@ -57,6 +64,9 @@ const searched = ref(false)
/** 回显标签 */
const displayLabel = ref('')
/** 是否启用分页"加载更多" */
const paged = computed(() => props.config.paged !== false)
/** 占位符:props.placeholder > config.placeholder > 默认 */
const placeholderText = computed(() => props.placeholder || props.config.placeholder || '请选择')
@@ -64,9 +74,41 @@ const placeholderText = computed(() => props.placeholder || props.config.placeho
let debounceTimer: ReturnType<typeof setTimeout> | null = null
/**
* 防抖触发远程搜索(300ms)
* 项目未引入 lodash-es,使用 setTimeout 自定义防抖
* 统一请求 Search 接口(GET/POST 兼容),返回解析后的选项与 has_more
* @param keyword 搜索关键字(空串=全部)
* @param offset 分页偏移(加载更多用)
*/
async function fetchOptions(keyword: string, offset: number): Promise<{ items: EntityOption[]; more: boolean }> {
const cfg = props.config
const searchParam = cfg.searchParam || 'keyword'
const method = cfg.method || 'GET'
const params: Record<string, unknown> = { [searchParam]: keyword }
if (offset > 0) params.offset = offset
let res: unknown
if (method === 'POST') {
res = await post<unknown>(cfg.api, params)
} else {
res = await get<unknown>(cfg.api, params)
}
return {
items: parseOptions(res, cfg),
more: parseHasMore(res),
}
}
/** 解析响应中的 has_more(兼容直接返回与包裹两种结构) */
function parseHasMore(res: unknown): boolean {
if (res && typeof res === 'object') {
const obj = res as Record<string, unknown>
if (typeof obj.has_more === 'boolean') return obj.has_more
if (typeof obj.hasMore === 'boolean') return obj.hasMore
// 兼容 total 字段:total > 当前已加载数即还有更多
if (typeof obj.total === 'number') return obj.total > loadedCount.value
}
return false
}
/** 防抖触发远程搜索(300ms) */
function debounceSearch(keyword: string): void {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
@@ -74,22 +116,16 @@ function debounceSearch(keyword: string): void {
}, 300)
}
/** 执行远程搜索 */
/** 执行远程搜索(重置分页状态) */
async function doSearch(keyword: string): Promise<void> {
const cfg = props.config
// 搜索参数名默认 keyword
const searchParam = cfg.searchParam || 'keyword'
// HTTP 方法默认 GET
const method = cfg.method || 'GET'
loading.value = true
loadedCount.value = 0
hasMore.value = false
try {
let res: unknown
if (method === 'POST') {
res = await post<unknown>(cfg.api, { [searchParam]: keyword })
} else {
res = await get<unknown>(cfg.api, { [searchParam]: keyword })
}
options.value = parseOptions(res, cfg)
const { items, more } = await fetchOptions(keyword, 0)
options.value = items
hasMore.value = more
loadedCount.value = items.length
} catch {
options.value = []
} finally {
@@ -97,6 +133,15 @@ async function doSearch(keyword: string): Promise<void> {
}
}
/** el-select remote-method 回调 */
function remoteSearch(keyword: string): void {
// 关键字为空:不发起搜索(浏览列表由面板负责)
if (!keyword) return
searched.value = true
lastKeyword.value = keyword
debounceSearch(keyword)
}
/**
* 解析响应为选项数组
* 响应拦截器已解包 data,res 通常直接是数组;
@@ -120,18 +165,6 @@ function parseOptions(res: unknown, cfg: EntityPickerConfig): EntityOption[] {
})
}
/** el-select remote-method 回调 */
function remoteSearch(keyword: string): void {
// 关键字为空:重置为回显状态,不发起搜索
if (!keyword) {
options.value = []
searched.value = false
return
}
searched.value = true
debounceSearch(keyword)
}
/** 选中变化处理 */
function handleChange(value: string): void {
emit('update:modelValue', value)
@@ -162,6 +195,86 @@ function syncDisplayLabel(): void {
watch(() => props.existingLabel, syncDisplayLabel, { immediate: true })
watch(() => props.modelValue, syncDisplayLabel)
// ===== 浏览面板(弹窗内列出全部可见实体,分页加载) =====
/** 面板显示状态 */
const browseVisible = ref(false)
/** 面板内搜索关键字 */
const browseKeyword = ref('')
/** 面板内列表 */
const browseList = ref<EntityOption[]>([])
/** 面板内加载态 */
const browseLoading = ref(false)
/** 面板内加载更多态 */
const browseLoadingMore = ref(false)
/** 面板内是否还有更多 */
const browseHasMore = ref(false)
/** 面板内已加载条数 */
const browseLoadedCount = ref(0)
/** 最近一次搜索关键字(供加载更多沿用) */
const lastKeyword = ref('')
/** 主下拉已加载条数(供 parseHasMore 的 total 兼容分支使用) */
const loadedCount = ref(0)
/** 主下拉是否还有更多(加载更多按钮用) */
const hasMore = ref(false)
/** 打开浏览面板:重置状态并加载第一页(不输入关键字=列出全部可见) */
function openBrowse(): void {
if (props.disabled) return
browseVisible.value = true
browseKeyword.value = ''
browseList.value = []
browseLoadedCount.value = 0
browseHasMore.value = false
void loadBrowsePage(0, '')
}
/** 面板内搜索 */
function browseSearch(): void {
browseList.value = []
browseLoadedCount.value = 0
browseHasMore.value = false
void loadBrowsePage(0, browseKeyword.value.trim())
}
/** 加载一页数据(offset=0 时重置,否则追加) */
async function loadBrowsePage(offset: number, keyword: string): Promise<void> {
const isFirst = offset === 0
if (isFirst) browseLoading.value = true
else browseLoadingMore.value = true
try {
const { items, more } = await fetchOptions(keyword, offset)
if (isFirst) {
browseList.value = items
} else {
// 去重追加(防后端重复数据导致 key 冲突)
const existing = new Set(browseList.value.map((o) => o.value))
browseList.value = [...browseList.value, ...items.filter((o) => !existing.has(o.value))]
}
browseHasMore.value = more
browseLoadedCount.value += items.length
} catch {
if (isFirst) browseList.value = []
} finally {
browseLoading.value = false
browseLoadingMore.value = false
}
}
/** 面板内加载更多 */
function browseLoadMore(): void {
if (browseLoadingMore.value || !browseHasMore.value) return
void loadBrowsePage(browseLoadedCount.value, browseKeyword.value.trim())
}
/** 面板内选中:回填到主选择器并关闭面板 */
function browseSelect(opt: EntityOption): void {
emit('update:modelValue', opt.value)
emit('update:label', opt.label)
displayLabel.value = opt.label
browseVisible.value = false
}
// 组件卸载前清理防抖定时器
onBeforeUnmount(() => {
if (debounceTimer) clearTimeout(debounceTimer)
@@ -169,44 +282,134 @@ onBeforeUnmount(() => {
</script>
<template>
<el-select
:model-value="modelValue"
:placeholder="placeholderText"
:disabled="disabled"
:clearable="clearable"
:loading="loading"
filterable
remote
:remote-method="remoteSearch"
@change="handleChange"
>
<!-- 回显选项:有值且未搜索时,显示回显标签 -->
<el-option
v-if="modelValue && !searched"
:value="modelValue"
:label="displayLabel"
/>
<!-- 空结果提示:已搜索但无匹配 -->
<el-option
v-else-if="searched && !loading && options.length === 0"
disabled
label="无匹配"
value="__no_match__"
/>
<!-- 正常选项:主文本 + 副文本 -->
<el-option
v-for="opt in options"
:key="opt.value"
:value="opt.value"
:label="opt.label"
<div class="entity-picker">
<div class="entity-picker-input">
<el-select
:model-value="modelValue"
:placeholder="placeholderText"
:disabled="disabled"
:clearable="clearable"
:loading="loading"
filterable
remote
:remote-method="remoteSearch"
@change="handleChange"
>
<!-- 回显选项:有值且未搜索时,显示回显标签 -->
<el-option
v-if="modelValue && !searched"
:value="modelValue"
:label="displayLabel"
/>
<!-- 空结果提示:已搜索但无匹配 -->
<el-option
v-else-if="searched && !loading && options.length === 0"
disabled
label="无匹配"
value="__no_match__"
/>
<!-- 正常选项:主文本 + 副文本 -->
<el-option
v-for="opt in options"
:key="opt.value"
:value="opt.value"
:label="opt.label"
>
<span>{{ opt.label }}</span>
<span v-if="opt.secondary" class="entity-picker-secondary">{{ opt.secondary }}</span>
</el-option>
</el-select>
<el-button
class="entity-picker-browse-btn"
:disabled="disabled"
title="浏览全部,无需输入"
@click="openBrowse"
>
<el-icon><Search /></el-icon>
</el-button>
</div>
<!-- 浏览面板:列出角色可见范围内的全部实体,分页加载 -->
<el-dialog
v-model="browseVisible"
:title="`选择${placeholderText}`"
width="560px"
append-to-body
destroy-on-close
>
<span>{{ opt.label }}</span>
<span v-if="opt.secondary" class="entity-picker-secondary">{{ opt.secondary }}</span>
</el-option>
</el-select>
<div class="entity-picker-browse">
<div class="entity-picker-browse-search">
<el-input
v-model="browseKeyword"
placeholder="输入关键字搜索,留空展示全部"
clearable
@keyup.enter="browseSearch"
>
<template #append>
<el-button @click="browseSearch">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</div>
<div v-loading="browseLoading" class="entity-picker-browse-list">
<el-empty
v-if="!browseLoading && browseList.length === 0"
description="暂无数据"
:image-size="60"
/>
<div
v-for="opt in browseList"
:key="opt.value"
class="entity-picker-browse-item"
@click="browseSelect(opt)"
>
<span class="entity-picker-browse-label">{{ opt.label }}</span>
<span v-if="opt.secondary" class="entity-picker-browse-secondary">{{ opt.secondary }}</span>
</div>
</div>
<div v-if="paged && browseHasMore" class="entity-picker-browse-more">
<el-button text type="primary" :loading="browseLoadingMore" @click="browseLoadMore">
加载更多
</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<style scoped>
/* 整体:输入框 + 浏览按钮一行 */
.entity-picker {
width: 100%;
}
.entity-picker-input {
display: flex;
gap: 4px;
width: 100%;
.el-select {
flex: 1;
}
}
.entity-picker-browse-btn {
flex-shrink: 0;
}
/* 深色模式:浏览按钮 hover 用半透明主色背景(避免默认 hover 偏白) */
html.dark {
.entity-picker-browse-btn {
&:hover {
background: color-mix(in srgb, var(--el-color-primary) 16%, transparent);
color: var(--el-color-primary-light-3);
}
}
}
/* 副文本:浅色小字,右对齐 */
.entity-picker-secondary {
float: right;
@@ -217,4 +420,82 @@ onBeforeUnmount(() => {
text-overflow: ellipsis;
white-space: nowrap;
}
/* 浏览面板 */
.entity-picker-browse {
display: flex;
flex-direction: column;
gap: 12px;
}
.entity-picker-browse-search {
flex-shrink: 0;
}
/* 列表:固定高度,内部滚动 */
.entity-picker-browse-list {
min-height: 220px;
max-height: 320px;
overflow-y: auto;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
padding: 4px;
box-sizing: border-box;
}
/* 列表项:hover 高亮 */
.entity-picker-browse-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background: var(--el-color-primary-light-9);
}
}
/* 深色模式:列表项 hover 用半透明主色(light-9 在深色下过亮) */
html.dark {
.entity-picker-browse-item {
&:hover {
background: color-mix(in srgb, var(--el-color-primary) 16%, transparent);
}
}
/* 搜索面板内部:el-input 及其 append 按钮在深色下的 hover/边框微调 */
.entity-picker-browse-search {
:deep(.el-input-group__append) {
background: var(--el-fill-color-blank);
color: var(--el-text-color-primary);
&:hover {
background: color-mix(in srgb, var(--el-color-primary) 12%, var(--el-fill-color-blank));
}
}
}
}
.entity-picker-browse-label {
font-size: 14px;
color: var(--el-text-color-primary);
}
.entity-picker-browse-secondary {
font-size: 12px;
color: var(--el-text-color-secondary);
max-width: 50%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 加载更多:居中 */
.entity-picker-browse-more {
flex-shrink: 0;
text-align: center;
}
</style>
+10
View File
@@ -59,6 +59,16 @@ export interface EntityPickerConfig {
searchParam?: string
/** 响应数据路径,默认 data */
resultPath?: string
/**
* 是否在下拉打开时自动加载前 20 条(无需输入关键字直接选择),默认 true。
* 公司/租户等数据量大的实体建议开启,配合 offset 分页逐页加载。
*/
fetchOnOpen?: boolean
/**
* 是否启用"加载更多"分页按钮(下拉底部),默认 true。
* 向后端 Search 接口传递 offset 参数,逐页追加 20 条。
*/
paged?: boolean
/** 关联码表名(可选,用于回显翻译) */
codeTable?: string
}