瀏覽代碼

docs: add implementation plan for reimburse server column filters.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 月之前
父節點
當前提交
e56e1c9144
共有 1 個文件被更改,包括 781 次插入 和 0 次删除
  1. 781 0
      docs/superpowers/plans/2026-09-06-reimburse-apply-server-column-filters.md

+ 781 - 0
docs/superpowers/plans/2026-09-06-reimburse-apply-server-column-filters.md

@@ -0,0 +1,781 @@
+# 报销申请列头整库服务端筛选 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 将【报销申请】列头漏斗从「当前页客户端筛」改为「整库服务端筛」;去掉顶部筛选区;检索仅列头漏斗 + 工具栏快捷搜索。
+
+**Architecture:** 后端抽取可复用的列表过滤拼装(扁平 GET/导出 body 参数);前端新增 `useServerColumnFilters`,改造 `ColumnHeaderFilter` 的 text 为模糊输入;页面去 `filter-block`,藏列时清空该列条件并重查。
+
+**Tech Stack:** FastAPI + SQLAlchemy、Vue 3、Element Plus、既有 `ColumnHeaderFilter` / `useServerPaged` / `usePersistedListColumns`
+
+**Spec:** `docs/superpowers/specs/2026-09-06-reimburse-apply-server-column-filters-design.md`
+
+---
+
+## 文件结构
+
+| 操作 | 文件 | 职责 |
+|------|------|------|
+| Create | `backend/app/services/bx/reimburse_list_filters.py` | 纯函数:根据查询参数拼装 SQLAlchemy filter 列表(含金额/状态多选/单号等) |
+| Modify | `backend/app/api/bx/export_common.py` | `BxApplyListExportBody` 增加列筛字段 |
+| Modify | `backend/app/api/bx/reimburse.py` | `my_reimburses` / `_reimburse_list_filters` 走新拼装;导出共用 |
+| Create | `backend/tests/test_bx_reimburse_list_filters.py` | 过滤拼装单测(不连库:断言表达式结构或对 mock 列) |
+| Create | `web-admin/src/composables/useServerColumnFilters.ts` | 服务端列条件 state、`toQueryParams`、藏列清条件 |
+| Modify | `web-admin/src/composables/useClientColumnFilters.ts` | 导出/对齐 `TextColumnFilterState`:text 改为 `{ text: string }`;enum 仍用 `selected`(见 Task 2) |
+| Modify | `web-admin/src/components/business/column-header-filter/index.vue` | text UI → 模糊输入;确认后仍 emit state |
+| Modify | `web-admin/src/api/bx/index.ts` | `reimburseApi.my` 参数类型扩展 |
+| Modify | `web-admin/src/views/bx/reimburse/apply/index.vue` | 去顶部筛;接服务端列筛;藏列清条件 |
+
+**不变:** CRM、其他 BX 列表、弹窗列表推广;OpenSpec 基线文档(标杆后再补)。
+
+---
+
+### Task 1: 后端过滤拼装函数 + 单测
+
+**Files:**
+- Create: `backend/app/services/bx/reimburse_list_filters.py`
+- Create: `backend/tests/test_bx_reimburse_list_filters.py`
+
+- [ ] **Step 1: 写失败单测(先测 amount / statusIn / applyCode)**
+
+```python
+# backend/tests/test_bx_reimburse_list_filters.py
+from app.services.bx.reimburse_list_filters import build_reimburse_column_filters
+from app.models.bx_models import BxReimburseApply
+
+
+def test_status_in_adds_in_clause():
+    filters = build_reimburse_column_filters(
+        db=None,
+        tenant_no="t1",
+        status_in="draft,pending",
+    )
+    assert len(filters) == 1
+    # SQLAlchemy IN
+    assert "IN" in str(filters[0]).upper() or hasattr(filters[0], "right")
+
+
+def test_apply_code_contains():
+    filters = build_reimburse_column_filters(db=None, tenant_no="t1", apply_code="BX01")
+    assert len(filters) == 1
+    assert "apply_code" in str(filters[0]).lower() or "apply_code" in repr(filters[0])
+
+
+def test_amount_between_min_only():
+    filters = build_reimburse_column_filters(
+        db=None, tenant_no="t1", amount_op="between", amount_min="10", amount_max=""
+    )
+    assert len(filters) == 1
+
+
+def test_amount_eq():
+    filters = build_reimburse_column_filters(
+        db=None, tenant_no="t1", amount_op="eq", amount="100"
+    )
+    assert len(filters) == 1
+
+
+def test_empty_params_yield_no_filters():
+    filters = build_reimburse_column_filters(db=None, tenant_no="t1")
+    assert filters == []
+```
+
+- [ ] **Step 2: 跑测确认失败**
+
+Run:
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_list_filters.py -v
+```
+
+Expected: FAIL(模块不存在)
+
+- [ ] **Step 3: 实现 `build_reimburse_column_filters`**
+
+```python
+# backend/app/services/bx/reimburse_list_filters.py
+"""报销申请列表列头/导出共用过滤条件拼装。"""
+from __future__ import annotations
+
+from decimal import Decimal, InvalidOperation
+from typing import Any, List, Optional
+
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+
+from app.models.bx_models import BxReimburseApply
+from app.services.bx.reimburse import ReimburseService
+from app.utils.bx_date_range import append_create_time_filters
+
+_AMOUNT_OPS = frozenset({"between", "eq", "gt", "gte", "lt", "lte"})
+
+
+def _parse_decimal(raw: Optional[str]) -> Optional[Decimal]:
+    text = str(raw or "").strip()
+    if not text:
+        return None
+    try:
+        return Decimal(text)
+    except (InvalidOperation, ValueError):
+        return None
+
+
+def build_reimburse_column_filters(
+    *,
+    db: Optional[Session],
+    tenant_no: str,
+    keyword: str = "",
+    status: Optional[str] = None,
+    status_in: Optional[str] = None,
+    apply_code: Optional[str] = None,
+    title: Optional[str] = None,
+    expense_name: Optional[str] = None,
+    expense_id: Optional[str] = None,
+    applicant_id: Optional[str] = None,
+    current_node: Optional[str] = None,
+    amount_op: Optional[str] = None,
+    amount: Optional[str] = None,
+    amount_min: Optional[str] = None,
+    amount_max: Optional[str] = None,
+    apply_date: Optional[str] = None,
+    apply_date_from: Optional[str] = None,
+    apply_date_to: Optional[str] = None,
+    ids: Optional[List[str]] = None,
+) -> list:
+    filters: list = []
+
+    # statusIn 优先;否则兼容单值 status
+    statuses: List[str] = []
+    if status_in and str(status_in).strip():
+        statuses = [s.strip() for s in str(status_in).split(",") if s.strip()]
+    elif status and str(status).strip():
+        statuses = [str(status).strip()]
+    if statuses:
+        filters.append(BxReimburseApply.status.in_(statuses))
+
+    kw = (keyword or "").strip()
+    if kw:
+        filters.append(
+            or_(
+                BxReimburseApply.apply_code.contains(kw),
+                BxReimburseApply.applicant_name.contains(kw),
+                BxReimburseApply.title.contains(kw),
+            )
+        )
+
+    code = (apply_code or "").strip()
+    if code:
+        filters.append(BxReimburseApply.apply_code.contains(code))
+
+    title_kw = (title or "").strip()
+    if title_kw:
+        filters.append(BxReimburseApply.title.contains(title_kw))
+
+    node_kw = (current_node or "").strip()
+    if node_kw:
+        filters.append(BxReimburseApply.current_node.contains(node_kw))
+
+    if expense_id and str(expense_id).strip():
+        filters.append(BxReimburseApply.expense_id == str(expense_id).strip())
+
+    if db is not None:
+        expense_filter = ReimburseService.build_expense_name_condition(db, tenant_no, expense_name)
+        if expense_filter is not None:
+            filters.append(expense_filter)
+    else:
+        name_kw = (expense_name or "").strip()
+        if name_kw:
+            filters.append(BxReimburseApply.expense_name.contains(name_kw))
+
+    if applicant_id and str(applicant_id).strip():
+        filters.append(BxReimburseApply.applicant_id == str(applicant_id).strip())
+
+    if ids:
+        filters.append(BxReimburseApply.id.in_([str(i) for i in ids]))
+
+    op = (amount_op or "").strip() or "between"
+    if op in _AMOUNT_OPS:
+        if op == "between":
+            dmin = _parse_decimal(amount_min)
+            dmax = _parse_decimal(amount_max)
+            if dmin is not None:
+                filters.append(BxReimburseApply.amount >= dmin)
+            if dmax is not None:
+                filters.append(BxReimburseApply.amount <= dmax)
+        else:
+            dval = _parse_decimal(amount)
+            if dval is not None:
+                col = BxReimburseApply.amount
+                if op == "eq":
+                    filters.append(col == dval)
+                elif op == "gt":
+                    filters.append(col > dval)
+                elif op == "gte":
+                    filters.append(col >= dval)
+                elif op == "lt":
+                    filters.append(col < dval)
+                elif op == "lte":
+                    filters.append(col <= dval)
+
+    append_create_time_filters(
+        filters,
+        BxReimburseApply,
+        apply_date=apply_date,
+        apply_date_from=apply_date_from,
+        apply_date_to=apply_date_to,
+    )
+    return filters
+```
+
+注意:单测里 `db=None` 时费用项目走主表 `contains` 分支即可。
+
+- [ ] **Step 4: 再跑测**
+
+Run:
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_list_filters.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/bx/reimburse_list_filters.py backend/tests/test_bx_reimburse_list_filters.py
+git commit -m "feat(bx): add shared reimburse list column filter builder."
+```
+
+---
+
+### Task 2: 接线 `my` 列表与导出 body
+
+**Files:**
+- Modify: `backend/app/api/bx/export_common.py`
+- Modify: `backend/app/api/bx/reimburse.py`
+
+- [ ] **Step 1: 扩展 `BxApplyListExportBody`**
+
+在 `BxApplyListExportBody` 增加(保持 `populate_by_name` + alias):
+
+```python
+apply_code: Optional[str] = Field(default=None, alias="applyCode")
+title: Optional[str] = None
+current_node: Optional[str] = Field(default=None, alias="currentNode")
+status_in: Optional[str] = Field(default=None, alias="statusIn")
+amount_op: Optional[str] = Field(default=None, alias="amountOp")
+amount: Optional[str] = None
+amount_min: Optional[str] = Field(default=None, alias="amountMin")
+amount_max: Optional[str] = Field(default=None, alias="amountMax")
+```
+
+- [ ] **Step 2: 改写 `_reimburse_list_filters`**
+
+```python
+from app.services.bx.reimburse_list_filters import build_reimburse_column_filters
+
+def _reimburse_list_filters(body: BxApplyListExportBody, db: Session, tenant_no: str) -> list:
+    return build_reimburse_column_filters(
+        db=db,
+        tenant_no=tenant_no,
+        keyword=body.keyword or "",
+        status=body.status,
+        status_in=body.status_in,
+        apply_code=body.apply_code,
+        title=body.title,
+        expense_name=body.expense_name,
+        expense_id=body.expense_id,
+        applicant_id=body.applicant_id,
+        current_node=body.current_node,
+        amount_op=body.amount_op,
+        amount=body.amount,
+        amount_min=body.amount_min,
+        amount_max=body.amount_max,
+        apply_date=body.apply_date,
+        apply_date_from=body.apply_date_from,
+        apply_date_to=body.apply_date_to,
+        ids=body.ids,
+    )
+```
+
+- [ ] **Step 3: 扩展 `my_reimburses` 查询参数并调用拼装**
+
+在 `my_reimburses` 增加 Query 参数:`applyCode`, `title`, `currentNode`, `statusIn`, `amountOp`, `amount`, `amountMin`, `amountMax`(保留既有 `status`/`keyword`/`expenseName`/`applyDate*`)。
+
+组装 filters 时:
+
+1. 先加「我的」范围 `or_(applicant_id == user.id, create_by == user.id)`
+2. 再 `extend(build_reimburse_column_filters(...))`(把 Query 传入)
+
+删除函数内手写的 keyword/status/expense/date 重复逻辑。
+
+- [ ] **Step 4: 冒烟检查签名**
+
+Run:
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_list_filters.py tests/test_bx_reimburse_my_page_size.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/api/bx/export_common.py backend/app/api/bx/reimburse.py
+git commit -m "feat(bx): wire reimburse my list and export to column filters."
+```
+
+---
+
+### Task 3: 前端类型与 `useServerColumnFilters`
+
+**Files:**
+- Modify: `web-admin/src/composables/useClientColumnFilters.ts`(统一 text state 形状,供组件共用类型)
+- Create: `web-admin/src/composables/useServerColumnFilters.ts`
+
+- [ ] **Step 1: 调整共享类型**
+
+在 `useClientColumnFilters.ts` 中:
+
+```typescript
+/** 文本列:服务端模糊 / 客户端也可只用 text */
+export interface TextColumnFilterState {
+  text: string
+  /** @deprecated 旧客户端勾选;服务端 text 不用 */
+  selected?: string[]
+  keyword?: string
+}
+
+export function isColumnFilterActive(
+  type: ColumnFilterType,
+  state?: ColumnFilterState | null
+): boolean {
+  if (!state) return false
+  if (type === 'text') {
+    const s = state as TextColumnFilterState
+    if (String(s.text || '').trim()) return true
+    return Array.isArray(s.selected) && s.selected.length > 0
+  }
+  if (type === 'enum') {
+    const s = state as TextColumnFilterState
+    return Array.isArray(s.selected) && s.selected.length > 0
+  }
+  // number / datetime 保持原逻辑
+  ...
+}
+```
+
+客户端 `matchOne` 对 `text`:若 `text` 有值则 `String(raw).includes(text)`;否则仍按 `selected`(兼容旧页,本仓库目前仅 apply 页使用)。
+
+- [ ] **Step 2: 实现 `useServerColumnFilters`**
+
+```typescript
+// web-admin/src/composables/useServerColumnFilters.ts
+import { computed, reactive, watch, type Ref } from 'vue'
+import {
+  isColumnFilterActive,
+  type ColumnFilterDef,
+  type ColumnFilterState
+} from '@/composables/useClientColumnFilters'
+
+export function useServerColumnFilters(
+  columnDefs: ColumnFilterDef[],
+  options?: {
+    initialFilters?: Record<string, ColumnFilterState | null>
+    onChange?: () => void
+  }
+) {
+  const defMap = new Map(columnDefs.map((d) => [d.prop, d]))
+  const filters = reactive<Record<string, ColumnFilterState>>({})
+
+  function applyInitial() {
+    const init = options?.initialFilters || {}
+    Object.keys(filters).forEach((k) => delete filters[k])
+    for (const [prop, state] of Object.entries(init)) {
+      const def = defMap.get(prop)
+      if (!def || !state || !isColumnFilterActive(def.type, state)) continue
+      filters[prop] = JSON.parse(JSON.stringify(state))
+    }
+  }
+  applyInitial()
+
+  const activeFilterProps = computed(() =>
+    Object.keys(filters).filter((prop) => {
+      const def = defMap.get(prop)
+      return def && isColumnFilterActive(def.type, filters[prop])
+    })
+  )
+  const hasActiveFilters = computed(() => activeFilterProps.value.length > 0)
+
+  function setFilterState(prop: string, state: ColumnFilterState | null) {
+    const def = defMap.get(prop)
+    if (!def) return
+    if (!state || !isColumnFilterActive(def.type, state)) {
+      delete filters[prop]
+    } else {
+      filters[prop] = JSON.parse(JSON.stringify(state))
+    }
+    options?.onChange?.()
+  }
+
+  function clearFilter(prop: string) {
+    if (!(prop in filters)) return
+    delete filters[prop]
+    options?.onChange?.()
+  }
+
+  function clearAllFilters() {
+    const had = Object.keys(filters).length > 0
+    Object.keys(filters).forEach((k) => delete filters[k])
+    if (had) options?.onChange?.()
+  }
+
+  function resetToInitial() {
+    applyInitial()
+    options?.onChange?.()
+  }
+
+  function isFilterActive(prop: string) {
+    const def = defMap.get(prop)
+    if (!def) return false
+    return isColumnFilterActive(def.type, filters[prop])
+  }
+
+  /** 隐藏列:清该列条件;若确实清过则触发 onChange */
+  function onColumnHidden(prop: string) {
+    if (!(prop in filters)) return
+    delete filters[prop]
+    options?.onChange?.()
+  }
+
+  function toQueryParams(): Record<string, string> {
+    const q: Record<string, string> = {}
+    for (const prop of Object.keys(filters)) {
+      const def = defMap.get(prop)
+      const state = filters[prop]
+      if (!def || !isColumnFilterActive(def.type, state)) continue
+
+      if (def.type === 'datetime') {
+        const s = state as { from?: string; to?: string }
+        if (s.from) q.applyDateFrom = s.from
+        if (s.to) q.applyDateTo = s.to
+        // 通用:若 prop 不是 applyTime,也可映射为 `${prop}From` — 本页仅 applyTime
+        continue
+      }
+      if (def.type === 'number') {
+        const s = state as { op: string; value: string; min: string; max: string }
+        q.amountOp = s.op || 'between'
+        if (s.op === 'between') {
+          if (s.min !== '' && s.min != null) q.amountMin = String(s.min)
+          if (s.max !== '' && s.max != null) q.amountMax = String(s.max)
+        } else if (s.value !== '' && s.value != null) {
+          q.amount = String(s.value)
+        }
+        continue
+      }
+      if (def.type === 'enum') {
+        const s = state as { selected?: string[] }
+        if (s.selected?.length) q.statusIn = s.selected.join(',')
+        continue
+      }
+      // text:prop 直接作为 query key(applyCode/title/expenseName/currentNode)
+      const s = state as { text?: string }
+      const text = String(s.text || '').trim()
+      if (text) q[prop] = text
+    }
+    return q
+  }
+
+  /**
+   * 监听可见列:从有→无时 onColumnHidden。
+   * visibleProps: Ref<string[]> 当前可见的业务列 prop 列表。
+   */
+  function watchVisibleColumns(visibleProps: Ref<string[]>) {
+    watch(
+      visibleProps,
+      (next, prev) => {
+        if (!prev) return
+        const nextSet = new Set(next || [])
+        for (const prop of prev) {
+          if (!nextSet.has(prop)) onColumnHidden(prop)
+        }
+      },
+      { deep: true }
+    )
+  }
+
+  return {
+    filters,
+    hasActiveFilters,
+    activeFilterProps,
+    setFilterState,
+    clearFilter,
+    clearAllFilters,
+    resetToInitial,
+    isFilterActive,
+    onColumnHidden,
+    toQueryParams,
+    watchVisibleColumns
+  }
+}
+```
+
+说明:`toQueryParams` 本页 datetime/number/enum 写死映射到报销 API 字段名(`applyDateFrom`/`amountOp`/`statusIn`)。若日后推广,可在 `ColumnFilterDef` 增加可选 `queryKey` 映射;本轮 YAGNI,仅在注释标明。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/composables/useClientColumnFilters.ts web-admin/src/composables/useServerColumnFilters.ts
+git commit -m "feat(web-admin): add useServerColumnFilters for list column queries."
+```
+
+---
+
+### Task 4: 改造 `ColumnHeaderFilter` 文本模式
+
+**Files:**
+- Modify: `web-admin/src/components/business/column-header-filter/index.vue`
+
+- [ ] **Step 1: text 面板改为模糊输入**
+
+将 `type === 'text'` 从「搜索可选值 + 勾选列表」改为:
+
+```vue
+<template v-if="type === 'text'">
+  <div class="field">
+    <label>模糊检索</label>
+    <el-input
+      v-model="draftText"
+      clearable
+      placeholder="输入关键字,包含即匹配"
+      size="small"
+      @keyup.enter="confirm"
+    />
+  </div>
+  <p class="field-tip">整库模糊匹配本列</p>
+</template>
+
+<template v-else-if="type === 'enum'">
+  <!-- 保留原勾选列表;options 来自 enumOptions;可去掉「本页」文案,改为「可选值」 -->
+  ...
+</template>
+```
+
+- [ ] **Step 2: draft / reset / buildState**
+
+```typescript
+const draftText = ref('')
+
+// resetDraftFromModel for text:
+draftText.value = (m as TextColumnFilterState)?.text || ''
+
+// buildState for text:
+return { text: draftText.value.trim() }
+
+// typeHint for text:
+'文本:输入关键字模糊匹配(整库)'
+```
+
+enum 仍用 `draftSelected` + `selected`。
+
+保留既有 `onDatePickerVisibleChange` / `holdPopoverOpen` 逻辑。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/components/business/column-header-filter/index.vue
+git commit -m "feat(web-admin): use fuzzy text input in column header filter."
+```
+
+---
+
+### Task 5: 改造【报销申请】页面
+
+**Files:**
+- Modify: `web-admin/src/api/bx/index.ts`
+- Modify: `web-admin/src/views/bx/reimburse/apply/index.vue`
+
+- [ ] **Step 1: 扩展 `reimburseApi.my` 类型**
+
+```typescript
+my(params: {
+  page: number
+  pageSize: number
+  status?: string
+  statusIn?: string
+  keyword?: string
+  applyCode?: string
+  title?: string
+  expenseName?: string
+  currentNode?: string
+  amountOp?: string
+  amount?: string
+  amountMin?: string
+  amountMax?: string
+} & BxApplyDateFilter) {
+  return bxRequest.get('/reimburses/my', { params })
+}
+```
+
+- [ ] **Step 2: 删除顶部 `filter-block` 整块模板与 `filterForm` / `handleReset` / `useDbInstantSearch` 对 filterForm 的绑定**
+
+保留工具栏 `ListQuickSearchInput`。
+
+- [ ] **Step 3: 用 `useServerColumnFilters` 替换 `useClientColumnFilters`**
+
+```typescript
+const columnFilterDefs: ColumnFilterDef[] = [
+  { prop: 'applyTime', label: '申请时间', type: 'datetime' },
+  { prop: 'applyCode', label: '报销单号', type: 'text' },
+  { prop: 'title', label: '报销标题', type: 'text' },
+  { prop: 'expenseName', label: '费用项目', type: 'text' },
+  { prop: 'amount', label: '报销金额(元)', type: 'number' },
+  {
+    prop: 'status',
+    label: '状态',
+    type: 'enum',
+    enumOptions: [
+      { value: 'draft', label: '待提交' },
+      { value: 'pending', label: '审批中' },
+      { value: 'approved', label: '审批通过' },
+      { value: 'rejected', label: '审批驳回' }
+    ]
+  },
+  { prop: 'currentNode', label: '当前节点', type: 'text' }
+]
+
+function runListSearch() {
+  search(buildSearchQuery())
+  nextTick(() => recalcTableHeight())
+}
+
+const {
+  filters,
+  hasActiveFilters,
+  setFilterState,
+  clearAllFilters,
+  isFilterActive,
+  toQueryParams,
+  resetToInitial,
+  watchVisibleColumns
+} = useServerColumnFilters(columnFilterDefs, {
+  initialFilters: {}, // 本页无默认;他页迁移顶部默认写这里
+  onChange: () => runListSearch()
+})
+
+// 表格 :data="rows"(不要再用 filteredRows)
+```
+
+列头 `ColumnHeaderFilter`:
+
+- text/enum:`:model-value="filters.xxx || null"`,enum 的 `:options` 改为由 `enumOptions` 映射为 `{ value, label, count: 0 }`(或组件内 enum 不依赖 count)
+- `@update:model-value="(v) => setFilterState('xxx', v)"`(`setFilterState` 已触发 onChange)
+
+注意:避免「确定」时 `setFilterState` → `onChange` → search,与组件内 `close()` 打架;现逻辑即可。
+
+- [ ] **Step 4: `buildSearchQuery`**
+
+```typescript
+function buildSearchQuery() {
+  const q: Record<string, string> = { ...toQueryParams() }
+  const kw = quickSearchKeyword.value.trim()
+  if (kw) q.keyword = kw
+  return q
+}
+```
+
+快捷搜索:`handleQuickSearch` / `handleQuickSearchClear` 调 `runListSearch()`,**不要**清空列条件。  
+「清空列过滤」按钮:`clearAllFilters()`(已 onChange)。  
+原 `handleSearch` 若被工具栏刷新使用:改为 `runListSearch()`,且**不要**再清空 `quickSearchKeyword`(或刷新保留两边条件)。
+
+- [ ] **Step 5: 藏列清条件**
+
+```typescript
+const visibleFilterProps = computed(() => {
+  const props: string[] = ['applyCode'] // 报销单号列不可隐藏时始终算可见
+  for (const def of listColumnDefs) {
+    if (isColumnVisible(def.prop)) props.push(def.prop)
+  }
+  return props
+})
+watchVisibleColumns(visibleFilterProps)
+```
+
+「重置列显示」:
+
+```typescript
+function handleResetColumns() {
+  resetToDefault()
+  resetToInitial()
+}
+```
+
+模板 `@reset-columns="handleResetColumns"`。
+
+- [ ] **Step 6: enum options 供给**
+
+```typescript
+function enumOptionsFor(prop: string) {
+  const def = columnFilterDefs.find((d) => d.prop === prop)
+  return (def?.enumOptions || []).map((o) => ({ ...o, count: 0 }))
+}
+```
+
+状态列:`:options="enumOptionsFor('status')"`,去掉 `collectUniqueOptions`。
+
+- [ ] **Step 7: 手工验收清单(本地 dev)**
+
+1. 无顶部筛选区。  
+2. 金额介于、状态多选、单号模糊、日期段均跨页生效,`total` 变化。  
+3. 快捷搜索与列条件 AND。  
+4. 隐藏「状态」列会清状态条件并刷新。  
+5. 导出与当前筛选一致。  
+6. 选申请时间开始日期后弹窗不关,可继续选结束日期。
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add web-admin/src/api/bx/index.ts web-admin/src/views/bx/reimburse/apply/index.vue
+git commit -m "feat(bx): server-side column filters on reimburse apply list."
+```
+
+- [ ] **Step 9: 更新知识图谱**
+
+```bash
+./scripts/update-graph.sh all
+```
+
+(若只改了前端/后端之一,可只更新对应侧。)
+
+---
+
+## Spec 覆盖自检
+
+| Spec 要求 | Task |
+|-----------|------|
+| 去顶部 filter-block | Task 5 |
+| 列头 + 快捷搜索 AND | Task 5 `buildSearchQuery` |
+| 日期段 / 数字 / 枚举 / 文本模糊 | Task 4 + 5;后端 Task 1–2 |
+| 整库服务端、total 正确 | Task 2 + 5 |
+| 原顶部字段均有列(本页已有) | 无需补列 |
+| 默认值迁移(本页空) | `initialFilters: {}` |
+| 藏列清条件并重查 | Task 3 `onColumnHidden` + Task 5 watch |
+| 再显示不恢复条件;重置列恢复 initial | Task 5 `handleResetColumns` |
+| 导出同参 | Task 2 body + Task 5 `getReimburseApplyExportFilterParams` |
+| 日期弹窗不误关 | Task 4 保留既有 fix |
+
+**无占位符:** 计划内无 TBD;`toQueryParams` 本页写死 API 字段名已标明。
+
+---
+
+## 执行交接
+
+Plan complete and saved to `docs/superpowers/plans/2026-09-06-reimburse-apply-server-column-filters.md`.
+
+**两种执行方式:**
+
+1. **Subagent-Driven(推荐)** — 每任务新开子代理,任务间复查,迭代快  
+2. **Inline Execution** — 本会话按 executing-plans 连续执行,设检查点
+
+选哪一种?