39 KiB
新增客源 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.2 页面清单
- 1.3 用户角色与权限差异
- 页面设计规范
- 2.1 录入私客页面(P0 🔴)
- 弹窗/抽屉设计规范
- 3.1 扩展联系人字段展开区(内联展开)
- 3.2 扩展基础信息字段展开区(内联展开)
- 交互状态规范
- 4.1 表单校验状态机
- 4.2 权限控制矩阵
- 4.3 HTMX 请求规范
- 关键数据字段说明
- 竞品截图对应关系
- 实现优先级与工期估算
- 开放问题(待决策)
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 |
| 顶部无操作按钮 | 本页为纯录入表单,顶部无次级按钮 |
<!-- 页面头部 -->
<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并可下拉修改。本设计与截图一致。
性别单选按钮规范:
<!-- 性别单选组 -->
<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(区号 + 手机号)规范:
<!-- 电话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="请输入" |
<input type="text"> |
否 | placeholder="请输入" |
<!-- 扩展字段折叠触发器 -->
<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起的区块标题行(含「删除」):
<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>
「+ 新增联系人」按钮:
<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)统一规范:
<!-- 示例:状态字段 -->
<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>
来源下拉选择:
<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)有入学时间字段。本录入页不包含入学时间(仅编辑页有,与截图一致)。
<!-- 扩展字段折叠触发器 -->
<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) |
— | 默认与首录人一致 |
<!-- 相关员工区块 -->
<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,随页面流):
<!-- 底部操作按钮组 -->
<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 态:
<!-- 提交中 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 单行灰色占位条,检测返回后替换 |
<!-- 重复检测 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="请输入" |
<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`)
校验失败态输入框示例:
<!-- 校验失败的字段 -->
<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 校验失败(无选中):
<!-- 错误提示直接展示在 Radio Group 下方 -->
<p id="status-error" class="text-xs text-danger-600 mt-1">请选择客源状态</p>
电话1 格式错误:
<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 属性(完整):
<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:
<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 |