22 KiB
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: 手工清单
- 客户:
createTime升/降/取消;dealTime空值升序靠前。 - 报价管理 + 审批:
quoteDate/expireDate。 - 合同管理 + 审批:
signDate/endDate。 - 产品列表:无双三角。
- 非法 sortField(DevTools 改请求)→ 默认序、不 500。
- 隐藏当前排序列 → 排序清除。
- 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。