|
|
@@ -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 连续执行,设检查点
|
|
|
+
|
|
|
+选哪一种?
|