Files
shanxia2.0/docs/superpowers/plans/2026-09-08-crm-date-column-sort.md
T

22 KiB
Raw Blame History

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: 写失败单测

# 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 版本差异失败,改为:

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: 跑测确认失败
cd backend && source venv/bin/activate && pytest tests/test_crm_list_sort.py -v

Expected: FAIL(模块不存在)

  • Step 3: 实现
# 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: 跑测通过
cd backend && source venv/bin/activate && pytest tests/test_crm_list_sort.py -v

Expected: PASS

  • Step 5: Commit
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 字段附近)增加:

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 中增加:

"sort_field": (self.sort_field or "").strip() or None,
"sort_order": (self.sort_order or "").strip() or None,
  • Step 2: list_customers 应用排序

文件顶部 import:

from app.services.crm.list_sort import resolve_datetime_order_by

将现有:

order_by = CrmCustomer.create_time.desc()
if view_mode == "toFollow":
    order_by = CrmCustomer.next_time.asc()

替换为:

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
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 增加

sortField: Optional[str] = None
sortOrder: Optional[str] = None

(extra=allow 已有;显式字段便于文档与类型。)

  • Step 2: 改 _build_quote_filters 末尾

将 return q.order_by(CrmQuote.create_time.desc()) 改为:

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
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 增加

sortField: Optional[str] = None
sortOrder: Optional[str] = None
  • Step 2: 改 _build_contract_filters 末尾

将 return q.order_by(CrmContract.create_time.desc()) 改为与报价同模式:

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)
)

若合同模块用不同取值函数,改为:

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
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)

// 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: 跑测失败
cd web-admin && pnpm exec vitest run src/composables/useColumnSort.test.ts

Expected: FAIL

  • Step 3: 实现 composable
// 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<string | null>(null)
  const sortOrder = ref<SortOrder | null>(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<string, string> {
    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<string[]>) {
    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: 跑测通过
cd web-admin && pnpm exec vitest run src/composables/useColumnSort.test.ts

Expected: PASS

  • Step 5: Commit
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):

<span
  v-if="showSort"
  class="col-header-sort-pair"
  @click.stop
>
  <button
    type="button"
    class="col-header-sort-btn"
    :class="{ on: sortApi.isSortActive(prop, 'asc') }"
    title="升序"
    aria-label="升序"
    @click="sortApi.setSort(prop, 'asc')"
  >▲</button>
  <button
    type="button"
    class="col-header-sort-btn"
    :class="{ on: sortApi.isSortActive(prop, 'desc') }"
    title="降序"
    aria-label="降序"
    @click="sortApi.setSort(prop, 'desc')"
  >▼</button>
</span>

script 增加:

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 的 <style lang="scss"> 末尾)
.col-header-sort-pair {
  display: inline-flex;
  flex-direction: column;
  flex-shrink: 0;
  gap: 0;
  line-height: 1;
}

.col-header-sort-btn {
  display: block;
  width: 14px;
  height: 10px;
  padding: 0;
  border: none;
  background: transparent;
  color: #cbd5e1;
  font-size: 9px;
  line-height: 1;
  cursor: pointer;
}

.col-header-sort-btn:hover {
  color: #64748b;
}

.col-header-sort-btn.on {
  color: #059669;
}
  • Step 3: Commit
git add web-admin/src/components/business/crm-col-filter-header/index.vue \
  web-admin/src/components/business/column-header-filter/index.vue
git commit -m "feat(web-admin): show dual carets on CRM datetime column headers."

Task 7: 接线客户列表页

Files:

  • Modify: web-admin/src/views/crm/customer/index.vue

  • Step 1: 创建 sort 并 provide

在 useServerColumnFilters 之后:

import { useColumnSort } from '@/composables/useColumnSort'

const {
  sortField,
  sortOrder,
  setSort,
  clearSort,
  isSortActive,
  toQueryParams: toSortQueryParams,
  watchVisibleColumns: watchSortVisibleColumns
} = useColumnSort({
  onChange: () => runListSearch()
})

provide('crmColumnSort', {
  setSort,
  clearSort,
  isSortActive,
  sortField,
  sortOrder
})
  • Step 2: 合并查询参数

在组装 list/export 的地方,现有 ...toQueryParams() 旁增加:

...toSortQueryParams()
  • Step 3: 藏列联动

已有 watchVisibleColumns(visibleFilterProps) 处增加:

watchSortVisibleColumns(visibleFilterProps)

确保 visibleFilterProps 含全部 datetime prop(与过滤同一列表)。

  • Step 4: 手动冒烟

浏览器打开客户管理:点「创建时间」上三角 → 升序;再点上三角 → 恢复默认;点「成交时间」下三角 → 倒序且空值靠后。

  • Step 5: Commit
git add web-admin/src/views/crm/customer/index.vue
git commit -m "feat(crm): wire date column sort on customer list page."

Task 8: 接线报价列表(管理+审批)

Files:

  • Modify: web-admin/src/api/crm/quote/types.ts

  • Modify: web-admin/src/views/crm/quote/QuoteListPage.vue

  • Step 1: QuoteQuery 增加

sortField?: string
sortOrder?: 'asc' | 'desc'

mapListQuery 已 ...rest,会带上 sort,无需改 index.ts(确认未从 rest 删除)。

  • Step 2: QuoteListPage.vue 同 Task 7

  • useColumnSort({ onChange: () => { queryParams.value.pageNum = 1; getList() } })

  • provide('crmColumnSort', …)

  • buildListQueryParams:Object.assign(params, toSortQueryParams()) 或 ...toSortQueryParams()

  • watchSortVisibleColumns(visibleFilterProps)(与现有列可见 watch 同源)

Tab 切换:saveTabFilter / restoreTabFilter 本批不强制持久化排序(切 Tab 可保留内存中的 sort;若现有逻辑会重置 filters,排序状态可不写入 tabFilterMap,简单起见全局保留 sort,直至用户取消——与 spec「不持久化」一致)。

  • Step 3: 冒烟

/#/.../quote 与审批页:日期列有三角;管理/审批均可见。

  • Step 4: Commit
git add web-admin/src/api/crm/quote/types.ts web-admin/src/views/crm/quote/QuoteListPage.vue
git commit -m "feat(crm): wire date column sort on quote manage and approval lists."

Task 9: 接线合同列表(管理+审批)

Files:

  • Modify: web-admin/src/api/crm/contract/types.ts(若有 ContractQuery / list 参数类型)

  • Modify: web-admin/src/views/crm/contract/ContractListPage.vue

  • Step 1: 类型增加 sortField? / sortOrder?

  • Step 2: 页面接线同报价

buildListQueryParams(或等价)合并 toSortQueryParams();provide + watch 藏列。

确认合同 list 请求方式(GET query vs POST body)会把 camelCase sortField 传到后端 ContractListQuery。

  • Step 3: 冒烟管理+审批

  • Step 4: Commit

git add web-admin/src/api/crm/contract/types.ts web-admin/src/views/crm/contract/ContractListPage.vue
git commit -m "feat(crm): wire date column sort on contract manage and approval lists."

Task 10: 回归与图谱

  • Step 1: 后端单测
cd backend && source venv/bin/activate && pytest tests/test_crm_list_sort.py -v
  • Step 2: 前端单测
cd web-admin && pnpm exec vitest run src/composables/useColumnSort.test.ts
  • Step 3: 手工清单
  1. 客户:createTime 升/降/取消;dealTime 空值升序靠前。
  2. 报价管理 + 审批:quoteDate/expireDate。
  3. 合同管理 + 审批:signDate/endDate。
  4. 产品列表:无双三角。
  5. 非法 sortField(DevTools 改请求)→ 默认序、不 500。
  6. 隐藏当前排序列 → 排序清除。
  • Step 4: 更新 graphify
./scripts/update-graph.sh all
  • Step 5: Commit 图谱(若有变更)
git add graphify-out backend/graphify-out web-admin/graphify-out
git commit -m "chore: update graphify after CRM date column sort."

Spec 覆盖自检

Spec 项 Task
双三角交互 / 同向取消 5, 6
仅 datetime 6 (showSort)
客户+报价+合同(含审批) 7–9
空值 MySQL 默认 1 注释 + 实现不写 IS NULL
sortField/sortOrder + 白名单 1–4
藏列清排序 5, 7–9
导出同序 2–4(共用 list 路径)
不做产品等 文件结构「不做」

无 TBD/「类似 Task N」占位;类型名全程 sortField/sortOrder/setSort/isSortActive。