Files
nexus/Project/fonrey/UI_DESIGN/客源管理/编辑客源_UI.md
2026-04-26 12:49:46 +08:00

734 lines
42 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-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.1 功能范围
- 1.2 页面清单
- 1.3 用户角色与权限差异
2. [页面设计规范](#2-页面设计规范)
- 2.1 编辑客源主页面(三 Tab 表单)
3. [弹窗/抽屉设计规范](#3-弹窗抽屉设计规范)
- 3.1 编辑基础信息弹窗(快捷入口,来自信息概览面板)
4. [交互状态规范](#4-交互状态规范)
- 4.1 全局状态说明
- 4.2 权限控制矩阵
- 4.3 HTMX 请求规范
5. [关键数据字段说明](#5-关键数据字段说明)
6. [竞品截图对应关系](#6-竞品截图对应关系)
7. [实现优先级与工期估算](#7-实现优先级与工期估算)
8. [开放问题(待决策)](#8-开放问题待决策)
---
## 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 |
| 编辑基础信息弹窗 | 无独立 URLHTMX 局部渲染 | 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` |
```html
<!-- 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但无打码逻辑 |
| QQ | 否 | `<input type="text">` | — | |
| 备注 | 否 | `<textarea>` | 最多200字字数计数器 | 新增字段(录入时无) |
**电话1 查看号码交互流程**
1. 页面加载时,`phone_enc` 解密后以打码形式(`137****1234`)展示在只读 `<span>` 中,输入框 `disabled`
2. 用户点击「查看号码」按钮 → HTMX GET 请求验证权限 → 后端返回:
- **成功**:返回明文号码,前端 Alpine.js 将 `<span>` 替换为可编辑 `<input>`,同时在 `client_follow_logs` 插入一条 `log_type='sensitive_view'` 记录
- **失败403/权限不足)**Toast 提示「无查看号码权限」
3. 「查看号码」按钮变为已点击态(灰色,禁用),显示文字「已查看」
4. 「标记无效」链接始终可见(无需查看号码),但触发时需后端权限校验
```html
<!-- 电话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 驱动):
```html
<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 驱动)**
```html
<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居及以上 | `bedroom_counts[]` |
| 楼层 | 否 | 多选复选框(横排) | — | □不要一层 □低楼层 □中楼层 □高楼层 □不要顶层 | `floor_preferences[]` |
| 朝向 | 否 | 多选复选框(横排) | — | □东 □南 □西 □北 | `orientations[]` |
| 装修 | 否 | 多选复选框(横排) | — | □毛坯 □清水 □简装 □中装 □精装 □豪装 | `decorations[]` |
| 楼龄 | 否 | 多选复选框(横排) | — | □5年以内 □5-10年 □10-15年 □15-20年 □20年以上 | `building_age_ranges[]` |
| 意向商圈 | 否 | `<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` |
**总价/面积区间双输入框**
```html
<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**
```html
<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>
```
---
**[底部操作按钮区]**
```html
<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/"`
- **成功响应**
- 关闭 ModalAlpine.js `open = false`
- 触发 Toast「保存成功」`success-600` 绿色3秒自动消失
- 刷新信息概览面板相关字段区域(`hx-target="#info-panel-basic-fields"` `hx-swap="innerHTML"`
- **失败响应422**
- 弹窗内字段级错误提示(输入框下方 `text-danger-600 text-xs`
- 必填字段边框变 `border-danger-600`
- 不关闭弹窗
```html
<!-- 编辑基础信息 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` | 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 页面清单 | 前端 + 后端工程师 |