1、搜索表单 xnSearch(基于:心念后台,后端 Java 21 + Spring Boot 4 + Spring Cloud,前端提供 ReactVue3+TS、Vue3+JS、Vue2+JS

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 交给表格去筛数据。

功能:

  • 按配置渲染:输入框、数字、下拉、日期、日期时间、日期范围、字典、省市区
  • 查询 / 重置;查询前自动去掉空字符串、空数组,避免后端把空值当过滤条件
  • 窗口变窄时,放不下的字段折进「展开」,宽屏再全部摊开
  • 字典字段走 xnDictSelectGET /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
共用 axiosdayjs 请求 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-systemPageUiController / 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/typessrc/apisrc/utilssrc/composables src/typessrc/apisrc/utilssrc/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;
}
相关推荐
计算机魔术师5 小时前
Dwarkesh Patel 对 OpenAI/Hugging Face 事件的爆款解读被指危险误导
前端
自进化Agent智能体6 小时前
Hermes GitHub PR 审查 —— 自动化代码评审
前端
涛涛ing7 小时前
OpenAI Astra 泄露:零样本生成 3D 网页,前端开发者慌了吗?
前端
ssshooter8 小时前
现在网页都能提供 MCP 了?!
前端·人工智能·程序员
杉氧8 小时前
状态管理变迁史:为什么我们放弃了 Redux 选择 Zustand?
android·前端·react native
cidy_988 小时前
OpenCode 手动配置火山方舟 Agent Plan 教程
前端
鹏多多8 小时前
PC 网站接入微信登录,这 10 个坑我替你踩完了!
前端·javascript·vue.js
律宏阔9 小时前
微信小程序使用 Orval + OpenAPI 自动生成接口:Axios 兼容踩坑记录
前端·微信小程序
律宏阔9 小时前
微信小程序集成 TDesign 完整记录:解决 NPM packages not found
前端·微信小程序