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

748 lines
39 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.2 录入私客 / Story 1
> **优先级**P0 功能在本文档中用 🔴 标注P1 用 🟡P2 用 ⚫
---
## 目录
1. [模块概述](#1-模块概述)
- 1.1 功能范围
- 1.2 页面清单
- 1.3 用户角色与权限差异
2. [页面设计规范](#2-页面设计规范)
- 2.1 录入私客页面P0 🔴)
3. [弹窗/抽屉设计规范](#3-弹窗抽屉设计规范)
- 3.1 扩展联系人字段展开区(内联展开)
- 3.2 扩展基础信息字段展开区(内联展开)
4. [交互状态规范](#4-交互状态规范)
- 4.1 表单校验状态机
- 4.2 权限控制矩阵
- 4.3 HTMX 请求规范
5. [关键数据字段说明](#5-关键数据字段说明)
6. [竞品截图对应关系](#6-竞品截图对应关系)
7. [实现优先级与工期估算](#7-实现优先级与工期估算)
8. [开放问题(待决策)](#8-开放问题待决策)
---
## 1. 模块概述
### 1.1 功能范围
本文档聚焦 **新增客源(录入私客)** 单一页面的 UI 设计。该页面是客源管理模块的核心入口,对应 PRD Story 1。
| 功能 | 优先级 | PRD 章节 |
|------|--------|---------|
| 联系人区块录入(姓名/性别/电话1 | P0 🔴 | §5.2.2 |
| 联系人扩展字段电话2/微信/QQ | P0 🔴 | §5.2.2 |
| 多联系人支持(新增/删除) | P0 🔴 | §5.2.2 |
| 基础信息录入(状态/用途/等级/来源) | P0 🔴 | §5.2.3 |
| 基础信息扩展字段(证件/意向学校) | P1 🟡 | §5.2.3 |
| 相关员工区块(首录人/归属人只读) | P0 🔴 | §5.2.4 |
| 表单提交校验与成功跳转 | P0 🔴 | §5.2.5 |
| 电话1 实时重复检测 | P0 🔴 | §5.2.5 / §5.4 |
### 1.2 页面清单
| 页面名称 | URL 模式建议 | 优先级 | 对应 PRD 章节 |
|---------|------------|--------|--------------|
| 录入私客 | `/clients/create/` | P0 🔴 | §5.2 Story 1 |
### 1.3 用户角色与权限差异
| 差异点 | 经纪人 | 店长 | 管理员 |
|--------|--------|------|--------|
| 访问入口 | 顶部导航「客源」→「+ 新增私客」 / 右侧浮动「增客」 | 同左 | 同左 |
| 首录人字段 | 只读(自动填充自身) | 只读(自动填充自身) | 只读(自动填充自身) |
| 归属人字段 | 只读(与首录人一致) | 只读(默认自身,可在详情页修改) | 只读(可在详情页修改) |
| 来源选项 | 使用运营维护的枚举值,不可新增 | 同左 | 可在系统设置中维护来源枚举 |
> **说明**:录入页面本身无角色差异(字段完全相同),权限差异体现在录入后的归属人修改权限。
---
## 2. 页面设计规范
### 2.1 录入私客页面P0 🔴)
#### 2.1.1 页面概述
- **URL**`/clients/create/`
- **访问入口**
- 顶部导航 「客源」 → 右上角「+ 新增私客」按钮
- 右侧浮动快捷入口「增客」图标按钮
- **页面职责**:录入新私客联系人信息、购房需求及相关员工,提交后跳转至客源详情页
- **竞品参考截图**`Project/fonrey/screenshots/客源/录入客源.png`
#### 2.1.2 布局结构
页面采用**居中单栏表单**布局,内容区水平居中显示,两侧留有 padding无侧边栏独立页面
```
┌──────────────────────────────────────────────────────────────┐
│ 顶部导航栏Topbar`bg-primary-800`56px
├──────────────────────────────────────────────────────────────┤
│ 面包屑 + 页面标题 H1「录入私客」 │
├──────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 联系人1 区块 │ │
│ │ (姓名 | 性别 | 电话1 + [电话2、微信、QQ等 ▾] │ │
│ ├────────────────────────────────────────────────────────┤ │
│ │ 联系人2 区块(若已新增)[删除] │ │
│ │ … │ │
│ │ [+ 新增联系人] │ │
│ ├────────────────────────────────────────────────────────┤ │
│ │ 基础信息区块 │ │
│ │ (状态 | 用途 | 等级 | 来源 + [证件、学校等 ▾] │ │
│ ├────────────────────────────────────────────────────────┤ │
│ │ 相关员工区块 │ │
│ │ (首录人 [只读] | 归属人 [只读] │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ [确定(橙色主按钮)] [取消] │
└──────────────────────────────────────────────────────────────┘
```
**布局核心参数:**
| 参数 | 值 |
|------|-----|
| 内容区最大宽度 | `max-w-5xl`1024px水平居中 `mx-auto` |
| 内容区内边距 | `px-6 py-6` |
| 页面背景 | `bg-neutral-50` |
| 各区块容器 | `bg-white rounded-lg border border-neutral-200 p-6 mb-4` |
| 区块间距 | `space-y-4` |
> **竞品截图对比**:竞品(巧房 V2.0使用居中略窄的单栏卡片布局表单内容在白色卡片区域内分区呈现。Fonrey 遵循同样模式,但改用 Teal 主色系,底部固定操作按钮与页面流同步(非 Sticky fixed
> **新增/编辑页面统一宽度约定(全局)**:从本页面起,客源模块后续所有「新增/编辑」类页面的内容区默认使用 `max-w-5xl`1024px+ `mx-auto`。若某页面字段更少可在局部收窄,但默认实现不得低于该基准,以避免右侧内容区留白过多与字段扩展空间不足。
#### 2.1.3 区域详细规范
---
**[区域 A页面头部]**
| 属性 | 说明 |
|------|------|
| 面包屑 | `客源 / 录入私客`,使用 `text-xs text-neutral-500`,分隔符 `/` |
| 页面标题 | `录入私客``text-xl font-semibold text-neutral-800` |
| 顶部无操作按钮 | 本页为纯录入表单,顶部无次级按钮 |
```html
<!-- 页面头部 -->
<div class="mb-6">
<nav class="flex items-center gap-1 text-xs text-neutral-400 mb-2" aria-label="面包屑">
<a href="/clients/" class="hover:text-neutral-600 hover:underline">客源</a>
<span>/</span>
<span class="text-neutral-600">录入私客</span>
</nav>
<h1 class="text-xl font-semibold text-neutral-800">录入私客</h1>
</div>
```
---
**[区域 B联系人区块]**
联系人区块支持动态增减默认展示联系人1主联系人不可删除通过「+ 新增联系人」按钮追加更多联系人上限5个。
**联系人1主联系人不可删除**
| 字段 | 组件类型 | 必填 | 校验规则 | 默认值 |
|------|---------|------|---------|--------|
| 区块标题 | 静态文本「联系人1」 | — | — | — |
| 姓名 | `<input type="text">` | 必填 ✱ | 不能为空 | `placeholder="请输入"` |
| 性别 | Radio Group | 必填 ✱ | 必须选一 | 无默认选中 |
| 电话1 | 区号下拉 + 手机号输入 | 必填 ✱ | 手机号格式11位数字`+86` 时) | 区号默认 `+86` |
> **截图依据**竞品截图中联系人1 无「删除」链接仅联系人2起才有姓名和电话1旁均有红色 `*` 标记,性别为「先生」/「女士」并排单选电话1 左侧区号默认 `+86` 并可下拉修改。本设计与截图一致。
**性别单选按钮规范:**
```html
<!-- 性别单选组 -->
<div class="flex items-center gap-4">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" name="contact_1_gender" value="male"
class="w-4 h-4 accent-primary-600">
<span class="text-sm text-neutral-700">先生</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" name="contact_1_gender" value="female"
class="w-4 h-4 accent-primary-600">
<span class="text-sm text-neutral-700">女士</span>
</label>
</div>
```
**电话1区号 + 手机号)规范:**
```html
<!-- 电话1分体输入框区号下拉 + 手机号输入) -->
<div class="flex gap-0 rounded-md border border-neutral-300 focus-within:border-primary-600 focus-within:ring-2 focus-within:ring-primary-600/20 overflow-hidden">
<!-- 区号下拉 -->
<select name="contact_1_phone_country_code"
class="w-20 px-2 py-2 text-sm bg-neutral-50 border-r border-neutral-200 text-neutral-700
focus:outline-none appearance-none">
<option value="+86">+86</option>
<option value="+852">+852</option>
<option value="+853">+853</option>
<option value="+886">+886</option>
<option value="+1">+1</option>
<!-- 更多国际区号 -->
</select>
<!-- 手机号输入 -->
<input type="tel" name="contact_1_phone"
placeholder="输入手机号"
class="flex-1 px-3 py-2 text-sm bg-white focus:outline-none"
hx-post="/clients/check-duplicate-phone/"
hx-trigger="blur"
hx-target="#phone1-duplicate-hint"
hx-include="[name='contact_1_phone'],[name='contact_1_phone_country_code']">
</div>
<!-- 重复检测提示区域 -->
<div id="phone1-duplicate-hint" class="mt-1"></div>
```
> **重复检测提示(来自后端 HTMX 响应)**
> - 无重复:返回空 HTML提示区域为空
> - 有重复:返回橙色警告提示 `<p class="text-xs text-warning-600 flex items-center gap-1"><svg><!-- ExclamationTriangleIcon --></svg>存在重复客源:<a href="/clients/123/" class="underline hover:text-warning-700">张三</a>(可点击查看)</p>`
**扩展字段折叠区「电话2、微信、QQ等 ▾」):**
Alpine.js 控制展开/收起,初始折叠。
| 字段 | 组件类型 | 必填 | 说明 |
|------|---------|------|------|
| 电话2 | 区号下拉 + 手机号输入 | 否 | 格式同电话1 |
| 微信 | `<input type="text">` | 否 | `placeholder="请输入"` |
| QQ | `<input type="text">` | 否 | `placeholder="请输入"` |
```html
<!-- 扩展字段折叠触发器 -->
<div x-data="{ expanded: false }">
<button type="button" @click="expanded = !expanded"
class="flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700 mt-2 mb-1">
<span>电话2、微信、QQ等</span>
<svg class="w-4 h-4 transition-transform" :class="expanded ? 'rotate-180' : ''" aria-hidden="true">
<!-- ChevronDownIcon -->
</svg>
</button>
<!-- 扩展字段区域 -->
<div x-show="expanded" x-collapse class="space-y-3 mt-2">
<!-- 电话2 -->
<div class="space-y-1">
<label class="block text-sm font-medium text-neutral-700">电话2</label>
<!-- 同电话1区号+手机号组件,字段名 contact_1_phone2 / contact_1_phone2_country_code -->
</div>
<!-- 微信 -->
<div class="space-y-1">
<label class="block text-sm font-medium text-neutral-700">微信</label>
<input type="text" name="contact_1_wechat" placeholder="请输入"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400
focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20">
</div>
<!-- QQ -->
<div class="space-y-1">
<label class="block text-sm font-medium text-neutral-700">QQ</label>
<input type="text" name="contact_1_qq" placeholder="请输入"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400
focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20">
</div>
</div>
</div>
```
**联系人2起的区块标题行含「删除」**
```html
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-neutral-700">联系人2</h3>
<button type="button" @click="removeContact(2)"
class="text-xs text-danger-600 hover:text-danger-700 hover:underline">
删除
</button>
</div>
```
**「+ 新增联系人」按钮:**
```html
<button type="button" @click="addContact()"
x-show="contacts.length < 5"
class="flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 mt-3">
<svg class="w-4 h-4" aria-hidden="true"><!-- PlusIcon --></svg>
新增联系人
</button>
```
---
**[区域 C基础信息区块]**
| 字段 | 组件类型 | 必填 | 枚举值 / 说明 | 默认值 |
|------|---------|------|--------------|--------|
| 状态(`status` | Radio Group横向排列 | 必填 ✱ | `buying`=求购 / `renting`=求租 / `buy_or_rent`=租购 | 无默认 |
| 用途(`property_usage` | Radio Group横向排列 | 必填 ✱ | `residential`=住宅 / `villa`=别墅 / `commercial_residential`=商住 / `shop`=商铺 / `office`=写字楼 / `other`=其他 | 默认选中 `residential`(住宅) |
| 等级(`grade` | Radio Group横向排列 | 必填 ✱ | `A_urgent`=A(急迫) / `A`=A / `B`=B(较强) / `C`=C(一般) / `D`=D(较弱) / `E`=E(暂不关注) | 无默认截图中「A(急迫)」与「A」是两个独立选项 |
| 来源(`source` | `<select>` 下拉 | 必填 ✱ | `lookup_items` 维护的来源标识,非外键 | `placeholder="请选择"` |
> **截图依据**:竞品截图中,状态/用途/等级均以横向 Radio Group 形式展示,用途默认选中「住宅」(橙色圆点)。来源为下拉选择框。本设计与截图完全一致。
**单选按钮Radio Group统一规范**
```html
<!-- 示例:状态字段 -->
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">
状态 <span class="text-danger-600">*</span>
</label>
<div class="flex flex-wrap items-center gap-x-6 gap-y-2">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" name="status" value="buying"
class="w-4 h-4 accent-primary-600">
<span class="text-sm text-neutral-700">求购</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" name="status" value="renting"
class="w-4 h-4 accent-primary-600">
<span class="text-sm text-neutral-700">求租</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" name="status" value="buy_or_rent"
class="w-4 h-4 accent-primary-600">
<span class="text-sm text-neutral-700">租购</span>
</label>
</div>
<!-- 校验错误提示(统一校验时注入) -->
<p id="status-error" class="hidden text-xs text-danger-600">请选择客源状态</p>
</div>
```
**来源下拉选择:**
```html
<div class="space-y-1.5">
<label for="source" class="block text-sm font-medium text-neutral-700">
来源 <span class="text-danger-600">*</span>
</label>
<select id="source" name="source"
class="block w-full max-w-xs px-3 py-2 text-sm rounded-md border border-neutral-300
focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20
bg-white">
<option value="">请选择</option>
{% for item in source_options %}
<option value="{{ item.value }}">{{ item.label }}</option>
{% endfor %}
</select>
<p id="source-error" class="hidden text-xs text-danger-600">请选择客户来源</p>
</div>
```
**扩展字段折叠区(「证件类型、证件号码、意向学校等 ▾」):**
| 字段 | 组件类型 | 必填 | 说明 |
|------|---------|------|------|
| 证件类型 | `<select>` 下拉 | 否 | 身份证 / 护照 / 港澳通行证 / 其他 |
| 证件号码 | `<input type="text">` | 否 | 选择「身份证」时校验18位格式 |
| 意向学校 | `<input type="text">` (可多条) | 否 | 支持「+ 添加学校」追加输入框 |
> **注意**PRD §5.2.3 扩展字段中「意向学校」在录入页为文本输入编辑页Story 14有入学时间字段。本录入页**不包含入学时间**(仅编辑页有,与截图一致)。
```html
<!-- 扩展字段折叠触发器 -->
<div x-data="{ expanded: false, schools: [''] }">
<button type="button" @click="expanded = !expanded"
class="flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700 mt-2 mb-1">
<span>证件类型、证件号码、意向学校等</span>
<svg class="w-4 h-4 transition-transform" :class="expanded ? 'rotate-180' : ''" aria-hidden="true">
<!-- ChevronDownIcon -->
</svg>
</button>
<div x-show="expanded" x-collapse class="space-y-3 mt-2">
<!-- 证件类型 + 证件号码(并排双列) -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label for="id_type" class="block text-sm font-medium text-neutral-700">证件类型</label>
<select id="id_type" name="id_type"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300
focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20 bg-white"
x-model="idType">
<option value="">请选择</option>
<option value="id_card">身份证</option>
<option value="passport">护照</option>
<option value="hk_macao">港澳通行证</option>
<option value="other">其他</option>
</select>
</div>
<div class="space-y-1.5">
<label for="id_number" class="block text-sm font-medium text-neutral-700">证件号码</label>
<input id="id_number" type="text" name="id_number" placeholder="请输入证件号码"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400
focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20">
</div>
</div>
<!-- 意向学校(多条动态) -->
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">意向学校</label>
<template x-for="(school, idx) in schools" :key="idx">
<input type="text" :name="'school_' + idx" x-model="schools[idx]"
placeholder="请输入学校名称"
class="block w-full max-w-xs px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400
focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20 mb-2">
</template>
<button type="button" @click="schools.push('')"
class="flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700">
<svg class="w-4 h-4"><!-- PlusIcon --></svg>
添加学校
</button>
</div>
</div>
</div>
```
---
**[区域 D相关员工区块]**
| 字段 | 组件类型 | 必填 | 说明 |
|------|---------|------|------|
| 首录人 | 只读文本输入框(`disabled` | — | 自动填充当前登录用户「姓名 - 门店-组别」 |
| 归属人 | 只读文本输入框(`disabled` | — | 默认与首录人一致 |
```html
<!-- 相关员工区块 -->
<div class="bg-white rounded-lg border border-neutral-200 p-6">
<h2 class="text-base font-semibold text-neutral-800 mb-4">相关员工</h2>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">首录人</label>
<input type="text" value="{{ request.user.display_name }}" disabled
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-200
bg-neutral-100 text-neutral-500 cursor-not-allowed">
<input type="hidden" name="first_recorder_id" value="{{ request.user.id }}">
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">归属人</label>
<input type="text" value="{{ request.user.display_name }}" disabled
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-200
bg-neutral-100 text-neutral-500 cursor-not-allowed">
<input type="hidden" name="owner_id" value="{{ request.user.id }}">
</div>
</div>
</div>
```
> **格式说明**`display_name` 格式为「姓名 - 门店-组别」,如「杜利强 - 系统管理组」(竞品截图展示)。
---
**[区域 E底部操作按钮]**
按钮组固定在表单内容底部(非 sticky fixed随页面流
```html
<!-- 底部操作按钮组 -->
<div class="flex items-center gap-3 mt-6 pb-8">
<button type="submit" id="submit-btn"
class="inline-flex items-center gap-1.5 px-6 py-2 text-sm font-medium
bg-warning-600 text-white rounded-md
hover:bg-warning-700 active:bg-warning-800
focus:outline-none focus-visible:ring-2 focus-visible:ring-warning-600/40
disabled:opacity-50 disabled:cursor-not-allowed">
确定
</button>
<a href="/clients/"
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">
取消
</a>
</div>
```
> **颜色说明**:竞品截图中「确定」按钮为橙色(竞品主色),对应 Fonrey 的 `warning-600``#D97706`)。根据截图优先原则,本模块的主提交按钮使用 `bg-warning-600`,而非 Fonrey 全局主色 `primary-600`,因为客源模块整体以橙色作为主操作色(与房源模块 Teal 色区分)。**Engineer 注意**:如产品决策统一为 Teal改为 `bg-primary-600` 即可见第8章开放问题
**Loading 态:**
```html
<!-- 提交中 Loading 态 -->
<button disabled
class="inline-flex items-center gap-1.5 px-6 py-2 text-sm font-medium
bg-warning-600 text-white rounded-md opacity-70 cursor-wait">
<svg class="w-4 h-4 animate-spin"><!-- Spinner --></svg>
保存中…
</button>
```
#### 2.1.4 使用的特殊组件
| 组件名 | 来源(组件规范设计.md 章节) | 用途 | 自定义说明 |
|--------|--------------------------|------|-----------|
| Modal Dialog | §7 Modal Dialog | 重复检测结果提示(若需二次确认) | 本模块不使用弹窗型重复确认,改为输入框下方内联提示 |
| Collapsible折叠展开 | UI_SYSTEM.md §3.11 Accordion | 联系人扩展字段 / 基础信息扩展字段的展开/收起 | 使用 `x-collapse` Alpine 插件,触发器为蓝色文字链接而非卡片标题 |
| Multi-select Tag Input | §17 Multi-select Tag Input | 意向学校多条输入 | 简化为动态多个独立 input非标准 Tag Input |
#### 2.1.5 空状态设计
本页为纯表单录入页,无列表空状态场景。以下为相关字段的空/初始状态:
| 场景 | 展示方式 |
|------|---------|
| 来源下拉无数据 | 显示「暂无可选来源,请联系管理员配置」,文字灰色禁用提示 |
| 意向学校扩展默认 | 折叠收起,展开后默认一个空输入框 |
| 重复检测提示区域初始 | 为空(不显示任何内容) |
#### 2.1.6 Loading 状态
| 场景 | 实现方式 |
|------|---------|
| 页面首次加载(来源枚举加载) | 来源 `<select>``<option disabled>加载中…</option>`HTMX 加载完后 `hx-swap="outerHTML"` 替换 |
| 提交表单 | 「确定」按钮 `disabled` + 内嵌 Spinner + 文案「保存中…」 |
| 电话1 失焦重复检测 | 输入框下方出现 `animate-pulse` 单行灰色占位条,检测返回后替换 |
```html
<!-- 重复检测 loading 占位 -->
<div class="mt-1 animate-pulse">
<div class="h-4 bg-neutral-200 rounded w-48"></div>
</div>
```
---
## 3. 弹窗/抽屉设计规范
本模块(新增客源)无独立弹窗或抽屉组件,所有扩展内容均通过**内联折叠展开**实现。以下记录两个折叠区域的完整规范。
### 3.1 联系人扩展字段展开区(内联展开)
#### 3.1.1 触发方式
- **触发位置**联系人区块内电话1 输入框下方「电话2、微信、QQ等 ▾」文字链接
- **组件类型**:内联 Collapsible非 Modal/Drawer
- **尺寸**:随内容自适应高度
#### 3.1.2 内容字段
| 字段名 | 组件类型 | 必填 | 校验规则 | 默认值 |
|--------|---------|------|---------|--------|
| 电话2 | 区号下拉 + 手机号输入 | 否 | 格式可选(非必填时不校验) | 空 |
| 微信 | `<input type="text">` | 否 | 无格式校验 | `placeholder="请输入"` |
| QQ | `<input type="text">` | 否 | 数字格式(可选) | `placeholder="请输入"` |
#### 3.1.3 交互行为
- 点击「电话2、微信、QQ等 ▾」展开,箭头旋转 180°
- 再次点击收起(已填内容不丢失)
- `x-collapse` 插件控制高度动画
### 3.2 基础信息扩展字段展开区(内联展开)
#### 3.2.1 触发方式
- **触发位置**:基础信息区块内,来源字段下方「证件类型、证件号码、意向学校等 ▾」文字链接
- **组件类型**:内联 Collapsible
#### 3.2.2 内容字段
| 字段名 | 组件类型 | 必填 | 校验规则 | 默认值 |
|--------|---------|------|---------|--------|
| 证件类型 | `<select>` 下拉 | 否 | — | 空(无默认选中) |
| 证件号码 | `<input type="text">` | 否 | 若选「身份证」则校验18位格式 | `placeholder="请输入证件号码"` |
| 意向学校 | 动态多 `<input>` | 否 | — | 1个空输入框后端存入 `client_school_preferences.school_name``school_id=NULL` 自由输入模式) |
#### 3.2.3 交互行为
- 证件类型选择「身份证」时,证件号码输入框 blur 触发格式校验
- 「+ 添加学校」追加一个新的学校输入框Alpine.js 管理 `schools` 数组
- 学校输入框可留空(提交时后端忽略空值)
---
## 4. 交互状态规范
### 4.1 表单校验状态机
```
[初始态] → 用户填写字段 → [中间态]
↓ 点击「确定」
[统一校验]
├── 所有必填字段已填 → [提交中] → 成功 → 跳转至客源详情页 + Toast「保存成功」
│ 失败5xx→ Toast「保存失败请重试」
└── 存在未填必填字段 → [校验失败态]
- 未填字段的输入框边框变红 `border-danger-600 ring-2 ring-danger-600/20`
- 输入框下方显示红色提示文字
- 页面自动滚动至第一个错误字段(`scrollIntoView`
```
**校验失败态输入框示例:**
```html
<!-- 校验失败的字段 -->
<input type="text" name="contact_1_name"
class="block w-full px-3 py-2 text-sm rounded-md
border border-danger-600 ring-2 ring-danger-600/20
focus:outline-none focus:border-danger-600">
<p class="text-xs text-danger-600 mt-1">姓名不能为空</p>
```
**Radio Group 校验失败(无选中):**
```html
<!-- 错误提示直接展示在 Radio Group 下方 -->
<p id="status-error" class="text-xs text-danger-600 mt-1">请选择客源状态</p>
```
**电话1 格式错误:**
```html
<p class="text-xs text-danger-600 mt-1">请输入有效的手机号码</p>
```
### 4.2 权限控制矩阵
| 操作 | 经纪人 | 店长 | 管理员 |
|------|--------|------|--------|
| 访问录入私客页面 | ✅ | ✅ | ✅ |
| 提交录入表单 | ✅ | ✅ | ✅ |
| 修改首录人字段 | ❌(只读) | ❌(只读) | ❌(只读,详情页才可改) |
| 修改归属人字段 | ❌(只读) | ❌(只读,详情页才可改) | ❌(只读,详情页才可改) |
| 查看来源枚举 | ✅ | ✅ | ✅(可在设置中维护) |
### 4.3 HTMX 请求规范
| 操作 | hx-trigger | hx-get/post/... | hx-target | hx-swap | Loading |
|------|-----------|----------------|-----------|---------|---------|
| 电话1 失焦重复检测 | `blur` | `hx-post="/clients/check-phone/"` | `#phone1-duplicate-hint` | `innerHTML` | 目标区出现骨架屏占位 |
| 来源枚举动态加载(页面初始化) | `load` | `hx-get="/api/client-sources/"` | `#source-select` | `outerHTML` | select 内 `<option>加载中…</option>` |
| 表单提交 | `submit`(原生 form | `hx-post="/clients/create/"` | `body`(跳转)或 `#form-errors`(错误) | 成功后端重定向422`innerHTML` | 「确定」按钮 Loading 态 |
**表单 HTMX 属性(完整):**
```html
<form id="create-client-form"
hx-post="/clients/create/"
hx-target="#form-feedback"
hx-swap="innerHTML"
hx-on:htmx:before-request="document.getElementById('submit-btn').disabled=true"
hx-on:htmx:after-settle="document.getElementById('submit-btn').disabled=false"
class="space-y-4">
<!-- 表单内容 -->
<div id="form-feedback"></div>
</form>
```
> **成功跳转方案**:后端提交成功时,返回 `HX-Redirect: /clients/{id}/` headerHTMX 自动执行跳转。同时返回 `HX-Trigger: {"fonrey:toast": {"type": "success", "message": "保存成功"}}` 触发 Toast。
**电话1 重复检测完整 HTMX**
```html
<input type="tel" name="contact_1_phone"
placeholder="输入手机号"
class="..."
hx-post="/clients/check-phone/"
hx-trigger="blur"
hx-target="#phone1-dup-hint"
hx-swap="innerHTML"
hx-include="[name='contact_1_phone'],[name='contact_1_phone_country_code']"
hx-indicator="#phone1-loading">
<!-- Loading 指示器隐藏HTMX 自动显隐) -->
<span id="phone1-loading" class="htmx-indicator">
<div class="mt-1 animate-pulse h-4 bg-neutral-200 rounded w-48"></div>
</span>
<!-- 检测结果注入区 -->
<div id="phone1-dup-hint"></div>
```
**Alpine.js 管理的状态:**
| 状态 | 变量名 | 说明 |
|------|--------|------|
| 联系人列表 | `contacts` (Array) | 动态联系人区块初始1个最多5个 |
| 每个联系人的扩展字段展开状态 | `contacts[i].expanded` (Boolean) | 控制「电话2/微信/QQ」区域展开 |
| 基础信息扩展字段展开状态 | `infoExpanded` (Boolean) | 控制「证件/学校」区域展开 |
| 意向学校列表 | `schools` (Array) | 动态学校输入框列表 |
| 证件类型(联动校验) | `idType` (String) | 决定证件号码校验规则 |
---
## 5. 关键数据字段说明
> **说明**:下表列出表单字段与数据库字段的对应关系。联系人字段来自 `client_contacts` 表,客源主字段来自 `clients` 表,意向学校通过 `client_school_preferences` 关联 `client_requirements` 存储。
| 字段名(表单 name | 映射数据库字段 | 所在表 | 数据类型 | 说明 |
|------------------|-------------|-------|---------|------|
| `contacts[i][name]` | `name` | `client_contacts` | VARCHAR(50) | 必填,不限字数 |
| `contacts[i][gender]` | `gender` | `client_contacts` | Enum | `male`=先生 / `female`=女士 |
| `contacts[i][phone_country_code]` | `phone_country_code` | `client_contacts` | VARCHAR(10) | 如 `+86`,默认 `+86` |
| `contacts[i][phone]` | `phone_enc`AES加密存储+ `phone_hash`SHA-256重复检测 | `client_contacts` | BYTEA + VARCHAR(64) | 必填;**明文仅在请求传输,后端加密存储,严禁明文落库** |
| `contacts[i][phone2]` | `phone2_enc` + `phone2_hash` | `client_contacts` | BYTEA + VARCHAR(64) | 选填同电话1加密规则 |
| `contacts[i][wechat]` | `wechat` | `client_contacts` | VARCHAR(100) | 选填 |
| `contacts[i][qq]` | `qq` | `client_contacts` | VARCHAR(20) | 选填 |
| `status` | `status` | `clients` | Enum | `buying`=求购 / `renting`=求租 / `buy_or_rent`=租购 |
| `property_usage` | `property_usage` | `clients` | Enum | `residential`=住宅 / `villa`=别墅 / `commercial_residential`=商住 / `shop`=商铺 / `office`=写字楼 / `other`=其他 |
| `grade` | `grade` | `clients` | Enum | `A_urgent`=A(急迫) / `A`=A / `B`=B(较强) / `C`=C(一般) / `D`=D(较弱) / `E`=E(暂不关注) |
| `source` | `source` | `clients` | VARCHAR(50) | 由 `lookup_items` 维护的来源标识,非外键 |
| `id_type` | `id_type` | `clients` | Enum | `id_card`=身份证 / `passport`=护照 / `hk_macao`=港澳通行证 / `other`=其他(选填) |
| `id_number` | `id_number_enc`AES加密存储 | `clients` | BYTEA | 选填;**后端加密存储,严禁明文落库**身份证格式前端校验18位 |
| `schools[i]` | `school_name` | `client_school_preferences` | VARCHAR(100) | 选填,多条;通过 `client_requirements` 关联;`school_id` 为 NULL自由输入模式 |
| `first_recorder_id` | `first_recorder_id` | `clients` | UUID FK→staff | 自动填充当前登录用户,不可用户修改 |
| `owner_id` | `owner_id` | `clients` | UUID FK→staff | 默认等于首录人,管理员可在详情页修改 |
---
## 6. 竞品截图对应关系
| 截图路径 | 对应功能 | 对应文档章节 | 采纳的设计要点 |
|---------|---------|------------|--------------|
| `Project/fonrey/screenshots/客源/录入客源.png` | 录入私客完整表单 | §2.1 整体布局 | 居中单栏布局;三区块结构(联系人/基础信息/相关员工「电话2、微信、QQ等 ▾」折叠文字链接联系人2 标题旁红色「删除」;「+ 新增联系人」蓝色文字链接;「确定」橙色主按钮;相关员工两字段灰色禁用输入框 |
| `Project/fonrey/screenshots/客源/录入客源.png` | 基础信息单选组布局 | §2.1.3 区域C | 状态/用途/等级 均横向 Radio Group用途默认选中「住宅」圆点显示来源为独立下拉框证件/学校字段以「证件类型、证件号码、意向学校等 ▾」折叠隐藏 |
| `Project/fonrey/screenshots/客源/编辑客源.png` | 编辑客源三Tab布局参考 | §2.1.2 布局(参考) | 本文档仅为新增页面,但从编辑截图可见基础信息和二手 Tab 的完整字段(入学时间、意向商圈等);新增页面**不含二手Tab**,仅录入基础联系信息和意向等级 |
| `Project/fonrey/screenshots/客源/编辑客源.png` | 联系人编辑字段参考 | §2.1.3 区域B字段定义 | 编辑截图展示联系人1 完整字段布局:姓名+电话1含「标记无效」+微信 单行三列;称呼 Radio电话2QQ备注。录入页简化版与此一致缺「标记无效」和「备注」字段与录入截图一致 |
**截图与 PRD 差异说明:**
| 差异点 | 截图呈现 | PRD 描述 | 采纳方案 |
|--------|---------|---------|---------|
| 联系人备注字段 | 编辑截图有「备注」,录入截图无 | Story 1 未提联系人备注 | **以录入截图为准,录入页不含联系人备注**(仅编辑页有) |
| 入学时间 | 仅出现在编辑截图基础信息Tab | Story 14 编辑功能描述 | **录入页不含入学时间**(仅编辑页有) |
| 按钮颜色 | 竞品使用橙色(`#FF6B00` 系) | PRD 描述「橙色主按钮」 | 使用 `warning-600``#D97706`详见第8章开放问题 |
---
## 7. 实现优先级与工期估算
| 页面/功能 | 优先级 | 特殊组件复杂度 | 工期估算(前端) |
|---------|--------|--------------|--------------|
| 录入私客页面整体骨架 | P0 🔴 | 低 | 0.5 天 |
| 联系人区块(必填字段) | P0 🔴 | 低Radio + Input | 0.5 天 |
| 电话1 区号+手机号分体输入框 | P0 🔴 | 中(自定义分体组件) | 0.5 天 |
| 联系人动态增删Alpine.js | P0 🔴 | 中(动态渲染) | 0.5 天 |
| 联系人扩展字段折叠展开 | P0 🔴 | 低x-collapse | 0.25 天 |
| 基础信息区块(必填字段) | P0 🔴 | 低Radio + Select | 0.5 天 |
| 基础信息扩展字段折叠展开 | P1 🟡 | 低x-collapse | 0.25 天 |
| 意向学校动态多条输入 | P1 🟡 | 低Alpine.js 数组) | 0.25 天 |
| 相关员工只读区块 | P0 🔴 | 低(禁用 Input | 0.25 天 |
| 表单统一校验 + 滚动定位 | P0 🔴 | 中JS 校验逻辑) | 0.5 天 |
| 电话1 实时重复检测HTMX | P0 🔴 | 中HTMX + 后端接口) | 0.5 天 |
| 提交成功跳转 + Toast | P0 🔴 | 低 | 0.25 天 |
| **合计** | — | — | **约 4.25 天** |
---
## 8. 开放问题(待决策)
| # | 问题 | 影响范围 | 待确认方 | 回答问题 |
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ----- | ------------------ |
| 1 | **主操作按钮颜色**:竞品截图和 PRD 均描述「确定」为橙色,但 Fonrey 设计规范主色为 Teal`primary-600`)。应使用 `warning-600`(与竞品一致,橙色)还是 `primary-600`与全局规范一致Teal | §2.1.3 区域E影响全模块提交按钮 | 产品/设计 | `primary-600`和全局一致 |
| 2 | **联系人上限**PRD §5.2.2 写「理论上不限联系人数量建议上限5个本文档取5个上限。是否采用5个或其他数量 | §2.1.3 区域B | 产品 | 3个 |
| 3 | **来源枚举加载时机**:来源下拉是页面加载时随 Django 模板渲染(同步),还是通过 HTMX 异步加载?若系统配置中来源枚举较少(<50条推荐同步渲染 | §2.1.3 区域C§4.3 HTMX规范 | 后端/产品 | |
| 4 | **电话重复检测后是否阻断提交**:当前方案为「警告不阻断」(允许继续提交),与 PRD §5.2.5 一致。是否在产品层面确认不需要强制阻断(即两人同一号码可同时保存)? | §4.3 电话检测行为 | 产品 | |
| 5 | **意向学校数据源**PRD §5.2.3 录入页为「下拉多选」,但编辑页截图显示为「文本输入 + 添加学校」。本文档以截图为准取文本输入。若产品希望关联楼盘数据库中的学校,需改为带搜索的下拉选择器 | §3.2.2 意向学校字段 | 产品/数据 | 改为带搜索的下拉选择器 |
| 6 | **区号选择器完整枚举**目前仅列出常用5个区号+86/+852/+853/+886/+1。是否需要完整的国际区号列表约240+)?若是,建议实现带搜索的 Alpine.js 自定义下拉,而非原生 `<select>` | §2.1.3 电话1规范 | 产品 | 只保留+86 |