实现了实体选择框可以直接选择可见范围内的租户/公司
This commit is contained in:
1 parent
6100dc114c
commit
703dea2655
2 files changed
+353
-62
No files matched your search
+343
-62
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user