docs: add implementation plan for reimburse server column filters.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
3fa1f8e312
commit
e56e1c9144
1 file changed
+781
@@ -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 连续执行,设检查点
|
||||
|
||||
选哪一种?
|
||||
Reference in new issue
Block a user