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

39 KiB
Raw Blame History

新增客源 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.2 页面清单
    • 1.3 用户角色与权限差异
  2. 页面设计规范
    • 2.1 录入私客页面P0 🔴
  3. 弹窗/抽屉设计规范
    • 3.1 扩展联系人字段展开区(内联展开)
    • 3.2 扩展基础信息字段展开区(内联展开)
  4. 交互状态规范
    • 4.1 表单校验状态机
    • 4.2 权限控制矩阵
    • 4.3 HTMX 请求规范
  5. 关键数据字段说明
  6. 竞品截图对应关系
  7. 实现优先级与工期估算
  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-5xl1024px水平居中 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-5xl1024px+ 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="请输入"
QQ <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="请输入"
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_nameschool_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(错误) 成功后端重定向422innerHTML 「确定」按钮 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}/ headerHTMX 自动执行跳转。同时返回 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_encAES加密存储+ phone_hashSHA-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_encAES加密存储 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 设计规范主色为 Tealprimary-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