# CRM 日期列头双三角排序 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:** 在客户 / 报价(管理+审批)/ 合同(管理+审批)列表的日期列头增加双三角排序,整库服务端升序/降序,取消后恢复默认 `create_time DESC`。 **Architecture:** 前端 `useColumnSort` + `CrmColFilterHeader` 仅对 `datetime` 列渲染双三角;请求带 `sortField`/`sortOrder`。后端共享 `resolve_datetime_order_by`(白名单 Column),接入客户 `list_customers` 与报价/合同 `_build_*_filters` 末尾 order_by(list/export 同路径)。 **Tech Stack:** Vue 3、`CrmColFilterHeader`、FastAPI、SQLAlchemy、pytest **Spec:** `docs/superpowers/specs/2026-09-08-crm-date-column-sort-design.md` **Demo:** `docs/crm-date-column-sort-demo.html` --- ## 文件结构 | 操作 | 文件 | 职责 | |------|------|------| | Create | `backend/app/services/crm/list_sort.py` | 白名单解析 `sortField`/`sortOrder` → SQLAlchemy order 表达式 | | Create | `backend/tests/test_crm_list_sort.py` | 排序解析单测 | | Modify | `backend/app/schemas/crm/customer.py` | `CustomerListBody` + `to_list_kwargs` 增加 sort 字段 | | Modify | `backend/app/crud/crm/customer.py` | `list_customers` 应用用户排序(覆盖 toFollow 默认序) | | Modify | `backend/app/schemas/crm/quote.py` | `QuoteListBody` 增加 `sortField`/`sortOrder` | | Modify | `backend/app/crud/crm/quote.py` | `_build_quote_filters` 末尾用白名单排序 | | Modify | `backend/app/schemas/crm/contract.py` | `ContractListQuery` 增加 `sortField`/`sortOrder` | | Modify | `backend/app/crud/crm/contract.py` | `_build_contract_filters` 末尾用白名单排序 | | Create | `web-admin/src/composables/useColumnSort.ts` | 单列排序状态 + `toQueryParams` + 藏列清除 | | Create | `web-admin/src/composables/useColumnSort.test.ts` | 前端纯逻辑单测(toggle) | | Modify | `web-admin/src/components/business/crm-col-filter-header/index.vue` | datetime 列渲染双三角 | | Modify | `web-admin/src/components/business/column-header-filter/index.vue` | 全局样式 `.col-header-sort-pair` | | Modify | `web-admin/src/views/crm/customer/index.vue` | provide sort、合并查询、藏列联动 | | Modify | `web-admin/src/api/crm/quote/types.ts` | `QuoteQuery` 增加可选 sort 字段 | | Modify | `web-admin/src/views/crm/quote/QuoteListPage.vue` | 同上 | | Modify | `web-admin/src/api/crm/contract/types.ts` | 列表 query 类型增加 sort(若已有 interface) | | Modify | `web-admin/src/views/crm/contract/ContractListPage.vue` | 同上 | **不做:** 产品/调价函/线索/商机/公海;非日期列排序。 --- ### Task 1: 后端 `resolve_datetime_order_by` + 单测 **Files:** - Create: `backend/app/services/crm/list_sort.py` - Create: `backend/tests/test_crm_list_sort.py` - [ ] **Step 1: 写失败单测** ```python # backend/tests/test_crm_list_sort.py from sqlalchemy import Column, DateTime, MetaData, String, Table from app.services.crm.list_sort import resolve_datetime_order_by def _fake_columns(): md = MetaData() t = Table( "t", md, Column("id", String(64)), Column("create_time", DateTime), Column("deal_time", DateTime), ) return { "createTime": t.c.create_time, "dealTime": t.c.deal_time, }, t.c.create_time.desc() def test_valid_asc(): wl, default = _fake_columns() expr = resolve_datetime_order_by("createTime", "asc", wl, default) assert "ASC" in str(expr).upper() or getattr(expr, "modifier", None) is not None # 更稳:比较 clause 指向白名单列 assert expr.element is wl["createTime"] or list(expr.element.base_columns)[0] is list(wl["createTime"].base_columns)[0] def test_valid_desc(): wl, default = _fake_columns() expr = resolve_datetime_order_by("dealTime", "desc", wl, default) assert expr.element.key == "deal_time" or "deal_time" in str(expr) def test_invalid_field_falls_back(): wl, default = _fake_columns() expr = resolve_datetime_order_by("hack", "asc", wl, default) assert expr is default def test_invalid_order_falls_back(): wl, default = _fake_columns() expr = resolve_datetime_order_by("createTime", "up", wl, default) assert expr is default def test_missing_falls_back(): wl, default = _fake_columns() assert resolve_datetime_order_by(None, None, wl, default) is default assert resolve_datetime_order_by("createTime", None, wl, default) is default assert resolve_datetime_order_by(None, "asc", wl, default) is default ``` 若 `expr.element` 断言因 SQLAlchemy 版本差异失败,改为: ```python def test_valid_asc_string(): wl, default = _fake_columns() expr = resolve_datetime_order_by("createTime", "asc", wl, default) s = str(expr.compile(compile_kwargs={"literal_binds": True})) assert "create_time" in s.lower() assert "asc" in s.lower() ``` - [ ] **Step 2: 跑测确认失败** ```bash cd backend && source venv/bin/activate && pytest tests/test_crm_list_sort.py -v ``` Expected: FAIL(模块不存在) - [ ] **Step 3: 实现** ```python # backend/app/services/crm/list_sort.py """CRM 列表日期列排序:白名单 prop → SQLAlchemy order 表达式。""" from __future__ import annotations from typing import Any, Mapping, Optional def resolve_datetime_order_by( sort_field: Optional[str], sort_order: Optional[str], whitelist: Mapping[str, Any], default, ): """ sort_field: 前端 camelCase prop(如 createTime) sort_order: asc | desc(大小写不敏感) whitelist: prop → Column / InstrumentedAttribute 非法或缺参 → 返回 default(通常 create_time.desc()) MySQL 对 NULL:ASC 前、DESC 后,与产品决策一致,无需额外 IS NULL。 """ field = (sort_field or "").strip() order = (sort_order or "").strip().lower() if not field or order not in ("asc", "desc"): return default col = whitelist.get(field) if col is None: return default return col.asc() if order == "asc" else col.desc() ``` - [ ] **Step 4: 跑测通过** ```bash cd backend && source venv/bin/activate && pytest tests/test_crm_list_sort.py -v ``` Expected: PASS - [ ] **Step 5: Commit** ```bash git add backend/app/services/crm/list_sort.py backend/tests/test_crm_list_sort.py git commit -m "feat(crm): add whitelist datetime list sort helper." ``` --- ### Task 2: 客户 list/export 接排序 **Files:** - Modify: `backend/app/schemas/crm/customer.py` - Modify: `backend/app/crud/crm/customer.py` - [ ] **Step 1: Schema 增加字段并写入 `to_list_kwargs`** 在 `CustomerListBody` 中(`columns` 字段附近)增加: ```python sort_field: Optional[str] = Field(default=None, alias="sortField", description="日期列排序字段(prop)") sort_order: Optional[str] = Field(default=None, alias="sortOrder", description="asc|desc") ``` 在 `to_list_kwargs` 的 `kwargs` 中增加: ```python "sort_field": (self.sort_field or "").strip() or None, "sort_order": (self.sort_order or "").strip() or None, ``` - [ ] **Step 2: `list_customers` 应用排序** 文件顶部 import: ```python from app.services.crm.list_sort import resolve_datetime_order_by ``` 将现有: ```python order_by = CrmCustomer.create_time.desc() if view_mode == "toFollow": order_by = CrmCustomer.next_time.asc() ``` 替换为: ```python default_order = CrmCustomer.create_time.desc() if view_mode == "toFollow": default_order = CrmCustomer.next_time.asc() _customer_sort_whitelist = { "lastTime": CrmCustomer.last_time, "nextTime": CrmCustomer.next_time, "dealTime": CrmCustomer.deal_time, "intoPoolTime": CrmCustomer.into_pool_time, "claimTime": CrmCustomer.claim_time, "protectTime": CrmCustomer.protect_time, "createTime": CrmCustomer.create_time, "updateTime": CrmCustomer.update_time, } order_by = resolve_datetime_order_by( filter_params.get("sort_field") or filter_params.get("sortField"), filter_params.get("sort_order") or filter_params.get("sortOrder"), _customer_sort_whitelist, default_order, ) ``` 说明:有合法用户排序时覆盖 `toFollow` 的 `next_time.asc()`;无排序时保持现网 Tab 默认行为。 - [ ] **Step 3: 确认 export 走 `list_customers` + `to_list_kwargs`** 打开 `backend/app/api/crm.py` 客户 export:若已 `**list_body.to_list_kwargs()`,无需改 API。若 export 自建 kwargs,补上 `sort_field`/`sort_order`。 - [ ] **Step 4: Commit** ```bash git add backend/app/schemas/crm/customer.py backend/app/crud/crm/customer.py git commit -m "feat(crm): support datetime column sort on customer list." ``` --- ### Task 3: 报价 list/export 接排序 **Files:** - Modify: `backend/app/schemas/crm/quote.py` - Modify: `backend/app/crud/crm/quote.py` - [ ] **Step 1: `QuoteListBody` 增加** ```python sortField: Optional[str] = None sortOrder: Optional[str] = None ``` (`extra=allow` 已有;显式字段便于文档与类型。) - [ ] **Step 2: 改 `_build_quote_filters` 末尾** 将 `return q.order_by(CrmQuote.create_time.desc())` 改为: ```python from app.services.crm.list_sort import resolve_datetime_order_by # 在函数末尾: default_order = CrmQuote.create_time.desc() whitelist = { "quoteDate": CrmQuote.quote_date, "expireDate": CrmQuote.expire_date, "lastViewTime": CrmQuote.last_view_time, "createTime": CrmQuote.create_time, "updateTime": CrmQuote.update_time, } sort_field = _get_field(filters, "sort_field", "sortField") sort_order = _get_field(filters, "sort_order", "sortOrder") return q.order_by( resolve_datetime_order_by(sort_field, sort_order, whitelist, default_order) ) ``` 确认 `CrmQuote` 确有 `last_view_time` / `quote_date` 等属性(与 models 对齐;若字段名不同以 model 为准)。 - [ ] **Step 3: Commit** ```bash git add backend/app/schemas/crm/quote.py backend/app/crud/crm/quote.py git commit -m "feat(crm): support datetime column sort on quote list." ``` --- ### Task 4: 合同 list/export 接排序 **Files:** - Modify: `backend/app/schemas/crm/contract.py` - Modify: `backend/app/crud/crm/contract.py` - [ ] **Step 1: `ContractListQuery` 增加** ```python sortField: Optional[str] = None sortOrder: Optional[str] = None ``` - [ ] **Step 2: 改 `_build_contract_filters` 末尾** 将 `return q.order_by(CrmContract.create_time.desc())` 改为与报价同模式: ```python from app.services.crm.list_sort import resolve_datetime_order_by default_order = CrmContract.create_time.desc() whitelist = { "signDate": CrmContract.sign_date, "startDate": CrmContract.start_date, "endDate": CrmContract.end_date, "createTime": CrmContract.create_time, "updateTime": CrmContract.update_time, } sort_field = _get_field(filters, "sort_field", "sortField") # 若无 _get_field 则用 filters.get sort_order = _get_field(filters, "sort_order", "sortOrder") return q.order_by( resolve_datetime_order_by(sort_field, sort_order, whitelist, default_order) ) ``` 若合同模块用不同取值函数,改为: ```python sort_field = (filters or {}).get("sortField") or (filters or {}).get("sort_field") sort_order = (filters or {}).get("sortOrder") or (filters or {}).get("sort_order") ``` 确认合同 list API 把 query/body 中的 `sortField` 放进 `filters`(GET `ContractListQuery` → `model_dump`)。 - [ ] **Step 3: Commit** ```bash git add backend/app/schemas/crm/contract.py backend/app/crud/crm/contract.py git commit -m "feat(crm): support datetime column sort on contract list." ``` --- ### Task 5: 前端 `useColumnSort` + 单测 **Files:** - Create: `web-admin/src/composables/useColumnSort.ts` - Create: `web-admin/src/composables/useColumnSort.test.ts` - [ ] **Step 1: 写失败测试(纯函数 toggle)** ```ts // web-admin/src/composables/useColumnSort.test.ts import { describe, expect, it } from 'vitest' import { nextSortState } from './useColumnSort' describe('nextSortState', () => { it('sets asc when empty', () => { expect(nextSortState(null, null, 'createTime', 'asc')).toEqual({ sortField: 'createTime', sortOrder: 'asc' }) }) it('clears when same field and same order', () => { expect(nextSortState('createTime', 'asc', 'createTime', 'asc')).toEqual({ sortField: null, sortOrder: null }) }) it('switches order on same field', () => { expect(nextSortState('createTime', 'asc', 'createTime', 'desc')).toEqual({ sortField: 'createTime', sortOrder: 'desc' }) }) it('replaces field', () => { expect(nextSortState('createTime', 'asc', 'dealTime', 'desc')).toEqual({ sortField: 'dealTime', sortOrder: 'desc' }) }) }) ``` - [ ] **Step 2: 跑测失败** ```bash cd web-admin && pnpm exec vitest run src/composables/useColumnSort.test.ts ``` Expected: FAIL - [ ] **Step 3: 实现 composable** ```ts // web-admin/src/composables/useColumnSort.ts import { computed, ref, watch, type Ref } from 'vue' export type SortOrder = 'asc' | 'desc' export function nextSortState( currentField: string | null, currentOrder: SortOrder | null, prop: string, order: SortOrder ): { sortField: string | null; sortOrder: SortOrder | null } { if (currentField === prop && currentOrder === order) { return { sortField: null, sortOrder: null } } return { sortField: prop, sortOrder: order } } export function useColumnSort(options?: { onChange?: () => void }) { const sortField = ref(null) const sortOrder = ref(null) const hasActiveSort = computed(() => Boolean(sortField.value && sortOrder.value)) function setSort(prop: string, order: SortOrder) { const next = nextSortState(sortField.value, sortOrder.value, prop, order) sortField.value = next.sortField sortOrder.value = next.sortOrder options?.onChange?.() } function clearSort() { if (!sortField.value && !sortOrder.value) return sortField.value = null sortOrder.value = null options?.onChange?.() } function isSortActive(prop: string, order?: SortOrder) { if (!sortField.value || sortField.value !== prop) return false if (order == null) return true return sortOrder.value === order } function toQueryParams(): Record { if (!sortField.value || !sortOrder.value) return {} return { sortField: sortField.value, sortOrder: sortOrder.value } } function onColumnHidden(prop: string) { if (sortField.value === prop) clearSort() } function watchVisibleColumns(visibleProps: Ref) { watch( visibleProps, (next, prev) => { if (!prev) return const nextSet = new Set(next || []) for (const p of prev) { if (!nextSet.has(p)) onColumnHidden(p) } }, { deep: true } ) } return { sortField, sortOrder, hasActiveSort, setSort, clearSort, isSortActive, toQueryParams, onColumnHidden, watchVisibleColumns } } ``` - [ ] **Step 4: 跑测通过** ```bash cd web-admin && pnpm exec vitest run src/composables/useColumnSort.test.ts ``` Expected: PASS - [ ] **Step 5: Commit** ```bash git add web-admin/src/composables/useColumnSort.ts web-admin/src/composables/useColumnSort.test.ts git commit -m "feat(web-admin): add useColumnSort for CRM date headers." ``` --- ### Task 6: `CrmColFilterHeader` 双三角 UI **Files:** - Modify: `web-admin/src/components/business/crm-col-filter-header/index.vue` - Modify: `web-admin/src/components/business/column-header-filter/index.vue`(样式段) - [ ] **Step 1: 扩展 header 模板** 在 label 与 `ColumnHeaderFilter` 之间插入(仅 datetime + 有 sort api): ```vue ``` script 增加: ```ts const sortApi = inject<{ setSort: (prop: string, order: 'asc' | 'desc') => void isSortActive: (prop: string, order?: 'asc' | 'desc') => boolean } | null>('crmColumnSort', null) const showSort = computed( () => Boolean(sortApi && def.value?.type === 'datetime') ) ``` - [ ] **Step 2: 全局样式**(`column-header-filter/index.vue` 的 `