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

42 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.15 + Story 14 优先级P0 功能在本文档中用 🔴 标注P1 用 🟡P2 用


目录

  1. 模块概述
    • 1.1 功能范围
    • 1.2 页面清单
    • 1.3 用户角色与权限差异
  2. 页面设计规范
    • 2.1 编辑客源主页面(三 Tab 表单)
  3. 弹窗/抽屉设计规范
    • 3.1 编辑基础信息弹窗(快捷入口,来自信息概览面板)
  4. 交互状态规范
    • 4.1 全局状态说明
    • 4.2 权限控制矩阵
    • 4.3 HTMX 请求规范
  5. 关键数据字段说明
  6. 竞品截图对应关系
  7. 实现优先级与工期估算
  8. 开放问题(待决策)

1. 模块概述

1.1 功能范围

编辑客源模块包含以下功能,按优先级分组:

P0 — MVP 上线必须实现 🔴

功能 PRD 来源
联系人 Tab编辑主联系人及多联系人信息姓名/称呼/电话1/电话2/微信/QQ/备注) Story 14 §5.15.2
联系人 Tab查看号码权限验证点击「查看号码」后才能编辑电话 §5.15.2
联系人 Tab「标记无效」号码操作 §5.15.2
联系人 Tab「+ 添加联系人」追加联系人区块 §5.15.2
基础信息 Tab必填字段编辑需求类型/用途/等级/来源) §5.15.3
基础信息 Tab选填字段编辑购房目的/付款方式/名下房产/贷款记录/证件/意向学校/入学时间) §5.15.3
二手 Tab全量需求字段可编辑总价/面积/居室/楼层/朝向/装修/楼龄/意向商圈/意向小区/交通/备注) §5.15.4
全局保存/取消操作(表单底部内联按钮,保存成功返回详情页) §5.15.5
Tab 切换时保持各 Tab 表单数据不丢失 §5.15.5
表单校验:必填字段红框高亮 + 滚动定位到首个错误 §5.15.5

P1 — 首迭代实现 🟡

功能 PRD 来源
新房 Tab新房需求字段编辑待截图补充确认字段 §5.15.4 注
租房 Tab租房需求字段编辑待截图补充确认字段 §5.15.4 注
编辑基础信息弹窗从信息概览面板「编辑客源」快捷入口触发Story 22 §5.23

1.2 页面清单

页面名称 URL 模式建议 优先级 对应 PRD 章节
编辑客源主页面 /clients/<client_id>/edit/ P0 🔴 §5.15 / Story 14
编辑基础信息弹窗 无独立 URLHTMX 局部渲染 P1 🟡 §5.23 / Story 22

1.3 用户角色与权限差异

功能 经纪人(归属人/首录人) 经纪人(非归属人) 店长/管理员
进入编辑客源页 可进入 无权限 可进入
查看并编辑电话号码 需点击「查看号码」验证,通过后可编辑
「标记无效」号码
编辑基础信息/需求信息
添加/删除联系人
「+ 添加联系人」按钮

说明

  • 电话1 默认打码显示(137****1234),需权限验证后才能查看完整号码并进入编辑态。
  • 非归属人/非首录人访问 /clients/<client_id>/edit/ 时,后端返回 403前端重定向至详情页并展示 Toast "无权限编辑该客源"。

2. 页面设计规范

2.1 编辑客源主页面P0 🔴

2.1.1 页面概述

  • URL/clients/<client_id>/edit/
  • 访问入口
    • 私客详情页右侧信息概览面板「编辑」文字按钮
    • 私客详情页需求信息 Tab 右上角「编辑」蓝色文字链接(跳转到编辑页的「二手/新房/租房」Tab 激活态)
  • 页面职责:允许经纪人修改客源联系人信息、基础属性、购房/租房需求的全量字段
  • 竞品参考截图Project/fonrey/screenshots/客源/编辑客源.png

2.1.2 布局结构

┌──────────────────────────────────────────────────────────┐
│ 面包屑导航:客源 / 客源管理 / 编辑客源                    │
│ 页面标题编辑客源text-xl font-semibold               │
├──────────────────────────────────────────────────────────┤
│ Tab 导航栏:[联系人] [基础信息] [二手/新房/租房]           │
│ Tab 下划线橙色激活 #F97316对应竞品非主色 Teal      │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  联系人 Tab 内容区                                        │
│  ┌────────────────────────────────────────────────────┐  │
│  │ [区块标题:联系人] [右上角:查看后可编辑号码 查看号码] [+添加联系人] │
│  │                                                    │  │
│  │ 联系人 1 区块(白色卡片 rounded-lg               │  │
│  │   ● 姓名 [input]  ● 电话1 [***打码] [标记无效]   │  │
│  │   ● 称呼 ○先生 ○女士  电话2 [-]  微信 [-]        │  │
│  │                        QQ [-]                     │  │
│  │   备注 [textarea]                                 │  │
│  │                                                    │  │
│  │ 联系人 2 区块(同上,右上角有「删除」红色链接)     │  │
│  └────────────────────────────────────────────────────┘  │
│                                                          │
│  ─ ─ ─ ─ 以下为基础信息区块(同页,分 Section 渲染)─ ─  │
│  ┌────────────────────────────────────────────────────┐  │
│  │ 基础信息                                           │  │
│  │ ● 需求类型 ☑二手 □新房                            │  │
│  │ ● 用途 ○住宅 ○别墅 ○商住 ○商铺 ○写字楼 ○其他   │  │
│  │ ● 等级 ○A ○B ●C ○D ○E                          │  │
│  │ ● 来源 [下拉]                                     │  │
│  │   购房目的 □刚需 □投资 □学区 □改善 □商用 □其他  │  │
│  │   付款方式 ○全额 ○商业贷款 ○商贷+公积金 ○公积金 │  │
│  │   名下房产 ○无 ○本地无外地有 ○本地有房           │  │
│  │   贷款记录 ○有 ○无                               │  │
│  │   证件类型 [下拉]  证件号码 [input]               │  │
│  │   意向学校1 [input]  + 添加学校                   │  │
│  │   入学时间 [月份选择器]                           │  │
│  └────────────────────────────────────────────────────┘  │
│                                                          │
│  ┌────────────────────────────────────────────────────┐  │
│  │ 二手需求信息区块仅在「二手」Tab 或同页展示)    │  │
│  │ ● 总价 [数字输入] - [数字输入] 万元               │  │
│  │ ● 面积 [数字输入] - [数字输入] m²                │  │
│  │ ● 居室 □1居 □2居 □3居 □4居 □5居及以上          │  │
│  │   楼层 □不要一层 □低楼层 □中楼层 □高楼层 □不要顶层│  │
│  │   朝向 □东 □南 □西 □北                          │  │
│  │   装修 □毛坯 □清水 □简装 □中装 □精装 □豪装      │  │
│  │   楼龄 □5年以内 □5-10年 □10-15年 □15-20年 □20年以上│  │
│  │   意向商圈 [下拉多选]                              │  │
│  │   意向小区1 [input]  + 添加小区                   │  │
│  │   交通 [input, max 50字]                          │  │
│  │   备注 [textarea, max 200字]                      │  │
│  └────────────────────────────────────────────────────┘  │
│                                                          │
├──────────────────────────────────────────────────────────┤
│ 表单底部内联操作区:[保存Teal 主按钮)] [取消(白色边框)]  │
└──────────────────────────────────────────────────────────┘

截图与文档差异说明:竞品截图(编辑客源.png)显示三个 Tab联系人/基础信息/二手)在同一页面内纵向分 Section 展示,并非切换 Tab 才能看到内容——联系人、基础信息、二手三个区块都在一个页面滚动展示。顶部 Tab 的功能是快速定位锚点(点击 Tab 滚动到对应区块),而非隐藏其他区块。以截图为准实现。

2.1.3 区域详细规范


[顶部面包屑 + 标题区]

属性 说明
面包屑 客源 / 客源管理 / 编辑客源,使用 text-sm text-neutral-500,分隔符 /,最后一级用 text-neutral-700
页面标题 编辑客源text-xl font-semibold text-neutral-800
布局 面包屑在上,标题在下,左对齐,顶部 padding pt-6 pb-4

[Tab 导航栏]

属性 说明
组件 §10 Tab Navigation组件规范设计.md
Tab 项 「联系人」「基础信息」「二手/新房/租房」(第三项 label 根据客源需求类型动态确定)
激活样式 注意:竞品使用橙色下划线 border-b-2 border-orange-500 text-orange-500,与系统主色 Teal 不同。为保持一致性,本模块 Tab 激活色统一使用橙色 #F97316text-orange-500 border-orange-500),以匹配竞品客源模块整体视觉风格
非激活样式 text-neutral-500 hover:text-neutral-700 border-b-2 border-transparent
行为 点击 Tab 触发 Alpine.js 平滑滚动到对应 Section 的锚点(scrollIntoView({ behavior: 'smooth' })),不做 HTMX 请求Tab 同时高亮当前可见区域Intersection Observer 驱动)
粘性 Tab 栏 sticky top-0 z-10 bg-white border-b border-neutral-200
<!-- Tab 导航栏示例 -->
<div class="sticky top-0 z-10 bg-white border-b border-neutral-200"
     x-data="{ activeTab: 'contacts' }">
  <nav class="flex gap-0 px-6">
    <button class="px-4 py-3 text-sm font-medium border-b-2 transition-colors"
            :class="activeTab === 'contacts'
              ? 'border-orange-500 text-orange-500'
              : 'border-transparent text-neutral-500 hover:text-neutral-700'"
            @click="activeTab = 'contacts'; document.getElementById('section-contacts').scrollIntoView({behavior:'smooth'})">
      联系人
    </button>
    <button class="px-4 py-3 text-sm font-medium border-b-2 transition-colors"
            :class="activeTab === 'basic'
              ? 'border-orange-500 text-orange-500'
              : 'border-transparent text-neutral-500 hover:text-neutral-700'"
            @click="activeTab = 'basic'; document.getElementById('section-basic').scrollIntoView({behavior:'smooth'})">
      基础信息
    </button>
    <button class="px-4 py-3 text-sm font-medium border-b-2 transition-colors"
            :class="activeTab === 'requirement'
              ? 'border-orange-500 text-orange-500'
              : 'border-transparent text-neutral-500 hover:text-neutral-700'"
            @click="activeTab = 'requirement'; document.getElementById('section-requirement').scrollIntoView({behavior:'smooth'})">
      {{ requirement_tab_label }}  {# 二手 / 新房 / 租房 #}
    </button>
  </nav>
</div>

[联系人 Section](锚点 id="section-contacts"

区块标题行:

元素 说明
标题 联系人text-base font-semibold text-neutral-800
右侧辅助文字 查看后可编辑号码text-sm text-neutral-400
「查看号码」按钮 text-sm text-info-600 hover:underline cursor-pointer,点击后触发查看号码确认流程(见下方)
「+ 添加联系人」按钮 btn-secondary text-sm,位于标题行右侧最末,点击后 Alpine.js 动态追加联系人区块

联系人区块(每个联系人独立卡片)

bg-white rounded-lg border border-neutral-200 p-5 mb-4

区块内字段布局为网格布局参考截图3列网格每列包含"字段名 + 输入组件"。

字段 必填 组件类型 校验 备注
姓名 <input type="text"> 不可为空最多50字 与录入一致
称呼 单选 Radio 选一 ○ 先生 ○ 女士
电话1 区号下拉 + 手机号 input 手机号格式 默认打码,需「查看号码」后才能编辑(见下方交互)
标记无效 蓝色文字链接 显示在电话1 旁,点击 HTMX PATCH 标记 phone_is_invalid=True
微信 <input type="text"> 显示 - 时可直接编辑
电话2 区号下拉 + 手机号 input 手机号格式(若填写) 同电话1但无打码逻辑
QQ <input type="text">
备注 <textarea> 最多200字字数计数器 新增字段(录入时无)

电话1 查看号码交互流程

  1. 页面加载时,phone_enc 解密后以打码形式(137****1234)展示在只读 <span> 中,输入框 disabled
  2. 用户点击「查看号码」按钮 → HTMX GET 请求验证权限 → 后端返回:
    • 成功:返回明文号码,前端 Alpine.js 将 <span> 替换为可编辑 <input>,同时在 client_follow_logs 插入一条 log_type='sensitive_view' 记录
    • 失败403/权限不足)Toast 提示「无查看号码权限」
  3. 「查看号码」按钮变为已点击态(灰色,禁用),显示文字「已查看」
  4. 「标记无效」链接始终可见(无需查看号码),但触发时需后端权限校验
<!-- 电话1 打码展示 + 查看号码按钮 -->
<div x-data="{ revealed: false, phoneValue: '' }">
  <!-- 打码显示态 -->
  <template x-if="!revealed">
    <div class="flex items-center gap-2">
      <span class="text-sm text-neutral-700">{{ contact.phone_masked }}</span>
      <button type="button"
              class="text-sm text-info-600 hover:underline"
              hx-get="/api/clients/{{ client.id }}/contacts/{{ contact.id }}/reveal-phone/"
              hx-target="#phone-reveal-{{ contact.id }}"
              hx-swap="outerHTML"
              @htmx:after-request="if(event.detail.successful) revealed = true">
        查看号码
      </button>
      <a href="#" class="text-sm text-info-600 hover:underline"
         hx-patch="/api/clients/{{ client.id }}/contacts/{{ contact.id }}/mark-invalid/"
         hx-confirm="确认将该号码标记为无效?"
         hx-swap="none"
         @htmx:after-request="/* 处理成功/失败 */">
        标记无效
      </a>
    </div>
  </template>
  <!-- 已解码可编辑态 -->
  <div id="phone-reveal-{{ contact.id }}">
    <template x-if="revealed">
      <div class="flex items-center gap-2">
        <input type="tel" name="contacts[{{ forloop.counter0 }}][phone]"
               :value="phoneValue"
               class="input-base w-40"
               placeholder="请输入手机号">
        <span class="text-xs text-neutral-400">已查看</span>
        <a href="#" class="text-sm text-info-600 hover:underline"
           hx-patch="/api/clients/{{ client.id }}/contacts/{{ contact.id }}/mark-invalid/"
           hx-confirm="确认将该号码标记为无效?"
           hx-swap="none">
          标记无效
        </a>
      </div>
    </template>
  </div>
</div>

联系人区块的添加/删除逻辑Alpine.js 驱动):

<div x-data="{
  contacts: [
    { id: '{{ c.id }}', name: '{{ c.name }}', ... }
    // 由 Django 模板初始化
  ],
  addContact() {
    this.contacts.push({ id: null, name: '', gender: 'male', phone: '', ... });
  },
  removeContact(index) {
    if (this.contacts.length > 1) this.contacts.splice(index, 1);
  }
}">
  <template x-for="(contact, index) in contacts" :key="index">
    <div class="bg-white rounded-lg border border-neutral-200 p-5 mb-4">
      <div class="flex items-center justify-between mb-4">
        <span class="text-sm font-medium text-neutral-700" x-text="'联系人 ' + (index+1)"></span>
        <button x-show="index > 0" type="button"
                class="text-sm text-danger-600 hover:underline"
                @click="removeContact(index)">删除</button>
      </div>
      <!-- 字段区域 -->
    </div>
  </template>
  <button type="button" @click="addContact()"
          class="flex items-center gap-1 text-sm text-info-600 hover:underline mt-2">
    <svg class="w-4 h-4"><!-- Heroicons plus --></svg>
    添加联系人
  </button>
</div>

[基础信息 Section](锚点 id="section-basic"

Section 容器:bg-white rounded-lg border border-neutral-200 p-5 mb-4 mt-6

字段布局:竞品截图显示为标签在左约100px 固定宽度)、控件在右的两列表单布局,行间距 space-y-4

字段 必填 组件类型 枚举值 后端字段
需求类型 * 复选框 ☑ 二手 / □ 新房 clients.status 间接关联;需求类型影响第三个 Tab
用途 * Radio 单选(横排) 住宅 / 别墅 / 商住 / 商铺 / 写字楼 / 其他 clients.property_usage
等级 * Radio 单选(横排) A(急迫) / B(较强) / C(一般) / D(较弱) / E(暂不关注) clients.grade
来源 * <select> 下拉 由运营维护,如「线下|门店接待」 clients.source
购房目的 多选复选框(横排) 刚需 / 投资 / 学区 / 改善 / 商用 / 其他 clients.buying_purpose[]
付款方式 Radio 单选(横排) 全额 / 商业贷款 / 商业贷款+公积金 / 公积金 clients.payment_method
名下房产 Radio 单选(横排) 无 / 本地无房外地有房 / 本地有房 clients.properties_owned
贷款记录 Radio 单选(横排) 有 / 无 clients.has_loan_record
证件类型 <select> 下拉 身份证(默认)/ 护照 / 港澳通行证 / 其他 clients.id_type
证件号码 <input type="text"> placeholder「请输入证件号码」选择身份证时校验18位 clients.id_number_encAES 加密存储)
意向学校 <input type="text"> + 「+ 添加学校」链接 支持多所,动态追加输入行 client_school_preferences.school_name
入学时间 月份选择器年月如「2027-09」 placeholder「请选择年月」Flatpickr mode:'single', showMonths:1 client_requirements.school_enrollment_date

意向学校多输入行Alpine.js 驱动)

<div x-data="{ schools: {{ schools_json }}, addSchool() { this.schools.push('') } }">
  <template x-for="(school, i) in schools" :key="i">
    <div class="flex items-center gap-2 mb-2">
      <input type="text" :name="`school_names[${i}]`" x-model="schools[i]"
             class="input-base w-48" placeholder="请输入学校名称">
      <button type="button" @click="schools.splice(i,1)" x-show="schools.length > 1"
              class="text-danger-600 text-sm hover:underline">删除</button>
    </div>
  </template>
  <button type="button" @click="addSchool()"
          class="text-sm text-info-600 hover:underline">+ 添加学校</button>
</div>

[需求信息 Section二手](锚点 id="section-requirement"

Section 容器同上Section 标题为「二手」(或「新房」/「租房」),text-base font-semibold text-neutral-800

字段布局:与竞品截图一致,标签在左约100px、控件在右,行间距 space-y-4

字段 必填 组件类型 约束 后端字段
总价 * 两个 <input type="number"> + 文字「-」+ 「万元」 正数;最小值 ≤ 最大值 client_requirements.budget_min / budget_max
面积 * 两个 <input type="number"> + 文字「-」+ 「m²」 正数;最小值 ≤ 最大值 area_min / area_max
居室 * 多选复选框(横排) 至少选一项 □1居 □2居 □3居 □4居 □5居及以上
楼层 多选复选框(横排) □不要一层 □低楼层 □中楼层 □高楼层 □不要顶层
朝向 多选复选框(横排) □东 □南 □西 □北
装修 多选复选框(横排) □毛坯 □清水 □简装 □中装 □精装 □豪装
楼龄 多选复选框(横排) □5年以内 □5-10年 □10-15年 □15-20年 □20年以上
意向商圈 <select multiple> 下拉多选 placeholder「请选择商圈」 intent_business_area_ids[]
意向小区 <input type="text"> + 「+ 添加小区」 支持多个,动态追加 intent_complex_names(逗号拼接)
交通 <input type="text"> 最多50字实时计数显示 0/50 transportation
备注 <textarea> 最多200字实时计数显示 0/200 requirement_notes

总价/面积区间双输入框

<div class="flex items-center gap-2">
  <input type="number" name="budget_min" value="{{ req.budget_min }}"
         min="0" step="0.01"
         class="input-base w-24"
         placeholder="最小值">
  <span class="text-neutral-400">-</span>
  <input type="number" name="budget_max" value="{{ req.budget_max }}"
         min="0" step="0.01"
         class="input-base w-24"
         placeholder="最大值">
  <span class="text-sm text-neutral-500">万元</span>
</div>

字数计数器Alpine.js

<div x-data="{ count: {{ req.transportation|length }} }">
  <input type="text" name="transportation" maxlength="50"
         x-model.debounce="value" @input="count = $event.target.value.length"
         class="input-base w-full">
  <span class="text-xs text-neutral-400 text-right block mt-1"
        x-text="count + ' / 50'"></span>
</div>

[底部操作按钮区]

<div class="flex items-center gap-3 mt-2 pb-8">
  <button type="submit" form="form-edit-client"
          :disabled="submitting"
          class="inline-flex items-center gap-1.5 px-6 py-2 text-sm font-medium bg-primary-600 text-white rounded-md hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-70 disabled:cursor-wait">
    <!-- spinner提交中显示 -->
    <span x-text="submitting ? '保存中...' : '保存'"></span>
  </button>
  <button type="button" @click="cancelForm" :disabled="submitting"
          class="inline-flex items-center px-4 py-2 text-sm font-medium bg-white border border-neutral-300 text-neutral-700 rounded-md hover:bg-neutral-50 hover:border-neutral-400 disabled:opacity-60">
    取消
  </button>
  <p x-show="redirectHint" x-text="redirectHint" class="text-xs text-success-600"></p>
</div>

说明:按钮区为表单内部的普通流式布局(mt-2 pb-8),与「新增客源」页保持一致。保存按钮使用系统主色 Tealbg-primary-600),取消按钮为白底边框风格 <button>,无需为固定底栏预留额外底部间距。

2.1.4 使用的特殊组件

组件名 来源(组件规范设计.md 章节) 用途 自定义说明
Tab Navigation §10 Tab Navigation 联系人/基础信息/二手 三 Tab Tab 激活色使用橙色(text-orange-500 border-orange-500)而非系统主色 Teal与竞品保持一致
Modal Dialog §7 Modal Dialog 查看号码时如需二次确认弹窗(权限验证提示) max-w-sm 尺寸
Date Range Picker (Flatpickr) §9 Date Range Picker 入学时间的月份选择器 模式:plugins: [new monthSelectPlugin()],只选年月
Multi-select Tag Input §17 Multi-select Tag Input 意向商圈下拉多选 可选参考 Choices.js或用原生 <select multiple> + 自定义样式

2.1.5 空状态设计

联系人列表无联系人:理论上不存在(保存时必须有至少一个联系人)。如因异常加载失败,展示错误提示区块而非空状态。

基础信息字段未填写:在只读详情中显示「-」,在编辑态中显示为空输入框(含 placeholder

意向学校/意向小区无条目时:动态列表为空时显示「+ 添加学校」/「+ 添加小区」入口,不显示空状态图案。

2.1.6 Loading 状态

场景 方案
页面初始加载 Django 服务端渲染,无需骨架屏,直接输出完整表单
点击「查看号码」 按钮显示 loading spinnerhtmx-request 状态),按钮文字临时变为「查看中...」
点击「标记无效」 按钮 loading完成后 Toast 提示
保存提交中 「保存」按钮变为 disabled + spinner文字「保存中...」;通过 hx-indicator 控制
意向商圈下拉加载选项 如为异步加载Select 容器内显示 <option>加载中...</option> 再替换

3. 弹窗/抽屉设计规范

3.1 编辑基础信息弹窗P1 🟡

此弹窗由信息概览面板「编辑客源」快捷图标触发Story 22 §5.23)。功能是快捷入口,字段是主编辑页基础信息 Tab 的子集。

3.1.1 触发方式

  • 触发位置:私客详情页右侧信息概览面板 → 快捷操作区2 → 「编辑客源」图标按钮
  • 竞品截图Project/fonrey/screenshots/客源/编辑基础信息.png
  • 组件类型Modal Dialog选择理由字段数量中等约10个字段Modal 比 Drawer 更紧凑;操作完成后不需要保留背景页面的上下文)
  • 尺寸max-w-lg512px竖向滚动支持

3.1.2 表单字段规范

字段名 组件类型 必填 校验规则 默认值/预填值
需求类型 复选框(横排) 至少选一项 回显 clients.status 对应的类型
用途 <select> 下拉 必选 回显 clients.property_usage
来源 <select> 下拉 必选 回显 clients.source
购房目的 多选复选框(横排) 回显 clients.buying_purpose[]
付款方式 <select> 下拉 回显 clients.payment_method
名下房产 <select> 下拉 回显 clients.properties_owned
贷款记录 Radio 单选(横排) 回显 clients.has_loan_record
证件类型 <select> 下拉 回显 clients.id_type
证件号码 <input type="text"> 身份证18位格式当证件类型为身份证时 回显解密后的证件号码
意向学校 <input> + 「+ 添加学校」 回显已有学校列表

3.1.3 提交行为

  • 提交方式hx-patch="/api/clients/{{ client.id }}/basic-info/"
  • 成功响应
    • 关闭 ModalAlpine.js open = false
    • 触发 Toast「保存成功」success-600 绿色3秒自动消失
    • 刷新信息概览面板相关字段区域(hx-target="#info-panel-basic-fields" hx-swap="innerHTML"
  • 失败响应422
    • 弹窗内字段级错误提示(输入框下方 text-danger-600 text-xs
    • 必填字段边框变 border-danger-600
    • 不关闭弹窗
<!-- 编辑基础信息 Modal 触发 -->
<button type="button"
        @click="$dispatch('open-modal', 'edit-basic-info')"
        class="flex flex-col items-center gap-1 text-xs text-neutral-600 hover:text-primary-600">
  <svg class="w-5 h-5"><!-- Heroicons pencil-square --></svg>
  编辑客源
</button>

<!-- Modal 内 form -->
<form id="form-edit-basic-info"
      hx-patch="/api/clients/{{ client.id }}/basic-info/"
      hx-target="#info-panel-basic-fields"
      hx-swap="innerHTML"
      hx-on::after-request="
        if(event.detail.successful) {
          $dispatch('close-modal');
          $dispatch('show-toast', {message:'保存成功', type:'success'});
        }
      ">
  <!-- 字段区域 -->
  <div class="px-6 py-4 space-y-4">
    <!-- 需求类型 -->
    <div>
      <label class="block text-sm font-medium text-neutral-700 mb-1">
        需求类型 <span class="text-danger-600">*</span>
      </label>
      <div class="flex gap-4">
        <label class="flex items-center gap-2 text-sm">
          <input type="checkbox" name="requirement_type[]" value="second_hand"
                 {{ 'checked' if 'second_hand' in requirement_types }} class="checkbox-base">
          二手
        </label>
        <label class="flex items-center gap-2 text-sm">
          <input type="checkbox" name="requirement_type[]" value="new_house"
                 {{ 'checked' if 'new_house' in requirement_types }} class="checkbox-base">
          新房
        </label>
      </div>
    </div>
    <!-- 其他字段省略... -->
  </div>
  <div class="px-6 py-4 border-t border-neutral-200 flex justify-end gap-3">
    <button type="button" @click="$dispatch('close-modal')"
            class="btn-secondary px-4 py-2 text-sm rounded-lg">取消</button>
    <button type="submit"
            class="btn-primary bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 text-sm rounded-lg">
      确定
    </button>
  </div>
</form>

3.1.4 使用的特殊组件

组件名 来源 用途
Modal Dialog §7 Modal Dialog 编辑基础信息弹窗容器

4. 交互状态规范

4.1 全局状态说明

Alpine.js 管理的状态

状态 位置 说明
contacts[] 联系人区块 x-data 联系人列表,支持动态追加/删除
schools[] 基础信息区块 x-data 意向学校列表,支持动态追加/删除
complexes[] 需求信息区块 x-data 意向小区列表,支持动态追加/删除
activeTab Tab 导航 x-data 当前高亮的 Tab联动 Intersection Observer
phoneRevealed[contactId] 每个联系人区块 x-data 该联系人号码是否已解码可编辑
editBasicInfoOpen 页面级 / 信息概览面板 编辑基础信息 Modal 的开关状态

HTMX 管理的数据流

操作 说明
查看号码 hx-get 获取明文号码,局部替换号码区域 HTML
标记无效 hx-patch 更新 phone_is_invalid,无需刷新页面(hx-swap="none"Toast 提示
整体表单保存 <form hx-post> 提交,成功后 HX-Redirect 跳转到详情页
编辑基础信息弹窗提交 hx-patch 局部更新面板字段区域

4.2 权限控制矩阵

操作 经纪人(归属人) 经纪人(非归属人) 店长 管理员
进入编辑页 后端403
查看电话号码明文 (需点击验证,留痕)
标记号码无效
编辑联系人信息
添加/删除联系人
编辑基础信息
编辑需求信息
使用编辑基础信息快捷弹窗

4.3 HTMX 请求规范

操作 hx-trigger hx-method + URL hx-target hx-swap Loading 行为
查看电话1明文 click hx-get="/api/clients/{id}/contacts/{cid}/reveal-phone/" #phone-reveal-{cid} outerHTML 按钮内 spinnerhx-indicator="#reveal-btn-{cid}"
标记号码无效 click + 确认对话框 hx-patch="/api/clients/{id}/contacts/{cid}/mark-invalid/" thishx-swap="none" none 按钮 disabled + spinner
保存整体表单 submit hx-post="/clients/{id}/edit/" body(后端返回 HX-Redirect 「保存」按钮 disabled + 保存中...
编辑基础信息弹窗提交 submit hx-patch="/api/clients/{id}/basic-info/" #info-panel-basic-fields innerHTML 「确定」按钮 disabled + spinner
意向商圈选项懒加载(如需) revealed(下拉打开) hx-get="/api/business-areas/?format=options" #business-area-select innerHTML Select 内显示「加载中」option

5. 关键数据字段说明

5.1 主表字段clients

字段名(英文) 显示名 数据类型 说明
id UUID 主键
status 需求状态 VARCHAR(20) buying=求购 / renting=求租 / buy_or_rent=租购,编辑页不直接修改此字段,通过「改状态」弹窗操作
grade 等级 VARCHAR(5) A_urgent/A/B/C/D/E,编辑页可直接修改
property_usage 用途 VARCHAR(30) residential/villa/commercial_residential/shop/office/other
buying_purpose 购房目的 VARCHAR(20)[] 多选数组,如 ['rigid', 'school_district']
payment_method 付款方式 VARCHAR(30) full/mortgage/mortgage_fund/fund
properties_owned 名下房产 VARCHAR(20) none/local_none/local_has
has_loan_record 贷款记录 BOOLEAN True=有 / False=无
id_type 证件类型 VARCHAR(20) id_card/passport/hk_macao/other
id_number_enc 证件号码 BYTEA AES 加密,后端解密后传前端,前端提交后后端加密存储
source 客户来源 VARCHAR(50) lookup_items 维护

5.2 联系人表字段client_contacts

字段名(英文) 显示名 数据类型 说明
id UUID 联系人主键
client_id UUID 外键→clients
sort_order 排序 SMALLINT 0=联系人1主联系人
name 姓名 VARCHAR(50) 必填
gender 称呼 VARCHAR(10) male=先生 / female=女士
phone_enc 电话1 BYTEA AES 加密,前端默认显示打码格式
phone_hash 电话1哈希 VARCHAR(64) 后端维护,用于重复检测
phone_country_code 区号 VARCHAR(10) 默认 +86
phone_is_invalid 号码是否无效 BOOLEAN True=已标记无效
phone2_enc 电话2 BYTEA 选填
wechat 微信 VARCHAR(100) 选填
qq QQ VARCHAR(20) 选填
remarks 备注 VARCHAR(200) 最多200字

5.3 需求信息表字段client_requirements

字段名(英文) 显示名 数据类型 说明
requirement_type 需求类型 VARCHAR(20) second_hand/new_house/rental
budget_min 总价最小值 NUMERIC(12,2) 万元
budget_max 总价最大值 NUMERIC(12,2) 万元
area_min 面积最小值 NUMERIC(8,2)
area_max 面积最大值 NUMERIC(8,2)
bedroom_counts 居室 SMALLINT[] [2, 3]
floor_preferences 楼层偏好 VARCHAR(20)[] no_first/low/mid/high/no_top
orientations 朝向 VARCHAR(10)[] east/south/west/north
decorations 装修 VARCHAR(10)[] 枚举同 properties
building_age_ranges 楼龄 VARCHAR(20)[] within_5y/5_10y/10_15y/15_20y/over_20y
intent_business_area_ids 意向商圈 UUID[] 商圈 ID 数组
intent_complex_names 意向小区 TEXT 逗号分隔
transportation 交通要求 VARCHAR(50) 最多50字
requirement_notes 备注 VARCHAR(200) 最多200字
school_enrollment_date 入学时间 DATE 月份精度存储为该月1日

5.4 意向学校表字段client_school_preferences

字段名(英文) 显示名 数据类型 说明
requirement_id UUID 外键→client_requirements
school_id UUID 外键→schools允许NULL自由输入时
school_name 学校名称 VARCHAR(100) 当 school_id 为 NULL 时为手动输入的名称

6. 竞品截图对应关系

截图路径 对应功能 对应文档章节 采纳的设计要点
Project/fonrey/screenshots/客源/编辑客源.png 编辑客源主页(三区块滚动页) §2.1 1. 顶部 Tab联系人/基础信息/二手)为锚点快速定位,非 Tab 切换隐藏2. 三区块纵向排列在同一页面3. Tab 激活使用橙色下划线4. 联系人区块右上角有「查看后可编辑号码」说明 + 橙色「查看号码」按钮 + 「+ 添加联系人」5. 电话1 旁有「标记无效」蓝色链接6. 基础信息区块所有字段(含购房目的/付款方式/名下房产/贷款记录/证件/意向学校/入学时间全部展开无折叠7. 需求信息二手区块紧跟其后字段全展开8. 底部操作按钮为表单内联布局,保存按钮使用系统主色 Teal与「新增客源」页保持一致
Project/fonrey/screenshots/客源/编辑基础信息.png 编辑基础信息快捷弹窗 §3.1 Modal 弹窗,含「确定」橙色按钮 + 「取消」按钮

7. 实现优先级与工期估算

页面/功能 优先级 特殊组件复杂度 工期估算(前端)
编辑客源主页面基础框架Tab 导航 + 三区块布局) P0 🔴 0.5 天
联系人 Tab表单字段渲染姓名/称呼/电话/微信/QQ/备注) P0 🔴 0.5 天
联系人 Tab查看号码 HTMX 交互(打码→明文→可编辑) P0 🔴 1 天
联系人 Tab标记无效 HTMX 操作 P0 🔴 0.5 天
联系人 Tab动态追加/删除联系人Alpine.js P0 🔴 0.5 天
基础信息区块:必填字段渲染(需求类型/用途/等级/来源) P0 🔴 0.5 天
基础信息区块:选填字段(购房目的/付款方式/名下房产/贷款记录/证件/意向学校/入学时间) P0 🔴 中(月份选择器) 1 天
需求信息区块(二手):全量字段表单渲染 P0 🔴 0.5 天
需求信息区块(二手):意向商圈下拉多选 P0 🔴 中(多选组件) 0.5 天
需求信息区块二手意向小区动态追加Alpine.js P0 🔴 0.25 天
表单保存提交HTMX + 后端 HX-Redirect P0 🔴 0.5 天
表单校验(必填高亮 + 滚动定位) P0 🔴 0.5 天
编辑基础信息弹窗(快捷入口 Modal P1 🟡 Modal + HTMX 局部刷新) 1 天
新房 Tab / 租房 Tab 需求字段 P1 🟡 低(字段待确认) 1 天
总计 约 8 天

8. 开放问题(待决策)

# 问题 影响范围 待确认方
1 新房 Tab 和租房 Tab 的具体字段清单截图中无对应截图PRD §5.15.4 仅说明「待新房/租房截图补充确认」 §2.1.3 需求信息区块、§7 工期 产品经理
2 意向商圈的数据来源:是后端 API 返回商圈列表,还是 Django 模板预渲染 <option>?数据量大时是否需要异步加载 + 搜索过滤? §4.3 HTMX 请求规范 后端工程师
3 「查看号码」验证流程:是否需要密码二次确认,还是仅凭当前 Session 权限直接返回明文?具体的验证逻辑由后端定义 §2.1.3 联系人区块 后端工程师 + 产品经理
4 编辑页保存时,如果同时修改了联系人电话(可能造成与已有私客重复),是否需要与「录入私客」相同的重复检测提示?若需要,是保存时拦截还是仅提示 Warning §2.1.3 联系人区块 产品经理
5 入学时间的月份选择器Flatpickr monthSelectPlugin 是否已在项目中引入?或使用其他方案(如原生 <input type="month"> §2.1.4 特殊组件 前端工程师
6 竞品截图中「基础信息」区块字段均已展开显示无折叠。PRD Story 14 §5.14 描述「录入时默认折叠,编辑时全部展开」,确认编辑态下所有字段一律展开(不需要点击「展开更多」)? §2.1.3 基础信息区块 产品经理(以截图为准,当前设计已按全展开处理)
7 编辑页面 URL 方案:是否为 /clients/<client_id>/edit/?还是 /clients/<client_id>/edit/?tab=contacts(支持直接定位到特定 Tab从需求信息 Tab「编辑」链接进入时希望直接滚动到需求区块 §1.2 页面清单 前端 + 后端工程师