1、搜索表单 xnSearch
本文基于开源项目 心念后台(xn-admin) :Apache License 2.0 可商用,后端 Java 21 + Spring Boot 4 + Spring Cloud(网关 + 系统 / 文件 / 日志 / 任务),前端提供 React 19、Vue3+TS、Vue3+JS、Vue3 Options API 四套管理端,共用同一套 REST API。前文已介绍整体架构、技术栈与启动方式;本文从内部组件切入,讲 xnSearch(搜索表单) 做什么、怎么接到列表页、前后端如何对接。演示:React · Vue3 TS。源码:后端 · React · Vue3+TS。
组件作用
xnSearch 是后台列表页顶部的查询条。业务页不用手写一排 Input / Select / 日期,而是丢一份字段配置进去,组件负责画出表单,并提供「查询」「重置」。
它解决的是:每个 CRUD 页都要重复做搜索区,字段还经常跟着权限、字典变。xnSearch 让搜索项可以来自后端 page-ui,也可以页面自己写死;点查询时把空条件丢掉,把干净的 queryForm 交给表格去筛数据。
功能:
- 按配置渲染:输入框、数字、下拉、日期、日期时间、日期范围、字典、省市区
- 查询 / 重置;查询前自动去掉空字符串、空数组,避免后端把空值当过滤条件
- 窗口变窄时,放不下的字段折进「展开」,宽屏再全部摊开
- 字典字段走
xnDictSelect(GET /api/dict-data);省市区走前端xnRegion,不打后端 - 和 xnButton、xnTable、xnTreePanel 一起放进 xnPageLayout,组成标准列表页
用在哪: 用户、角色、日志、文件等几乎所有列表页的搜索区。
前端依赖(npm)
| 栈 | 依赖 | 用途 |
|---|---|---|
| Vue3 TS | vue ^3.5、element-plus ^2.14、@element-plus/icons-vue |
表单、输入、日期、按钮 |
| React TS | react ^19、antd ^6 |
Form / Input / Select / DatePicker |
| 共用 | axios、dayjs |
请求 page-ui / 字典;日期字段 |
不额外引入搜索专用库。字典、省市区是项目内组件与数据,不是第三方包。
后端依赖(Maven)与接口
服务:xn-system。Page-UI 配置存在表 sys_page_ui_config,按菜单 path + 当前用户权限过滤后返回。
| Maven 坐标(xn-system) | 用途 |
|---|---|
org.springframework.boot:spring-boot-starter-webmvc |
REST |
org.springframework.boot:spring-boot-starter-data-jpa |
读 Page-UI / 字典 |
org.springframework.boot:spring-boot-starter-security |
按权限裁剪搜索项 |
io.lettuce:lettuce-core |
会话与权限缓存 |
| 接口 | 说明 |
|---|---|
GET /api/page-ui?path= |
当前页搜索项、工具栏按钮、行按钮 |
GET /api/dict-data(及字典类型接口) |
type=dict 字段的选项 |
Java 在 xn-admin-cloud/xn-system:PageUiController / PageUiService / PageUiInitializer / Entity / DTO,以及 DictDataController。
源码位置
路径均相对于 xn-admin/。
| Vue3 TS | React TS | |
|---|---|---|
| 组件 | xn-admin-vue3-ts/src/components/xnSearch/ |
xn-admin-react-ts/src/components/XnSearch/ |
| 配套 | src/types、src/api、src/utils、src/composables |
src/types、src/api、src/utils、src/hooks |
| 后端 | xn-admin-cloud/xn-system 的 PageUi*、DictData* |
同 |
用法
vue
<xnSearch :search-item="pageUi.searchItems" @query-form="onQuery" @reset="onReset" />
单项可设 width 覆盖默认 fieldWidth(200)。深度变更 searchItem 会重置表单与折叠布局。
截图
用户管理列表页上方即为搜索区(用户名、状态等):
截图/react-users-list.png截图/vue3-ts-users-list.png
源码(可直接粘贴 CSDN)
CSDN 请用 Markdown 编辑器整篇粘贴。语言标识必须小写。完整文件见上方工程路径。
1. 字段类型(前后端共用这套约定)
typescript
/** 搜索项控件类型:dict 走字典接口,region 走前端省市区数据 */
export type SearchItemType =
| 'input'
| 'number'
| 'select'
| 'date'
| 'daterange'
| 'datetime'
| 'dict'
| 'region'
export interface SearchItem {
label: string
prop: string // 提交给后端的查询字段名
type: SearchItemType
placeholder?: string
options?: { label: string; value: string | number | boolean | null }[]
dictType?: string // type=dict 时的字典类型;有 options 则不请求
level?: 2 | 3 // type=region:2=省市,3=省市区
width?: string | number
clearable?: boolean
multiple?: boolean
}
export type SearchForm = Record<string, unknown>
export const SEARCH_FIELD_DEFAULT_WIDTH = 200
2. 拉 page-ui(Vue)
typescript
import { onMounted, ref } from 'vue'
import { getPageUiConfig } from '@/api/page-ui'
import { mapButtonItems, mapSearchItems } from '@/utils/page-ui'
/** path 一般用当前路由,如 /users */
export function usePageUi(routePath: string) {
const searchItems = ref([])
const buttonItems = ref([])
const tableButtonItems = ref([])
async function loadPageUi() {
const res = await getPageUiConfig(routePath)
// 后端已按当前用户权限裁剪过
searchItems.value = mapSearchItems(res.data.searchItems ?? [])
buttonItems.value = mapButtonItems(res.data.buttons ?? [])
tableButtonItems.value = mapButtonItems(res.data.tableButtons ?? [])
}
onMounted(loadPageUi)
return { searchItems, buttonItems, tableButtonItems, reloadPageUi: loadPageUi }
}
// api/page-ui.ts
export function getPageUiConfig(path: string) {
return request.get('/page-ui', { params: { path } })
}
3. Vue 3 核心:按 type 渲染 + 查询时丢掉空值
vue
<template>
<el-form :inline="true" :model="form" @submit.prevent="handleQuery">
<!-- 放不下的字段 display:none,点展开再显示 -->
<div v-for="(item, index) in searchItem" :key="item.prop" :style="{ display: fieldDisplay(index) }">
<el-form-item :label="item.label">
<el-input v-if="item.type === 'input'" v-model="form[item.prop]" @keydown.enter.stop="handleQuery" />
<xnDictSelect v-else-if="item.type === 'dict'" :dict-type="item.dictType" v-model="form[item.prop]" />
<xnRegion v-else-if="item.type === 'region'" :level="item.level || 3" v-model="form[item.prop]" />
<el-date-picker v-else-if="item.type === 'daterange'" v-model="form[item.prop]" type="daterange" value-format="YYYY-MM-DD" />
<!-- 还有 number / select / date / datetime,完整见 xnSearch.vue -->
</el-form-item>
</div>
<el-button type="primary" @click="handleQuery">查询</el-button>
<el-button type="warning" @click="handleReset">重置</el-button>
</el-form>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import type { SearchForm, SearchItem } from '@/types/search'
const props = defineProps<{ searchItem: SearchItem[] }>()
const emit = defineEmits<{ queryForm: [form: SearchForm]; reset: [form: SearchForm] }>()
const form = reactive<SearchForm>({})
/** 空字符串 / 空数组不传给列表接口,避免后端把空条件当有效过滤 */
function buildQueryForm() {
const result: SearchForm = { ...form }
for (const key of Object.keys(result)) {
const value = result[key]
if (value !== 0 && (value === '' || value === null || value === undefined)) delete result[key]
if (Array.isArray(value) && value.length === 0) delete result[key]
}
return result
}
function handleQuery() {
emit('queryForm', buildQueryForm())
}
function handleReset() {
// 按字段类型恢复默认:daterange/region 用 [],其余用 ''
emit('reset', buildQueryForm())
}
</script>
4. React 对照:折叠用 slice,查询同样剥空值
tsx
function stripEmpty(form: SearchForm): SearchForm {
const next: SearchForm = {}
for (const [k, v] of Object.entries(form)) {
if (v === '' || v === undefined || v === null) continue
if (Array.isArray(v) && v.length === 0) continue
next[k] = v
}
return next
}
export default function XnSearch({ searchItem = [], onQueryForm, onReset, collapseCount = 3 }: XnSearchProps) {
const [form] = Form.useForm()
const [expanded, setExpanded] = useState(false)
// Vue 按容器宽度测量溢出;React 简化为前 N 项,超出显示展开按钮
const visibleItems = expanded || searchItem.length <= collapseCount
? searchItem
: searchItem.slice(0, collapseCount)
function handleQuery() {
onQueryForm?.(stripEmpty(form.getFieldsValue(true)))
}
return (
<Form form={form} layout="inline" onFinish={handleQuery}>
{visibleItems.map((item) => (
<Form.Item key={item.prop} name={item.prop} label={item.label}>
{item.type === 'dict' ? <XnDictSelect dictType={item.dictType} /> : <Input allowClear />}
</Form.Item>
))}
<Button type="primary" htmlType="submit">查询</Button>
<Button onClick={() => { form.resetFields(); onReset?.(stripEmpty(form.getFieldsValue(true))) }}>重置</Button>
</Form>
)
}
5. 后端:按 path 返回搜索项,按钮按权限裁剪
java
@RestController
@RequestMapping("/api/page-ui")
@RequiredArgsConstructor
public class PageUiController {
private final PageUiService pageUiService;
/** path = 前端路由,如 /users */
@GetMapping
public ApiResponse<PageUiConfigVO> getConfig(@RequestParam String path) {
return ApiResponse.success(pageUiService.getConfigForCurrentUser(path));
}
}
java
@Data
public class PageUiSearchItemDTO {
private String label;
private String prop;
private String type; // 与前端 SearchItemType 对齐
private String permission; // 无权限则前端不渲染该搜索项
private String dictType;
private Integer level;
private Boolean multiple;
private List<PageUiOptionDTO> options = new ArrayList<>();
}
java
@Transactional(readOnly = true)
public PageUiConfigVO getConfigForCurrentUser(String routePath) {
PageUiConfigVO vo = new PageUiConfigVO();
vo.setRoutePath(routePath);
// 搜索项来自 sys_page_ui_config JSON
pageUiConfigRepository.findByRoutePath(routePath).ifPresent(config ->
vo.setSearchItems(filterSearchItems(parseSearchConfig(config.getSearchConfig()))));
// 工具栏 BUTTON、行内 TABLE_BUTTON 来自权限内容,按 RBAC 过滤
Permission menu = resolveMenuPermission(routePath);
if (menu != null) {
vo.setButtons(collectButtons(menu, PermissionType.BUTTON));
vo.setTableButtons(collectButtons(menu, PermissionType.TABLE_BUTTON));
}
return vo;
}
