فهرست منبع

docs: add implementation plan for CRM date column sort.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 2 ماه پیش
والد
کامیت
b731c728a7
1فایلهای تغییر یافته به همراه771 افزوده شده و 0 حذف شده
  1. 771 0
      docs/superpowers/plans/2026-09-08-crm-date-column-sort.md

+ 771 - 0
docs/superpowers/plans/2026-09-08-crm-date-column-sort.md

@@ -0,0 +1,771 @@
+# 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<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: 跑测通过**
+
+```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
+<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 增加:
+
+```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` 的 `<style lang="scss">` 末尾)
+
+```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**
+
+```bash
+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` 之后:
+
+```ts
+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()` 旁增加:
+
+```ts
+...toSortQueryParams()
+```
+
+- [ ] **Step 3: 藏列联动**
+
+已有 `watchVisibleColumns(visibleFilterProps)` 处增加:
+
+```ts
+watchSortVisibleColumns(visibleFilterProps)
+```
+
+确保 `visibleFilterProps` 含全部 datetime prop(与过滤同一列表)。
+
+- [ ] **Step 4: 手动冒烟**
+
+浏览器打开客户管理:点「创建时间」上三角 → 升序;再点上三角 → 恢复默认;点「成交时间」下三角 → 倒序且空值靠后。
+
+- [ ] **Step 5: Commit**
+
+```bash
+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` 增加**
+
+```ts
+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**
+
+```bash
+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**
+
+```bash
+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: 后端单测**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_crm_list_sort.py -v
+```
+
+- [ ] **Step 2: 前端单测**
+
+```bash
+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**
+
+```bash
+./scripts/update-graph.sh all
+```
+
+- [ ] **Step 5: Commit 图谱(若有变更)**
+
+```bash
+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`。