42 KiB
编辑客源 UI 设计文档
版本:v1.0 · 日期:2026-04-26 依赖规范:UI_SYSTEM.md v1.2 · 组件规范设计.md v1.0 PRD 来源:
Project/fonrey/PRD/客源管理/客源管理模块PRD.md§5.15 + Story 14 优先级:P0 功能在本文档中用 🔴 标注,P1 用 🟡,P2 用 ⚫
目录
- 模块概述
- 1.1 功能范围
- 1.2 页面清单
- 1.3 用户角色与权限差异
- 页面设计规范
- 2.1 编辑客源主页面(三 Tab 表单)
- 弹窗/抽屉设计规范
- 3.1 编辑基础信息弹窗(快捷入口,来自信息概览面板)
- 交互状态规范
- 4.1 全局状态说明
- 4.2 权限控制矩阵
- 4.3 HTMX 请求规范
- 关键数据字段说明
- 竞品截图对应关系
- 实现优先级与工期估算
- 开放问题(待决策)
1. 模块概述
1.1 功能范围
编辑客源模块包含以下功能,按优先级分组:
P0 — MVP 上线必须实现 🔴
| 功能 | PRD 来源 |
|---|---|
| 联系人 Tab:编辑主联系人及多联系人信息(姓名/称呼/电话1/电话2/微信/QQ/备注) | Story 14 §5.15.2 |
| 联系人 Tab:查看号码权限验证(点击「查看号码」后才能编辑电话) | §5.15.2 |
| 联系人 Tab:「标记无效」号码操作 | §5.15.2 |
| 联系人 Tab:「+ 添加联系人」追加联系人区块 | §5.15.2 |
| 基础信息 Tab:必填字段编辑(需求类型/用途/等级/来源) | §5.15.3 |
| 基础信息 Tab:选填字段编辑(购房目的/付款方式/名下房产/贷款记录/证件/意向学校/入学时间) | §5.15.3 |
| 二手 Tab:全量需求字段可编辑(总价/面积/居室/楼层/朝向/装修/楼龄/意向商圈/意向小区/交通/备注) | §5.15.4 |
| 全局保存/取消操作(表单底部内联按钮,保存成功返回详情页) | §5.15.5 |
| Tab 切换时保持各 Tab 表单数据不丢失 | §5.15.5 |
| 表单校验:必填字段红框高亮 + 滚动定位到首个错误 | §5.15.5 |
P1 — 首迭代实现 🟡
| 功能 | PRD 来源 |
|---|---|
| 新房 Tab:新房需求字段编辑(待截图补充确认字段) | §5.15.4 注 |
| 租房 Tab:租房需求字段编辑(待截图补充确认字段) | §5.15.4 注 |
| 编辑基础信息弹窗(从信息概览面板「编辑客源」快捷入口触发,Story 22) | §5.23 |
1.2 页面清单
| 页面名称 | URL 模式建议 | 优先级 | 对应 PRD 章节 |
|---|---|---|---|
| 编辑客源主页面 | /clients/<client_id>/edit/ |
P0 🔴 | §5.15 / Story 14 |
| 编辑基础信息弹窗 | 无独立 URL,HTMX 局部渲染 | P1 🟡 | §5.23 / Story 22 |
1.3 用户角色与权限差异
| 功能 | 经纪人(归属人/首录人) | 经纪人(非归属人) | 店长/管理员 |
|---|---|---|---|
| 进入编辑客源页 | ✅ 可进入 | ❌ 无权限 | ✅ 可进入 |
| 查看并编辑电话号码 | 需点击「查看号码」验证,通过后可编辑 | ❌ | ✅ |
| 「标记无效」号码 | ✅ | ❌ | ✅ |
| 编辑基础信息/需求信息 | ✅ | ❌ | ✅ |
| 添加/删除联系人 | ✅ | ❌ | ✅ |
| 「+ 添加联系人」按钮 | ✅ | ❌ | ✅ |
说明:
- 电话1 默认打码显示(
137****1234),需权限验证后才能查看完整号码并进入编辑态。- 非归属人/非首录人访问
/clients/<client_id>/edit/时,后端返回 403,前端重定向至详情页并展示 Toast "无权限编辑该客源"。
2. 页面设计规范
2.1 编辑客源主页面(P0 🔴)
2.1.1 页面概述
- URL:
/clients/<client_id>/edit/ - 访问入口:
- 私客详情页右侧信息概览面板「编辑」文字按钮
- 私客详情页需求信息 Tab 右上角「编辑」蓝色文字链接(跳转到编辑页的「二手/新房/租房」Tab 激活态)
- 页面职责:允许经纪人修改客源联系人信息、基础属性、购房/租房需求的全量字段
- 竞品参考截图:
Project/fonrey/screenshots/客源/编辑客源.png
2.1.2 布局结构
┌──────────────────────────────────────────────────────────┐
│ 面包屑导航:客源 / 客源管理 / 编辑客源 │
│ 页面标题:编辑客源(text-xl font-semibold) │
├──────────────────────────────────────────────────────────┤
│ Tab 导航栏:[联系人] [基础信息] [二手/新房/租房] │
│ (Tab 下划线橙色激活 #F97316,对应竞品,非主色 Teal) │
├──────────────────────────────────────────────────────────┤
│ │
│ 联系人 Tab 内容区 │
│ ┌────────────────────────────────────────────────────┐ │
│ │ [区块标题:联系人] [右上角:查看后可编辑号码 查看号码] [+添加联系人] │
│ │ │ │
│ │ 联系人 1 区块(白色卡片 rounded-lg) │ │
│ │ ● 姓名 [input] ● 电话1 [***打码] [标记无效] │ │
│ │ ● 称呼 ○先生 ○女士 电话2 [-] 微信 [-] │ │
│ │ QQ [-] │ │
│ │ 备注 [textarea] │ │
│ │ │ │
│ │ 联系人 2 区块(同上,右上角有「删除」红色链接) │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ─ ─ ─ ─ 以下为基础信息区块(同页,分 Section 渲染)─ ─ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 基础信息 │ │
│ │ ● 需求类型 ☑二手 □新房 │ │
│ │ ● 用途 ○住宅 ○别墅 ○商住 ○商铺 ○写字楼 ○其他 │ │
│ │ ● 等级 ○A ○B ●C ○D ○E │ │
│ │ ● 来源 [下拉] │ │
│ │ 购房目的 □刚需 □投资 □学区 □改善 □商用 □其他 │ │
│ │ 付款方式 ○全额 ○商业贷款 ○商贷+公积金 ○公积金 │ │
│ │ 名下房产 ○无 ○本地无外地有 ○本地有房 │ │
│ │ 贷款记录 ○有 ○无 │ │
│ │ 证件类型 [下拉] 证件号码 [input] │ │
│ │ 意向学校1 [input] + 添加学校 │ │
│ │ 入学时间 [月份选择器] │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 二手(需求信息区块,仅在「二手」Tab 或同页展示) │ │
│ │ ● 总价 [数字输入] - [数字输入] 万元 │ │
│ │ ● 面积 [数字输入] - [数字输入] m² │ │
│ │ ● 居室 □1居 □2居 □3居 □4居 □5居及以上 │ │
│ │ 楼层 □不要一层 □低楼层 □中楼层 □高楼层 □不要顶层│ │
│ │ 朝向 □东 □南 □西 □北 │ │
│ │ 装修 □毛坯 □清水 □简装 □中装 □精装 □豪装 │ │
│ │ 楼龄 □5年以内 □5-10年 □10-15年 □15-20年 □20年以上│ │
│ │ 意向商圈 [下拉多选] │ │
│ │ 意向小区1 [input] + 添加小区 │ │
│ │ 交通 [input, max 50字] │ │
│ │ 备注 [textarea, max 200字] │ │
│ └────────────────────────────────────────────────────┘ │
│ │
├──────────────────────────────────────────────────────────┤
│ 表单底部内联操作区:[保存(Teal 主按钮)] [取消(白色边框)] │
└──────────────────────────────────────────────────────────┘
截图与文档差异说明:竞品截图(
编辑客源.png)显示三个 Tab(联系人/基础信息/二手)在同一页面内纵向分 Section 展示,并非切换 Tab 才能看到内容——联系人、基础信息、二手三个区块都在一个页面滚动展示。顶部 Tab 的功能是快速定位锚点(点击 Tab 滚动到对应区块),而非隐藏其他区块。以截图为准实现。
2.1.3 区域详细规范
[顶部面包屑 + 标题区]
| 属性 | 说明 |
|---|---|
| 面包屑 | 客源 / 客源管理 / 编辑客源,使用 text-sm text-neutral-500,分隔符 /,最后一级用 text-neutral-700 |
| 页面标题 | 编辑客源,text-xl font-semibold text-neutral-800 |
| 布局 | 面包屑在上,标题在下,左对齐,顶部 padding pt-6 pb-4 |
[Tab 导航栏]
| 属性 | 说明 |
|---|---|
| 组件 | §10 Tab Navigation(组件规范设计.md) |
| Tab 项 | 「联系人」「基础信息」「二手/新房/租房」(第三项 label 根据客源需求类型动态确定) |
| 激活样式 | 注意:竞品使用橙色下划线 border-b-2 border-orange-500 text-orange-500,与系统主色 Teal 不同。为保持一致性,本模块 Tab 激活色统一使用橙色 #F97316(text-orange-500 border-orange-500),以匹配竞品客源模块整体视觉风格 |
| 非激活样式 | text-neutral-500 hover:text-neutral-700 border-b-2 border-transparent |
| 行为 | 点击 Tab 触发 Alpine.js 平滑滚动到对应 Section 的锚点(scrollIntoView({ behavior: 'smooth' })),不做 HTMX 请求;Tab 同时高亮当前可见区域(Intersection Observer 驱动) |
| 粘性 | Tab 栏 sticky top-0 z-10 bg-white border-b border-neutral-200 |
<!-- Tab 导航栏示例 -->
<div class="sticky top-0 z-10 bg-white border-b border-neutral-200"
x-data="{ activeTab: 'contacts' }">
<nav class="flex gap-0 px-6">
<button class="px-4 py-3 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === 'contacts'
? 'border-orange-500 text-orange-500'
: 'border-transparent text-neutral-500 hover:text-neutral-700'"
@click="activeTab = 'contacts'; document.getElementById('section-contacts').scrollIntoView({behavior:'smooth'})">
联系人
</button>
<button class="px-4 py-3 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === 'basic'
? 'border-orange-500 text-orange-500'
: 'border-transparent text-neutral-500 hover:text-neutral-700'"
@click="activeTab = 'basic'; document.getElementById('section-basic').scrollIntoView({behavior:'smooth'})">
基础信息
</button>
<button class="px-4 py-3 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === 'requirement'
? 'border-orange-500 text-orange-500'
: 'border-transparent text-neutral-500 hover:text-neutral-700'"
@click="activeTab = 'requirement'; document.getElementById('section-requirement').scrollIntoView({behavior:'smooth'})">
{{ requirement_tab_label }} {# 二手 / 新房 / 租房 #}
</button>
</nav>
</div>
[联系人 Section](锚点 id="section-contacts")
区块标题行:
| 元素 | 说明 |
|---|---|
| 标题 | 联系人,text-base font-semibold text-neutral-800 |
| 右侧辅助文字 | 查看后可编辑号码,text-sm text-neutral-400 |
| 「查看号码」按钮 | text-sm text-info-600 hover:underline cursor-pointer,点击后触发查看号码确认流程(见下方) |
| 「+ 添加联系人」按钮 | btn-secondary text-sm,位于标题行右侧最末,点击后 Alpine.js 动态追加联系人区块 |
联系人区块(每个联系人独立卡片):
bg-white rounded-lg border border-neutral-200 p-5 mb-4
区块内字段布局为网格布局,参考截图:3列网格,每列包含"字段名 + 输入组件"。
| 字段 | 必填 | 组件类型 | 校验 | 备注 |
|---|---|---|---|---|
| 姓名 | ✅ | <input type="text"> |
不可为空,最多50字 | 与录入一致 |
| 称呼 | ✅ | 单选 Radio | 选一 | ○ 先生 ○ 女士 |
| 电话1 | ✅ | 区号下拉 + 手机号 input | 手机号格式 | 默认打码,需「查看号码」后才能编辑(见下方交互) |
| 标记无效 | — | 蓝色文字链接 | — | 显示在电话1 旁,点击 HTMX PATCH 标记 phone_is_invalid=True |
| 微信 | 否 | <input type="text"> |
— | 显示 - 时可直接编辑 |
| 电话2 | 否 | 区号下拉 + 手机号 input | 手机号格式(若填写) | 同电话1,但无打码逻辑 |
| 否 | <input type="text"> |
— | ||
| 备注 | 否 | <textarea> |
最多200字,字数计数器 | 新增字段(录入时无) |
电话1 查看号码交互流程:
- 页面加载时,
phone_enc解密后以打码形式(137****1234)展示在只读<span>中,输入框disabled - 用户点击「查看号码」按钮 → HTMX GET 请求验证权限 → 后端返回:
- 成功:返回明文号码,前端 Alpine.js 将
<span>替换为可编辑<input>,同时在client_follow_logs插入一条log_type='sensitive_view'记录 - 失败(403/权限不足):Toast 提示「无查看号码权限」
- 成功:返回明文号码,前端 Alpine.js 将
- 「查看号码」按钮变为已点击态(灰色,禁用),显示文字「已查看」
- 「标记无效」链接始终可见(无需查看号码),但触发时需后端权限校验
<!-- 电话1 打码展示 + 查看号码按钮 -->
<div x-data="{ revealed: false, phoneValue: '' }">
<!-- 打码显示态 -->
<template x-if="!revealed">
<div class="flex items-center gap-2">
<span class="text-sm text-neutral-700">{{ contact.phone_masked }}</span>
<button type="button"
class="text-sm text-info-600 hover:underline"
hx-get="/api/clients/{{ client.id }}/contacts/{{ contact.id }}/reveal-phone/"
hx-target="#phone-reveal-{{ contact.id }}"
hx-swap="outerHTML"
@htmx:after-request="if(event.detail.successful) revealed = true">
查看号码
</button>
<a href="#" class="text-sm text-info-600 hover:underline"
hx-patch="/api/clients/{{ client.id }}/contacts/{{ contact.id }}/mark-invalid/"
hx-confirm="确认将该号码标记为无效?"
hx-swap="none"
@htmx:after-request="/* 处理成功/失败 */">
标记无效
</a>
</div>
</template>
<!-- 已解码可编辑态 -->
<div id="phone-reveal-{{ contact.id }}">
<template x-if="revealed">
<div class="flex items-center gap-2">
<input type="tel" name="contacts[{{ forloop.counter0 }}][phone]"
:value="phoneValue"
class="input-base w-40"
placeholder="请输入手机号">
<span class="text-xs text-neutral-400">已查看</span>
<a href="#" class="text-sm text-info-600 hover:underline"
hx-patch="/api/clients/{{ client.id }}/contacts/{{ contact.id }}/mark-invalid/"
hx-confirm="确认将该号码标记为无效?"
hx-swap="none">
标记无效
</a>
</div>
</template>
</div>
</div>
联系人区块的添加/删除逻辑(Alpine.js 驱动):
<div x-data="{
contacts: [
{ id: '{{ c.id }}', name: '{{ c.name }}', ... }
// 由 Django 模板初始化
],
addContact() {
this.contacts.push({ id: null, name: '', gender: 'male', phone: '', ... });
},
removeContact(index) {
if (this.contacts.length > 1) this.contacts.splice(index, 1);
}
}">
<template x-for="(contact, index) in contacts" :key="index">
<div class="bg-white rounded-lg border border-neutral-200 p-5 mb-4">
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-medium text-neutral-700" x-text="'联系人 ' + (index+1)"></span>
<button x-show="index > 0" type="button"
class="text-sm text-danger-600 hover:underline"
@click="removeContact(index)">删除</button>
</div>
<!-- 字段区域 -->
</div>
</template>
<button type="button" @click="addContact()"
class="flex items-center gap-1 text-sm text-info-600 hover:underline mt-2">
<svg class="w-4 h-4"><!-- Heroicons plus --></svg>
添加联系人
</button>
</div>
[基础信息 Section](锚点 id="section-basic")
Section 容器:bg-white rounded-lg border border-neutral-200 p-5 mb-4 mt-6
字段布局:竞品截图显示为标签在左(约100px 固定宽度)、控件在右的两列表单布局,行间距 space-y-4。
| 字段 | 必填 | 组件类型 | 枚举值 | 后端字段 |
|---|---|---|---|---|
| 需求类型 | ✅(*) | 复选框 | ☑ 二手 / □ 新房 | clients.status 间接关联;需求类型影响第三个 Tab |
| 用途 | ✅(*) | Radio 单选(横排) | 住宅 / 别墅 / 商住 / 商铺 / 写字楼 / 其他 | clients.property_usage |
| 等级 | ✅(*) | Radio 单选(横排) | A(急迫) / B(较强) / C(一般) / D(较弱) / E(暂不关注) | clients.grade |
| 来源 | ✅(*) | <select> 下拉 |
由运营维护,如「线下|门店接待」 | clients.source |
| 购房目的 | 否 | 多选复选框(横排) | 刚需 / 投资 / 学区 / 改善 / 商用 / 其他 | clients.buying_purpose[] |
| 付款方式 | 否 | Radio 单选(横排) | 全额 / 商业贷款 / 商业贷款+公积金 / 公积金 | clients.payment_method |
| 名下房产 | 否 | Radio 单选(横排) | 无 / 本地无房外地有房 / 本地有房 | clients.properties_owned |
| 贷款记录 | 否 | Radio 单选(横排) | 有 / 无 | clients.has_loan_record |
| 证件类型 | 否 | <select> 下拉 |
身份证(默认)/ 护照 / 港澳通行证 / 其他 | clients.id_type |
| 证件号码 | 否 | <input type="text"> |
placeholder「请输入证件号码」;选择身份证时校验18位 | clients.id_number_enc(AES 加密存储) |
| 意向学校 | 否 | <input type="text"> + 「+ 添加学校」链接 |
支持多所,动态追加输入行 | client_school_preferences.school_name |
| 入学时间 | 否 | 月份选择器(年月,如「2027-09」) | placeholder「请选择年月」,Flatpickr mode:'single', showMonths:1 |
client_requirements.school_enrollment_date |
意向学校多输入行(Alpine.js 驱动):
<div x-data="{ schools: {{ schools_json }}, addSchool() { this.schools.push('') } }">
<template x-for="(school, i) in schools" :key="i">
<div class="flex items-center gap-2 mb-2">
<input type="text" :name="`school_names[${i}]`" x-model="schools[i]"
class="input-base w-48" placeholder="请输入学校名称">
<button type="button" @click="schools.splice(i,1)" x-show="schools.length > 1"
class="text-danger-600 text-sm hover:underline">删除</button>
</div>
</template>
<button type="button" @click="addSchool()"
class="text-sm text-info-600 hover:underline">+ 添加学校</button>
</div>
[需求信息 Section(二手)](锚点 id="section-requirement")
Section 容器同上,Section 标题为「二手」(或「新房」/「租房」),text-base font-semibold text-neutral-800。
字段布局:与竞品截图一致,标签在左(约100px)、控件在右,行间距 space-y-4。
| 字段 | 必填 | 组件类型 | 约束 | 后端字段 |
|---|---|---|---|---|
| 总价 | ✅(*) | 两个 <input type="number"> + 文字「-」+ 「万元」 |
正数;最小值 ≤ 最大值 | client_requirements.budget_min / budget_max |
| 面积 | ✅(*) | 两个 <input type="number"> + 文字「-」+ 「m²」 |
正数;最小值 ≤ 最大值 | area_min / area_max |
| 居室 | ✅(*) | 多选复选框(横排) | 至少选一项 | □1居 □2居 □3居 □4居 □5居及以上 |
| 楼层 | 否 | 多选复选框(横排) | — | □不要一层 □低楼层 □中楼层 □高楼层 □不要顶层 |
| 朝向 | 否 | 多选复选框(横排) | — | □东 □南 □西 □北 |
| 装修 | 否 | 多选复选框(横排) | — | □毛坯 □清水 □简装 □中装 □精装 □豪装 |
| 楼龄 | 否 | 多选复选框(横排) | — | □5年以内 □5-10年 □10-15年 □15-20年 □20年以上 |
| 意向商圈 | 否 | <select multiple> 下拉多选 |
placeholder「请选择商圈」 | intent_business_area_ids[] |
| 意向小区 | 否 | <input type="text"> + 「+ 添加小区」 |
支持多个,动态追加 | intent_complex_names(逗号拼接) |
| 交通 | 否 | <input type="text"> |
最多50字,实时计数显示 0/50 |
transportation |
| 备注 | 否 | <textarea> |
最多200字,实时计数显示 0/200 |
requirement_notes |
总价/面积区间双输入框:
<div class="flex items-center gap-2">
<input type="number" name="budget_min" value="{{ req.budget_min }}"
min="0" step="0.01"
class="input-base w-24"
placeholder="最小值">
<span class="text-neutral-400">-</span>
<input type="number" name="budget_max" value="{{ req.budget_max }}"
min="0" step="0.01"
class="input-base w-24"
placeholder="最大值">
<span class="text-sm text-neutral-500">万元</span>
</div>
字数计数器(Alpine.js):
<div x-data="{ count: {{ req.transportation|length }} }">
<input type="text" name="transportation" maxlength="50"
x-model.debounce="value" @input="count = $event.target.value.length"
class="input-base w-full">
<span class="text-xs text-neutral-400 text-right block mt-1"
x-text="count + ' / 50'"></span>
</div>
[底部操作按钮区]
<div class="flex items-center gap-3 mt-2 pb-8">
<button type="submit" form="form-edit-client"
:disabled="submitting"
class="inline-flex items-center gap-1.5 px-6 py-2 text-sm font-medium bg-primary-600 text-white rounded-md hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-70 disabled:cursor-wait">
<!-- spinner,提交中显示 -->
<span x-text="submitting ? '保存中...' : '保存'"></span>
</button>
<button type="button" @click="cancelForm" :disabled="submitting"
class="inline-flex items-center px-4 py-2 text-sm font-medium bg-white border border-neutral-300 text-neutral-700 rounded-md hover:bg-neutral-50 hover:border-neutral-400 disabled:opacity-60">
取消
</button>
<p x-show="redirectHint" x-text="redirectHint" class="text-xs text-success-600"></p>
</div>
说明:按钮区为表单内部的普通流式布局(
mt-2 pb-8),与「新增客源」页保持一致。保存按钮使用系统主色 Teal(bg-primary-600),取消按钮为白底边框风格<button>,无需为固定底栏预留额外底部间距。
2.1.4 使用的特殊组件
| 组件名 | 来源(组件规范设计.md 章节) | 用途 | 自定义说明 |
|---|---|---|---|
| Tab Navigation | §10 Tab Navigation | 联系人/基础信息/二手 三 Tab | Tab 激活色使用橙色(text-orange-500 border-orange-500)而非系统主色 Teal,与竞品保持一致 |
| Modal Dialog | §7 Modal Dialog | 查看号码时如需二次确认弹窗(权限验证提示) | max-w-sm 尺寸 |
| Date Range Picker (Flatpickr) | §9 Date Range Picker | 入学时间的月份选择器 | 模式:plugins: [new monthSelectPlugin()],只选年月 |
| Multi-select Tag Input | §17 Multi-select Tag Input | 意向商圈下拉多选 | 可选参考 Choices.js,或用原生 <select multiple> + 自定义样式 |
2.1.5 空状态设计
联系人列表无联系人:理论上不存在(保存时必须有至少一个联系人)。如因异常加载失败,展示错误提示区块而非空状态。
基础信息字段未填写:在只读详情中显示「-」,在编辑态中显示为空输入框(含 placeholder)。
意向学校/意向小区无条目时:动态列表为空时显示「+ 添加学校」/「+ 添加小区」入口,不显示空状态图案。
2.1.6 Loading 状态
| 场景 | 方案 |
|---|---|
| 页面初始加载 | Django 服务端渲染,无需骨架屏,直接输出完整表单 |
| 点击「查看号码」 | 按钮显示 loading spinner(htmx-request 状态),按钮文字临时变为「查看中...」 |
| 点击「标记无效」 | 按钮 loading,完成后 Toast 提示 |
| 保存提交中 | 「保存」按钮变为 disabled + spinner,文字「保存中...」;通过 hx-indicator 控制 |
| 意向商圈下拉加载选项 | 如为异步加载,Select 容器内显示 <option>加载中...</option> 再替换 |
3. 弹窗/抽屉设计规范
3.1 编辑基础信息弹窗(P1 🟡)
此弹窗由信息概览面板「编辑客源」快捷图标触发(Story 22 §5.23)。功能是快捷入口,字段是主编辑页基础信息 Tab 的子集。
3.1.1 触发方式
- 触发位置:私客详情页右侧信息概览面板 → 快捷操作区2 → 「编辑客源」图标按钮
- 竞品截图:
Project/fonrey/screenshots/客源/编辑基础信息.png - 组件类型:Modal Dialog(选择理由:字段数量中等,约10个字段,Modal 比 Drawer 更紧凑;操作完成后不需要保留背景页面的上下文)
- 尺寸:
max-w-lg(512px),竖向滚动支持
3.1.2 表单字段规范
| 字段名 | 组件类型 | 必填 | 校验规则 | 默认值/预填值 |
|---|---|---|---|---|
| 需求类型 | 复选框(横排) | ✅ | 至少选一项 | 回显 clients.status 对应的类型 |
| 用途 | <select> 下拉 |
✅ | 必选 | 回显 clients.property_usage |
| 来源 | <select> 下拉 |
✅ | 必选 | 回显 clients.source |
| 购房目的 | 多选复选框(横排) | 否 | — | 回显 clients.buying_purpose[] |
| 付款方式 | <select> 下拉 |
否 | — | 回显 clients.payment_method |
| 名下房产 | <select> 下拉 |
否 | — | 回显 clients.properties_owned |
| 贷款记录 | Radio 单选(横排) | 否 | — | 回显 clients.has_loan_record |
| 证件类型 | <select> 下拉 |
否 | — | 回显 clients.id_type |
| 证件号码 | <input type="text"> |
否 | 身份证18位格式(当证件类型为身份证时) | 回显解密后的证件号码 |
| 意向学校 | <input> + 「+ 添加学校」 |
否 | — | 回显已有学校列表 |
3.1.3 提交行为
- 提交方式:
hx-patch="/api/clients/{{ client.id }}/basic-info/" - 成功响应:
- 关闭 Modal(Alpine.js
open = false) - 触发 Toast:「保存成功」(
success-600绿色,3秒自动消失) - 刷新信息概览面板相关字段区域(
hx-target="#info-panel-basic-fields"hx-swap="innerHTML")
- 关闭 Modal(Alpine.js
- 失败响应(422):
- 弹窗内字段级错误提示(输入框下方
text-danger-600 text-xs) - 必填字段边框变
border-danger-600 - 不关闭弹窗
- 弹窗内字段级错误提示(输入框下方
<!-- 编辑基础信息 Modal 触发 -->
<button type="button"
@click="$dispatch('open-modal', 'edit-basic-info')"
class="flex flex-col items-center gap-1 text-xs text-neutral-600 hover:text-primary-600">
<svg class="w-5 h-5"><!-- Heroicons pencil-square --></svg>
编辑客源
</button>
<!-- Modal 内 form -->
<form id="form-edit-basic-info"
hx-patch="/api/clients/{{ client.id }}/basic-info/"
hx-target="#info-panel-basic-fields"
hx-swap="innerHTML"
hx-on::after-request="
if(event.detail.successful) {
$dispatch('close-modal');
$dispatch('show-toast', {message:'保存成功', type:'success'});
}
">
<!-- 字段区域 -->
<div class="px-6 py-4 space-y-4">
<!-- 需求类型 -->
<div>
<label class="block text-sm font-medium text-neutral-700 mb-1">
需求类型 <span class="text-danger-600">*</span>
</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="requirement_type[]" value="second_hand"
{{ 'checked' if 'second_hand' in requirement_types }} class="checkbox-base">
二手
</label>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="requirement_type[]" value="new_house"
{{ 'checked' if 'new_house' in requirement_types }} class="checkbox-base">
新房
</label>
</div>
</div>
<!-- 其他字段省略... -->
</div>
<div class="px-6 py-4 border-t border-neutral-200 flex justify-end gap-3">
<button type="button" @click="$dispatch('close-modal')"
class="btn-secondary px-4 py-2 text-sm rounded-lg">取消</button>
<button type="submit"
class="btn-primary bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 text-sm rounded-lg">
确定
</button>
</div>
</form>
3.1.4 使用的特殊组件
| 组件名 | 来源 | 用途 |
|---|---|---|
| Modal Dialog | §7 Modal Dialog | 编辑基础信息弹窗容器 |
4. 交互状态规范
4.1 全局状态说明
Alpine.js 管理的状态:
| 状态 | 位置 | 说明 |
|---|---|---|
contacts[] |
联系人区块 x-data | 联系人列表,支持动态追加/删除 |
schools[] |
基础信息区块 x-data | 意向学校列表,支持动态追加/删除 |
complexes[] |
需求信息区块 x-data | 意向小区列表,支持动态追加/删除 |
activeTab |
Tab 导航 x-data | 当前高亮的 Tab(联动 Intersection Observer) |
phoneRevealed[contactId] |
每个联系人区块 x-data | 该联系人号码是否已解码可编辑 |
editBasicInfoOpen |
页面级 / 信息概览面板 | 编辑基础信息 Modal 的开关状态 |
HTMX 管理的数据流:
| 操作 | 说明 |
|---|---|
| 查看号码 | hx-get 获取明文号码,局部替换号码区域 HTML |
| 标记无效 | hx-patch 更新 phone_is_invalid,无需刷新页面(hx-swap="none"),Toast 提示 |
| 整体表单保存 | <form hx-post> 提交,成功后 HX-Redirect 跳转到详情页 |
| 编辑基础信息弹窗提交 | hx-patch 局部更新面板字段区域 |
4.2 权限控制矩阵
| 操作 | 经纪人(归属人) | 经纪人(非归属人) | 店长 | 管理员 |
|---|---|---|---|---|
| 进入编辑页 | ✅ | ❌(后端403) | ✅ | ✅ |
| 查看电话号码明文 | ✅(需点击验证,留痕) | ❌ | ✅ | ✅ |
| 标记号码无效 | ✅ | ❌ | ✅ | ✅ |
| 编辑联系人信息 | ✅ | ❌ | ✅ | ✅ |
| 添加/删除联系人 | ✅ | ❌ | ✅ | ✅ |
| 编辑基础信息 | ✅ | ❌ | ✅ | ✅ |
| 编辑需求信息 | ✅ | ❌ | ✅ | ✅ |
| 使用编辑基础信息快捷弹窗 | ✅ | ❌ | ✅ | ✅ |
4.3 HTMX 请求规范
| 操作 | hx-trigger | hx-method + URL | hx-target | hx-swap | Loading 行为 |
|---|---|---|---|---|---|
| 查看电话1明文 | click |
hx-get="/api/clients/{id}/contacts/{cid}/reveal-phone/" |
#phone-reveal-{cid} |
outerHTML |
按钮内 spinner,hx-indicator="#reveal-btn-{cid}" |
| 标记号码无效 | click + 确认对话框 |
hx-patch="/api/clients/{id}/contacts/{cid}/mark-invalid/" |
this(hx-swap="none") |
none |
按钮 disabled + spinner |
| 保存整体表单 | submit |
hx-post="/clients/{id}/edit/" |
body(后端返回 HX-Redirect) |
— | 「保存」按钮 disabled + 保存中... |
| 编辑基础信息弹窗提交 | submit |
hx-patch="/api/clients/{id}/basic-info/" |
#info-panel-basic-fields |
innerHTML |
「确定」按钮 disabled + spinner |
| 意向商圈选项懒加载(如需) | revealed(下拉打开) |
hx-get="/api/business-areas/?format=options" |
#business-area-select |
innerHTML |
Select 内显示「加载中」option |
5. 关键数据字段说明
5.1 主表字段(clients)
| 字段名(英文) | 显示名 | 数据类型 | 说明 |
|---|---|---|---|
id |
— | UUID | 主键 |
status |
需求状态 | VARCHAR(20) | buying=求购 / renting=求租 / buy_or_rent=租购,编辑页不直接修改此字段,通过「改状态」弹窗操作 |
grade |
等级 | VARCHAR(5) | A_urgent/A/B/C/D/E,编辑页可直接修改 |
property_usage |
用途 | VARCHAR(30) | residential/villa/commercial_residential/shop/office/other |
buying_purpose |
购房目的 | VARCHAR(20)[] | 多选数组,如 ['rigid', 'school_district'] |
payment_method |
付款方式 | VARCHAR(30) | full/mortgage/mortgage_fund/fund |
properties_owned |
名下房产 | VARCHAR(20) | none/local_none/local_has |
has_loan_record |
贷款记录 | BOOLEAN | True=有 / False=无 |
id_type |
证件类型 | VARCHAR(20) | id_card/passport/hk_macao/other |
id_number_enc |
证件号码 | BYTEA | AES 加密,后端解密后传前端,前端提交后后端加密存储 |
source |
客户来源 | VARCHAR(50) | lookup_items 维护 |
5.2 联系人表字段(client_contacts)
| 字段名(英文) | 显示名 | 数据类型 | 说明 |
|---|---|---|---|
id |
— | UUID | 联系人主键 |
client_id |
— | UUID | 外键→clients |
sort_order |
排序 | SMALLINT | 0=联系人1(主联系人) |
name |
姓名 | VARCHAR(50) | 必填 |
gender |
称呼 | VARCHAR(10) | male=先生 / female=女士 |
phone_enc |
电话1 | BYTEA | AES 加密,前端默认显示打码格式 |
phone_hash |
电话1哈希 | VARCHAR(64) | 后端维护,用于重复检测 |
phone_country_code |
区号 | VARCHAR(10) | 默认 +86 |
phone_is_invalid |
号码是否无效 | BOOLEAN | True=已标记无效 |
phone2_enc |
电话2 | BYTEA | 选填 |
wechat |
微信 | VARCHAR(100) | 选填 |
qq |
VARCHAR(20) | 选填 | |
remarks |
备注 | VARCHAR(200) | 最多200字 |
5.3 需求信息表字段(client_requirements)
| 字段名(英文) | 显示名 | 数据类型 | 说明 |
|---|---|---|---|
requirement_type |
需求类型 | VARCHAR(20) | second_hand/new_house/rental |
budget_min |
总价最小值 | NUMERIC(12,2) | 万元 |
budget_max |
总价最大值 | NUMERIC(12,2) | 万元 |
area_min |
面积最小值 | NUMERIC(8,2) | ㎡ |
area_max |
面积最大值 | NUMERIC(8,2) | ㎡ |
bedroom_counts |
居室 | SMALLINT[] | 如 [2, 3] |
floor_preferences |
楼层偏好 | VARCHAR(20)[] | no_first/low/mid/high/no_top |
orientations |
朝向 | VARCHAR(10)[] | east/south/west/north |
decorations |
装修 | VARCHAR(10)[] | 枚举同 properties |
building_age_ranges |
楼龄 | VARCHAR(20)[] | within_5y/5_10y/10_15y/15_20y/over_20y |
intent_business_area_ids |
意向商圈 | UUID[] | 商圈 ID 数组 |
intent_complex_names |
意向小区 | TEXT | 逗号分隔 |
transportation |
交通要求 | VARCHAR(50) | 最多50字 |
requirement_notes |
备注 | VARCHAR(200) | 最多200字 |
school_enrollment_date |
入学时间 | DATE | 月份精度(存储为该月1日) |
5.4 意向学校表字段(client_school_preferences)
| 字段名(英文) | 显示名 | 数据类型 | 说明 |
|---|---|---|---|
requirement_id |
— | UUID | 外键→client_requirements |
school_id |
— | UUID | 外键→schools,允许NULL(自由输入时) |
school_name |
学校名称 | VARCHAR(100) | 当 school_id 为 NULL 时为手动输入的名称 |
6. 竞品截图对应关系
| 截图路径 | 对应功能 | 对应文档章节 | 采纳的设计要点 |
|---|---|---|---|
Project/fonrey/screenshots/客源/编辑客源.png |
编辑客源主页(三区块滚动页) | §2.1 | 1. 顶部 Tab(联系人/基础信息/二手)为锚点快速定位,非 Tab 切换隐藏;2. 三区块纵向排列在同一页面;3. Tab 激活使用橙色下划线;4. 联系人区块右上角有「查看后可编辑号码」说明 + 橙色「查看号码」按钮 + 「+ 添加联系人」;5. 电话1 旁有「标记无效」蓝色链接;6. 基础信息区块所有字段(含购房目的/付款方式/名下房产/贷款记录/证件/意向学校/入学时间)全部展开(无折叠);7. 需求信息(二手)区块紧跟其后,字段全展开;8. 底部操作按钮为表单内联布局,保存按钮使用系统主色 Teal,与「新增客源」页保持一致 |
Project/fonrey/screenshots/客源/编辑基础信息.png |
编辑基础信息快捷弹窗 | §3.1 | Modal 弹窗,含「确定」橙色按钮 + 「取消」按钮 |
7. 实现优先级与工期估算
| 页面/功能 | 优先级 | 特殊组件复杂度 | 工期估算(前端) |
|---|---|---|---|
| 编辑客源主页面基础框架(Tab 导航 + 三区块布局) | P0 🔴 | 低 | 0.5 天 |
| 联系人 Tab:表单字段渲染(姓名/称呼/电话/微信/QQ/备注) | P0 🔴 | 低 | 0.5 天 |
| 联系人 Tab:查看号码 HTMX 交互(打码→明文→可编辑) | P0 🔴 | 中 | 1 天 |
| 联系人 Tab:标记无效 HTMX 操作 | P0 🔴 | 低 | 0.5 天 |
| 联系人 Tab:动态追加/删除联系人(Alpine.js) | P0 🔴 | 中 | 0.5 天 |
| 基础信息区块:必填字段渲染(需求类型/用途/等级/来源) | P0 🔴 | 低 | 0.5 天 |
| 基础信息区块:选填字段(购房目的/付款方式/名下房产/贷款记录/证件/意向学校/入学时间) | P0 🔴 | 中(月份选择器) | 1 天 |
| 需求信息区块(二手):全量字段表单渲染 | P0 🔴 | 低 | 0.5 天 |
| 需求信息区块(二手):意向商圈下拉多选 | P0 🔴 | 中(多选组件) | 0.5 天 |
| 需求信息区块(二手):意向小区动态追加(Alpine.js) | P0 🔴 | 低 | 0.25 天 |
| 表单保存提交(HTMX + 后端 HX-Redirect) | P0 🔴 | 低 | 0.5 天 |
| 表单校验(必填高亮 + 滚动定位) | P0 🔴 | 中 | 0.5 天 |
| 编辑基础信息弹窗(快捷入口 Modal) | P1 🟡 | 中(Modal + HTMX 局部刷新) | 1 天 |
| 新房 Tab / 租房 Tab 需求字段 | P1 🟡 | 低(字段待确认) | 1 天 |
| 总计 | — | — | 约 8 天 |
8. 开放问题(待决策)
| # | 问题 | 影响范围 | 待确认方 |
|---|---|---|---|
| 1 | 新房 Tab 和租房 Tab 的具体字段清单?截图中无对应截图,PRD §5.15.4 仅说明「待新房/租房截图补充确认」 | §2.1.3 需求信息区块、§7 工期 | 产品经理 |
| 2 | 意向商圈的数据来源:是后端 API 返回商圈列表,还是 Django 模板预渲染 <option>?数据量大时是否需要异步加载 + 搜索过滤? |
§4.3 HTMX 请求规范 | 后端工程师 |
| 3 | 「查看号码」验证流程:是否需要密码二次确认,还是仅凭当前 Session 权限直接返回明文?具体的验证逻辑由后端定义 | §2.1.3 联系人区块 | 后端工程师 + 产品经理 |
| 4 | 编辑页保存时,如果同时修改了联系人电话(可能造成与已有私客重复),是否需要与「录入私客」相同的重复检测提示?若需要,是保存时拦截还是仅提示 Warning? | §2.1.3 联系人区块 | 产品经理 |
| 5 | 入学时间的月份选择器:Flatpickr monthSelectPlugin 是否已在项目中引入?或使用其他方案(如原生 <input type="month">)? |
§2.1.4 特殊组件 | 前端工程师 |
| 6 | 竞品截图中「基础信息」区块字段均已展开显示(无折叠)。PRD Story 14 §5.14 描述「录入时默认折叠,编辑时全部展开」,确认编辑态下所有字段一律展开(不需要点击「展开更多」)? | §2.1.3 基础信息区块 | 产品经理(以截图为准,当前设计已按全展开处理) |
| 7 | 编辑页面 URL 方案:是否为 /clients/<client_id>/edit/?还是 /clients/<client_id>/edit/?tab=contacts(支持直接定位到特定 Tab)?从需求信息 Tab「编辑」链接进入时希望直接滚动到需求区块 |
§1.2 页面清单 | 前端 + 后端工程师 |