Files
nexus/Project/fonrey/UI_DESIGN/客源管理/客源列表_UI.md
2026-04-26 14:03:16 +08:00

1104 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 客源列表 UI 设计文档
> **版本**v1.0 · **日期**2026-04-25
> **依赖规范**UI_SYSTEM.md v1.1 · 组件规范设计.md v1.0
> **PRD 来源**`Project/fonrey/PRD/客源管理/客源管理模块PRD.md` §5.1 客源列表
> **优先级**P0 功能在本文档中用 🔴 标注P1 用 🟡P2 用 ⚫
---
## 目录
1. [模块概述](#1-模块概述)
- 1.1 功能范围
- 1.2 页面清单
- 1.3 用户角色与权限差异
2. [页面设计规范](#2-页面设计规范)
- 2.1 客源列表主页(私客视图)
3. [Data Table 规范](#3-data-table-规范)
- 3.1 列定义
- 3.2 列状态变体
- 3.3 操作列
- 3.4 表格交互状态
4. [弹窗设计规范(列表页)](#4-弹窗设计规范列表页)
- 4.1 自定义列弹窗
5. [交互状态规范](#5-交互状态规范)
- 5.1 客源状态机
- 5.2 权限控制矩阵
- 5.3 HTMX 请求规范
6. [关键数据字段说明](#6-关键数据字段说明)
7. [竞品截图对应关系](#7-竞品截图对应关系)
8. [实现优先级与工期估算](#8-实现优先级与工期估算)
9. [开放问题(待决策)](#9-开放问题待决策)
---
## 1. 模块概述
### 1.1 功能范围
**P0 功能MVP 必须实现)🔴**
- 私客列表展示(全部私客 / 求购 / 求租 / 暂缓 四个二级 Tab
- 关键词搜索(姓名/号码/号码后4位/客源编号/备注)
- 多维度筛选(状态、等级、需求类型、位置、价格区间、房室)
- 展开更多筛选(相关方、委托日期、来源、购房目的等)
- 列表数据展示(含活跃度标签)
- 批量操作(修改相关方、修改来源、删除客源、合并客源)
- 分页与每页条数控制
- 重复客源提示(私客与成交客重复、私客与公客重复、已删客源)
- 常用快捷筛选(即将掉公、录入时间、与我相关、我部门相关)
**P1 功能(第一迭代)🟡**
- 已存搜索条件保存与快速调用
- 导出当前筛选结果Excel
- 自定义列表显示字段
**P2 功能(路线图)⚫**
- 公客列表 Tab
- 成交客列表 Tab
- 暂缓私客高级操作
### 1.2 页面清单
| 页面名称 | URL 模式建议 | 优先级 | 对应 PRD 章节 |
|---|---|---|---|
| 客源列表(私客·全部) | `/clients/private/` | P0 🔴 | §5.1, Story 2 |
| 客源列表(私客·求购) | `/clients/private/?tab=buying` | P0 🔴 | §5.1, Story 3 |
| 客源列表(私客·求租) | `/clients/private/?tab=renting` | P0 🔴 | §5.1, Story 4 |
| 客源列表(私客·暂缓) | `/clients/private/?tab=suspended` | P2 ⚫ | §5.1, Story 5 |
| 客源列表(公客) | `/clients/public/` | P2 ⚫ | Story 12 |
| 客源列表(成交客) | `/clients/transacted/` | P2 ⚫ | Story 13 |
### 1.3 用户角色与权限差异
| 差异点 | 经纪人 | 店长 | 管理员 |
|---|---|---|---|
| 默认数据范围 | 仅自己名下(`owner_id = me` | 本门店全部(`org_unit_id IN my_stores` | 全司所有 |
| 「与我相关」快捷筛选 | 可用(默认可能已激活) | 可用 | 可用 |
| 「我部门相关」快捷筛选 | 仅限自己所属部门 | 本门店 | 全司 |
| 批量删除 | 仅限自己名下客源 | 本门店客源 | 所有客源 |
| 合并客源 | 不可操作 | 可操作 | 可操作 |
| 重复客源提示数字链接 | 可见 | 可见 | 可见 |
| 「+ 新增私客」按钮 | 可见 | 可见 | 可见 |
| 导出按钮 | 仅自己数据 | 本门店数据 | 全量 |
---
## 2. 页面设计规范
### 2.1 客源列表主页私客视图P0 🔴)
#### 2.1.1 页面概述
- **URL**`/clients/private/`query params`tab`, `status`, `grade`, `page`, `q` 等)
- **访问入口**:顶部全局导航栏「客源」菜单 → 默认进入私客列表
- **页面职责**:展示经纪人名下(或门店/全司)的私客列表,支持多维度搜索筛选、批量操作、状态快览
- **竞品参考截图**
- `Project/fonrey/screenshots/客源/全部私客.png`(主参考)
- `Project/fonrey/screenshots/客源/求购私客.png`
- `Project/fonrey/screenshots/客源/求租私客.png`
#### 2.1.2 布局结构
```
┌──────────────────────────────────────────────────────────────────────┐
│ 一级 Tab 导航(私客 / 资料客 / 营销客 / 成交客 / 公客) │
│ 右侧:重复检测提示 | 已删客源 | + 新增私客 按钮 │
├──────────────────────────────────────────────────────────────────────┤
│ 二级 Tab 导航(求购 / 求租 / 暂缓 / 全部私客) │
├──────────────────────────────────────────────────────────────────────┤
│ 搜索区域(关键词搜索 + 已存搜索快速调用 + 收起/展开筛选) │
├──────────────────────────────────────────────────────────────────────┤
│ 快捷筛选行(即将掉公 / 录入时间 / 与我相关 / 我部门相关) │
├──────────────────────────────────────────────────────────────────────┤
│ 分组筛选区(状态 / 需求 / 等级 / 位置 / 购价或租价 / 房室 / 更多筛选) │
├──────────────────────────────────────────────────────────────────────┤
│ 工具栏(批量操作 | 总条数显示 | 导出 | 自定义列表) │
├──────────────────────────────────────────────────────────────────────┤
│ 数据表格主体 │
├──────────────────────────────────────────────────────────────────────┤
│ 分页栏(上一页 / 页码 / 下一页 / 每页条数 / 跳页) │
└──────────────────────────────────────────────────────────────────────┘
```
整体页面背景:`bg-neutral-50`
主内容区外层容器:`max-w-[1600px] mx-auto px-6 py-4`
各区块背景:`bg-white rounded-lg border border-neutral-200`
#### 2.1.3 区域详细规范
---
**[一级 Tab 导航区]**
| 属性 | 说明 |
|---|---|
| 组件 | Tab Navigation§10 Tab Navigation |
| 位置 | 页面最顶部,紧贴全局顶导下方,无额外卡片容器,直接贴页面背景 |
| Tab 项 | 私客(激活)/ 资料客 / 营销客 / 成交客 / 公客 |
| 激活样式 | `border-b-2 border-primary-600 text-primary-600 font-medium` |
| 非激活样式 | `text-neutral-500 hover:text-neutral-700` |
| 右侧内容(绝对定位至 Tab 栏右侧) | 重复检测提示 + 「已删客源」链接 + 「+ 新增私客」按钮 |
**重复检测提示区Tab 栏右侧)**
```html
<div class="flex items-center gap-4 text-sm">
<span class="text-neutral-500">
私客与成交客重复:
<a href="/clients/duplicates/transacted/"
class="text-info-600 hover:underline font-medium">{{ dup_transacted_count }}</a>
</span>
<span class="text-neutral-500">
私客与公客重复:
<a href="/clients/duplicates/public/"
class="text-info-600 hover:underline font-medium">{{ dup_public_count }}</a>
</span>
<a href="/clients/deleted/" class="text-neutral-500 hover:text-neutral-700">
已删客源
</a>
<a href="/clients/private/create/"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-600
hover:bg-primary-700 text-white text-sm font-medium rounded-lg
transition-colors">
<svg class="w-4 h-4"><!-- heroicon: plus --></svg>
新增私客
</a>
</div>
```
> **截图差异说明**:竞品截图中「+ 新增私客」为橙色按钮(`#F97316`Fonrey 使用主色 Teal`bg-primary-600`),这是品牌差异,无需对齐竞品色。
---
**[二级 Tab 导航区]**
| 属性 | 说明 |
|---|---|
| 组件 | Tab Navigation§10 Tab NavigationPill 变体 |
| Tab 项 | 求购(含数量 Badge/ 求租(含数量 Badge/ 暂缓 / 全部私客 |
| 激活样式 | `bg-primary-50 text-primary-700 font-semibold rounded-md px-4 py-1.5` |
| 非激活样式 | `text-neutral-600 hover:bg-neutral-100 rounded-md px-4 py-1.5` |
| 数量 Badge | `ml-1.5 bg-neutral-200 text-neutral-600 text-xs px-1.5 py-0.5 rounded-full` |
| 切换行为 | HTMX `hx-get` 刷新整个筛选区+表格区URL 更新 query param `tab` |
> **截图说明**:竞品中激活 Tab 为橙色底色「求购」橙色高亮Fonrey 遵循系统主色 Teal以 `bg-primary-50 text-primary-700` 替代橙色激活态。「全部私客」Tab 在截图中有橙色边框高亮——映射为 Fonrey 的 `bg-primary-600 text-white`(当激活)。
```html
<!-- 二级 Tab 容器Alpine.js 管理本地激活 tabHTMX 负责数据刷新 -->
<div x-data="{ activeTab: '{{ active_tab|default:\"all\" }}' }"
class="flex items-center gap-1 p-1 bg-neutral-100 rounded-lg w-fit">
<template x-for="tab in tabs" :key="tab.key">
<button
:class="activeTab === tab.key
? 'bg-white text-primary-700 shadow-sm font-semibold'
: 'text-neutral-600 hover:bg-white/60'"
class="px-4 py-1.5 text-sm rounded-md transition-all"
@click="activeTab = tab.key"
hx-get="/clients/private/"
:hx-vals="JSON.stringify({tab: tab.key})"
hx-target="#client-list-container"
hx-swap="innerHTML"
hx-push-url="true">
<span x-text="tab.label"></span>
<span x-show="tab.count !== null"
class="ml-1.5 bg-neutral-200 text-neutral-600 text-xs
px-1.5 py-0.5 rounded-full"
x-text="tab.count"></span>
</button>
</template>
</div>
```
---
**[搜索区域]**
| 属性 | 说明 |
| ------- | -------------------------------------------------------------------------------------------------------------------------- |
| 组件 | 搜索输入框 + 下拉范围选择 |
| 容器 | `bg-white rounded-lg border border-neutral-200 px-4 py-3 mt-3` |
| 搜索框左侧 | 下拉范围选择器「客户信息 ▾」(`select` 元素,`text-sm text-neutral-600`),选项:客户信息(默认)/ 小区 |
| 搜索输入框 | `w-80 pl-3 pr-10 py-2 border border-neutral-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-600/40` |
| 搜索触发 | 输入后 300ms debounce + Enter 键 + 搜索按钮点击HTMX `hx-trigger="keyup changed delay:300ms, search"` |
| 搜索图标按钮 | Heroicon `magnifying-glass``bg-primary-600 text-white w-9 h-9 rounded-lg` |
| 已存搜索 | 搜索框右侧:「✦ N条已存搜索 ▾」下拉展开历史搜索条件列表 |
| 收起/展开筛选 | 最右侧文字链接:「收起筛选 ∧」/ 「展开筛选 Alpine.js 控制筛选区展开状态 |
```html
<div class="bg-white rounded-lg border border-neutral-200 px-4 py-3 mt-3"
x-data="{ showFilters: true }">
<!-- 搜索行 -->
<div class="flex items-center gap-3">
<!-- 范围选择器 -->
<select class="text-sm text-neutral-600 border border-neutral-300
rounded-lg px-3 py-2 bg-white focus-visible:ring-2
focus-visible:ring-primary-600/40">
<option value="all">客户信息</option>
<option value="client_no">客源编号</option>
</select>
<!-- 搜索框 -->
<div class="relative flex-1 max-w-md">
<input type="search"
name="q"
placeholder="输入客源姓名/号码/号码后4位/客源编号/备注信息"
class="w-full pl-3 pr-10 py-2 border border-neutral-300 rounded-lg
text-sm focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-primary-600/40"
hx-get="/clients/private/"
hx-trigger="keyup changed delay:300ms, search"
hx-target="#client-table-body"
hx-swap="innerHTML"
hx-include="[name='tab'],[name='status'],[name='grade'],[name='page_size']">
<button type="submit"
class="absolute right-1 top-1/2 -translate-y-1/2
bg-primary-600 hover:bg-primary-700 text-white
w-8 h-8 rounded-md flex items-center justify-center">
<svg class="w-4 h-4"><!-- magnifying-glass --></svg>
</button>
</div>
<!-- 已存搜索 -->
<div x-data="{ open: false }" class="relative">
<button @click="open = !open"
class="text-sm text-neutral-500 hover:text-neutral-700 flex items-center gap-1">
<svg class="w-4 h-4"><!-- bookmark --></svg>
<span>{{ saved_search_count }}条已存搜索</span>
<svg class="w-3 h-3"><!-- chevron-down --></svg>
</button>
<!-- 下拉内容saved searches list -->
<div x-show="open" x-cloak
class="absolute left-0 top-full mt-1 w-64 bg-white shadow-lg
border border-neutral-200 rounded-lg z-50 py-1">
<!-- 历史搜索条目循环 -->
</div>
</div>
<!-- 收起/展开 -->
<button @click="showFilters = !showFilters"
class="ml-auto text-sm text-neutral-500 hover:text-primary-600
flex items-center gap-1">
<span x-text="showFilters ? '收起筛选' : '展开筛选'"></span>
<svg class="w-4 h-4 transition-transform"
:class="showFilters ? 'rotate-180' : ''"><!-- chevron-down --></svg>
</button>
</div>
<!-- 筛选区(可折叠) -->
<div x-show="showFilters"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
class="mt-3 space-y-2.5">
<!-- 快捷筛选行 / 分组筛选行... 见下方 -->
</div>
</div>
```
---
**[快捷筛选行]**
| 属性 | 说明 |
|---|---|
| 位置 | 筛选区第一行,标签「常用」 |
| 筛选项 | 即将掉公Checkbox/ 录入时间(下拉)/ 与我相关Checkbox/ 我部门相关Checkbox |
```html
<div class="flex items-center gap-4 text-sm">
<span class="text-neutral-400 text-xs w-6 shrink-0">常用</span>
<!-- 即将掉公 -->
<label class="flex items-center gap-1.5 cursor-pointer text-neutral-600 hover:text-primary-600">
<input type="checkbox" name="expiring_soon" value="1"
class="w-4 h-4 rounded accent-primary-600"
hx-get="/clients/private/"
hx-trigger="change"
hx-target="#client-list-container"
hx-swap="innerHTML"
hx-include="closest form">
即将掉公
</label>
<!-- 录入时间 -->
<div class="flex items-center gap-1.5">
<span class="text-neutral-600">录入时间</span>
<select name="created_period"
class="text-sm border-0 text-neutral-600 bg-transparent cursor-pointer
focus-visible:ring-0 hover:text-primary-600"
hx-get="/clients/private/"
hx-trigger="change"
hx-target="#client-list-container"
hx-swap="innerHTML"
hx-include="closest form">
<option value="">不限</option>
<option value="today">今天</option>
<option value="7d">最近7天</option>
<option value="30d">最近30天</option>
<option value="custom">自定义</option>
</select>
</div>
<!-- 与我相关 -->
<label class="flex items-center gap-1.5 cursor-pointer text-neutral-600 hover:text-primary-600">
<input type="checkbox" name="related_to_me" value="1"
class="w-4 h-4 rounded accent-primary-600"
hx-get="/clients/private/"
hx-trigger="change"
hx-target="#client-list-container"
hx-swap="innerHTML"
hx-include="closest form">
与我相关
<svg class="w-3.5 h-3.5 text-neutral-400"><!-- information-circle --></svg>
</label>
<!-- 我部门相关 -->
<label class="flex items-center gap-1.5 cursor-pointer text-neutral-600 hover:text-primary-600">
<input type="checkbox" name="my_dept" value="1"
class="w-4 h-4 rounded accent-primary-600"
hx-get="/clients/private/"
hx-trigger="change"
hx-target="#client-list-container"
hx-swap="innerHTML"
hx-include="closest form">
我部门相关
<svg class="w-3.5 h-3.5 text-neutral-400"><!-- information-circle --></svg>
</label>
</div>
```
---
**[分组筛选区]**
每个筛选组为一行,格式:`[标签] [选项1] [选项2] ... [自定义输入(如有)]`
整体容器:`space-y-2`,每行:`flex items-center flex-wrap gap-x-3 gap-y-1.5 text-sm`
| 筛选组 | 标签宽 | 选项形式 | 特殊说明 |
| --------- | ------------------------------ | ---------------------- | -------------------------------------------------------------------- |
| 状态 | `w-6 text-xs text-neutral-400` | 单选 Tag 按钮组 | Tab 决定显示哪些选项;激活为 `bg-primary-600 text-white`,默认为「不限], 「求购],「租购] |
| 需求 | 同上 | 单选 Tag 按钮组 | 二手/新房(求购);租房(求租) |
| 等级 | 同上 | 单选 Tag 按钮组 | A急迫/B较强/C一般/D较弱/E暂不关注/未填写 |
| 位置 | 同上 | 单选Tag 按钮组 | 宝山/崇明/...共19个区 |
| 区域 | 同上 | 单选Tag按钮组 | 位置选择后出现二级区域选择 |
| 购价求购Tab | 同上 | 单选 Tag + 自定义区间输入 | 预设区间 + 「最小值」「~」「最大值」万 |
| 租价求租Tab | 同上 | 单选 Tag + 自定义区间输入 | 预设区间 + 「最小值」「~」「最大值」元 |
| 居室 | 同上 | 单选 Tag + 「是大价值」复选框(求购) | 不限/1居/2居/3居/4居/5居及以上 |
| 筛选(展开) | 同上 | 各类下拉/DateRange | 含「展开 ∨」按钮;相关方/委托日期/来源/购房目的/带看进度/活跃情况/是否有效/面积/跟进时间/带看时间/审批中/审批驳回/收藏客源 |
| 筛选(收起后隐藏) | 同上 | 各类 Checkbox/下拉 | 保护客/合作者/偏好新房/巧客力访客/置顶/用途/带看次数 |
**Tag 按钮通用样式**
```html
<!-- 非激活 -->
<button class="px-3 py-1 text-xs border border-neutral-200 rounded-md
text-neutral-600 hover:border-primary-400 hover:text-primary-600
transition-colors">
选项文字
</button>
<!-- 激活橙色在竞品中Fonrey 用 primary-600 -->
<button class="px-3 py-1 text-xs bg-primary-600 text-white rounded-md
border border-primary-600">
不限
</button>
```
**价格自定义区间**
```html
<div class="flex items-center gap-1.5 mt-1">
<input type="number" name="price_min" placeholder="最小值"
class="w-20 px-2 py-1 text-xs border border-neutral-300 rounded-md
focus-visible:ring-2 focus-visible:ring-primary-600/40">
<span class="text-neutral-400">~</span>
<input type="number" name="price_max" placeholder="最大值"
class="w-20 px-2 py-1 text-xs border border-neutral-300 rounded-md
focus-visible:ring-2 focus-visible:ring-primary-600/40">
<span class="text-xs text-neutral-500"></span>
</div>
```
**展开更多筛选(下方收起区)**
- 点击「展开 Alpine.js `showMoreFilters` 切换
- 展开后显示额外筛选行:相关方(`MultiSelect`、委托日期Date Range Picker §9、来源下拉、购房目的多选 Tag
- 「收起 ∧」收回
HTMX 行为:所有筛选变更统一通过 `hx-get="/clients/private/" hx-trigger="change" hx-target="#client-list-container" hx-swap="innerHTML" hx-include="closest form"`,由 Django 视图根据所有 query params 返回完整刷新片段。
---
**[工具栏区]**
| 属性 | 说明 |
| --- | ------------------------------------------------------------------------------------ |
| 组件 | Toolbar§4 Toolbar |
| 容器 | `flex items-center justify-between px-4 py-2.5 bg-white border-b border-neutral-100` |
| 左侧 | 批量操作按钮组(勾选时激活)+ 总条数文字 |
| 右侧 | 导出按钮 + 自定义列表按钮 |
**批量操作按钮(勾选 ≥1 条后变为可点击)**
```html
<div x-data="{ selectedCount: 0 }"
class="flex items-center gap-2">
<!-- 批量操作按钮:仅 selectedCount > 0 时高亮 -->
<button :disabled="selectedCount === 0"
:class="selectedCount > 0
? 'text-neutral-700 hover:bg-neutral-100 border-neutral-300'
: 'text-neutral-300 border-neutral-200 cursor-not-allowed'"
class="px-3 py-1.5 text-sm border rounded-md transition-colors"
@click="selectedCount > 0 && $dispatch('open-batch-related-modal')">
修改相关方
</button>
<button :disabled="selectedCount === 0" ...>修改来源</button>
<button :disabled="selectedCount === 0" ...>删除客源</button>
<button :disabled="selectedCount === 0" ...>合并客客</button>
<!-- 总条数 -->
<span class="text-sm text-neutral-500 ml-2">
<strong class="text-neutral-800">{{ total_count }}</strong>
</span>
<!-- 已选提示 -->
<span x-show="selectedCount > 0" class="text-sm text-primary-600">
已选 <span x-text="selectedCount"></span>
</span>
</div>
<!-- 右侧工具 -->
<div class="flex items-center gap-2">
<!-- 导出§5 Export Button -->
<button hx-post="/clients/private/export/"
hx-trigger="click"
hx-vals='{"format": "excel"}'
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-neutral-600
border border-neutral-300 rounded-md hover:bg-neutral-50">
<svg class="w-4 h-4"><!-- arrow-down-tray --></svg>
导出
</button>
<!-- 自定义列表§3 Column Visibility Panel -->
<button @click="$dispatch('open-column-settings')"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-neutral-600
border border-neutral-300 rounded-md hover:bg-neutral-50">
<svg class="w-4 h-4"><!-- adjustments-horizontal --></svg>
自定义列表
</button>
</div>
```
---
**[数据表格]**
| 属性 | 说明 |
|---|---|
| 组件 | Data Table§1 Data Table |
| 容器 id | `client-list-container`HTMX swap 目标),内嵌 `client-table-body` |
| 行高 | 56px`h-14`),含活跃度标签时允许 auto 高度,最小 56px |
| 横向滚动 | `overflow-x-auto` 包裹 `<table>`,宽屏 ≥1280px 可全列展示 |
**列规范(全部私客 / 求购私客视图)**
| 列名 | 数据字段 | 宽度 | 排序 | 特殊渲染 |
|---|---|---|---|---|
| 复选框 | — | `w-10 px-4` | 否 | `<input type="checkbox">` 全选/单选Alpine.js `selected[]` 数组 |
| 姓名 | `contact_name`(主联系人)+ `grade` + 活跃度标签 | `min-w-[160px]` | 否 | 蓝色链接跳转详情;姓名下方渲染活跃度 Tag多个可并排名字过长截断 `truncate max-w-[140px]` |
| 状态 | `status` | `80px` | 否 | Status Badge见下方说明 |
| 需求类型 | `requirement_type`(主需求) | `80px` | 否 | 文字:二手 / 新房 / 租房 |
| 需求/解读 | `budget_min~budget_max` + `area_min~area_max` | `min-w-[180px]` | 否 | 格式:`550-600万100㎡-110㎡...`;截断 `truncate`;点击可展开 Tooltip |
| 智能配房 | `match_count` | `90px` | 否 | 数字 + Heroicon `information-circle``w-4 h-4 text-neutral-400`);点击 ⓘ 弹出配房预览 |
| 意向商圈/小区 | `intent_business_area` / `intent_complex_names` | `min-w-[120px]` | 否 | 多值逗号分隔,截断显示,`-` 表示未填 |
| 归属人 | `owner_name` + `org_unit_name` | `min-w-[140px]` | 否 | 格式:`雷威-都市港湾店一组``text-sm text-neutral-700` |
| 带看进度 | `viewing_progress_label` | `80px` | 否 | 文字标签:未带看 / 一看 / 二看 / 复看;「一看」用 `bg-warning-50 text-warning-600 px-2 py-0.5 rounded-full text-xs` |
| 带看次数 | `viewing_count` | `70px` | 是 | `N次`;排序时显示箭头 |
| 委托日期 | `commission_date` | `90px` | 是 | `YYYY-MM-DD` |
| 最近时间 | `last_follow_at` 距今天数 | `90px` | 是(默认降序) | `N天前` / `今天`超过30天用 `text-danger-600` |
| 操作 | — | `60px` | 否 | 「拨号」按钮Heroicon `phone``text-primary-600`),点击触发拨号弹层 |
**活跃度标签渲染规则**
| 标签值 | 文字 | Tailwind 样式 |
|---|---|---|
| `new_matched` | 新配偶 | `bg-info-50 text-info-600` |
| `active_7d` | 7日活跃 | `bg-success-50 text-success-600` |
| `active_30d` | 30日活跃 | `bg-green-50 text-green-500` |
| `expiring` | 即将过期 | `bg-warning-50 text-warning-600` |
| `frozen` | 暂缓 | `bg-neutral-100 text-neutral-500` |
| `invalid` | 无效 | `bg-danger-50 text-danger-600` |
| 来源为营销 | 营销客 | `bg-purple-50 text-purple-600` |
| 来源为销售 | 销售客 | `bg-orange-50 text-orange-600` |
| 来源为访客 | 访客 | `bg-neutral-100 text-neutral-500` |
标签 HTML
```html
<span class="text-[11px] px-1.5 py-0.5 rounded-full font-medium bg-info-50 text-info-600">
新配偶
</span>
```
**状态 Badge 渲染**
| status 值 | 显示文字 | 样式 |
|---|---|---|
| `buying` | 求购 | `bg-primary-50 text-primary-700 text-xs px-2 py-0.5 rounded-full` |
| `renting` | 求租 | `bg-info-50 text-info-600 text-xs px-2 py-0.5 rounded-full` |
| `buy_or_rent` | 租购 | `bg-warning-50 text-warning-600 text-xs px-2 py-0.5 rounded-full` |
| `suspended` | 暂缓 | `bg-neutral-100 text-neutral-500 text-xs px-2 py-0.5 rounded-full` |
**行点击行为**:点击姓名链接跳转至 `/clients/private/{id}/`(全页跳转,非 HTMX点击行其他区域不跳转避免误操作
**表格 HTML 结构(关键片段)**
```html
<div id="client-list-container">
<div class="rounded-lg border border-neutral-200 overflow-hidden bg-white mt-3">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-neutral-200">
<thead class="bg-neutral-50">
<tr>
<th class="w-10 px-4 py-3">
<input type="checkbox" id="select-all"
class="w-4 h-4 rounded accent-primary-600"
@change="toggleAll($event)">
</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
姓名
</th>
<!-- 其他列头... 带排序的列头包含 hx-get 触发排序 -->
<th class="px-4 py-3 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide cursor-pointer"
hx-get="/clients/private/"
hx-vals='{"sort": "viewing_count", "order": "{{ sort_order_toggle }}"}'
hx-target="#client-list-container"
hx-swap="innerHTML"
hx-include="closest form">
带看次数
<svg class="inline w-4 h-4 text-neutral-300 ml-0.5"><!-- chevron-up-down --></svg>
</th>
</tr>
</thead>
<tbody id="client-table-body" class="divide-y divide-neutral-100 bg-white">
{% for client in clients %}
<tr class="hover:bg-neutral-50 transition-colors h-14"
:class="selected.includes('{{ client.id }}') ? 'bg-primary-50' : ''">
<td class="w-10 px-4">
<input type="checkbox" :value="'{{ client.id }}'"
class="w-4 h-4 rounded accent-primary-600"
x-model="selected">
</td>
<td class="px-4 py-2">
<div class="flex flex-col gap-0.5">
<a href="/clients/private/{{ client.id }}/"
class="text-info-600 hover:underline font-medium text-sm truncate max-w-[160px]">
{{ client.contact_name }}
</a>
<!-- 活跃度标签行 -->
<div class="flex items-center gap-1 flex-wrap">
{% if client.grade %}
<span class="text-[11px] text-neutral-500">{{ client.grade_display }}</span>
{% endif %}
{% if client.activity_level %}
<span class="text-[11px] px-1.5 py-0.5 rounded-full font-medium
{{ client.activity_level_class }}">
{{ client.activity_level_display }}
</span>
{% endif %}
</div>
</div>
</td>
<!-- 其他列... -->
<td class="px-4 py-2">
<button class="text-primary-600 hover:text-primary-700 p-1.5 rounded-md hover:bg-primary-50"
title="拨号">
<svg class="w-5 h-5"><!-- phone --></svg>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
```
**求租 Tab 差异**
- 需求/解读列:展示租价区间(元/月)+ 面积区间如「4000-5000元45㎡-60㎡...」
- 价格筛选行标签改为「租价」,区间单位为「元」
- 状态筛选选项改为:不限 / 求租 / 租购
- 需求筛选改为:租房(仅此一项)
**暂缓 Tab 差异**
- 状态筛选:不限 / 暂缓(租购) / 暂缓(求租) / 暂缓(求购)
- 无「是大价值」复选框
---
**[分页栏]**
| 属性 | 说明 |
|---|---|
| 组件 | Pagination§2 Pagination |
| 位置 | 表格下方 `mt-4 flex items-center justify-between px-1` |
| 左侧 | 总条数文字「共 N 条」 |
| 中间 | 页码导航:`← 上一页 [1] [2] [3] [4] [5] … [196] 下一页 →` |
| 右侧 | 每页条数选择「20条/页 ▾」选项20/50/100+ 跳页输入框「跳至」+ 输入框 + 「页」+ 「确定」 |
| HTMX | 页码按钮 `hx-get="/clients/private/" hx-vals='{"page": N}' hx-target="#client-list-container" hx-swap="innerHTML" hx-include="closest form"` |
| 当前页样式 | `bg-primary-600 text-white rounded-md w-8 h-8 font-medium` |
| 非当前页 | `text-neutral-600 hover:bg-neutral-100 rounded-md w-8 h-8` |
---
#### 2.1.4 使用的特殊组件
| 组件名 | 来源(§章节) | 用途 | 自定义说明 |
| ----------------------- | -------------------------- | -------------------- | ------------------------------------- |
| Data Table | §1 Data Table | 客源数据主体展示 | 行高兼容双行(姓名+活跃度标签),允许 `min-h-14 h-auto` |
| Pagination | §2 Pagination | 底部分页控件 | 新增跳页输入框,与标准实现一致 |
| Column Visibility Panel | §3 Column Visibility Panel | 自定义列表字段选择 | 触发按钮为「自定义列表」文字 + 图标 |
| Toolbar | §4 Toolbar | 批量操作 + 导出 + 统计 | 批量按钮默认 disabledselectedCount > 0 激活 |
| Export Button | §5 Export Button | 导出 Excel | HTMX `hx-post` 异步触发 Celery 任务 |
| Tab Navigation | §10 Tab Navigation | 一级/二级 Tab 切换 | 一级用 underline 样式,二级用 pill 样式 |
| Date Range Picker | §9 Date Range Picker | 「录入时间」「委托日期」「跟进时间」筛选 | 集成 Flatpickrrange 模式 |
| Multi-select Tag Input | §17 Multi-select Tag Input | 位置(多选区)、等级(多选)筛选 | 无需 Tag Input 样式,直接用按钮多选组 |
| Modal Dialog | §7 Modal Dialog | 自定义列弹窗 | 见§4.1 自定义列弹窗规范 |
#### 2.1.5 空状态设计
**筛选无结果(最常见场景)**
```html
<div class="flex flex-col items-center justify-center py-20 text-center">
<svg class="w-12 h-12 text-neutral-300 mb-4"><!-- user-group --></svg>
<p class="text-neutral-500 text-sm font-medium">暂无客源</p>
<p class="text-neutral-400 text-xs mt-1">当前筛选条件下没有客源,尝试调整筛选条件</p>
<button class="mt-4 text-sm text-primary-600 hover:underline"
hx-get="/clients/private/"
hx-target="#client-list-container"
hx-swap="innerHTML">
清空筛选条件
</button>
</div>
```
**首次进入无数据(无任何客源)**
```html
<div class="flex flex-col items-center justify-center py-24 text-center">
<svg class="w-16 h-16 text-neutral-200 mb-4"><!-- user-plus --></svg>
<p class="text-neutral-600 text-base font-medium">还没有私客</p>
<p class="text-neutral-400 text-sm mt-1.5">开始录入您的第一位私客</p>
<a href="/clients/private/create/"
class="mt-5 inline-flex items-center gap-1.5 px-4 py-2
bg-primary-600 hover:bg-primary-700 text-white text-sm
font-medium rounded-lg transition-colors">
<svg class="w-4 h-4"><!-- plus --></svg>
新增私客
</a>
</div>
```
#### 2.1.6 Loading 状态
**筛选/分页触发 HTMX 请求期间**`#client-list-container` 内显示骨架屏:
```html
<!-- 骨架屏5行占位 -->
<div class="htmx-indicator animate-pulse space-y-0
rounded-lg border border-neutral-200 overflow-hidden">
{% for i in "12345" %}
<div class="flex items-center gap-4 px-4 h-14 border-b border-neutral-100 last:border-0">
<div class="w-4 h-4 bg-neutral-200 rounded"></div>
<div class="h-4 bg-neutral-200 rounded w-32"></div>
<div class="h-4 bg-neutral-200 rounded w-16 ml-8"></div>
<div class="h-4 bg-neutral-200 rounded w-48 ml-4"></div>
<div class="h-4 bg-neutral-200 rounded w-16"></div>
<div class="ml-auto h-4 bg-neutral-200 rounded w-20"></div>
</div>
{% endfor %}
</div>
```
HTMX loading 触发:在 `<div id="client-list-container">` 添加 `hx-indicator="#client-skeleton"`;骨架屏默认隐藏,请求期间通过 `htmx-request` class 显示。
---
## 3. Data Table 规范
> 引用基础规范:`组件规范设计.md` §1 Data Table
> 本章描述客源列表页 Data Table 的**具体实例化配置**,包含列定义、单元格渲染、排序、选中、空状态和操作列。
### 3.1 列定义(全部私客 / 求购视图)
表格容器:`<table class="min-w-full divide-y divide-neutral-200">`
表头行:`<thead class="bg-neutral-50">`,列头单元格统一样式:`px-4 py-3 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide whitespace-nowrap`
数据行:`<tr class="hover:bg-neutral-50 transition-colors" style="height:56px">`
| # | 列名 | 数据字段 | 列宽 | 对齐 | 可排序 | 特殊渲染说明 |
|---|---|---|---|---|---|---|
| 1 | (复选框) | — | `w-10`40px`px-4` | 居中 | 否 | 全选:表头 `<input type="checkbox" id="select-all">`;单选:行内 `<input type="checkbox" x-model="selected" :value="client.id">` |
| 2 | 姓名 | `contact_name` + `grade_display` + 活跃度标签 | `min-w-[160px] max-w-[200px]` | 左对齐 | 否 | 蓝色链接 `text-info-600 hover:underline`;下方渲染 Grade Badge + 活跃度 Tag见 §3.2 |
| 3 | 状态 | `status_display` | `w-20`80px | 左对齐 | 否 | Status Badge见 §3.2 |
| 4 | 需求类型 | `requirement_type_display` | `w-20`80px | 左对齐 | 否 | 纯文字:二手 / 新房 / 租房 |
| 5 | 需求/解读 | `budget_area_display` | `min-w-[180px]` | 左对齐 | 否 | 截断 `truncate`Tooltip 展示完整内容(`title` 属性) |
| 6 | 智能配房 | `match_count` | `w-24`96px | 左对齐 | 否 | `N套` + Heroicon `information-circle``w-4 h-4 text-neutral-400 ml-1 cursor-pointer`);点击弹出配房预览 Popover |
| 7 | 意向商圈/小区 | `intent_location_display` | `min-w-[120px]` | 左对齐 | 否 | 多值逗号分隔;`-` 表示未填;截断 `truncate max-w-[160px]` |
| 8 | 归属人 | `owner_display` | `min-w-[140px]` | 左对齐 | 否 | 格式:`姓名-门店组``text-sm text-neutral-700` |
| 9 | 带看进度 | `viewing_progress_display` | `w-20`80px | 左对齐 | 否 | 「未带看」灰色文字;「一看」`bg-warning-50 text-warning-600 px-2 py-0.5 rounded-full text-xs`;「二看」`bg-info-50 text-info-600`;「复看」`bg-success-50 text-success-600` |
| 10 | 带看次数 | `viewing_count` | `w-[72px]` | 左对齐 | 是 | `N次`;点击列头触发 HTMX 排序 |
| 11 | 委托日期 | `commission_date` | `w-24`96px | 左对齐 | 是 | `YYYY-MM-DD`;未填显示 `-` |
| 12 | 最近时间 | `last_follow_display` / `last_contact_display` | `w-24`96px | 左对齐 | 是(默认降序) | `N天前` / `今天`;超过 30 天字色 `text-danger-600` |
| 13 | 操作 | — | `w-16`64px | 居中 | 否 | 见 §3.3 |
> **自定义列**P1 🟡用户通过「自定义列表」弹窗§4.1)选择显示字段后,后端将用户配置存入 `UserColumnPreference`Django 模板根据配置动态渲染列头和列单元格。可选字段见截图 `客源列表-自定义字段.png`:录入日期、最近通话日期、用途、来源、客源编号、首录人、成交人等。
---
### 3.2 列状态变体
**活跃度标签(姓名列下方,行内渲染,多个并排)**
| 标签值 | 显示文字 | Tailwind 样式 |
|---|---|---|
| `new_matched` | 新配房 | `bg-info-50 text-info-600` |
| `active_7d` | 7日活跃 | `bg-success-50 text-success-600` |
| `active_30d` | 30日活跃 | `bg-green-50 text-green-500` |
| `expiring` | 即将过期 | `bg-warning-50 text-warning-600` |
| `frozen` | 暂缓 | `bg-neutral-100 text-neutral-500` |
| `invalid` | 无效 | `bg-danger-50 text-danger-600` |
| 来源为营销 | 营销客 | `bg-purple-50 text-purple-600` |
| 来源为销售 | 销售客 | `bg-orange-50 text-orange-600` |
| 来源为访客 | 访客 | `bg-neutral-100 text-neutral-500` |
标签 HTML
```html
<span class="text-[11px] px-1.5 py-0.5 rounded-full font-medium bg-info-50 text-info-600">
新配房
</span>
```
**状态 Badge状态列**
| `status` 值 | 显示文字 | 样式 |
|---|---|---|
| `buying` | 求购 | `bg-primary-50 text-primary-700 text-xs px-2 py-0.5 rounded-full` |
| `renting` | 求租 | `bg-info-50 text-info-600 text-xs px-2 py-0.5 rounded-full` |
| `buy_or_rent` | 租购 | `bg-warning-50 text-warning-600 text-xs px-2 py-0.5 rounded-full` |
| `suspended` | 暂缓 | `bg-neutral-100 text-neutral-500 text-xs px-2 py-0.5 rounded-full` |
**排序列头**(带看次数 / 委托日期 / 最近时间):
```html
<th class="px-4 py-3 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide
cursor-pointer hover:bg-neutral-100 select-none whitespace-nowrap"
hx-get="/clients/private/"
:hx-vals="JSON.stringify({sort: 'viewing_count', order: currentOrder})"
hx-target="#client-list-container"
hx-swap="innerHTML"
hx-include="closest form">
带看次数
<!-- 未排序:双箭头图标 -->
<svg class="inline w-4 h-4 text-neutral-300 ml-0.5"><!-- chevron-up-down --></svg>
<!-- 升序激活:向上箭头 text-primary-600 -->
<!-- 降序激活:向下箭头 text-primary-600 -->
</th>
```
**行选中态**
```html
<tr class="hover:bg-neutral-50 transition-colors h-14"
:class="selected.includes(client.id) ? 'bg-primary-50 hover:bg-primary-100' : ''">
```
---
### 3.3 操作列
操作列宽 `w-16`64px居中对齐固定在表格最右侧。
MVP 阶段操作列仅包含一个「拨号」图标按钮:
```html
<td class="px-3 py-2 text-center">
<button class="inline-flex items-center justify-center w-8 h-8 rounded-md
text-primary-600 hover:bg-primary-50 hover:text-primary-700
transition-colors"
title="拨号"
@click="$dispatch('open-dial-modal', { clientId: '{{ client.id }}', phone: '{{ client.contact_phone_masked }}' })">
<svg class="w-5 h-5"><!-- heroicon: phone --></svg>
</button>
</td>
```
> 后续迭代可在操作列增加「更多操作」下拉菜单(`...` 图标),包含跟进记录、收藏、转无效等快捷操作,但 MVP 阶段不实现,避免列表行交互过重。
---
### 3.4 表格交互状态
| 状态 | 触发场景 | 视觉表现 |
|---|---|---|
| 默认(无选中) | 页面加载完毕 | 所有行 `bg-white`hover 时 `bg-neutral-50` |
| 行选中 | 勾选复选框 | `bg-primary-50 hover:bg-primary-100`;工具栏批量操作按钮激活 |
| 全选 | 点击表头复选框 | 当前页所有行选中;表头 checkbox `indeterminate``checked` |
| LoadingHTMX 请求中) | 筛选/分页/排序触发 | 骨架屏覆盖 `#client-list-container`(见 §2.1.6 |
| 空状态(无数据) | 筛选无结果 / 首次进入 | 见 §2.1.5 空状态设计 |
| 置顶行 | `is_pinned = true` | 行头部显示 `⬆``bg-warning-50/30` 浅黄底色(产品待确认)|
---
## 4. 弹窗设计规范(列表页)
> **范围说明**:本章仅包含从**客源列表页直接触发**的弹窗。改等级、改状态、转公客、转成交、转无效、收藏夹等操作弹窗从**客源详情页**触发,应记录于详情页 UI 设计文档。
### 4.1 自定义列弹窗P1 🟡)
#### 4.1.1 触发方式
- **触发位置**工具栏右侧「自定义列表」按钮Heroicon `adjustments-horizontal` + 文字)
- **组件类型**Modal Dialog`组件规范设计.md` §7
- **尺寸**`max-w-2xl`640px
- **竞品参考截图**`Project/fonrey/screenshots/客源/客源列表-自定义字段.png`
#### 4.1.2 弹窗布局
弹窗分为左右两栏:
```
┌─────────────────────────────────────────────────────┐
│ 标题:自定义信息 [×] │
├────────────────────────┬────────────────────────────┤
│ 未选信息 │ 已选信息 │
│ (可勾选字段列表) │ (已选字段,拖拽排序) │
│ │ ┌─────────────────────┐ │
│ □ 录入日期 │ │ ⋮⋮ 姓名 [删] │ │
│ □ 最近通话日期 │ │ ⋮⋮ 状态 [删] │ │
│ □ 用途 │ │ ⋮⋮ 需求类型 [删] │ │
│ □ 来源 │ │ ⋮⋮ 需求/解读 [删] │ │
│ □ 客源编号 │ │ ... │ │
│ □ 首录人 │ └─────────────────────┘ │
│ □ 成交人 │ 提示:拖拽可调整展示顺序 │
├────────────────────────┴────────────────────────────┤
│ [恢复默认] [取消] [确定] │
└─────────────────────────────────────────────────────┘
```
#### 4.1.3 字段说明
**未选信息区(左栏)**
- 显示所有可用但当前未选中的字段
- 每项为 Checkbox + 字段名,点击勾选后字段移至右栏「已选信息」
- 可用字段(参考截图):录入日期、最近通话日期、用途、来源、客源编号、首录人、成交人
**已选信息区(右栏)**
- 显示当前已选中的字段列表,带序号/排序手柄(`⋮⋮` Heroicon `bars-2`
- 拖拽排序SortableJS 或原生 HTML5 drag-and-drop
- 每行末尾「删除」图标Heroicon `x-mark``text-neutral-400 hover:text-danger-600`)点击将字段移回左栏
- 固定字段(如「姓名」)不可删除(无删除图标,显示锁形图标 `lock-closed`
#### 4.1.4 底部操作
| 按钮 | 位置 | 行为 |
|---|---|---|
| 恢复默认 | 底部左侧 | 重置为系统默认列配置Alpine.js 本地状态重置,不立即提交 |
| 取消 | 底部右侧次按钮 | 关闭弹窗,不保存 |
| 确定 | 底部右侧主按钮 | `hx-post="/clients/column-preferences/"` 保存配置 → 关闭弹窗 → HTMX 刷新 `#client-list-container` 以重新渲染列 |
#### 4.1.5 提交行为
```html
<form hx-post="/clients/column-preferences/"
hx-target="#client-list-container"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful){ $dispatch('close-modal'); }">
<!-- 隐藏域:序列化已选字段顺序 -->
<input type="hidden" name="columns" :value="JSON.stringify(selectedColumns)">
...
<button type="submit"
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white
text-sm font-medium rounded-lg transition-colors">
确定
</button>
</form>
```
#### 4.1.6 使用的特殊组件
| 组件名 | 来源 | 用途 |
|---|---|---|
| Modal Dialog | §7 Modal Dialog | 弹窗容器,`max-w-2xl` |
Alpine.js 管理:`selectedColumns`(有序数组)、`availableColumns`(剩余可选)、`dragging` 状态;拖拽排序通过 Alpine.js + SortableJS CDN 实现。
## 5. 交互状态规范
### 5.1 全局状态机
**客源状态流转**(适用于列表页行内操作和详情页操作):
```
buying求购 ←→ suspended暂缓
renting求租
buy_or_rent租购→ public公客[不可逆]
→ bought已购[不可逆]
→ rented_done已租[不可逆]
→ invalid无效[需审批可恢复]
```
视觉呈现:在列表状态列、详情页标题区用 Badge 展示;状态变更后相关 Badge 实时 HTMX 局部刷新。
### 5.2 权限控制矩阵
| 操作 | 经纪人(自己的客源) | 店长 | 管理员 |
|---|---|---|---|
| 查看列表 | 仅自己名下 | 本门店 | 全司 |
| 新增私客 | ✅ | ✅ | ✅ |
| 删除私客(批量) | ✅(仅自己) | ✅(本门店) | ✅ |
| 合并客源 | ❌ | ✅ | ✅ |
| 改等级 | ✅(自己的) | ✅ | ✅ |
| 改状态 | ✅(自己的) | ✅ | ✅ |
| 转公客 | ✅(归属人/首录人) | ✅ | ✅ |
| 转成交 | ✅(归属人) | ✅ | ✅ |
| 转无效 | ✅(归属人/首录人) | ✅ | ✅ |
| 导出 | 仅自己数据 | 本门店 | 全量 |
| 查看号码 | ✅(需审计日志) | ✅ | ✅ |
| 修改相关员工(跨店) | ❌ | ✅ | ✅ |
权限控制实现Django 视图层通过 `request.user` 的 role 和 org_unit 过滤 QuerySet前端通过 Django 模板 `{% if user.role == 'manager' %}` 条件渲染隐藏不可用按钮(双重防护)。
### 5.3 HTMX 请求规范
| 操作 | hx-trigger | hx-method + URL | hx-target | hx-swap | Loading 行为 |
|---|---|---|---|---|---|
| 关键词搜索 | `keyup changed delay:300ms, search` | `GET /clients/private/` | `#client-list-container` | `innerHTML` | 骨架屏覆盖表格区 |
| 筛选条件变更 | `change` | `GET /clients/private/` | `#client-list-container` | `innerHTML` | 骨架屏 |
| 二级 Tab 切换 | `click`Alpine.js 配合) | `GET /clients/private/?tab=X` | `#client-list-container` | `innerHTML` | 骨架屏 |
| 分页跳转 | `click` | `GET /clients/private/?page=N` | `#client-list-container` | `innerHTML` | 骨架屏 |
| 每页条数变更 | `change` | `GET /clients/private/?page_size=N` | `#client-list-container` | `innerHTML` | 骨架屏 |
| 列排序 | `click`(表头) | `GET /clients/private/?sort=field&order=asc\|desc` | `#client-list-container` | `innerHTML` | 骨架屏 |
| 导出 | `click` | `POST /clients/private/export/` | `body` | `none` | Toast 提示「正在生成,完成后下载」 |
| 批量删除 | `click` | `DELETE /clients/private/batch/` | `#client-list-container` | `innerHTML` | 按钮 loading |
| 批量修改相关方 | `submit`Modal内 | `PATCH /clients/private/batch/related/` | `#client-list-container` | `innerHTML` | 按钮 loading |
| 保存自定义列 | `submit`§4.1 弹窗内) | `POST /clients/column-preferences/` | `#client-list-container` | `innerHTML` | 按钮 loading spinner |
**按钮 Loading 实现**(用于提交按钮):
```html
<button type="submit"
class="..."
hx-disabled-elt="this"
_="on htmx:beforeRequest add .opacity-75 to me
on htmx:afterRequest remove .opacity-75 from me">
<span class="htmx-indicator">
<svg class="animate-spin w-4 h-4"><!-- spinner --></svg>
</span>
确定
</button>
```
---
## 6. 关键数据字段说明
以下字段为客源列表页后端 API 需返回的完整字段集:
| 字段名(英文) | 显示名 | 数据类型 | 说明 |
|---|---|---|---|
| `id` | 客源ID | UUID | 路由参数 |
| `client_no` | 客源编号 | string | 如 KY20260424001 |
| `client_type` | 客源类型 | enum | private/public/transacted |
| `status` | 状态 | enum | buying/renting/buy_or_rent/suspended/... |
| `status_display` | 状态显示名 | string | 求购/求租/租购/暂缓 |
| `grade` | 等级 | enum | A_urgent/A/B/C/D/E |
| `grade_display` | 等级显示名 | string | A(急迫)/B(较强)/C(一般) 等 |
| `activity_level` | 活跃度 | enum | new_matched/active_7d/active_30d/expiring/frozen/invalid |
| `activity_level_display` | 活跃度显示名 | string | 新配偶/7日活跃 等 |
| `activity_level_class` | 活跃度 CSS 类 | string | Django 模板方法生成对应 Tailwind class 字符串 |
| `contact_name` | 主联系人姓名 | string | client_contacts.sort_order=0 的 name |
| `contact_phone_masked` | 主联系人手机(打码) | string | `135****6789` |
| `requirement_type` | 主需求类型 | enum | second_hand/new_house/rental |
| `requirement_type_display` | 需求类型显示名 | string | 二手/新房/租房 |
| `budget_min` | 预算下限 | decimal | 万元(购房)或元/月(租房) |
| `budget_max` | 预算上限 | decimal | |
| `area_min` | 面积下限 | decimal | ㎡ |
| `area_max` | 面积上限 | decimal | ㎡ |
| `budget_area_display` | 需求/解读列显示文本 | string | 如「550-600万100㎡-110㎡...」,后端格式化 |
| `match_count` | 智能配房套数 | int | 未反馈不合适的配房数 |
| `intent_location_display` | 意向商圈/小区 | string | 后端聚合格式化 |
| `owner_name` | 归属人姓名 | string | staff.name |
| `org_unit_name` | 归属门店-组 | string | 格式:`都市港湾店一组` |
| `owner_display` | 归属人+门店(合并) | string | `雷威-都市港湾店一组` |
| `viewing_progress` | 带看进度编号 | int | 0=未带看1=一看2=二看... |
| `viewing_progress_display` | 带看进度显示名 | string | 未带看/一看/二看/复看 |
| `viewing_count` | 带看次数 | int | |
| `commission_date` | 委托日期 | date | YYYY-MM-DD |
| `last_follow_display` | 最近时间显示 | string | `3天前` / `今天` / `8天前` |
| `last_follow_at` | 最近跟进时间 | datetime | 排序用原始值 |
| `is_starred` | 是否收藏 | bool | 影响收藏图标状态 |
| `is_pinned` | 是否置顶 | bool | 置顶的客源排在最前 |
| `is_big_value` | 是否大价值 | bool | 用于「是大价值」筛选 |
| `is_protected` | 是否保护客 | bool | 保护客不会自动转公 |
| `source` | 客户来源 | string | lookup_items 维护 |
| `dup_transacted_count` | 与成交客重复数量 | int | 顶部提示用 |
| `dup_public_count` | 与公客重复数量 | int | 顶部提示用 |
| `total_count` | 当前筛选总条数 | int | 工具栏「共N条」 |
| `page` | 当前页码 | int | |
| `page_size` | 每页条数 | int | 默认20 |
| `total_pages` | 总页数 | int | |
---
## 7. 竞品截图对应关系
| 截图路径 | 对应功能 | 对应文档章节 | 采纳的设计要点 |
|---|---|---|---|
| `screenshots/客源/全部私客.png` | 全部私客 Tab 完整视图 | §2.1 全部 | ① 二级 Tab 全部私客样式;② 筛选区「常用」快捷行布局;③ 状态/需求/等级/位置/购价/房室分组筛选行布局;④ 工具栏批量按钮 + 总条数 + 导出 + 自定义列表按钮排列;⑤ 表格12列完整列定义含活跃度标签双行渲染⑥ 分页栏「20条/页」跳页格式 |
| `screenshots/客源/求购私客.png` | 求购 Tab 专属视图 | §2.1(求购差异) | ① 购价筛选行展开样式(含「收起」链接);② 「展开」更多筛选行含保护客/合作者/偏好新房等;③ 求购 Tab 激活态橙色→Fonrey 用 primary-600④ 「是大价值」复选框在房室行末尾 |
| `screenshots/客源/求租私客.png` | 求租 Tab 专属视图 | §2.1(求租差异) | ① 租价筛选行单位为「元」;② 租价预设区间2000元以下10000以上③ 状态筛选改为「不限/求租/租购」;④ 需求类型仅「租房」;⑤ 列表行「需求类型」列显示「租房」;⑥ 需求/解读列格式为租价+面积 |
**截图与 PRD 差异说明**
1. 竞品截图中一级 Tab 包含「资料客」「营销客」PRD §5.1 亦提及,但 PRD_MVP.md 未将这两个 Tab 列为 P0本文档将其作为空 Tab 占位展示(不可点击或点击提示「即将上线」),避免 MVP 实现复杂度。
2. 竞品右上角有「商城」入口和用户头像/姓名,属于全局顶导,不在本文档范围。
3. 竞品颜色为橙色系Fonrey 统一使用 Teal 主色,所有橙色激活态映射为 `bg-primary-600` / `text-primary-600`
4. 竞品截图中「全部私客」Tab 有橙色边框高亮(`border border-orange-400`Fonrey 对应为 pill 激活态 `bg-white text-primary-700 shadow-sm`
---
## 8. 实现优先级与工期估算
| 页面/功能 | 优先级 | 特殊组件复杂度 | 工期估算(前端) |
|---|---|---|---|
| 一级 Tab + 二级 Tab 导航框架 | P0 🔴 | 低Tab Navigation §10 | 0.5 天 |
| 搜索框 + 已存搜索 | P0 🔴 | 中(下拉交互) | 0.5 天 |
| 快捷筛选行 + 分组筛选条(含展开/收起) | P0 🔴 | 中Alpine.js 管理展开状态 + HTMX | 1.5 天 |
| 价格区间筛选(预设+自定义) | P0 🔴 | 低 | 0.5 天 |
| 工具栏(批量操作 + 总条数 + 导出) | P0 🔴 | 低Toolbar §4 | 0.5 天 |
| Data Table 主体(列定义 + 活跃度标签渲染) | P0 🔴 | 高§3 Data Table多行单元格 | 2 天 |
| 分页栏(含跳页) | P0 🔴 | 低Pagination §2 | 0.5 天 |
| 重复检测提示区 | P0 🔴 | 低 | 0.25 天 |
| 空状态设计 | P0 🔴 | 低 | 0.25 天 |
| 骨架屏 Loading | P0 🔴 | 低 | 0.25 天 |
| 已存搜索保存与调用 | P1 🟡 | 中 | 1 天 |
| 导出 ExcelCelery 异步) | P1 🟡 | 中(后端为主) | 0.5 天前端 |
| 自定义列弹窗§4.1 Column Visibility | P1 🟡 | 中Modal + 拖拽排序) | 1.5 天 |
| **合计 P0** | | | **约 6.25 天** |
| **合计 P1** | | | **约 3 天** |
---
## 9. 开放问题(待决策)
| # | 问题 | 影响范围 | 待确认方 | 回答 |
| --- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------------------- | ----------- |
| 1 | 「资料客」「营销客」Tab 在 MVP 阶段是否展示为灰色禁用 Tab还是直接不显示 | 一级 Tab 导航 §2.1.3 | 产品经理 | 展示为灰色禁用 |
| 2 | 二级 Tab 上的客源数量 Badge如「求购 913」是否实时计数若是是否有性能开销建议改为后端分 Tab 预聚合或缓存 | 二级 Tab §2.1.3 | 后端 + 产品 | 改为后端分Tab预聚合 |
| 3 | 「与我相关」和「我部门相关」的精确业务定义:经纪人同时是首录人和归属人时,「与我相关」指 `owner_id=me OR first_recorder_id=me`?还是仅 `owner_id=me` | 快捷筛选行 §2.1.3 | 产品经理 | owner_id=me |
| 4 | 「即将掉公」筛选的时间阈值(距自动转公还有多少天开始提示)是运营后台可配置项还是硬编码?需要前端在筛选行旁边展示剩余天数吗? | 快捷筛选行 | 产品 + 后端 | 后台可配置 |
| 5 | 价格筛选的自定义区间输入:用户手动输入后是否需要点击「搜索」按钮才触发,还是 blur 后自动 HTMX与其他 Tag 筛选项行为需统一) | 价格筛选 §2.1.3 | 产品经理 | blur后自动HTMX |
| 6 | 表格「最近时间」列PRD 写的是「最近时间」最近跟进或带看的距今天数截图中显示「N天前」+ 日期(如`2026-04-19`)两行,是否需要双行展示? | 表格列定义 §2.1.3 | 产品经理(截图已有双行,建议对齐截图) | |
| 7 | 导出功能Celery 异步生成后如何通知用户下载WebSocket Push / 轮询 / 下载中心页MVP 阶段建议使用轮询+下载链接 Toast | 导出按钮 §2.1.3 | 后端 + 产品 | 可在通知中心里显示消息 |
| 8 | 批量合并客源:需要独立的合并规则弹窗(选择主记录 + 字段合并规则),复杂度高,是否降级到 P2 | 工具栏批量操作 | 产品经理 | P0不做 |
| 9 | 转成交弹窗中「成交方」人员选择器:默认带入当前用户所属门店,支持修改的范围是全司还是当前用户权限内? | 详情页转成交弹窗(待详情页文档) | 产品 + 后端 | 全司 |
| 10 | 活跃度标签「营销客」「销售客」「访客」的触发条件(截图可见但 DATA_MODEL_CLIENT.md 中的 `activity_level` 枚举不含这三项):这些是 `source` 字段衍生的展示标签,还是独立的 `activity_level` 值?需后端澄清 | 活跃度标签渲染 §2.1.3 | 后端 | 后台可配置 |