# 结构化搜索

## 使用方法

结构化搜索把一条查询拆成字段、运算符和值。聚焦输入框会列出字段。选中之后，如果这个运算符还要填值，就打开编辑器；布尔值、空运算符和全文搜索会直接变成一条条件，编辑器不留在页面上，焦点回到搜索框。再点一条已经存在的空条件，内置编辑器不会打开，只会再提交一次修改。如果调用方用 `components.empty.editor` 或编辑器插槽换了空运算符的编辑器，点这条条件仍会打开那个编辑器。

输入为空时，退格键删掉最后一条。左右方向键在标签之间移动，回车打开当前标签，退格或删除键去掉它。只读的条件不能改，也不能删。按 Esc 会取消编辑并把焦点还回去。

编辑器的宽度跟搜索条走，最窄大约 400，最宽 720，并且不越过浮层留白（视口两侧各 16）。窗口放不下一个最小宽度时，下限让给这道留白。只有被最大宽度压窄时，编辑器才贴向打开它的那一侧。位置在打开时定住，关掉以后不再跟着搜索条挪。编辑器自己窄于 400 时，字段上下排列，不看整个窗口有多宽。

`createPowerSearchConfig` 根据字段定义生成运算符，并返回一个独立的 `applyFilters`。它只负责把行滤出来，不经过表格自己的列过滤。时间、相对日期、实体和自定义值默认全部保留，调用方要自己解释。嵌套条件默认每一条都要满足；运算符名字里带 any，或者就是 or，则满足任意一条即可。

失焦时标签可以收成一行。`unfocused-inline` 在原处展开，`unfocused-layer` 浮在字段上，不把下面的内容往下推。结果数量是数字时会按单复数读出来，是字符串时原样显示。数量变化会礼貌地播报，第一次出现不播。

禁用原因和禁用一起用时，输入框仍然可以聚焦，悬停或键盘聚焦能看到原因。不要再在外面套一层提示，原生禁用会把悬停吃掉。

```vue
<script setup lang="ts">
import { createPowerSearchConfig, PowerSearch } from '@astryx-vue/core/PowerSearch'
import { computed, ref } from 'vue'

const people = [
  { name: 'Alice', active: true },
  { name: 'Bob', active: false },
]
const { config, applyFilters } = createPowerSearchConfig([
  { key: 'name', type: 'string', label: 'Name' },
  { key: 'active', type: 'boolean', label: 'Active' },
])
const filters = ref([])
const matched = computed(() => applyFilters(filters.value, people))
</script>

<template>
  <PowerSearch v-model:filters="filters" :config="config" :result-count="matched.length" />
</template>
```

## 建议

| 建议 | 做法 |
| --- | --- |
| 推荐 | 字段很多时给它们分组，并给常用字段加上别名，方便搜到。 |
| 推荐 | 全文搜索单独指定一个字符串字段，不要让每个字段都吃掉用户随手打的字。 |
| 避免 | 把结构化搜索塞进表格列上的过滤。列过滤仍然只做包含和枚举匹配。 |
| 避免 | 在嵌套条件还没填完时允许保存。没填完的子条件不能变成一条真正的筛选。 |

## 示例

### 字段和运算符

按姓名、角色、是否在职和年龄筛选。下面的名单跟着当前条件变。

```vue
<script setup lang="ts">
import type { PowerSearchFilter } from '@astryx-vue/core/PowerSearch'
import { createPowerSearchConfig, PowerSearch } from '@astryx-vue/core/PowerSearch'
import { computed, ref } from 'vue'

interface Person {
  name: string
  role: string
  active: boolean
  age: number
}

const people: Person[] = [
  { name: 'Alice', role: 'engineer', active: true, age: 32 },
  { name: 'Bob', role: 'designer', active: false, age: 28 },
  { name: 'Carol', role: 'engineer', active: true, age: 41 },
  { name: 'Diego', role: 'manager', active: true, age: 36 },
]

const { config, applyFilters } = createPowerSearchConfig([
  { key: 'name', type: 'string', label: 'Name' },
  {
    key: 'role',
    type: 'enum',
    label: 'Role',
    enumValues: [
      { value: 'engineer', label: 'Engineer' },
      { value: 'designer', label: 'Designer' },
      { value: 'manager', label: 'Manager' },
    ],
  },
  { key: 'active', type: 'boolean', label: 'Active' },
  { key: 'age', type: 'number', label: 'Age' },
] as const, 'people')

const searchConfig = { ...config, contentSearchFieldKey: 'name' }
const filters = ref<PowerSearchFilter[]>([])
const matched = computed(() => applyFilters(filters.value, people))
</script>

<template>
  <div class="power-search-demo">
    <PowerSearch
      v-model:filters="filters"
      :config="searchConfig"
      :result-count="matched.length"
    />
    <ul>
      <li v-for="person in matched" :key="person.name">
        {{ person.name }}
      </li>
    </ul>
  </div>
</template>

<style scoped>
.power-search-demo {
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: 100%;
}

ul {
  margin: 0;
  padding-left: 1.25rem;
}
</style>
```

### 放不下的标签

失焦时收成一行，末尾写出还藏着几条。聚焦后再展开。

```vue
<script setup lang="ts">
import type { PowerSearchFilter } from '@astryx-vue/core/PowerSearch'
import { createPowerSearchConfig, PowerSearch } from '@astryx-vue/core/PowerSearch'
import { ref } from 'vue'

const { config } = createPowerSearchConfig([
  { key: 'name', type: 'string', label: 'Name' },
  { key: 'city', type: 'string', label: 'City' },
  { key: 'team', type: 'string', label: 'Team' },
  { key: 'active', type: 'boolean', label: 'Active' },
] as const)

const filters = ref<PowerSearchFilter[]>([
  { field: 'name', operator: 'contains', value: { type: 'string', value: 'li' } },
  { field: 'city', operator: 'starts_with', value: { type: 'string', value: 'San' } },
  { field: 'team', operator: 'is', value: { type: 'string', value: 'Design' } },
  { field: 'active', operator: 'is_true', value: { type: 'empty' } },
])
</script>

<template>
  <div class="power-search-narrow">
    <PowerSearch
      v-model:filters="filters"
      :config="config"
      overflow="unfocused-inline"
    />
  </div>
</template>

<style scoped>
.power-search-narrow {
  width: min(100%, 280px);
}
</style>
```

### 组合条件

匹配可以要求全部子条件，也可以只要求其中任意一条。

```vue
<script setup lang="ts">
import type { PowerSearchConfig, PowerSearchFilter } from '@astryx-vue/core/PowerSearch'
import { PowerSearch } from '@astryx-vue/core/PowerSearch'
import { ref } from 'vue'

const config: PowerSearchConfig = {
  name: 'people',
  fields: [
    {
      key: 'match',
      label: 'Match',
      defaultOperator: 'all',
      operators: [
        { key: 'all', label: 'all of', value: { type: 'nested' } },
        { key: 'any', label: 'any of', value: { type: 'nested' } },
      ],
    },
    {
      key: 'name',
      label: 'Name',
      defaultOperator: 'contains',
      operators: [
        { key: 'contains', i18nKey: '@astryx.powersearch.operator.contains', value: { type: 'string' } },
        { key: 'is', i18nKey: '@astryx.powersearch.operator.is', value: { type: 'string' } },
      ],
    },
    {
      key: 'active',
      label: 'Active',
      defaultOperator: 'is_true',
      operators: [
        { key: 'is_true', i18nKey: '@astryx.powersearch.operator.isTrue', value: { type: 'empty' } },
        { key: 'is_false', i18nKey: '@astryx.powersearch.operator.isFalse', value: { type: 'empty' } },
      ],
    },
  ],
}

const filters = ref<PowerSearchFilter[]>([])
</script>

<template>
  <PowerSearch v-model:filters="filters" :config="config" />
</template>
```

### 暂停搜索

索引重建时不能改条件，但输入框仍能聚焦，并说明原因。

```vue
<script setup lang="ts">
import type { PowerSearchFilter } from '@astryx-vue/core/PowerSearch'
import { createPowerSearchConfig, PowerSearch } from '@astryx-vue/core/PowerSearch'
import { ref } from 'vue'

const { config } = createPowerSearchConfig([
  { key: 'name', type: 'string', label: 'Name' },
] as const)

const filters = ref<PowerSearchFilter[]>([
  { field: 'name', operator: 'contains', value: { type: 'string', value: 'Ada' }, isReadOnly: true },
])
</script>

<template>
  <PowerSearch
    v-model:filters="filters"
    :config="config"
    disabled
    disabled-reason="Search is paused while the index rebuilds."
  />
</template>
```

## 属性

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `config` | `PowerSearchConfig` | 必需 | 有哪些字段、运算符，以及每种运算符要什么值。 |
| `filters` | `PowerSearchFilter[]` | - | 当前条件。传入之后由外面保存，配合更新事件使用。 |
| `label` | `string` | 搜索 | 输入框的无障碍名称。 |
| `isLabelHidden` | `boolean` | `true` | 视觉上藏起标签，读屏仍然读得到。不传就隐藏；传 `false` 会显示。旧名 `labelHidden` 同样可用。两个都传时听正名。 |
| `placeholder` | `string` | 搜索…… | 还没有条件时的占位文字。 |
| `autofocus` | `boolean` | `false` | 挂载后立刻聚焦。 |
| `hasClear` | `boolean` | `true` | 有筛选条件时显示全部清除。旧名 `clearable` 同样可用。 |
| `readOnly` | `boolean` | `false` | 不能新增、修改或删除。 |
| `disabled` | `boolean` | `false` | 整个搜索停用。 |
| `disabledMessage` | `string` | - | 停用原因。和 `disabled` 一起用时，悬停或键盘聚焦会显示这段提示，输入框仍可聚焦（带 `aria-disabled`）。旧名 `disabledReason` 同样可用。 |
| `status` | `'default' \| 'success' \| 'warning' \| 'error'` | `default` | 校验状态。 |
| `statusMessage` | `string` | - | 状态说明。 |
| `statusVariant` | `'attached' \| 'detached'` | `attached` | 说明贴在输入框下面，或隔开一段。 |
| `maxTokenLength` | `number` | `40` | 标签里的值最多显示多少个字符。 |
| `maxSearchResults` | `number` | `10` | 输入了文字之后，字段菜单最多几条。空查询最多浏览 1000 个字段。 |
| `maxOperatorMenuItems` | `number` | `10` | 字符串和实体的值补全最多几条。不影响字段菜单和枚举菜单。 |
| `menuWidth` | `number` | - | 字段菜单的精确宽度，单位是像素。不作用于值编辑器。 |
| `popoverSaveButtonLabel` | `string` | 应用 | 筛选编辑器里保存按钮的文字。旧名 `saveLabel` 同样可用。 |
| `timezoneID` | `string` | - | 日期怎么显示所用的时区 ID，例如 America/New_York。旧名 `timeZone` 同样可用。 |
| `overflow` | `'none' \| 'unfocused-inline' \| 'unfocused-layer'` | `none` | 标签放不下时换行、失焦收成一行，或失焦后浮层展开。 |
| `resultCount` | `number \| string` | - | 匹配数量。数字会按结果的单复数格式化。 |
| `size` | `'sm' \| 'md' \| 'lg'` | `md` | 输入框和标签的尺寸。 |
| `components` | `PowerSearchComponents` | - | 按值的类型替换标签或编辑器。 |
| `containerClass` | `string` | - | 加到字段外壳，也就是标签和状态说明那一层。不会进到画边框的外框，也不会进到原生 input。 |
| `containerStyle` | `StyleValue` | - | 字段外壳的行内样式。落点和 `containerClass` 相同。 |
| `controlClass` | `string` | - | 加到画边框的外框。写在组件上的 `class` 也落在这里，不会进到原生 input。 |
| `controlStyle` | `StyleValue` | - | 外框的行内样式。写在组件上的 `style` 也落在这里。 |

## 事件

| 名称 | 载荷 | 说明 |
| --- | --- | --- |
| `update:filters` | 新的条件列表 | 条件变化。可以配合双向绑定。 |
| `change` | 条件、变更类型、下标 | 类型是新增、修改或删除，下标是被碰到的那一条。 |
| `focus` | 焦点事件 | 焦点进入输入框。 |
| `blur` | 焦点事件 | 焦点离开输入框。 |

## 插槽

图标放在条件前面。<code>endContent</code>（也接受旧名 <code>end</code>）放在清除按钮之前，适合再放一个操作。标签和编辑器插槽优先于按类型注册的替换组件。

组件还暴露聚焦和取消聚焦两个方法，给外面的按钮把光标送回输入框。

## 字段定义

`createPowerSearchConfig` 接受字符串、数字、布尔、日期、枚举、枚举列表和字符串列表。字符串有包含、开头、结尾和等于。数字有大小比较。日期有早于、晚于和介于。布尔值不需要再填值。

手写配置时还可以使用时间、相对日期、实体列表、自定义编辑器和嵌套条件。自定义编辑器要同时提供怎么把值读成文字。
