修改文档

This commit is contained in:
Shen Wei
2026-04-26 12:49:46 +08:00
parent ecdf295ded
commit 6270ba56ee
14 changed files with 4764 additions and 848 deletions

View File

@@ -0,0 +1,733 @@
# 编辑客源 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 功能范围
- 1.2 页面清单
- 1.3 用户角色与权限差异
2. [页面设计规范](#2-页面设计规范)
- 2.1 编辑客源主页面(三 Tab 表单)
3. [弹窗/抽屉设计规范](#3-弹窗抽屉设计规范)
- 3.1 编辑基础信息弹窗(快捷入口,来自信息概览面板)
4. [交互状态规范](#4-交互状态规范)
- 4.1 全局状态说明
- 4.2 权限控制矩阵
- 4.3 HTMX 请求规范
5. [关键数据字段说明](#5-关键数据字段说明)
6. [竞品截图对应关系](#6-竞品截图对应关系)
7. [实现优先级与工期估算](#7-实现优先级与工期估算)
8. [开放问题(待决策)](#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 激活色统一使用橙色** `#F97316``text-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` |
```html
<!-- 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. 「标记无效」链接始终可见(无需查看号码),但触发时需后端权限校验
```html
<!-- 电话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 驱动):
```html
<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_enc`AES 加密存储) |
| 意向学校 | 否 | `<input type="text">` + 「+ 添加学校」链接 | 支持多所,动态追加输入行 | `client_school_preferences.school_name` |
| 入学时间 | 否 | 月份选择器年月如「2027-09」 | placeholder「请选择年月」Flatpickr `mode:'single', showMonths:1` | `client_requirements.school_enrollment_date` |
**意向学校多输入行Alpine.js 驱动)**
```html
<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居及以上 | `bedroom_counts[]` |
| 楼层 | 否 | 多选复选框(横排) | — | □不要一层 □低楼层 □中楼层 □高楼层 □不要顶层 | `floor_preferences[]` |
| 朝向 | 否 | 多选复选框(横排) | — | □东 □南 □西 □北 | `orientations[]` |
| 装修 | 否 | 多选复选框(横排) | — | □毛坯 □清水 □简装 □中装 □精装 □豪装 | `decorations[]` |
| 楼龄 | 否 | 多选复选框(横排) | — | □5年以内 □5-10年 □10-15年 □15-20年 □20年以上 | `building_age_ranges[]` |
| 意向商圈 | 否 | `<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` |
**总价/面积区间双输入框**
```html
<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**
```html
<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>
```
---
**[底部操作按钮区]**
```html
<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`),与「新增客源」页保持一致。保存按钮使用系统主色 Teal`bg-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 spinner`htmx-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-lg`512px竖向滚动支持
#### 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`
- 不关闭弹窗
```html
<!-- 编辑基础信息 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` | 按钮内 spinner`hx-indicator="#reveal-btn-{cid}"` |
| 标记号码无效 | `click` + 确认对话框 | `hx-patch="/api/clients/{id}/contacts/{cid}/mark-invalid/"` | `this`hx-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 页面清单 | 前端 + 后端工程师 |

View File

@@ -0,0 +1,643 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1280">
<title>Fonrey 新增客源 · 静态原型</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
50: '#F0FDFA',
100: '#CCFBF1',
200: '#99F6E4',
500: '#14B8A6',
600: '#0F766E',
700: '#115E59',
800: '#134E4A'
},
neutral: {
50: '#F8FAFC',
100: '#F1F5F9',
200: '#E2E8F0',
300: '#CBD5E1',
400: '#94A3B8',
500: '#64748B',
600: '#475569',
700: '#334155',
800: '#1E293B',
900: '#0F172A'
},
success: { 50: '#F0FDF4', 600: '#16A34A' },
warning: { 50: '#FFFBEB', 600: '#D97706' },
danger: { 50: '#FEF2F2', 600: '#DC2626' },
info: { 50: '#EFF6FF', 600: '#2563EB' }
},
boxShadow: {
xs: '0 1px 2px rgba(15,23,42,0.04)'
},
fontFamily: {
sans: ['Inter', 'PingFang SC', 'Microsoft YaHei', 'sans-serif']
}
}
}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
html { scroll-behavior: smooth; }
[x-cloak] { display: none !important; }
.tabular-nums { font-variant-numeric: tabular-nums; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94A3B8; }
</style>
</head>
<body class="bg-neutral-50 text-sm text-neutral-700 antialiased" x-data="createClientPage()">
<header class="fixed top-0 left-0 right-0 h-14 z-20 bg-primary-800 flex items-center justify-between">
<div class="flex items-center gap-2 px-4 w-60 shrink-0">
<div class="w-7 h-7 rounded-md bg-primary-500 flex items-center justify-center text-white text-sm font-semibold">F</div>
<span class="text-base font-semibold text-white">Fonrey</span>
</div>
<nav class="flex items-center gap-1 flex-1 px-2" aria-label="主导航">
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">工作台</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">房源</a>
<a class="px-3 py-1.5 text-sm rounded-md bg-primary-600 text-white font-medium">客源</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">营销</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">交易</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">数据</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">人事</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">系统</a>
</nav>
<div class="flex items-center gap-1 px-4 shrink-0">
<button class="p-1.5 text-primary-200 hover:bg-primary-700 hover:text-white rounded-md" aria-label="消息">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022 23.848 23.848 0 0 0 5.454 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
</button>
<div class="flex items-center gap-2 pl-3 ml-1 border-l border-primary-700">
<div class="w-8 h-8 rounded-full bg-primary-600 text-white flex items-center justify-center text-sm font-semibold"></div>
<span class="text-sm font-medium text-primary-100">魏深</span>
</div>
</div>
</header>
<aside class="fixed left-0 top-14 h-[calc(100vh-56px)] w-60 z-20 border-r border-neutral-200 bg-white">
<nav class="p-3 space-y-0.5">
<div class="px-2 pt-2 pb-1 text-xs font-medium text-neutral-500 uppercase tracking-wide">客源管理</div>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md bg-primary-50 text-primary-700 font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/></svg>
私客列表
</a>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100">公客池</a>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100">成交客</a>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100">已删客源</a>
</nav>
</aside>
<main class="ml-60 pt-[72px] min-h-screen bg-neutral-50 px-6 py-5">
<div class="mx-auto max-w-5xl">
<div class="mb-6">
<nav class="flex items-center gap-1 text-xs text-neutral-500 mb-2" aria-label="面包屑">
<a href="/clients/" class="hover:text-neutral-700">客源</a>
<span>/</span>
<span class="text-neutral-700">录入私客</span>
</nav>
<h1 class="text-xl font-semibold text-neutral-800">录入私客</h1>
</div>
<form id="create-client-form" @submit.prevent="submitForm" class="space-y-4">
<section class="bg-white rounded-lg border border-neutral-200 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-neutral-800">联系人</h2>
<span class="text-xs text-neutral-500">最多添加 5 位联系人</span>
</div>
<div class="space-y-5">
<template x-for="(contact, idx) in form.contacts" :key="contact.id">
<div class="border border-neutral-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-neutral-700" x-text="'联系人' + (idx + 1)"></h3>
<button
type="button"
x-show="idx > 0"
@click="removeContact(idx)"
class="text-xs text-danger-600 hover:text-danger-600 hover:underline"
>删除</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">
姓名 <span class="text-danger-600">*</span>
</label>
<input
type="text"
x-model.trim="contact.name"
:data-field-key="fieldKey('contacts', idx, 'name')"
placeholder="请输入"
class="block w-full px-3 py-2 text-sm rounded-md border placeholder:text-neutral-400 focus:outline-none focus:ring-2"
:class="inputClass(fieldKey('contacts', idx, 'name'))"
>
<p class="text-xs text-danger-600" x-show="errors[fieldKey('contacts', idx, 'name')]" x-text="errors[fieldKey('contacts', idx, 'name')]"></p>
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">
性别 <span class="text-danger-600">*</span>
</label>
<div :data-field-key="fieldKey('contacts', idx, 'gender')" class="flex items-center gap-6 rounded-md border px-3 py-2 min-h-[42px]"
:class="groupClass(fieldKey('contacts', idx, 'gender'))">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" x-model="contact.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" x-model="contact.gender" value="female" class="w-4 h-4 accent-primary-600">
<span class="text-sm text-neutral-700">女士</span>
</label>
</div>
<p class="text-xs text-danger-600" x-show="errors[fieldKey('contacts', idx, 'gender')]" x-text="errors[fieldKey('contacts', idx, 'gender')]"></p>
</div>
<div class="md:col-span-2 space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">
电话1 <span class="text-danger-600">*</span>
</label>
<div
:data-field-key="fieldKey('contacts', idx, 'phone')"
class="flex rounded-md border overflow-hidden focus-within:ring-2"
:class="phoneWrapClass(fieldKey('contacts', idx, 'phone'))"
>
<select x-model="contact.phoneCountryCode" class="w-24 px-2 py-2 text-sm bg-neutral-50 border-r border-neutral-200 text-neutral-700 focus:outline-none">
<template x-for="code in countryCodes" :key="code">
<option :value="code" x-text="code"></option>
</template>
</select>
<input
type="tel"
x-model.trim="contact.phone"
@blur="idx === 0 ? checkDuplicatePhone() : null"
placeholder="输入手机号"
class="flex-1 px-3 py-2 text-sm bg-white focus:outline-none"
>
</div>
<p class="text-xs text-danger-600" x-show="errors[fieldKey('contacts', idx, 'phone')]" x-text="errors[fieldKey('contacts', idx, 'phone')]"></p>
<template x-if="idx === 0">
<div class="mt-1 min-h-[20px]">
<template x-if="checkingDuplicate">
<div class="animate-pulse h-4 bg-neutral-200 rounded w-56"></div>
</template>
<p
x-show="duplicateHint"
x-text="duplicateHint"
class="text-xs text-warning-600"
></p>
</div>
</template>
</div>
</div>
<div class="mt-3">
<button
type="button"
@click="contact.expanded = !contact.expanded"
class="flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700"
>
<span>电话2、微信、QQ等</span>
<svg class="w-4 h-4 transition-transform" :class="contact.expanded ? 'rotate-180' : ''" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6"/></svg>
</button>
<div x-show="contact.expanded" x-transition class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">电话2</label>
<div class="flex rounded-md border border-neutral-300 overflow-hidden focus-within:border-primary-600 focus-within:ring-2 focus-within:ring-primary-600/20">
<select x-model="contact.phone2CountryCode" class="w-24 px-2 py-2 text-sm bg-neutral-50 border-r border-neutral-200 text-neutral-700 focus:outline-none">
<template x-for="code in countryCodes" :key="'p2-' + code">
<option :value="code" x-text="code"></option>
</template>
</select>
<input type="tel" x-model.trim="contact.phone2" placeholder="输入手机号" class="flex-1 px-3 py-2 text-sm bg-white focus:outline-none">
</div>
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">微信</label>
<input type="text" x-model.trim="contact.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>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">QQ</label>
<input type="text" x-model.trim="contact.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>
</div>
</template>
<button
type="button"
x-show="form.contacts.length < 5"
@click="addContact"
class="flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
新增联系人
</button>
</div>
</section>
<section 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="space-y-4">
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">状态 <span class="text-danger-600">*</span></label>
<div data-field-key="status" class="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border px-3 py-2 min-h-[42px]" :class="groupClass('status')">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" x-model="form.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" x-model="form.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" x-model="form.status" value="buy_or_rent" class="w-4 h-4 accent-primary-600">
<span class="text-sm text-neutral-700">租购</span>
</label>
</div>
<p class="text-xs text-danger-600" x-show="errors.status" x-text="errors.status"></p>
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">用途 <span class="text-danger-600">*</span></label>
<div data-field-key="property_usage" class="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border px-3 py-2 min-h-[42px]" :class="groupClass('property_usage')">
<template x-for="item in usageOptions" :key="item.value">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" x-model="form.propertyUsage" :value="item.value" class="w-4 h-4 accent-primary-600">
<span class="text-sm text-neutral-700" x-text="item.label"></span>
</label>
</template>
</div>
<p class="text-xs text-danger-600" x-show="errors.property_usage" x-text="errors.property_usage"></p>
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">等级 <span class="text-danger-600">*</span></label>
<div data-field-key="grade" class="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border px-3 py-2 min-h-[42px]" :class="groupClass('grade')">
<template x-for="item in gradeOptions" :key="item.value">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" x-model="form.grade" :value="item.value" class="w-4 h-4 accent-primary-600">
<span class="text-sm text-neutral-700" x-text="item.label"></span>
</label>
</template>
</div>
<p class="text-xs text-danger-600" x-show="errors.grade" x-text="errors.grade"></p>
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">来源 <span class="text-danger-600">*</span></label>
<select
data-field-key="source"
x-model="form.source"
:disabled="sourceLoading"
class="block w-full max-w-xs px-3 py-2 text-sm rounded-md border bg-white focus:outline-none focus:ring-2 disabled:bg-neutral-100 disabled:text-neutral-400"
:class="inputClass('source')"
>
<option value="" x-text="sourceLoading ? '加载中...' : '请选择'"></option>
<template x-for="item in sourceOptions" :key="item.value">
<option :value="item.value" x-text="item.label"></option>
</template>
</select>
<p class="text-xs text-danger-600" x-show="errors.source" x-text="errors.source"></p>
</div>
<div>
<button type="button" @click="infoExpanded = !infoExpanded" class="flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700">
<span>证件类型、证件号码、意向学校等</span>
<svg class="w-4 h-4 transition-transform" :class="infoExpanded ? 'rotate-180' : ''" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6"/></svg>
</button>
<div x-show="infoExpanded" x-transition class="mt-3 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">证件类型</label>
<select x-model="form.idType" class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 bg-white focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20">
<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 class="block text-sm font-medium text-neutral-700">证件号码</label>
<input type="text" x-model.trim="form.idNumber" 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-2">
<label class="block text-sm font-medium text-neutral-700">意向学校</label>
<template x-for="(school, sIdx) in form.schools" :key="'school-' + sIdx">
<div class="flex items-center gap-2 max-w-md">
<input type="text" x-model.trim="form.schools[sIdx]" 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">
<button type="button" @click="removeSchool(sIdx)" x-show="form.schools.length > 1" class="px-2 py-2 text-xs text-danger-600 hover:bg-danger-50 rounded-md">删除</button>
</div>
</template>
<button type="button" @click="addSchool" class="flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
添加学校
</button>
</div>
</div>
</div>
</div>
</section>
<section 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-1 md: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="魏深 - 都市港湾店一组" 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" x-model="form.firstRecorderId">
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">归属人</label>
<input type="text" value="魏深 - 都市港湾店一组" 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" x-model="form.ownerId">
</div>
</div>
</section>
<div class="flex items-center gap-3 mt-2 pb-8">
<button
type="submit"
: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"
>
<svg x-show="submitting" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity=".25" stroke-width="4"></circle>
<path d="M22 12a10 10 0 0 1-10 10" stroke="currentColor" stroke-width="4"></path>
</svg>
<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>
</form>
</div>
</main>
<div x-show="toast.show" x-transition.opacity class="fixed bottom-6 right-6 z-[70]">
<div class="w-80 bg-white rounded-lg shadow-lg border border-neutral-200 flex items-start gap-3 p-3">
<svg class="w-5 h-5 text-success-600 shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
<div class="flex-1 text-sm">
<p class="font-medium text-neutral-800" x-text="toast.message"></p>
<p class="text-xs text-neutral-500">客源信息已保存</p>
</div>
</div>
</div>
<script>
function createClientPage() {
return {
countryCodes: ['+86', '+852', '+853', '+886', '+1'],
usageOptions: [
{ value: 'residential', label: '住宅' },
{ value: 'villa', label: '别墅' },
{ value: 'commercial_residential', label: '商住' },
{ value: 'shop', label: '商铺' },
{ value: 'office', label: '写字楼' },
{ value: 'other', label: '其他' }
],
gradeOptions: [
{ value: 'A_urgent', label: 'A(急迫)' },
{ value: 'A', label: 'A' },
{ value: 'B', label: 'B(较强)' },
{ value: 'C', label: 'C(一般)' },
{ value: 'D', label: 'D(较弱)' },
{ value: 'E', label: 'E(暂不关注)' }
],
sourceLoading: true,
sourceOptions: [],
infoExpanded: false,
checkingDuplicate: false,
duplicateHint: '',
submitting: false,
redirectHint: '',
errors: {},
toast: {
show: false,
message: ''
},
form: {
contacts: [],
status: '',
propertyUsage: 'residential',
grade: '',
source: '',
idType: '',
idNumber: '',
schools: [''],
firstRecorderId: 'staff-1001',
ownerId: 'staff-1001'
},
init() {
this.form.contacts = [this.newContact()];
this.simulateSourceLoading();
},
newContact() {
return {
id: 'c-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
name: '',
gender: '',
phoneCountryCode: '+86',
phone: '',
expanded: false,
phone2CountryCode: '+86',
phone2: '',
wechat: '',
qq: ''
};
},
fieldKey(group, idx, field) {
return group + '_' + idx + '_' + field;
},
inputClass(key) {
return this.errors[key]
? 'border-danger-600 ring-danger-600/20 focus:border-danger-600 focus:ring-danger-600/20'
: 'border-neutral-300 focus:border-primary-600 focus:ring-primary-600/20';
},
groupClass(key) {
return this.errors[key]
? 'border-danger-600 ring-2 ring-danger-600/20'
: 'border-neutral-300';
},
phoneWrapClass(key) {
return this.errors[key]
? 'border-danger-600 ring-danger-600/20 focus-within:border-danger-600 focus-within:ring-danger-600/20'
: 'border-neutral-300 focus-within:border-primary-600 focus-within:ring-primary-600/20';
},
addContact() {
if (this.form.contacts.length >= 5) return;
this.form.contacts.push(this.newContact());
},
removeContact(idx) {
if (idx === 0) return;
this.form.contacts.splice(idx, 1);
},
addSchool() {
this.form.schools.push('');
},
removeSchool(idx) {
if (this.form.schools.length <= 1) return;
this.form.schools.splice(idx, 1);
},
simulateSourceLoading() {
setTimeout(() => {
this.sourceOptions = [
{ value: 'store_visit', label: '线下丨门店接待' },
{ value: 'old_client_referral', label: '老客户转介绍' },
{ value: 'online_form', label: '线上丨留资表单' },
{ value: 'community_push', label: '社群活动' }
];
this.sourceLoading = false;
}, 900);
},
validatePhone(phone, countryCode) {
if (!phone) return false;
if (countryCode === '+86') return /^1\d{10}$/.test(phone);
return /^[0-9]{5,20}$/.test(phone);
},
validateForm() {
this.errors = {};
let firstErrorKey = '';
this.form.contacts.forEach((contact, idx) => {
const nameKey = this.fieldKey('contacts', idx, 'name');
const genderKey = this.fieldKey('contacts', idx, 'gender');
const phoneKey = this.fieldKey('contacts', idx, 'phone');
if (!contact.name) {
this.errors[nameKey] = '姓名不能为空';
if (!firstErrorKey) firstErrorKey = nameKey;
}
if (!contact.gender) {
this.errors[genderKey] = '请选择性别';
if (!firstErrorKey) firstErrorKey = genderKey;
}
if (!contact.phone) {
this.errors[phoneKey] = '请输入手机号';
if (!firstErrorKey) firstErrorKey = phoneKey;
} else if (!this.validatePhone(contact.phone, contact.phoneCountryCode)) {
this.errors[phoneKey] = contact.phoneCountryCode === '+86' ? '请输入有效的手机号码' : '请输入有效的电话号码';
if (!firstErrorKey) firstErrorKey = phoneKey;
}
});
if (!this.form.status) {
this.errors.status = '请选择客源状态';
if (!firstErrorKey) firstErrorKey = 'status';
}
if (!this.form.propertyUsage) {
this.errors.property_usage = '请选择用途';
if (!firstErrorKey) firstErrorKey = 'property_usage';
}
if (!this.form.grade) {
this.errors.grade = '请选择客源等级';
if (!firstErrorKey) firstErrorKey = 'grade';
}
if (!this.form.source) {
this.errors.source = '请选择客户来源';
if (!firstErrorKey) firstErrorKey = 'source';
}
if (firstErrorKey) {
this.$nextTick(() => {
const target = document.querySelector('[data-field-key="' + firstErrorKey + '"]');
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (typeof target.focus === 'function') target.focus({ preventScroll: true });
}
});
return false;
}
return true;
},
checkDuplicatePhone() {
const c = this.form.contacts[0];
this.duplicateHint = '';
if (!c.phone || !this.validatePhone(c.phone, c.phoneCountryCode)) return;
this.checkingDuplicate = true;
setTimeout(() => {
const duplicatedPhones = ['13800138000', '13911112222', '13700009999'];
if (c.phoneCountryCode === '+86' && duplicatedPhones.includes(c.phone)) {
this.duplicateHint = '存在重复客源:张三(可前往详情查看),可继续提交但建议先核对。';
}
this.checkingDuplicate = false;
}, 600);
},
showToast(message) {
this.toast.message = message;
this.toast.show = true;
setTimeout(() => {
this.toast.show = false;
}, 2200);
},
submitForm() {
if (this.submitting) return;
if (!this.validateForm()) return;
this.submitting = true;
this.redirectHint = '';
setTimeout(() => {
this.submitting = false;
this.showToast('保存成功');
this.redirectHint = '模拟跳转中:即将进入客源详情页 /clients/CL20260426001/';
}, 1200);
},
cancelForm() {
this.redirectHint = '已取消录入,模拟返回客源列表 /clients/';
}
};
}
</script>
</body>
</html>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,5 @@
# Fonrey 模块 UI 设计文档生成提示词
> **用途**:每次针对一个具体业务模块,填入变量后直接发给 AI输出该模块的标准化 UI 设计文档 # 任务:为 新增客源生成模块 UI 设计文档
> **输出文件**`Project/fonrey/UI_DESIGN/{模块名}_UI.md`
---
## 使用方法
1. 复制下方「---PROMPT START---」到「---PROMPT END---」之间的全部内容
2. 将所有 `{{变量}}` 替换为本次模块的实际值
3. 把替换后的提示词发给 AI
---PROMPT START---
# 任务:为 {{模块名称}} 生成模块 UI 设计文档
## 你的角色 ## 你的角色
@@ -45,36 +31,30 @@
## 本次任务输入 ## 本次任务输入
### 1. 目标模块 ### 1. 目标模块
**模块名称**{{模块名称}} **模块名称**新增客源
**模块描述**{{一句话描述模块核心功能}} **模块描述**新增客源
### 2. PRD 功能文档路径 ### 2. PRD 功能文档路径
``` ```
{{PRD文件路径Project/fonrey/PRD/源管理/源管理模块PRD.md}} `Project/fonrey/PRD/源管理/源管理模块PRD.md` 章节5.2 录入私客
``` ```
请读取该文件,理解每个功能点的业务逻辑和验收标准。 请读取该文件,理解每个功能点的业务逻辑和验收标准。
### 3. DATA_MODEL 数据模型文档路径 ### 3. 竞品参考截图
```
{{DATA_MODEL文件路径Project/fonrey/DATA_MODEL/DATA_MODEL_PROPERTY.md}}
```
请读取该文件,理解该模块的数据模型以及字段命名。
### 4. 竞品参考截图
请读取以下截图文件作为视觉参考(所有截图均在 `Project/fonrey/screenshots/` 目录下): 请读取以下截图文件作为视觉参考(所有截图均在 `Project/fonrey/screenshots/` 目录下):
{{截图列表,格式如下,每行一张 - 客源列表
- 功能名称`Project/fonrey/screenshots/模块/截图名.png` - 录入客源`Project/fonrey/screenshots/客源/录入客源.png`
}} - 编辑客源: `Project/fonrey/screenshots/客源/编辑客源.png`
### 5. MVP 优先级参考 ### 4. MVP 优先级参考
请参考 `Project/fonrey/PRD/PRD_MVP.md`,在设计文档中标注每个页面/功能的优先级P0/P1/P2 请参考 `Project/fonrey/PRD/PRD_MVP.md`,在设计文档中标注每个页面/功能的优先级P0/P1/P2
--- ---
## 输出格式要求 ## 输出格式要求
输出一份完整的 Markdown 文档,文件名为 `{{模块名称}}_UI.md`,结构如下: 输出一份完整的 Markdown 文档,文件名为 `新增客源_UI.md`,结构如下:
--- ---
@@ -331,7 +311,7 @@
3. 【本次模块UI设计文档】本次需要实现的模块设计说明 3. 【本次模块UI设计文档】本次需要实现的模块设计说明
- `Project/fonrey/UI_DESIGN/新增客源_UI.md` - `Project/fonrey/UI_DESIGN/新增客源_UI.md`
4. 【本次模块UI输出静态原型文件】 4. 【本次模块UI输出静态原型文件】
- `Project/fonrey/UI_DESIGN/新增客源_UI.html`** - `Project/fonrey/UI_DESIGN/新增客源_UI.html`
### 强制约束(不可违反) ### 强制约束(不可违反)
@@ -363,4 +343,4 @@
#### 注意事项 #### 注意事项
- 如果设计文档与 UI_SYSTEM 存在冲突,以 UI_SYSTEM 为准,并告知我冲突点 - 如果设计文档与 UI_SYSTEM 存在冲突,以 UI_SYSTEM 为准,并告知我冲突点
- 如果设计文档描述不清晰,不要自行猜测,先列出疑问再继续 - 如果设计文档描述不清晰,不要自行猜测,先列出疑问再继续

View File

@@ -0,0 +1,307 @@
# 任务:为 编辑客源生成模块 UI 设计文档
## 你的角色
你是 Fonrey 房产经纪管理系统的 **UI/UX 架构师**,负责根据竞品截图和 PRD 功能描述,产出一份标准化的模块级 UI 设计文档。该文档将直接交给 AI Engineer 用于编码实现必须包含足够的细节Engineer 无需再问任何问题。
**注意**
以下所有的文档或图片是基于文档库的相对路径。
文档库的根路径为:`/mnt/d/Workspace/nexus`
---
## 全局设计约束(必须严格遵守)
> 所有设计决策必须符合 `Project/fonrey/UI_SYSTEM/UI_SYSTEM.md` 中的设计规范。核心约束如下:
- **技术栈**Tailwind CSS + HTMX + Alpine.js + Django HTML 模板(非 React/Vue/JSX
- **图标库**Heroicons v2Outline 24px 默认Solid 20px 强调Mini 16px 极密场景)
- **主色**Teal `#0F766E``primary-600`),所有颜色引用 Token禁止硬编码 Hex
- **圆角**`rounded-lg`8px为默认表格行/小组件用 `rounded-md`6px
- **表格行高**56px`h-14`
- **字体**Inter + PingFang SC正文 `text-sm`14px
- **焦点环**`focus-visible:ring-2 focus-visible:ring-primary-600/40`
- **桌面优先**≥1280px不做移动端适配
- **禁止独立 CSS 文件或 CSS-in-JS**:所有样式用 Tailwind utility class少量例外如 Flatpickr 覆盖样式)
- **组件实现参考**`Project/fonrey/UI_SYSTEM/组件规范设计.md`(含 20 个特殊组件的完整 HTML + Alpine.js 实现)
---
## 本次任务输入
### 1. 目标模块
**模块名称**:编辑客源
**模块描述**:编辑客源
### 2. PRD 功能文档路径
```
`Project/fonrey/PRD/客源管理/客源管理模块PRD.md` 章节5.15 编辑客源
```
请读取该文件,理解每个功能点的业务逻辑和验收标准。
### 3. DATA_MODEL 数据模型文档路径
```
DATA_MODEL文件路径Project/fonrey/DATA_MODEL/DATA_MODEL_CLIENT.md
```
请读取该文件,理解该模块的数据模型以及字段命名。
### 4. 竞品参考截图
请读取以下截图文件作为视觉参考(所有截图均在 `Project/fonrey/screenshots/` 目录下):
- 客源列表:
- 编辑客源: `Project/fonrey/screenshots/客源/编辑客源.png`
### 5. MVP 优先级参考
请参考 `Project/fonrey/PRD/PRD_MVP.md`,在设计文档中标注每个页面/功能的优先级P0/P1/P2
---
## 输出格式要求
输出一份完整的 Markdown 文档,文件名为 `编辑客源_UI.md`,结构如下:
---
```markdown
# {{模块名称}} UI 设计文档
> **版本**v1.0 · **日期**{今日日期}
> **依赖规范**UI_SYSTEM.md v1.1 · 组件规范设计.md v1.0
> **PRD 来源**{PRD文件路径}
> **优先级**P0 功能在本文档中用 🔴 标注P1 用 🟡P2 用 ⚫
---
## 目录
(列出本文档所有章节)
---
## 1. 模块概述
### 1.1 功能范围
(从 PRD 提取本模块包含的所有功能,按优先级分组列表)
### 1.2 页面清单
(列出本模块所有页面/视图,每行包含:页面名称 | URL 模式建议 | 优先级 | 对应 PRD 章节)
### 1.3 用户角色与权限差异
(说明不同角色(经纪人/店长/管理员)在本模块的视图差异,如哪些字段/按钮对特定角色隐藏)
---
## 2. 页面设计规范
> 每个页面单独一节,按以下子结构输出。
### 2.N {页面名称}{优先级} 🔴/🟡/⚫)
#### 2.N.1 页面概述
- **URL**`/模块/页面/`
- **访问入口**:(从哪里进入此页面)
- **页面职责**:(一句话)
- **竞品参考截图**`{截图路径}`
#### 2.N.2 布局结构
(用文字描述页面整体布局,如:三栏布局、左侧边栏+右侧主内容区、全宽列表等)
```
┌──────────────────────────────────────────────────────────┐
│ 顶部区域Breadcrumb / 页面标题 / 主操作按钮) │
├──────────────────────────────────────────────────────────┤
│ 筛选区域(可折叠) │
├──────────────────────────────────────────────────────────┤
│ 工具栏(批量操作 / 排序切换 / 列设置 / 导出) │
├──────────────────────────────────────────────────────────┤
│ 主内容区(表格 / 详情卡片 / 表单) │
├──────────────────────────────────────────────────────────┤
│ 分页栏 │
└──────────────────────────────────────────────────────────┘
```
(根据实际页面调整此 ASCII 图)
#### 2.N.3 区域详细规范
> 每个区域独立描述,包含:组件类型、字段/按钮清单、交互逻辑
**[区域名称,如:搜索筛选区]**
| 属性 | 说明 |
|---|---|
| 组件 | (引用组件规范设计.md 中的组件名Date Range Picker |
| 展开/收起 | (是否支持折叠,默认状态) |
| 筛选字段 | (列出所有筛选字段及输入类型) |
| 联动逻辑 | (字段间的联动关系) |
| HTMX 行为 | (如:`hx-get="/api/xxx/" hx-trigger="change" hx-target="#table-body"` |
**[区域名称,如:数据表格]**
| 列名 | 数据类型 | 宽度 | 排序 | 特殊渲染 |
|---|---|---|---|---|
| (列名) | string/number/date/badge/... | fixed px 或 auto | (是/否) | Tag、趋势箭头、行内按钮 |
(补充表格交互说明:行点击跳转、批量选择、列固定等)
#### 2.N.4 使用的特殊组件
| 组件名 | 来源(组件规范设计.md 章节) | 用途 | 自定义说明 |
|---|---|---|---|
| (组件名) | §1 Data Table | 用于展示xxx列表 | (如果有与标准实现不同的地方,详细说明) |
#### 2.N.5 空状态设计
(描述列表/表格无数据时的展示方式,参考 UI_SYSTEM.md §6.3 空状态设计)
#### 2.N.6 Loading 状态
(描述数据加载中的骨架屏或加载指示方案)
---
## 3. 弹窗/抽屉设计规范
> 每个弹窗/抽屉独立一节,按以下结构输出。
### 3.N {弹窗/抽屉名称}{触发入口}
#### 3.N.1 触发方式
- **触发位置**:(如:房源详情页-调价链接)
- **组件类型**Modal Dialog / Drawer选一个说明选择理由
- **尺寸**Modal: max-w-sm/md/lg/xl/2xlDrawer: w-[480px]/w-[640px]
- **竞品截图**`{截图路径}`
#### 3.N.2 表单字段规范
| 字段名 | 组件类型 | 必填 | 校验规则 | 默认值/预填值 |
|---|---|---|---|---|
| (字段名) | Input/Select/Textarea/DatePicker/Toggle/TreeSelect/MultiTag/... | (是/否) | (规则描述) | (如有) |
#### 3.N.3 提交行为
- **提交方式**HTMX `hx-post` / `hx-put` / `hx-patch`
- **成功响应**:(如:关闭弹窗 + Toast "保存成功" + 刷新目标区域)
- **失败响应422**:(字段级错误提示)
- **HTMX 属性**:(完整写出 hx-post/hx-target/hx-swap/hx-on 等)
#### 3.N.4 使用的特殊组件
| 组件名 | 来源 | 用途 |
|---|---|---|
---
## 4. 交互状态规范
### 4.1 全局状态机(如有)
(如房源状态机:在售 → 暂缓 → 成交 → 下架,用状态流转图描述)
### 4.2 权限控制矩阵
(描述不同角色对本模块各操作的权限,如:删除只有管理员可见)
| 操作 | 经纪人 | 店长 | 管理员 |
|---|---|---|---|
### 4.3 HTMX 请求规范
(列出本模块所有 HTMX 请求包含触发事件、URL、target、swap 方式、Loading 行为)
| 操作 | hx-trigger | hx-get/post/... | hx-target | hx-swap | Loading |
|---|---|---|---|---|---|
---
## 5. 关键数据字段说明
(列出本模块所有需要后端支持的数据字段,便于 Engineer 与后端联调)
| 字段名(英文) | 显示名 | 数据类型 | 说明 |
|---|---|---|---|
---
## 6. 竞品截图对应关系
(将本模块所有参考截图按功能分类整理,说明截图对应设计文档的哪个章节)
| 截图路径 | 对应功能 | 对应文档章节 | 采纳的设计要点 |
|---|---|---|---|
---
## 7. 实现优先级与工期估算
| 页面/功能 | 优先级 | 特殊组件复杂度 | 工期估算(前端) |
|---|---|---|---|
---
## 8. 开放问题(待决策)
(列出设计过程中发现的、需要产品/后端确认的问题)
| # | 问题 | 影响范围 | 待确认方 |
|---|---|---|---|
```
---
---
## 额外要求
1. **截图优先**:有截图的功能,以截图呈现的 UI 为主要参考PRD 文字为补充说明;截图和 PRD 有冲突时,以截图为准,并在文档中注明差异。
2. **组件引用**:每次使用特殊组件(如 Data Table、Tree Select、Drawer 等),必须在"使用的特殊组件"表格中引用组件规范设计.md 的对应章节编号,并说明如有差异的自定义部分。
3. **HTMX 落地**:每个需要异步更新的交互(筛选、分页、弹窗提交)必须写出完整的 HTMX 属性Engineer 可以直接复制使用。
4. **Alpine.js 分工**:说明哪些状态由 Alpine.js 管理(弹窗开关、选中状态、表单联动),哪些交互走 HTMX数据加载、表单提交
5. **禁止设计移动端**:所有布局仅针对 ≥1280px 桌面端。
6. **优先级标注**P0 功能用 🔴P1 用 🟡P2 用 ⚫,确保 Engineer 知道实现顺序。
7. **不要遗漏边界状态**:每个列表页必须包含空状态设计;每个表单必须包含校验失败状态;每个异步操作必须包含 Loading 状态。
---PROMPT END---
---
## 已生成的模块 UI 设计文档
| 模块 | 文件路径 | 生成日期 | 覆盖 PRD 版本 |
|---|---|---|---|
| (待填入) | | | |
---
## 变量填写示例(房源列表页)
```
{{模块名称}} → 房源管理
{{一句话描述模块核心功能}} → 管理房产经纪公司的二手房/租赁房源,支持录入、筛选、跟进、状态变更
{{PRD文件路径}} → Project/fonrey/PRD/房源管理/房源管理模块PRD.md
{{截图列表}} →
- 房源列表(二手&租赁):`Project/fonrey/screenshots/房源/全部房源.png`
- 房源列表(全部商铺):`Project/fonrey/screenshots/房源/全部商铺.png`
- 房源详情第1屏`Project/fonrey/screenshots/房源/房源详情1.png`
- 房源详情第2屏`Project/fonrey/screenshots/房源/房源详情2.png`
- 房源详情第3屏`Project/fonrey/screenshots/房源/房源详情3.png`
- 新增住宅表单:`Project/fonrey/screenshots/房源/增房/新增住宅.png`
- 调价弹窗:`Project/fonrey/screenshots/房源/调价.png`
- 调价记录弹窗:`Project/fonrey/screenshots/房源/调价记录.png`
- 房源状态变更:`Project/fonrey/screenshots/房源/房源状态变更.png`
- 跟进管理-全部:`Project/fonrey/screenshots/房源/跟进管理/全部.png`
- 跟进管理-写入跟进:`Project/fonrey/screenshots/房源/跟进管理/写入跟进.png`
- 相册管理:`Project/fonrey/screenshots/房源/增房/上传图片.png`
```
---
## 注意事项
- 单次提示词只针对**一个模块**,不要同时处理多个模块
- 对于同一模块内页面较多的情况(如房源管理有列表、详情、新增、跟进等多个页面),**全部包含在同一份文档中**,通过 `§2.N` 分节区分
- 弹窗数量较多时(如房源详情有 10+ 个编辑弹窗),可以将**结构相似的弹窗合并为一个通用弹窗规范**,仅列出字段差异表
- 生成完成后,将文档路径更新到上方「已生成的模块 UI 设计文档」表格中

View File

@@ -0,0 +1,310 @@
# Fonrey 模块 UI 设计文档生成提示词
> **用途**:每次针对一个具体业务模块,填入变量后直接发给 AI输出该模块的标准化 UI 设计文档。
> **输出文件**`Project/fonrey/UI_DESIGN/{模块名}_UI.md`
---
## 使用方法
1. 复制下方「---PROMPT START---」到「---PROMPT END---」之间的全部内容
2. 将所有 `{{变量}}` 替换为本次模块的实际值
3. 把替换后的提示词发给 AI
---PROMPT START---
# 任务:为 {{模块名称}} 生成模块 UI 设计文档
## 你的角色
你是 Fonrey 房产经纪管理系统的 **UI/UX 架构师**,负责根据竞品截图和 PRD 功能描述,产出一份标准化的模块级 UI 设计文档。该文档将直接交给 AI Engineer 用于编码实现必须包含足够的细节Engineer 无需再问任何问题。
**注意**
以下所有的文档或图片是基于文档库的相对路径。
文档库的根路径为:`/mnt/d/Workspace/nexus`
---
## 全局设计约束(必须严格遵守)
> 所有设计决策必须符合 `Project/fonrey/UI_SYSTEM/UI_SYSTEM.md` 中的设计规范。核心约束如下:
- **技术栈**Tailwind CSS + HTMX + Alpine.js + Django HTML 模板(非 React/Vue/JSX
- **图标库**Heroicons v2Outline 24px 默认Solid 20px 强调Mini 16px 极密场景)
- **主色**Teal `#0F766E``primary-600`),所有颜色引用 Token禁止硬编码 Hex
- **圆角**`rounded-lg`8px为默认表格行/小组件用 `rounded-md`6px
- **表格行高**56px`h-14`
- **字体**Inter + PingFang SC正文 `text-sm`14px
- **焦点环**`focus-visible:ring-2 focus-visible:ring-primary-600/40`
- **桌面优先**≥1280px不做移动端适配
- **禁止独立 CSS 文件或 CSS-in-JS**:所有样式用 Tailwind utility class少量例外如 Flatpickr 覆盖样式)
- **组件实现参考**`Project/fonrey/UI_SYSTEM/组件规范设计.md`(含 20 个特殊组件的完整 HTML + Alpine.js 实现)
---
## 本次任务输入
### 1. 目标模块
**模块名称**{{模块名称}}
**模块描述**{{一句话描述模块核心功能}}
### 2. PRD 功能文档路径
```
{{PRD文件路径Project/fonrey/PRD/房源管理/房源管理模块PRD.md}}
```
请读取该文件,理解每个功能点的业务逻辑和验收标准。
### 3. DATA_MODEL 数据模型文档路径
```
{{DATA_MODEL文件路径Project/fonrey/DATA_MODEL/DATA_MODEL_PROPERTY.md}}
```
请读取该文件,理解该模块的数据模型以及字段命名。
### 4. 竞品参考截图
请读取以下截图文件作为视觉参考(所有截图均在 `Project/fonrey/screenshots/` 目录下):
{{截图列表,格式如下,每行一张:
- 功能名称:`Project/fonrey/screenshots/模块/截图名.png`
}}
### 5. MVP 优先级参考
请参考 `Project/fonrey/PRD/PRD_MVP.md`,在设计文档中标注每个页面/功能的优先级P0/P1/P2
---
## 输出格式要求
输出一份完整的 Markdown 文档,`Project/fonrey/UI_DESIGN/{模块名}_UI.md`,结构如下:
---
```markdown
# {{模块名称}} UI 设计文档
> **版本**v1.0 · **日期**{今日日期}
> **依赖规范**UI_SYSTEM.md v1.1 · 组件规范设计.md v1.0
> **PRD 来源**{PRD文件路径}
> **优先级**P0 功能在本文档中用 🔴 标注P1 用 🟡P2 用 ⚫
---
## 目录
(列出本文档所有章节)
---
## 1. 模块概述
### 1.1 功能范围
(从 PRD 提取本模块包含的所有功能,按优先级分组列表)
### 1.2 页面清单
(列出本模块所有页面/视图,每行包含:页面名称 | URL 模式建议 | 优先级 | 对应 PRD 章节)
### 1.3 用户角色与权限差异
(说明不同角色(经纪人/店长/管理员)在本模块的视图差异,如哪些字段/按钮对特定角色隐藏)
---
## 2. 页面设计规范
> 每个页面单独一节,按以下子结构输出。
### 2.N {页面名称}{优先级} 🔴/🟡/⚫)
#### 2.N.1 页面概述
- **URL**`/模块/页面/`
- **访问入口**:(从哪里进入此页面)
- **页面职责**:(一句话)
- **竞品参考截图**`{截图路径}`
#### 2.N.2 布局结构
(用文字描述页面整体布局,如:三栏布局、左侧边栏+右侧主内容区、全宽列表等)
```
┌──────────────────────────────────────────────────────────┐
│ 顶部区域Breadcrumb / 页面标题 / 主操作按钮) │
├──────────────────────────────────────────────────────────┤
│ 筛选区域(可折叠) │
├──────────────────────────────────────────────────────────┤
│ 工具栏(批量操作 / 排序切换 / 列设置 / 导出) │
├──────────────────────────────────────────────────────────┤
│ 主内容区(表格 / 详情卡片 / 表单) │
├──────────────────────────────────────────────────────────┤
│ 分页栏 │
└──────────────────────────────────────────────────────────┘
```
(根据实际页面调整此 ASCII 图)
#### 2.N.3 区域详细规范
> 每个区域独立描述,包含:组件类型、字段/按钮清单、交互逻辑
**[区域名称,如:搜索筛选区]**
| 属性 | 说明 |
|---|---|
| 组件 | (引用组件规范设计.md 中的组件名Date Range Picker |
| 展开/收起 | (是否支持折叠,默认状态) |
| 筛选字段 | (列出所有筛选字段及输入类型) |
| 联动逻辑 | (字段间的联动关系) |
| HTMX 行为 | (如:`hx-get="/api/xxx/" hx-trigger="change" hx-target="#table-body"` |
**[区域名称,如:数据表格]**
| 列名 | 数据类型 | 宽度 | 排序 | 特殊渲染 |
|---|---|---|---|---|
| (列名) | string/number/date/badge/... | fixed px 或 auto | (是/否) | Tag、趋势箭头、行内按钮 |
(补充表格交互说明:行点击跳转、批量选择、列固定等)
#### 2.N.4 使用的特殊组件
| 组件名 | 来源(组件规范设计.md 章节) | 用途 | 自定义说明 |
|---|---|---|---|
| (组件名) | §1 Data Table | 用于展示xxx列表 | (如果有与标准实现不同的地方,详细说明) |
#### 2.N.5 空状态设计
(描述列表/表格无数据时的展示方式,参考 UI_SYSTEM.md §6.3 空状态设计)
#### 2.N.6 Loading 状态
(描述数据加载中的骨架屏或加载指示方案)
---
## 3. 弹窗/抽屉设计规范
> 每个弹窗/抽屉独立一节,按以下结构输出。
### 3.N {弹窗/抽屉名称}{触发入口}
#### 3.N.1 触发方式
- **触发位置**:(如:房源详情页-调价链接)
- **组件类型**Modal Dialog / Drawer选一个说明选择理由
- **尺寸**Modal: max-w-sm/md/lg/xl/2xlDrawer: w-[480px]/w-[640px]
- **竞品截图**`{截图路径}`
#### 3.N.2 表单字段规范
| 字段名 | 组件类型 | 必填 | 校验规则 | 默认值/预填值 |
|---|---|---|---|---|
| (字段名) | Input/Select/Textarea/DatePicker/Toggle/TreeSelect/MultiTag/... | (是/否) | (规则描述) | (如有) |
#### 3.N.3 提交行为
- **提交方式**HTMX `hx-post` / `hx-put` / `hx-patch`
- **成功响应**:(如:关闭弹窗 + Toast "保存成功" + 刷新目标区域)
- **失败响应422**:(字段级错误提示)
- **HTMX 属性**:(完整写出 hx-post/hx-target/hx-swap/hx-on 等)
#### 3.N.4 使用的特殊组件
| 组件名 | 来源 | 用途 |
|---|---|---|
---
## 4. 交互状态规范
### 4.1 全局状态机(如有)
(如房源状态机:在售 → 暂缓 → 成交 → 下架,用状态流转图描述)
### 4.2 权限控制矩阵
(描述不同角色对本模块各操作的权限,如:删除只有管理员可见)
| 操作 | 经纪人 | 店长 | 管理员 |
|---|---|---|---|
### 4.3 HTMX 请求规范
(列出本模块所有 HTMX 请求包含触发事件、URL、target、swap 方式、Loading 行为)
| 操作 | hx-trigger | hx-get/post/... | hx-target | hx-swap | Loading |
|---|---|---|---|---|---|
---
## 5. 关键数据字段说明
(列出本模块所有需要后端支持的数据字段,便于 Engineer 与后端联调)
| 字段名(英文) | 显示名 | 数据类型 | 说明 |
|---|---|---|---|
---
## 6. 竞品截图对应关系
(将本模块所有参考截图按功能分类整理,说明截图对应设计文档的哪个章节)
| 截图路径 | 对应功能 | 对应文档章节 | 采纳的设计要点 |
|---|---|---|---|
---
## 7. 实现优先级与工期估算
| 页面/功能 | 优先级 | 特殊组件复杂度 | 工期估算(前端) |
|---|---|---|---|
---
## 8. 开放问题(待决策)
(列出设计过程中发现的、需要产品/后端确认的问题)
| # | 问题 | 影响范围 | 待确认方 |
|---|---|---|---|
```
---
## 额外要求
1. **PRD优先**:有截图的功能,以截图呈现的 UI 为补充说明以PRD 文字为主要参考;截图和 PRD 有冲突时以PRD为准并在文档中注明差异。
2. **组件引用**:每次使用特殊组件(如 Data Table、Tree Select、Drawer 等),必须在"使用的特殊组件"表格中引用组件规范设计.md 的对应章节编号,并说明如有差异的自定义部分。
3. **HTMX 落地**:每个需要异步更新的交互(筛选、分页、弹窗提交)必须写出完整的 HTMX 属性Engineer 可以直接复制使用。
4. **Alpine.js 分工**:说明哪些状态由 Alpine.js 管理(弹窗开关、选中状态、表单联动),哪些交互走 HTMX数据加载、表单提交
5. **禁止设计移动端**:所有布局仅针对 ≥1280px 桌面端。
6. **优先级标注**P0 功能用 🔴P1 用 🟡P2 用 ⚫,确保 Engineer 知道实现顺序。
7. **不要遗漏边界状态**:每个列表页必须包含空状态设计;每个表单必须包含校验失败状态;每个异步操作必须包含 Loading 状态。
---PROMPT END---
---
## 已生成的模块 UI 设计文档
| 模块 | 文件路径 | 生成日期 | 覆盖 PRD 版本 |
|---|---|---|---|
| (待填入) | | | |
---
## 变量填写示例(房源列表页)
```
{{模块名称}} → 房源管理
{{一句话描述模块核心功能}} → 管理房产经纪公司的二手房/租赁房源,支持录入、筛选、跟进、状态变更
{{PRD文件路径}} → Project/fonrey/PRD/房源管理/房源管理模块PRD.md
{{截图列表}} →
- 房源列表(二手&租赁):`Project/fonrey/screenshots/房源/全部房源.png`
- 房源列表(全部商铺):`Project/fonrey/screenshots/房源/全部商铺.png`
- 房源详情第1屏`Project/fonrey/screenshots/房源/房源详情1.png`
- 房源详情第2屏`Project/fonrey/screenshots/房源/房源详情2.png`
- 房源详情第3屏`Project/fonrey/screenshots/房源/房源详情3.png`
- 新增住宅表单:`Project/fonrey/screenshots/房源/增房/新增住宅.png`
- 调价弹窗:`Project/fonrey/screenshots/房源/调价.png`
- 调价记录弹窗:`Project/fonrey/screenshots/房源/调价记录.png`
- 房源状态变更:`Project/fonrey/screenshots/房源/房源状态变更.png`
- 跟进管理-全部:`Project/fonrey/screenshots/房源/跟进管理/全部.png`
- 跟进管理-写入跟进:`Project/fonrey/screenshots/房源/跟进管理/写入跟进.png`
- 相册管理:`Project/fonrey/screenshots/房源/增房/上传图片.png`
```
---
## 注意事项
- 单次提示词只针对**一个模块**,不要同时处理多个模块
- 对于同一模块内页面较多的情况(如房源管理有列表、详情、新增、跟进等多个页面),**全部包含在同一份文档中**,通过 `§2.N` 分节区分
- 弹窗数量较多时(如房源详情有 10+ 个编辑弹窗),可以将**结构相似的弹窗合并为一个通用弹窗规范**,仅列出字段差异表
- 生成完成后,将文档路径更新到上方「已生成的模块 UI 设计文档」表格中

View File

@@ -0,0 +1,72 @@
# 任务:为 {{模块}}生成模块 UI 静态原型
## 你的角色
你是 Fonrey 房产经纪管理系统的 **UI/UX 架构师**,负责根据竞品截图和 PRD 功能描述,产出一份标准化的模块级 UI 设计文档。该文档将直接交给 AI Engineer 用于编码实现必须包含足够的细节Engineer 无需再问任何问题。
**注意**
以下所有的文档或图片是基于文档库的相对路径。
文档库的根路径为:`/mnt/d/Workspace/nexus`
---
## 全局设计约束(必须严格遵守)
> 所有设计决策必须符合 `Project/fonrey/UI_SYSTEM/UI_SYSTEM.md` 中的设计规范。核心约束如下:
- **技术栈**Tailwind CSS + HTMX + Alpine.js + Django HTML 模板(非 React/Vue/JSX
- **图标库**Heroicons v2Outline 24px 默认Solid 20px 强调Mini 16px 极密场景)
- **主色**Teal `#0F766E``primary-600`),所有颜色引用 Token禁止硬编码 Hex
- **圆角**`rounded-lg`8px为默认表格行/小组件用 `rounded-md`6px
- **表格行高**56px`h-14`
- **字体**Inter + PingFang SC正文 `text-sm`14px
- **焦点环**`focus-visible:ring-2 focus-visible:ring-primary-600/40`
- **桌面优先**≥1280px不做移动端适配
- **禁止独立 CSS 文件或 CSS-in-JS**:所有样式用 Tailwind utility class少量例外如 Flatpickr 覆盖样式)
- **组件实现参考**`Project/fonrey/UI_SYSTEM/组件规范设计.md`(含 20 个特殊组件的完整 HTML + Alpine.js 实现)
**输入文件**
1. 【UI_SYSTEM】全局UI设计规范文档 `Project/fonrey/UI_SYSTEM/UI_SYSTEM.md`
2. 【现有原型页面】已完成的HTML页面作为视觉和代码参考基准
- `Project/fonrey/UI_SYSTEM/preview.html`
- `Project/fonrey/UI_DESIGN/客源列表_UI.html`
- `Project/fonrey/UI_DESIGN/客源详情_UI.html`
- `Project/fonrey/UI_DESIGN/新增客源_UI.html`
3. 【本次模块UI设计文档】本次需要实现的模块设计说明
- `Project/fonrey/UI_DESIGN/编辑客源_UI.md`
**输出文件**
- 【本次模块UI输出静态原型文件】
- `Project/fonrey/UI_DESIGN/编辑客源_UI.html`
### 强制约束(不可违反)
#### 一致性约束
- 颜色、字体、字号、圆角、阴影、间距等视觉变量,必须与 UI_SYSTEM 保持完全一致,不得自行创造新的变量
- 公共组件(导航栏、侧边栏、顶部栏、按钮、表单、卡片、标签等)的样式和结构,必须与现有原型页面中的实现保持一致
- 如果现有页面使用了 CSS 变量或特定 class 命名规范,本次输出必须沿用相同的规范
#### 布局约束
- 整体页面框架(如侧边栏宽度、顶栏高度、内容区边距)必须与现有原型页面保持一致
- 响应式断点策略(如有)需与已有页面对齐
#### 代码约束
- 输出单一 HTML 文件CSS 写在 `<style>` 标签内JS 写在 `<script>` 标签内
- 不引入任何外部依赖,除非现有原型页面已经使用了该依赖
- 类名、变量名的命名风格与现有代码保持一致
#### 执行步骤(按顺序执行)
1. 通读所有输入材料,识别 UI_SYSTEM 中的核心设计 token
2. 分析现有原型页面,提取公共组件的 HTML 结构和 CSS 实现
3. 阅读本次模块设计文档,理解页面结构、交互状态和内容层级
4. 以现有页面为外壳,将本次模块内容填入正确的内容区域
5. 对照设计文档逐项检查还原度,确认无遗漏后输出
#### 输出要求
- 直接输出完整可运行的 HTML 文件内容
- 页面中需要数据的地方使用合理的占位内容(不要留空)
- 交互状态hover、active、selected、disabled需在 CSS 中体现
- 输出完成后,列出你在本次实现中做出的所有设计假设或补充决策
#### 注意事项
- 如果设计文档与 UI_SYSTEM 存在冲突,以 UI_SYSTEM 为准,并告知我冲突点
- 如果设计文档描述不清晰,不要自行猜测,先列出疑问再继续

View File

@@ -0,0 +1,42 @@
# Project Task Board
## 项目状态总览
- 当前阶段MVP Phase 1
- 技术栈:[你的 tech stack 简述]
- 最后更新YYYY-MM-DD
---
## Phase 1 - MVPP0必须完成
### [模块名,如:用户认证]
- [ ] US-001 用户可以用邮箱注册账号
- 涉及文件:`/auth/register.tsx`, `userModel`
- 验收标准:表单校验通过 → 写入 DB → 跳转登录页
- [ ] US-002 用户可以登录并保持会话
- 涉及文件:`/auth/login.tsx`, `sessionModel`
- 验收标准JWT 生成 → localStorage 存储 → 保护路由生效
### [模块名,如:仪表盘]
- [ ] US-005 用户可以看到核心数据概览
- 涉及文件:`/dashboard/index.tsx`
- 验收标准:数据从 API 读取,与 UI 原型视觉一致
---
## Phase 2 - 增强功能P1MVP 后实现)
- [ ] US-010 用户可以导出数据为 CSV
- [ ] US-011 支持第三方登录Google OAuth
---
## Phase 3 - 锦上添花P2有时间再做
- [ ] US-020 动画和过渡效果优化
- [ ] US-021 深色模式支持
---
## 已完成
- [x] US-XXX xxxxxxx完成日期