748 lines
39 KiB
Markdown
748 lines
39 KiB
Markdown
# 新增客源 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}/` header,HTMX 自动执行跳转。同时返回 `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;电话2;QQ;备注。录入页简化版与此一致(缺「标记无效」和「备注」字段,与录入截图一致) |
|
||
|
||
**截图与 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 |
|