|
|
@@ -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`。
|