AiSearch.vue 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. <!--
  2. 搜索表单组件
  3. 基于 AiForm 封装,专门用于列表页搜索场景
  4. -->
  5. <template>
  6. <div class="ai-search-box">
  7. <AiForm
  8. ref="formRef"
  9. v-model:value="formData"
  10. :schema="schema"
  11. :grid-cols="gridCols"
  12. :label-placement="labelPlacement"
  13. :label-width="labelWidth"
  14. :size="size"
  15. :enable-collapse="enableCollapse"
  16. :max-visible-fields="maxVisibleFields"
  17. :show-submit="false"
  18. :show-reset="false"
  19. :context="formContext"
  20. :show-feedback="false"
  21. :y-gap="yGap"
  22. >
  23. <!-- 透传所有插槽 -->
  24. <template v-for="slotName in Object.keys($slots)" #[slotName]="slotProps">
  25. <slot :name="slotName" v-bind="slotProps" />
  26. </template>
  27. <!-- 搜索操作按钮 -->
  28. <template #formAction="{ formData: data }">
  29. <n-space>
  30. <n-button
  31. type="primary"
  32. size="small"
  33. :loading="searchLoading"
  34. :disabled="searchLoading"
  35. @click="handleSearch"
  36. >
  37. <template #icon>
  38. <n-icon><SearchOutline /></n-icon>
  39. </template>
  40. {{ searchText }}
  41. </n-button>
  42. <n-button
  43. size="small"
  44. strong
  45. secondary
  46. :loading="resetLoading"
  47. :disabled="searchLoading || resetLoading"
  48. @click="handleReset"
  49. >
  50. <template #icon>
  51. <n-icon><RefreshOutline /></n-icon>
  52. </template>
  53. {{ resetText }}
  54. </n-button>
  55. <!-- 额外操作按钮插槽 -->
  56. <slot name="extra-actions" :form-data="data" />
  57. </n-space>
  58. </template>
  59. </AiForm>
  60. </div>
  61. </template>
  62. <script setup>
  63. import { RefreshOutline, SearchOutline } from '@vicons/ionicons5'
  64. import { computed, ref, watch } from 'vue'
  65. import AiForm from './AiForm.vue'
  66. const props = defineProps({
  67. // 表单配置(兼容 options 命名)
  68. schema: {
  69. type: Array,
  70. default: () => [],
  71. },
  72. options: {
  73. type: Array,
  74. default: () => [],
  75. },
  76. // 初始值
  77. modelValue: {
  78. type: Object,
  79. default: () => ({}),
  80. },
  81. // 栅格列数
  82. gridCols: {
  83. type: Number,
  84. default: 4,
  85. },
  86. // 标签位置
  87. labelPlacement: {
  88. type: String,
  89. default: 'left',
  90. },
  91. // 标签宽度
  92. labelWidth: {
  93. type: [String, Number],
  94. default: 'auto',
  95. },
  96. // 尺寸
  97. size: {
  98. type: String,
  99. default: 'medium',
  100. },
  101. // 是否启用折叠
  102. enableCollapse: {
  103. type: Boolean,
  104. default: true,
  105. },
  106. // 最大显示字段数
  107. maxVisibleFields: {
  108. type: Number,
  109. default: 3,
  110. },
  111. // 搜索按钮文本
  112. searchText: {
  113. type: String,
  114. default: '搜索',
  115. },
  116. // 重置按钮文本
  117. resetText: {
  118. type: String,
  119. default: '重置',
  120. },
  121. // 上下文对象
  122. context: {
  123. type: Object,
  124. default: () => ({}),
  125. },
  126. // 重置前的钩子函数
  127. beforeReset: {
  128. type: Function,
  129. default: null,
  130. },
  131. // 表单项间距
  132. yGap: {
  133. type: Number,
  134. default: 16,
  135. },
  136. })
  137. const emit = defineEmits(['search', 'reset', 'update:modelValue'])
  138. const formRef = ref(null)
  139. const formData = ref({ ...props.modelValue })
  140. const searchLoading = ref(false)
  141. const resetLoading = ref(false)
  142. // 同步父组件 modelValue 变化到表单(用于设置初始默认值、重置等场景)
  143. let isInternalUpdate = false
  144. watch(() => props.modelValue, (newVal) => {
  145. if (isInternalUpdate) {
  146. isInternalUpdate = false
  147. return
  148. }
  149. // 深比较避免无意义更新
  150. const currentStr = JSON.stringify(formData.value)
  151. const newStr = JSON.stringify(newVal || {})
  152. if (currentStr !== newStr) {
  153. formData.value = { ...newVal }
  154. }
  155. }, { deep: true })
  156. // 兼容 options 和 schema 两种命名
  157. const schema = computed(() => props.schema.length > 0 ? props.schema : props.options)
  158. const formContext = computed(() => ({
  159. ...props.context,
  160. isSearch: true,
  161. }))
  162. /**
  163. * 搜索
  164. */
  165. async function handleSearch() {
  166. // 防止重复点击
  167. if (searchLoading.value || resetLoading.value) {
  168. return
  169. }
  170. try {
  171. searchLoading.value = true
  172. await formRef.value?.validate()
  173. isInternalUpdate = true
  174. emit('search', { ...formData.value })
  175. emit('update:modelValue', { ...formData.value })
  176. }
  177. catch (error) {
  178. console.warn('表单验证失败:', error)
  179. }
  180. finally {
  181. // 延迟 300ms 再关闭 loading,防止连续点击
  182. setTimeout(() => {
  183. searchLoading.value = false
  184. }, 300)
  185. }
  186. }
  187. /**
  188. * 重置
  189. */
  190. async function handleReset() {
  191. // 防止重复点击
  192. if (searchLoading.value || resetLoading.value) {
  193. return
  194. }
  195. try {
  196. resetLoading.value = true
  197. // 执行重置前的钩子
  198. if (props.beforeReset && typeof props.beforeReset === 'function') {
  199. const result = props.beforeReset(formData.value)
  200. // 如果是 Promise,等待执行完成
  201. if (result instanceof Promise) {
  202. await result
  203. }
  204. }
  205. // 重置表单
  206. formRef.value?.reset()
  207. formData.value = {}
  208. isInternalUpdate = true
  209. emit('reset')
  210. emit('update:modelValue', {})
  211. // 重置后自动搜索
  212. await handleSearch()
  213. }
  214. catch (error) {
  215. console.error('重置失败:', error)
  216. }
  217. finally {
  218. setTimeout(() => {
  219. resetLoading.value = false
  220. }, 300)
  221. }
  222. }
  223. /**
  224. * 更新单个字段值
  225. */
  226. function updateField(name, value) {
  227. formData.value[name] = value
  228. }
  229. /**
  230. * 获取表单数据
  231. */
  232. function getFormData() {
  233. return { ...formData.value }
  234. }
  235. /**
  236. * 设置表单数据
  237. */
  238. function setFormData(data) {
  239. formData.value = { ...data }
  240. }
  241. // 暴露方法
  242. defineExpose({
  243. handleSearch,
  244. handleReset,
  245. updateField,
  246. getFormData,
  247. setFormData,
  248. validate: () => formRef.value?.validate(),
  249. reset: () => formRef.value?.reset(),
  250. })
  251. </script>
  252. <style scoped>
  253. .ai-search-box {
  254. padding: 8px 12px 0;
  255. background: var(--bg-primary);
  256. border-bottom: 1px solid var(--border-light);
  257. }
  258. </style>