Files
nexus/Project/fonrey/UI_DESIGN/房源管理/房源列表_UI.md
2026-04-26 19:50:01 +08:00

1380 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 房源列表 UI 设计文档
> **版本**v1.0 · **日期**2026-04-26
> **依赖规范**UI_SYSTEM.md v1.2 · 组件规范设计.md v1.0
> **PRD 来源**`Project/fonrey/PRD/房源管理/房源管理模块PRD.md` §5.1 房源列表
> **优先级**P0 功能在本文档中用 🔴 标注P1 用 🟡P2 用 ⚫
---
## 目录
1. [模块概述](#1-模块概述)
- 1.1 功能范围
- 1.2 页面清单
- 1.3 用户角色与权限差异
2. [页面设计规范](#2-页面设计规范)
- 2.1 房源列表主页
3. [Data Table 规范](#3-data-table-规范)
- 3.1 列定义
- 3.2 列状态变体
- 3.3 操作列
- 3.4 表格交互状态
4. [弹窗设计规范(列表页)](#4-弹窗设计规范列表页)
- 4.1 自定义列弹窗
5. [交互状态规范](#5-交互状态规范)
- 5.1 房源状态机
- 5.2 权限控制矩阵
- 5.3 HTMX 请求规范
6. [关键数据字段说明](#6-关键数据字段说明)
7. [竞品截图对应关系](#7-竞品截图对应关系)
8. [实现优先级与工期估算](#8-实现优先级与工期估算)
9. [开放问题(待决策)](#9-开放问题待决策)
---
## 1. 模块概述
### 1.1 功能范围
**P0 功能MVP 必须实现)🔴**
- 房源列表展示(出售 / 出租 / 未挂牌 / 成交房源 / 全部房源 五个一级 Tab
- 关键词搜索(房源编号、小区/学校名称、地址、业主主姓名、电话、钥匙编号等)
- 楼栋 / 单元 / 房号独立精确输入
- 多维度组合筛选(范围、区域、价格、面积、户型、楼层、标签、筛选、维护)
- 列表数据展示(含交易类型标签、状态 Badge、价格趋势箭头
- 批量操作(批量收藏、取消收藏、设置保护房、修改相关方、删除)
- 分页与每页条数控制(每页 20 条,可跳页)
- 新增房源主 CTA 按钮
**P1 功能(第一迭代)🟡**
- 已存搜索条件保存与快速调用
- 导出当前筛选结果Excel
- 自定义列表显示字段
- 智能排序(系统推荐)
- 海报视图切换
- 关注小区配置提示
- 重复房源检测
- 疑似问题号码房源查询
**P2 功能(路线图)⚫**
- 地图找房视图
- 全部商铺列表 Tab
- 全部写字楼列表 Tab
### 1.2 页面清单
| 页面名称 | URL 模式建议 | 优先级 | 对应 PRD 章节 |
|---|---|---|---|
| 房源列表(出售) | `/properties/?tab=for_sale` | P0 🔴 | §5.1, Story 2 |
| 房源列表(出租) | `/properties/?tab=for_rent` | P0 🔴 | §5.1, Story 2 |
| 房源列表(未挂牌) | `/properties/?tab=unlisted` | P0 🔴 | §5.1, Story 2 |
| 房源列表(成交房源) | `/properties/?tab=sold` | P0 🔴 | §5.1, Story 2 |
| 房源列表(全部房源) | `/properties/` | P0 🔴 | §5.1, Story 2 |
### 1.3 用户角色与权限差异
| 差异点 | 经纪人 | 店长 | 管理员 |
|---|---|---|---|
| 默认数据范围 | 仅自己名下(`seller_agent_id = me` | 本门店全部(`org_unit_id IN my_stores` | 全司所有 |
| 「与我相关」快捷筛选 | 可用(默认可能已激活) | 可用 | 可用 |
| 「我部门相关」快捷筛选 | 仅限自己所属部门 | 本门店 | 全司 |
| 批量删除 | 仅限自己名下房源 | 本门店房源 | 所有房源 |
| 修改相关方 | 不可操作他人房源 | 本门店范围 | 所有 |
| 重复房源检测链接 | 可见 | 可见 | 可见 |
| 「+ 新增房源」按钮 | 可见 | 可见 | 可见 |
| 导出按钮 | 仅自己数据 | 本门店数据 | 全量 |
| 业主电话 | 打码显示 | 打码显示(可申请查看) | 明文 |
---
## 2. 页面设计规范
### 2.1 房源列表主页P0 🔴)
#### 2.1.1 页面概述
- **URL**`/properties/`query params`tab`, `status`, `grade`, `page`, `q`, `sort`, `order` 等)
- **访问入口**:顶部全局导航栏「房源」菜单 → 默认进入全部房源列表
- **页面职责**:展示经纪人名下(或门店/全司)的房源列表,支持多维度搜索筛选、批量操作、状态快览
- **竞品参考截图**
- `Project/fonrey/screenshots/房源/房源列表.png`(出售 Tab 视图,主参考)
- `Project/fonrey/screenshots/房源/全部房源.png`(全部房源 Tab 视图)
#### 2.1.2 布局结构
```
┌──────────────────────────────────────────────────────────────────────┐
│ 一级 Tab 导航(出售 / 出租 / 未挂牌 / 成交房源 / 全部房源) │
│ 右侧:更多 ▾ | 重复房源 | 疑似问题号码房源 | + 新增房源 按钮 │
├──────────────────────────────────────────────────────────────────────┤
│ 搜索区域(关键词搜索 + 楼栋/单元/房号精确输入 + 地图找房入口) │
│ 关注小区配置提示条(可关闭) │
├──────────────────────────────────────────────────────────────────────┤
│ 快捷筛选行(范围:最新挂牌 / 最新降价 / 与我相关 / 我部门相关 / 收藏房源 / 超时未跟进)│
│ 区域筛选行(区域按钮组 + 地铁选项) │
│ 价格筛选行(售价/单价 + 预设区间 + 自定义区间) │
│ 面积筛选行(预设区间 + 自定义区间) │
│ 房型筛选行(室数 + 卫生间数量) │
│ 楼层筛选行(低/中/高/顶/底 + 自定义区间) │
│ 标签筛选行(速销/独家/有钥匙/电梯等) │
│ 筛选行(相关方/维护人/房屋现状/状态属性/装修朝向等下拉) │
│ 维护行(发布/实勘/核验/跟进带看/钥匙委托/维护完成度) │
│ 底部操作行(查询 / 重置 / 已存搜索条件 ▾ / 收起更多 ∧) │
├──────────────────────────────────────────────────────────────────────┤
│ 工具栏(房源海报 | 批量操作按钮 | 更多 ▾ | 共N条 | 导出 | 自定义列表 | 智能排序)│
├──────────────────────────────────────────────────────────────────────┤
│ 数据表格主体 │
├──────────────────────────────────────────────────────────────────────┤
│ 分页栏共N条 | 上一页 / 页码 / 下一页 / 每页20条 / 跳页) │
└──────────────────────────────────────────────────────────────────────┘
```
整体页面背景:`bg-neutral-50`
主内容区外层容器:`max-w-[1600px] mx-auto px-6 py-4`
各区块背景:`bg-white rounded-lg border border-neutral-200`
#### 2.1.3 区域详细规范
---
**[一级 Tab 导航区]**
| 属性 | 说明 |
|---|---|
| 组件 | Tab Navigation§10 Tab Navigationunderline 变体 |
| 位置 | 页面最顶部,紧贴全局顶导下方,无额外卡片容器 |
| Tab 项 | 出售(含数量 Badge/ 出租(含数量 Badge/ 未挂牌 / 成交房源 / 全部房源 |
| 激活样式 | `border-b-2 border-primary-600 text-primary-600 font-medium` |
| 非激活样式 | `text-neutral-500 hover:text-neutral-700` |
| 右侧内容 | 「更多 ▾」下拉菜单 + 「重复房源」链接 + 「疑似问题号码房源」链接 + 「+ 新增房源」主按钮 |
**右侧操作区Tab 栏右侧,绝对定位)**
```html
<div class="flex items-center gap-3 text-sm">
<!-- 更多下拉 -->
<div x-data="{ open: false }" class="relative">
<button @click="open = !open"
class="flex items-center gap-1 text-sm text-neutral-600
border border-neutral-300 rounded-lg px-3 py-1.5
hover:bg-neutral-50 transition-colors">
更多
<svg class="w-4 h-4"><!-- heroicon: chevron-down --></svg>
</button>
<div x-show="open" x-cloak
class="absolute right-0 top-full mt-1 w-40 bg-white shadow-lg
border border-neutral-200 rounded-lg z-50 py-1">
<a href="#" class="block px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-50">
海报视图
</a>
</div>
</div>
<!-- 重复房源P1 🟡) -->
<a href="/properties/duplicates/"
class="text-neutral-500 hover:text-neutral-700 text-sm">
重复房源
</a>
<!-- 疑似问题号码P1 🟡) -->
<a href="/properties/suspect-numbers/"
class="text-neutral-500 hover:text-neutral-700 text-sm">
疑似问题号码房源
</a>
<!-- 新增房源主 CTA -->
<a href="/properties/create/"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-600
hover:bg-primary-700 text-white text-sm font-medium rounded-lg
transition-colors">
<svg class="w-4 h-4"><!-- heroicon: plus --></svg>
新增房源
</a>
</div>
```
> **截图差异说明**:竞品截图中「+ 新增房源」为橙色按钮Fonrey 使用主色 Teal`bg-primary-600`),品牌差异,无需对齐竞品色。
---
**[搜索区域]**
| 属性 | 说明 |
|---|---|
| 组件 | 搜索输入框组合 |
| 容器 | `bg-white rounded-lg border border-neutral-200 px-4 py-3 mt-3` |
| 关键词搜索框 | 宽约 `flex-1 max-w-lg`,占位符「房源编号/小区/学校名称/地址/业主姓名/电话/钥匙编号等」 |
| 楼栋输入框 | 标签「楼栋」+ 独立 Input`w-24`,精确匹配 `block_no` |
| 单元输入框 | 标签「单元」+ 独立 Input`w-24`,精确匹配 `unit_no` |
| 房号输入框 | 标签「房号」+ 独立 Input + Heroicon `information-circle` 提示,`w-24`,精确匹配 `room_no` |
| 搜索按钮 | `bg-primary-600 text-white` Heroicon `magnifying-glass``w-9 h-9 rounded-lg` |
| 地图找房P2 ⚫)| 右侧浅色按钮「🗺 地图找房 →」P2 阶段置灰不可点击 |
```html
<div class="bg-white rounded-lg border border-neutral-200 px-4 py-3 mt-3"
x-data="{ showFilters: true, showMoreFilters: false }">
<!-- 搜索行 -->
<div class="flex items-center gap-2 flex-wrap">
<!-- 关键词搜索框 -->
<div class="relative flex-1 min-w-[280px] max-w-lg">
<input type="search"
name="q"
placeholder="房源编号/小区/学校名称/地址/业主姓名/电话/钥匙编号等"
class="w-full pl-3 pr-10 py-2 border border-neutral-300 rounded-lg
text-sm focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-primary-600/40"
hx-get="/properties/"
hx-trigger="keyup changed delay:300ms, search"
hx-target="#property-list-container"
hx-swap="innerHTML"
hx-include="[name='tab'],[name='status'],[name='page_size']">
</div>
<!-- 楼栋输入 -->
<div class="flex items-center gap-1.5">
<span class="text-sm text-neutral-500 whitespace-nowrap">楼栋</span>
<input type="text" name="block_no" placeholder="请输入"
class="w-20 px-2 py-2 border border-neutral-300 rounded-lg text-sm
focus-visible:ring-2 focus-visible:ring-primary-600/40">
</div>
<!-- 单元输入 -->
<div class="flex items-center gap-1.5">
<span class="text-sm text-neutral-500 whitespace-nowrap">单元</span>
<input type="text" name="unit_no" placeholder="请输入"
class="w-20 px-2 py-2 border border-neutral-300 rounded-lg text-sm
focus-visible:ring-2 focus-visible:ring-primary-600/40">
</div>
<!-- 房号输入 -->
<div class="flex items-center gap-1.5">
<span class="text-sm text-neutral-500 whitespace-nowrap">房号</span>
<div class="relative">
<input type="text" name="room_no" placeholder="请输入"
class="w-20 px-2 pr-6 py-2 border border-neutral-300 rounded-lg text-sm
focus-visible:ring-2 focus-visible:ring-primary-600/40">
<svg class="absolute right-1.5 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400 cursor-pointer"
title="支持精确匹配房间号码"><!-- heroicon: information-circle --></svg>
</div>
</div>
<!-- 搜索按钮 -->
<button type="submit"
class="bg-primary-600 hover:bg-primary-700 text-white
w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0
transition-colors">
<svg class="w-4 h-4"><!-- heroicon: magnifying-glass --></svg>
</button>
<!-- 地图找房P2置灰 -->
<button disabled
class="ml-auto flex items-center gap-1.5 px-3 py-2 text-sm
text-neutral-400 border border-neutral-200 rounded-lg
cursor-not-allowed bg-neutral-50">
<svg class="w-4 h-4"><!-- heroicon: map --></svg>
地图找房
<svg class="w-4 h-4"><!-- heroicon: arrow-right --></svg>
</button>
</div>
<!-- 搜索历史提示(仅非首次使用时展示) -->
<p class="mt-1.5 text-xs text-neutral-400">搜索历史在后台记录,请勿违规!</p>
<!-- 关注小区配置提示条P1 🟡,可关闭) -->
<div x-data="{ show: true }" x-show="show"
class="mt-2 flex items-center gap-2 px-3 py-2 bg-warning-50
border border-warning-200 rounded-lg text-sm">
<svg class="w-4 h-4 text-warning-600 flex-shrink-0"><!-- heroicon: information-circle --></svg>
<span class="text-warning-700">
<a href="/settings/watch-complexes/"
class="font-medium text-warning-700 hover:underline">配置关注小区</a>
(关注小区后,当该小区产生对应交易类型下的新上房源、降价房源时,系统将第一时间通知您,提升您的作业效率哦!)
</span>
<button @click="show = false" class="ml-auto text-neutral-400 hover:text-neutral-600">
<svg class="w-4 h-4"><!-- heroicon: x-mark --></svg>
</button>
</div>
<!-- 筛选区(可折叠) -->
<div x-show="showFilters"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
class="mt-3 space-y-2">
<!-- 各筛选行,见下方 -->
</div>
</div>
```
---
**[筛选区 - 详细规范]**
筛选区按行组织,每行格式:`[标签(w-8)] [选项组]`,标签使用 `text-xs text-neutral-400 whitespace-nowrap w-8`
**范围筛选行**
| 筛选项 | 类型 | 说明 |
|---|---|---|
| 最新挂牌 | Checkbox | 筛选最近挂牌房源(`listed_at` 降序默认) |
| 最新降价 | Checkbox | 最近有调价记录且降价 |
| 与我相关 | Checkbox + ⓘ | `seller_agent_id = me OR first_recorder_id = me` |
| 我部门相关 | Checkbox + ⓘ | 本门店员工相关房源 |
| 收藏房源 | 下拉 ▾ | 展开选择收藏集合(来自 `property_favorites` |
| 超时未跟进房源 | Checkbox | `last_followed_at < NOW() - 7d`(具体阈值待产品确认) |
```html
<div class="flex items-center gap-4 text-sm flex-wrap">
<span class="text-neutral-400 text-xs w-8 shrink-0">范围</span>
<label class="flex items-center gap-1.5 cursor-pointer text-neutral-600 hover:text-primary-600">
<input type="checkbox" name="scope_latest_listed" value="1"
class="w-4 h-4 rounded accent-primary-600"
hx-get="/properties/" hx-trigger="change"
hx-target="#property-list-container" hx-swap="innerHTML"
hx-include="closest form">
最新挂牌
</label>
<label class="flex items-center gap-1.5 cursor-pointer text-neutral-600 hover:text-primary-600">
<input type="checkbox" name="scope_latest_reduced" value="1"
class="w-4 h-4 rounded accent-primary-600"
hx-get="/properties/" hx-trigger="change"
hx-target="#property-list-container" hx-swap="innerHTML"
hx-include="closest form">
最新降价
</label>
<label class="flex items-center gap-1.5 cursor-pointer text-neutral-600 hover:text-primary-600">
<input type="checkbox" name="related_to_me" value="1"
class="w-4 h-4 rounded accent-primary-600"
hx-get="/properties/" hx-trigger="change"
hx-target="#property-list-container" hx-swap="innerHTML"
hx-include="closest form">
与我相关
<svg class="w-3.5 h-3.5 text-neutral-400"><!-- heroicon: information-circle --></svg>
</label>
<label class="flex items-center gap-1.5 cursor-pointer text-neutral-600 hover:text-primary-600">
<input type="checkbox" name="my_dept" value="1"
class="w-4 h-4 rounded accent-primary-600"
hx-get="/properties/" hx-trigger="change"
hx-target="#property-list-container" hx-swap="innerHTML"
hx-include="closest form">
我部门相关
<svg class="w-3.5 h-3.5 text-neutral-400"><!-- heroicon: information-circle --></svg>
</label>
<!-- 收藏房源下拉 -->
<div x-data="{ open: false }" class="relative">
<button @click="open = !open"
class="flex items-center gap-1 text-sm text-neutral-600 hover:text-primary-600">
收藏房源
<svg class="w-3 h-3"><!-- heroicon: chevron-down --></svg>
</button>
<div x-show="open" x-cloak
class="absolute left-0 top-full mt-1 w-40 bg-white shadow-lg
border border-neutral-200 rounded-lg z-50 py-1">
<label class="flex items-center gap-2 px-4 py-2 text-sm text-neutral-600 hover:bg-neutral-50 cursor-pointer">
<input type="checkbox" name="favorited" value="1" class="w-4 h-4 rounded accent-primary-600">
我的收藏
</label>
</div>
</div>
<label class="flex items-center gap-1.5 cursor-pointer text-neutral-600 hover:text-primary-600">
<input type="checkbox" name="overdue_follow" value="1"
class="w-4 h-4 rounded accent-primary-600"
hx-get="/properties/" hx-trigger="change"
hx-target="#property-list-container" hx-swap="innerHTML"
hx-include="closest form">
超时未跟进房源
</label>
</div>
```
**区域筛选行**
| 筛选项 | 类型 | 说明 |
|---|---|---|
| 地区(行政区) | Tag 按钮多选 | 宝山/嘉定/静安/闵行/普陀/松江/长宁等,多选,来自 `complexes.district` |
| 地铁 | 下拉 ▾ | 选择地铁线 + 站点(二级联动),来自楼盘数据 |
```html
<div class="flex items-start gap-2 text-sm flex-wrap">
<span class="text-neutral-400 text-xs w-8 shrink-0 mt-1">区域</span>
<div class="flex items-center gap-1.5 flex-wrap flex-1">
<!-- 地区 Tag 多选 -->
{% for district in districts %}
<button type="button"
:class="selectedDistricts.includes('{{ district.value }}')
? 'bg-primary-600 text-white border-primary-600'
: 'text-neutral-600 border-neutral-200 hover:border-primary-400 hover:text-primary-600'"
class="px-3 py-1 text-xs border rounded-md transition-colors"
@click="toggleDistrict('{{ district.value }}')">
{{ district.label }}
</button>
{% endfor %}
<!-- 地铁 -->
<div class="relative ml-2" x-data="{ open: false }">
<button @click="open = !open"
class="flex items-center gap-1 px-3 py-1 text-xs border border-neutral-200
rounded-md text-neutral-600 hover:border-primary-400 hover:text-primary-600">
地铁
<svg class="w-3 h-3"><!-- heroicon: chevron-down --></svg>
</button>
<!-- 地铁线 + 站点联动面板 -->
</div>
</div>
</div>
```
**价格筛选行**
| 筛选项 | 类型 | 说明 |
|---|---|---|
| 售价/单价 切换 | 单选 Tab | 出售 Tab 默认显示;出租 Tab 替换为「租价」 |
| 价格预设区间 | Tag 单选 | 200万以下 / 250-300万 / 300-400万 / 400-500万 / 500-700万 / 700-1000万 / 1000-1500万 / 1500-2000万 / 2000万以上 |
| 自定义区间 | 数字输入框 × 2 + 单位 | `最小值 ~ 最大值 万元` |
| 收起/展开 | 文字链接 | Alpine.js 控制显示部分预设区间 |
```html
<div class="flex items-start gap-2 text-sm flex-wrap">
<span class="text-neutral-400 text-xs w-8 shrink-0 mt-1">价格</span>
<div class="flex-1 space-y-1.5">
<!-- 售价/单价 切换(仅出售 Tab 显示) -->
<div class="flex items-center gap-1 mb-1"
x-show="activeTab !== 'for_rent'">
<button :class="priceMode === 'sale' ? 'text-primary-600 font-medium' : 'text-neutral-500'"
class="text-xs hover:text-primary-600 transition-colors"
@click="priceMode = 'sale'">售价</button>
<span class="text-neutral-300">|</span>
<button :class="priceMode === 'unit' ? 'text-primary-600 font-medium' : 'text-neutral-500'"
class="text-xs hover:text-primary-600 transition-colors"
@click="priceMode = 'unit'">单价</button>
</div>
<!-- 预设区间 Tag -->
<div class="flex items-center gap-1.5 flex-wrap">
<button class="px-3 py-1 text-xs bg-primary-600 text-white rounded-md border border-primary-600">
不限
</button>
<button class="px-3 py-1 text-xs border border-neutral-200 rounded-md text-neutral-600
hover:border-primary-400 hover:text-primary-600 transition-colors">
200万以下
</button>
<button class="px-3 py-1 text-xs border border-neutral-200 rounded-md text-neutral-600
hover:border-primary-400 hover:text-primary-600 transition-colors">
250-300万
</button>
<!-- ... 其他预设区间 ... -->
<button class="text-xs text-primary-600 hover:underline">收起 ∧</button>
</div>
<!-- 自定义区间 -->
<div class="flex items-center gap-1.5">
<input type="number" name="price_min" placeholder="最小值"
class="w-20 px-2 py-1 text-xs border border-neutral-300 rounded-md
focus-visible:ring-2 focus-visible:ring-primary-600/40">
<span class="text-neutral-400">~</span>
<input type="number" name="price_max" placeholder="最大值"
class="w-20 px-2 py-1 text-xs border border-neutral-300 rounded-md
focus-visible:ring-2 focus-visible:ring-primary-600/40">
<span class="text-xs text-neutral-500">万元</span>
</div>
</div>
</div>
```
**面积筛选行**
| 筛选项 | 类型 | 说明 |
|---|---|---|
| 面积预设区间 | Tag 单选 | 50m²以下 / 50-70m² / 70-90m² / 90-110m² / 110-130m² / 130-150m² / 150m²以上 |
| 自定义区间 | 数字输入框 × 2 + 单位「m²」 | 精确区间输入 |
**房型筛选行**
| 筛选项 | 类型 | 说明 |
|---|---|---|
| 室数 | Tag 多选 | 1室 / 2室 / 3室 / 4室 / 5室及以上对应 `bedroom_count` |
| 卫生间 | Tag 多选 | 1卫 / 2卫 / 3卫 / 4卫对应 `bathroom_count` |
| 收起/展开 | 文字链接 | 控制卫生间行显示 |
**楼层筛选行**
| 筛选项 | 类型 | 说明 |
|---|---|---|
| 楼层段 | Tag 多选 | 低层1-3层 / 中层4-9层 / 高层10层+ / 顶楼 / 底楼 |
| 自定义区间 | 数字输入框 × 2 + 单位「层」 | 精确楼层范围 |
**标签筛选行**
| 筛选项 | 类型 | 说明 |
|---|---|---|
| 标签 | Checkbox 多选 | 速销、独家、有钥匙、电梯、唯一、有照片、贷款、视频、AI视频、有VR、3D |
| 一键装换 | Checkbox | 快捷筛选 |
| 一般委托 | Checkbox | 快捷筛选 |
**筛选行(展开更多)**
下拉筛选项,使用 `<select>` 或 Multi-select每项触发 `hx-get="/properties/"`:
| 筛选项 | 组件类型 | 对应字段 |
|---|---|---|
| 相关方 | Multi-select人员选择器 | `seller_agent_id / first_recorder_id` |
| 维护人 | Multi-select人员选择器 | — |
| 房屋现状 | 下拉多选 | `house_status` |
| 状态/属性 | 下拉多选 | `status / attribute` |
| 装修/朝向 | 下拉多选 | `decoration / orientation` |
| 学校 | 下拉多选 | — |
| 等级 | Tag 多选 | `grade`A急迫/B较强/C一般 |
| 用途 | 下拉多选 | `usage_type` |
| 房本年限 | 下拉多选 | `ownership_years` |
| 唯一住房 | 单选 | `is_only_house` |
| 税费/贷款 | 下拉多选 | `tax_included / has_loan` |
| 建成年代 | 区间输入 | `built_year` |
| 产权性质 | 下拉多选 | `ownership_nature` |
| 挂牌类型 | 下拉多选 | `listing_type`(来源) |
| 来源 | 下拉多选 | `source` |
| 看房时间 | 下拉多选 | `viewing_time` |
| 审核 | 下拉单选 | 审核状态 |
| 保护房 | Checkbox | `property_protections` |
**维护行**
| 筛选项 | 组件类型 | 说明 |
|---|---|---|
| 发布 | 下拉 ▾ | 发布状态(已发布/未发布) |
| 实勘 | 下拉 ▾ | 有/无实勘记录 |
| 核验 | 下拉 ▾ | 核验状态 |
| 跟进/带看 | 下拉 ▾ | 最近跟进时间段筛选 |
| 钥匙/委托 | 下拉 ▾ | 有/无钥匙,有/无委托 |
| 维护完成度 | 下拉 ▾ | 完成度区间筛选(`completeness_score` |
**底部操作行**
```html
<div class="flex items-center gap-3 mt-3 pt-3 border-t border-neutral-100">
<!-- 查询按钮 -->
<button type="submit"
class="px-5 py-2 bg-primary-600 hover:bg-primary-700 text-white
text-sm font-medium rounded-lg transition-colors"
hx-get="/properties/"
hx-target="#property-list-container"
hx-swap="innerHTML"
hx-include="closest form">
查询
</button>
<!-- 重置 -->
<button type="reset"
class="px-5 py-2 text-sm text-neutral-600 border border-neutral-300
rounded-lg hover:bg-neutral-50 transition-colors"
hx-get="/properties/"
hx-target="#property-list-container"
hx-swap="innerHTML">
重置
</button>
<!-- 已存搜索条件P1 🟡) -->
<div x-data="{ open: false }" class="relative">
<button @click="open = !open"
class="flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700">
<svg class="w-4 h-4"><!-- heroicon: bookmark --></svg>
已存搜索条件
<svg class="w-3 h-3"><!-- heroicon: chevron-down --></svg>
</button>
<!-- 已存搜索下拉列表 -->
</div>
<!-- 收起更多 -->
<button @click="showFilters = !showFilters"
class="ml-auto text-sm text-neutral-500 hover:text-primary-600
flex items-center gap-1">
<span x-text="showFilters ? '收起更多 ∧' : '展开更多 '"></span>
</button>
</div>
```
---
**[工具栏区]**
| 属性 | 说明 |
|---|---|
| 组件 | Toolbar§4 Toolbar |
| 容器 | `flex items-center gap-2 px-4 py-2.5 bg-white border-b border-neutral-100 mt-3 rounded-t-lg` |
| 左侧 | 「房源海报」视图切换按钮 + 批量操作按钮组(勾选时激活)+ 更多 ▾ + 总条数文字 |
| 右侧 | 导出按钮 + 自定义列表按钮 + 智能排序按钮P1 🟡) |
```html
<div class="flex items-center gap-2 px-4 py-2.5 bg-white border border-neutral-200
rounded-t-lg mt-3"
x-data="{ selectedCount: 0 }">
<!-- 房源海报切换P1 🟡) -->
<button class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-neutral-600
border border-neutral-300 rounded-md hover:bg-neutral-50">
<svg class="w-4 h-4"><!-- heroicon: squares-2x2 --></svg>
房源海报
</button>
<!-- 批量操作(勾选 ≥1 条后激活) -->
<button :disabled="selectedCount === 0"
:class="selectedCount > 0
? 'text-neutral-700 hover:bg-neutral-100 border-neutral-300 cursor-pointer'
: 'text-neutral-300 border-neutral-200 cursor-not-allowed'"
class="px-3 py-1.5 text-sm border rounded-md transition-colors"
@click="selectedCount > 0 && $dispatch('open-batch-favorite-modal')">
批量收藏
</button>
<button :disabled="selectedCount === 0"
:class="selectedCount > 0
? 'text-neutral-700 hover:bg-neutral-100 border-neutral-300 cursor-pointer'
: 'text-neutral-300 border-neutral-200 cursor-not-allowed'"
class="px-3 py-1.5 text-sm border rounded-md transition-colors">
取消收藏
</button>
<button :disabled="selectedCount === 0"
:class="selectedCount > 0
? 'text-neutral-700 hover:bg-neutral-100 border-neutral-300 cursor-pointer'
: 'text-neutral-300 border-neutral-200 cursor-not-allowed'"
class="px-3 py-1.5 text-sm border rounded-md transition-colors">
设置保护房
</button>
<button :disabled="selectedCount === 0"
:class="selectedCount > 0
? 'text-neutral-700 hover:bg-neutral-100 border-neutral-300 cursor-pointer'
: 'text-neutral-300 border-neutral-200 cursor-not-allowed'"
class="px-3 py-1.5 text-sm border rounded-md transition-colors">
修改相关方
</button>
<!-- 更多批量操作 ▾ -->
<div x-data="{ open: false }" class="relative">
<button @click="open = !open"
:disabled="selectedCount === 0"
class="px-3 py-1.5 text-sm border border-neutral-300 rounded-md
text-neutral-700 hover:bg-neutral-100 flex items-center gap-1">
更多
<svg class="w-3 h-3"><!-- heroicon: chevron-down --></svg>
</button>
<div x-show="open" x-cloak
class="absolute left-0 top-full mt-1 w-32 bg-white shadow-lg
border border-neutral-200 rounded-lg z-50 py-1">
<button class="w-full text-left px-4 py-2 text-sm text-danger-600 hover:bg-danger-50">
删除
</button>
</div>
</div>
<!-- 总条数 -->
<span class="text-sm text-neutral-500 ml-1">
<strong class="text-neutral-800">{{ total_count }}</strong>
</span>
<!-- 已选提示 -->
<span x-show="selectedCount > 0" class="text-sm text-primary-600 ml-1">
已选 <span x-text="selectedCount"></span>
</span>
<!-- 右侧工具 -->
<div class="flex items-center gap-2 ml-auto">
<!-- 导出P1 🟡) -->
<button hx-post="/properties/export/"
hx-trigger="click"
hx-vals='{"format": "excel"}'
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-neutral-600
border border-neutral-300 rounded-md hover:bg-neutral-50">
<svg class="w-4 h-4"><!-- heroicon: arrow-down-tray --></svg>
导出
</button>
<!-- 自定义列表P1 🟡) -->
<button @click="$dispatch('open-column-settings')"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-neutral-600
border border-neutral-300 rounded-md hover:bg-neutral-50">
<svg class="w-4 h-4"><!-- heroicon: adjustments-horizontal --></svg>
自定义列表
</button>
<!-- 智能排序P1 🟡) -->
<button class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-neutral-600
border border-neutral-300 rounded-md hover:bg-neutral-50">
<svg class="w-4 h-4"><!-- heroicon: sparkles --></svg>
智能排序
</button>
</div>
</div>
```
---
**[数据表格]**
| 属性 | 说明 |
|---|---|
| 组件 | Data Table§1 Data Table |
| 容器 id | `property-list-container`HTMX swap 目标),内嵌 `property-table-body` |
| 行高 | 56px`h-14`),含多行标签时允许 auto 高度,最小 56px |
| 横向滚动 | `overflow-x-auto` 包裹 `<table>`,宽屏 ≥1280px 尽量全列展示 |
详细列规范见 §3.1。
---
**[分页栏]**
| 属性 | 说明 |
|---|---|
| 组件 | Pagination§2 Pagination |
| 位置 | 表格下方 `mt-0 flex items-center justify-between px-4 py-3 border-t border-neutral-100` |
| 左侧 | 总条数「共 N 条」 |
| 中间 | 页码导航:`← 上一页 [1] [2] [3] [4] [5] … [N] 下一页 →` |
| 右侧 | 每页条数「20条/页 ▾」选项20/50/100+ 跳页「跳至」+ 输入框 + 「页」 |
| HTMX | `hx-get="/properties/" hx-vals='{"page": N}' hx-target="#property-list-container" hx-swap="innerHTML" hx-include="closest form"` |
| 当前页 | `bg-primary-600 text-white rounded-md w-8 h-8 font-medium` |
| 非当前页 | `text-neutral-600 hover:bg-neutral-100 rounded-md w-8 h-8` |
#### 2.1.4 使用的特殊组件
| 组件名 | 来源(§章节) | 用途 | 自定义说明 |
|---|---|---|---|
| Data Table | §1 Data Table | 房源数据主体展示 | 房源名称列含交易类型 Badge + 速卖标签,行高 56px |
| Pagination | §2 Pagination | 底部分页控件 | 含跳页输入框,与标准实现一致 |
| Column Visibility Panel | §3 Column Visibility Panel | 自定义列表字段选择P1 | 触发按钮为「自定义列表」文字 + 图标 |
| Toolbar | §4 Toolbar | 批量操作 + 导出 + 统计 | 批量按钮默认 disabledselectedCount > 0 激活 |
| Export Button | §5 Export Button | 导出 ExcelP1 | HTMX `hx-post` 异步触发 Celery 任务 |
| Tab Navigation | §10 Tab Navigation | 一级 Tab出售/出租等)切换 | Underline 变体,右侧含操作区 |
| Date Range Picker | §9 Date Range Picker | 挂牌日期、跟进时间筛选 | 集成 Flatpickrrange 模式 |
| Multi-select Tag Input | §17 Multi-select Tag Input | 相关方、学校等多选筛选 | 展开更多筛选行内使用 |
| Modal Dialog | §7 Modal Dialog | 自定义列弹窗 | 见 §4.1 规范 |
#### 2.1.5 空状态设计
**筛选无结果(最常见场景)**
```html
<div class="flex flex-col items-center justify-center py-20 text-center">
<svg class="w-12 h-12 text-neutral-300 mb-4"><!-- heroicon: home --></svg>
<p class="text-neutral-500 text-sm font-medium">暂无房源</p>
<p class="text-neutral-400 text-xs mt-1">当前筛选条件下没有房源,尝试调整筛选条件</p>
<button class="mt-4 text-sm text-primary-600 hover:underline"
hx-get="/properties/"
hx-target="#property-list-container"
hx-swap="innerHTML">
清空筛选条件
</button>
</div>
```
**首次进入无数据(无任何房源)**
```html
<div class="flex flex-col items-center justify-center py-24 text-center">
<svg class="w-16 h-16 text-neutral-200 mb-4"><!-- heroicon: building-office --></svg>
<p class="text-neutral-600 text-base font-medium">还没有房源</p>
<p class="text-neutral-400 text-sm mt-1.5">开始录入第一套房源</p>
<a href="/properties/create/"
class="mt-5 inline-flex items-center gap-1.5 px-4 py-2
bg-primary-600 hover:bg-primary-700 text-white text-sm
font-medium rounded-lg transition-colors">
<svg class="w-4 h-4"><!-- heroicon: plus --></svg>
新增房源
</a>
</div>
```
#### 2.1.6 Loading 状态
**筛选/分页触发 HTMX 请求期间**`#property-list-container` 内显示骨架屏:
```html
<!-- 骨架屏8 行占位 -->
<div class="htmx-indicator animate-pulse space-y-0
rounded-b-lg border border-t-0 border-neutral-200 overflow-hidden bg-white">
{% for i in "12345678" %}
<div class="flex items-center gap-4 px-4 h-14 border-b border-neutral-100 last:border-0">
<div class="w-4 h-4 bg-neutral-200 rounded flex-shrink-0"></div>
<div class="h-4 bg-neutral-200 rounded w-40"></div>
<div class="h-4 bg-neutral-200 rounded w-8 ml-2"></div>
<div class="h-4 bg-neutral-200 rounded w-8 ml-2"></div>
<div class="h-4 bg-neutral-200 rounded w-20 ml-4"></div>
<div class="h-4 bg-neutral-200 rounded w-16 ml-2"></div>
<div class="h-4 bg-neutral-200 rounded w-16 ml-2"></div>
<div class="h-4 bg-neutral-200 rounded w-12 ml-2"></div>
<div class="h-4 bg-neutral-200 rounded w-12 ml-2"></div>
<div class="ml-auto h-4 bg-neutral-200 rounded w-24"></div>
</div>
{% endfor %}
</div>
```
---
## 3. Data Table 规范
> 引用基础规范:`组件规范设计.md` §1 Data Table
> 本章描述房源列表页 Data Table 的**具体实例化配置**。
### 3.1 列定义(全部房源视图,默认显示列)
表格容器:`<table class="min-w-full divide-y divide-neutral-200">`
表头行:`<thead class="bg-neutral-50">`,列头单元格统一样式:`px-4 py-3 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide whitespace-nowrap`
数据行:`<tr class="hover:bg-neutral-50 transition-colors" style="height:56px">`
| # | 列名 | 数据字段 | 列宽 | 对齐 | 可排序 | 特殊渲染说明 |
|---|---|---|---|---|---|---|
| 1 | (复选框) | — | `w-10`40px`px-4` | 居中 | 否 | 全选/单选 `<input type="checkbox">`Alpine.js `selected[]` 数组 |
| 2 | 房源名称 | `complex_name` + `block_no` + `unit_no` + `room_no` + 交易类型标签 + 速卖/电梯等标签 | `min-w-[200px] max-w-[280px]` | 左对齐 | 否 | 蓝色链接跳详情;名称行下方渲染标签组(见 §3.2「切换展示」链接P1 🟡) |
| 3 | 楼栋 | `block_no` | `w-16`64px | 左对齐 | 否 | 纯文字;无数据显示 `-` |
| 4 | 单元 | `unit_no` | `w-16`64px | 左对齐 | 否 | 纯文字;无数据显示 `-` |
| 5 | 房号 | `room_no` | `w-16`64px | 左对齐 | 否 | 纯文字;无数据显示 `-` |
| 6 | 区域板块 | `district` + `business_area` | `min-w-[100px]` | 左对齐 | 否 | 格式:`嘉定 丰庄`,两行或空格分隔 |
| 7 | 状态 | `status` | `w-16`64px | 左对齐 | 否 | Status Badge见 §3.2 |
| 8 | 售价(万) | `sale_price` | `w-24`96px | 右对齐 | **是** | 价格数字 + 价格变动趋势箭头(见 §3.2);出租 Tab 此列隐藏 |
| 9 | 单价(元/m² | `sale_unit_price` | `w-28`112px | 右对齐 | **是** | 数字,格式化千分位;出租 Tab 此列隐藏 |
| 10 | 租价(元/月) | `rent_price` | `w-28`112px | 右对齐 | **是** | 数字,格式化千分位;出售 Tab 此列隐藏 |
| 11 | 面积 | `area` | `w-20`80px | 右对齐 | **是** | 保留1位小数`81.3` |
| 12 | 户型 | `bedroom_count` + `living_room_count` | `w-16`64px | 左对齐 | 否 | 格式:`3/1/1`(室/厅/卫);简显为 `X室X厅` |
| 13 | 楼层 | `floor` + `total_floors` | `w-16`64px | 左对齐 | 否 | 格式:`4/6`;无数据显示 `-` |
| 14 | 朝向 | `orientation` | `w-12`48px | 左对齐 | 否 | 中文显示:南北/东南等;无数据显示 `-` |
| 15 | 挂牌日期 | `listed_at` | `w-28`112px | 左对齐 | **是** | 格式:`YYYY-MM-DD` |
| 16 | 房源最后跟进日 | `last_followed_at` | `w-28`112px | 左对齐 | **是**(默认降序) | 格式:`YYYY-MM-DD`;超过 30 天字色 `text-danger-600` |
> **全部房源 Tab** 与**出售/出租 Tab** 的差异:全部房源 Tab 同时显示「售价」和「租价」列,部分行会有一列为 `-`,属正常显示。
> **`whitespace-nowrap` 要求**:以下列的数据单元格(`<td>`)必须加 `whitespace-nowrap`,防止内容折行:状态、售价、单价、租价、户型、楼层、朝向、挂牌日期、最后跟进日期。
> **自定义列P1 🟡)**用户通过「自定义列表」弹窗§4.1)选择显示字段,可选字段包括:单价、等级、属性、装修、建成年代、来源、相关方、维护完成度等。
**表格 HTML 结构(关键片段)**
```html
<div id="property-list-container">
<div class="rounded-b-lg border border-t-0 border-neutral-200 overflow-hidden bg-white">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-neutral-200">
<thead class="bg-neutral-50">
<tr>
<th class="w-10 px-4 py-3">
<input type="checkbox" id="select-all"
class="w-4 h-4 rounded accent-primary-600"
@change="toggleAll($event)">
</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-neutral-500
uppercase tracking-wide whitespace-nowrap min-w-[200px]">
房源名称
</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-neutral-500
uppercase tracking-wide whitespace-nowrap w-16">
楼栋
</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-neutral-500
uppercase tracking-wide whitespace-nowrap w-16">
单元
</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-neutral-500
uppercase tracking-wide whitespace-nowrap w-16">
房号
</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-neutral-500
uppercase tracking-wide whitespace-nowrap min-w-[100px]">
区域板块
</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-neutral-500
uppercase tracking-wide whitespace-nowrap w-16">
状态
</th>
<!-- 售价列(含排序,出租 Tab 隐藏) -->
<th class="px-4 py-3 text-right text-xs font-semibold text-neutral-500
uppercase tracking-wide whitespace-nowrap w-24 cursor-pointer
hover:bg-neutral-100 select-none"
x-show="activeTab !== 'for_rent'"
hx-get="/properties/"
hx-vals='{"sort": "sale_price", "order": "{{ sort_order_toggle }}"}'
hx-target="#property-list-container"
hx-swap="innerHTML"
hx-include="closest form">
售价(万)
<svg class="inline w-4 h-4 text-neutral-300 ml-0.5"><!-- heroicon: chevron-up-down --></svg>
</th>
<!-- 其他列头... -->
<th class="px-4 py-3 text-left text-xs font-semibold text-neutral-500
uppercase tracking-wide whitespace-nowrap w-28 cursor-pointer
hover:bg-neutral-100 select-none"
hx-get="/properties/"
hx-vals='{"sort": "last_followed_at", "order": "{{ sort_order_toggle }}"}'
hx-target="#property-list-container"
hx-swap="innerHTML"
hx-include="closest form">
房源最后跟进日
<svg class="inline w-4 h-4 text-primary-600 ml-0.5"><!-- 当前排序列chevron-down --></svg>
</th>
</tr>
</thead>
<tbody id="property-table-body" class="divide-y divide-neutral-100 bg-white">
{% for prop in properties %}
<tr class="hover:bg-neutral-50 transition-colors"
style="height: 56px"
:class="selected.includes('{{ prop.id }}') ? 'bg-primary-50 hover:bg-primary-100' : ''">
<!-- 复选框 -->
<td class="w-10 px-4">
<input type="checkbox" :value="'{{ prop.id }}'"
class="w-4 h-4 rounded accent-primary-600"
x-model="selected">
</td>
<!-- 房源名称 -->
<td class="px-4 py-2 min-w-[200px]">
<div class="flex flex-col gap-0.5">
<!-- 交易类型标签 + 名称 -->
<div class="flex items-center gap-1.5 flex-wrap">
<!-- 交易类型 Badge -->
<span class="{{ prop.transaction_type_class }} text-[10px] px-1.5 py-0.5 rounded font-medium flex-shrink-0">
{{ prop.transaction_type_display }}
</span>
<!-- 房源名称链接 -->
<a href="/properties/{{ prop.id }}/"
class="text-info-600 hover:underline font-medium text-sm truncate max-w-[180px]">
{{ prop.complex_name }}{{ prop.room_display }}
</a>
</div>
<!-- 速卖/电梯/视频等标签行 -->
<div class="flex items-center gap-1 flex-wrap">
{% for tag in prop.display_tags %}
<span class="text-[10px] px-1.5 py-0.5 rounded-sm {{ tag.style_class }}">
{{ tag.name }}
</span>
{% endfor %}
<!-- 满五等特殊标签 -->
{% if prop.ownership_label %}
<span class="text-[10px] px-1.5 py-0.5 text-success-600 bg-success-50 rounded-sm font-medium">
{{ prop.ownership_label }}
</span>
{% endif %}
</div>
</div>
</td>
<!-- 楼栋 -->
<td class="px-4 py-2 text-sm text-neutral-700 w-16">{{ prop.block_no|default:"-" }}</td>
<!-- 单元 -->
<td class="px-4 py-2 text-sm text-neutral-700 w-16">{{ prop.unit_no|default:"-" }}</td>
<!-- 房号 -->
<td class="px-4 py-2 text-sm text-neutral-700 w-16">{{ prop.room_no|default:"-" }}</td>
<!-- 区域板块 -->
<td class="px-4 py-2 text-sm text-neutral-700 min-w-[100px]">
<div class="flex flex-col">
<span>{{ prop.district }}</span>
<span class="text-neutral-400 text-xs">{{ prop.business_area }}</span>
</div>
</td>
<!-- 状态 -->
<td class="px-4 py-2 w-16">
<span class="text-xs px-2 py-0.5 rounded-full font-medium {{ prop.status_badge_class }}">
{{ prop.status_display }}
</span>
</td>
<!-- 售价(含价格趋势箭头) -->
<td class="px-4 py-2 text-right w-24" x-show="activeTab !== 'for_rent'">
{% if prop.sale_price %}
<div class="flex flex-col items-end gap-0.5">
<span class="text-sm font-medium text-neutral-800">{{ prop.sale_price }}</span>
{% if prop.price_change_direction == 'down' %}
<span class="text-[10px] text-danger-600">
↓ {{ prop.price_change_display }}
</span>
{% elif prop.price_change_direction == 'up' %}
<span class="text-[10px] text-success-600">
↑ {{ prop.price_change_display }}
</span>
{% endif %}
</div>
{% else %}-{% endif %}
</td>
<!-- 单价 -->
<td class="px-4 py-2 text-right w-28" x-show="activeTab !== 'for_rent'">
<span class="text-sm text-neutral-700">{{ prop.sale_unit_price|default:"-" }}</span>
</td>
<!-- 租价 -->
<td class="px-4 py-2 text-right w-28" x-show="activeTab !== 'for_sale'">
<span class="text-sm text-neutral-700">
{% if prop.rent_price %}{{ prop.rent_price }}{% else %}-{% endif %}
</span>
</td>
<!-- 面积 -->
<td class="px-4 py-2 text-right w-20">
<span class="text-sm text-neutral-700">{{ prop.area }}</span>
</td>
<!-- 户型 -->
<td class="px-4 py-2 w-16">
<span class="text-sm text-neutral-700">{{ prop.layout_display }}</span>
</td>
<!-- 楼层 -->
<td class="px-4 py-2 w-16">
<span class="text-sm text-neutral-700">
{% if prop.floor %}{{ prop.floor }}/{{ prop.total_floors }}{% else %}-{% endif %}
</span>
</td>
<!-- 朝向 -->
<td class="px-4 py-2 w-12">
<span class="text-sm text-neutral-700">{{ prop.orientation_display|default:"-" }}</span>
</td>
<!-- 挂牌日期 -->
<td class="px-4 py-2 w-28">
<span class="text-sm text-neutral-700">{{ prop.listed_at|date:"Y-m-d"|default:"-" }}</span>
</td>
<!-- 最后跟进日 -->
<td class="px-4 py-2 w-28">
<span class="text-sm {% if prop.follow_overdue %}text-danger-600{% else %}text-neutral-700{% endif %}">
{{ prop.last_followed_at|date:"Y-m-d"|default:"-" }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
```
### 3.2 列状态变体
**交易类型标签(房源名称列内,蓝/橙色 Badge**
| `property_type` + `status` 场景 | 显示文字 | 样式 |
|---|---|---|
| 出售中(`for_sale` | 买卖 | `bg-danger-600 text-white text-[10px] px-1.5 py-0.5 rounded` |
| 出租中(`for_rent` | 租赁 | `bg-info-600 text-white text-[10px] px-1.5 py-0.5 rounded` |
| 出售+出租(`for_sale_rent` | 租售 | 同时展示「买卖」和「租赁」两个 Badge |
**房源附加标签(名称行下方标签组)**
| 标签类型 | 来源 | 样式 |
|---|---|---|
| 满五 | `ownership_years` 含「满五」 | `bg-success-50 text-success-600 text-[10px] px-1.5 py-0.5 rounded-sm font-medium` |
| 电梯 | `has_elevator = true` 且有电梯 | `bg-neutral-100 text-neutral-600 text-[10px] px-1.5 py-0.5 rounded-sm` |
| 视频 | `property_tags` 含视频标签 | `bg-neutral-100 text-neutral-600 text-[10px] px-1.5 py-0.5 rounded-sm` |
| 私(私盘) | `attribute = 'private'` | `bg-warning-50 text-warning-700 text-[10px] px-1.5 py-0.5 rounded-sm font-medium` |
| 速销 | `property_tags` 含速销标签 | `bg-danger-50 text-danger-600 text-[10px] px-1.5 py-0.5 rounded-sm` |
| 独家 | `property_tags` 含独家标签 | `bg-primary-50 text-primary-600 text-[10px] px-1.5 py-0.5 rounded-sm` |
**状态 Badge状态列**
| `status` 值 | 显示文字 | 样式 |
|---|---|---|
| `for_sale` | 出售 | `bg-success-50 text-success-700 text-xs px-2 py-0.5 rounded-full` |
| `for_rent` | 出租 | `bg-info-50 text-info-600 text-xs px-2 py-0.5 rounded-full` |
| `for_sale_rent` | 租售 | `bg-primary-50 text-primary-700 text-xs px-2 py-0.5 rounded-full` |
| `suspended` | 暂缓 | `bg-neutral-100 text-neutral-500 text-xs px-2 py-0.5 rounded-full` |
| `sold_elsewhere` | 他售 | `bg-warning-50 text-warning-600 text-xs px-2 py-0.5 rounded-full` |
| `rented_elsewhere` | 他租 | `bg-warning-50 text-warning-600 text-xs px-2 py-0.5 rounded-full` |
| `sold` | 成交 | `bg-neutral-200 text-neutral-600 text-xs px-2 py-0.5 rounded-full` |
| `unlisted` | 未挂牌 | `bg-neutral-100 text-neutral-400 text-xs px-2 py-0.5 rounded-full` |
**价格趋势箭头(售价列)**
```html
<!-- 价格下降(最近一次调价为降价) -->
<div class="flex flex-col items-end">
<span class="text-sm font-medium text-neutral-800">275</span>
<span class="text-[10px] text-danger-600 flex items-center gap-0.5">
<svg class="w-3 h-3"><!-- heroicon: arrow-down --></svg>
</span>
</div>
<!-- 价格上涨 -->
<div class="flex flex-col items-end">
<span class="text-sm font-medium text-neutral-800">650</span>
<span class="text-[10px] text-success-600 flex items-center gap-0.5">
<svg class="w-3 h-3"><!-- heroicon: arrow-up --></svg>
</span>
</div>
```
> **PRD 补充说明**竞品截图中「全部房源」Tab 显示的价格趋势为小型下箭头(`↓`)贴在价格数字下方。设计为:降价→ `text-danger-600` 下箭头;涨价 → `text-success-600` 上箭头;未变化 → 不显示箭头。
**排序列头(售价/单价/面积/挂牌日期/最后跟进日)**
```html
<th class="px-4 py-3 text-right text-xs font-semibold text-neutral-500 uppercase
tracking-wide cursor-pointer hover:bg-neutral-100 select-none whitespace-nowrap"
hx-get="/properties/"
:hx-vals="JSON.stringify({sort: 'sale_price', order: currentSalePriceOrder})"
hx-target="#property-list-container"
hx-swap="innerHTML"
hx-include="closest form">
售价(万)
<!-- 未排序 -->
<svg class="inline w-4 h-4 text-neutral-300 ml-0.5"><!-- heroicon: chevron-up-down --></svg>
<!-- 升序激活(隐藏上方) -->
<!-- 降序激活(隐藏下方) -->
</th>
```
**行选中态**
```html
<tr class="hover:bg-neutral-50 transition-colors" style="height: 56px"
:class="selected.includes(prop.id) ? 'bg-primary-50 hover:bg-primary-100' : ''">
```
### 3.3 操作列
MVP 阶段房源列表无独立操作列(行点击跳转到详情页即可);点击「房源名称」链接跳转。
> **注**:竞品截图中房源列表最右侧无固定操作列按钮,操作通过 checkbox 批量选中后在工具栏操作,或直接点击行跳详情。本设计遵循此交互模式,**不添加操作列**,降低信息密度。
### 3.4 表格交互状态
| 状态 | 触发场景 | 视觉表现 |
|---|---|---|
| 默认(无选中) | 页面加载完毕 | 所有行 `bg-white`hover 时 `bg-neutral-50` |
| 行选中 | 勾选复选框 | `bg-primary-50 hover:bg-primary-100`;工具栏批量操作按钮激活 |
| 全选 | 点击表头复选框 | 当前页所有行选中;表头 checkbox `indeterminate``checked` |
| LoadingHTMX 请求中) | 筛选/分页/排序触发 | 骨架屏覆盖 `#property-list-container`(见 §2.1.6 |
| 空状态(无数据) | 筛选无结果 / 首次进入 | 见 §2.1.5 空状态设计 |
---
## 4. 弹窗设计规范(列表页)
> **范围说明**:本章仅包含从**房源列表页直接触发**的弹窗。调价、改状态、改等级等操作弹窗从**房源详情页**触发,记录于详情页 UI 设计文档。
### 4.1 自定义列弹窗P1 🟡)
#### 4.1.1 触发方式
- **触发位置**工具栏右侧「自定义列表」按钮Heroicon `adjustments-horizontal` + 文字)
- **组件类型**Modal Dialog`组件规范设计.md` §7
- **尺寸**`max-w-2xl`640px
#### 4.1.2 弹窗布局
```
┌─────────────────────────────────────────────────────┐
│ 标题:自定义列表信息 [×] │
├────────────────────────┬────────────────────────────┤
│ 未选信息 │ 已选信息 │
│ (可勾选字段列表) │ (已选字段,拖拽排序) │
│ │ ┌─────────────────────┐ │
│ □ 等级 │ │ ⋮⋮ 房源名称 [🔒] │ │
│ □ 属性 │ │ ⋮⋮ 楼栋 [删] │ │
│ □ 装修 │ │ ⋮⋮ 单元 [删] │ │
│ □ 建成年代 │ │ ⋮⋮ 房号 [删] │ │
│ □ 来源 │ │ ⋮⋮ 区域板块 [删] │ │
│ □ 完成度 │ │ ... │ │
│ □ 相关方 │ └─────────────────────┘ │
├────────────────────────┴────────────────────────────┤
│ [恢复默认] [取消] [确定] │
└─────────────────────────────────────────────────────┘
```
#### 4.1.3 字段说明
**固定不可隐藏列**:房源名称(锁定,无删除按钮)
**可选字段(完整清单)**:楼栋、单元、房号、区域板块、状态、售价(万)、单价(元/m²、租价元/月、面积、户型、楼层、朝向、挂牌日期、最后跟进日、等级、属性、装修、建成年代、来源、维护完成度、出售方、首录方
#### 4.1.4 提交行为
- **提交方式**`hx-post="/properties/column-preferences/"`
- **成功响应**:关闭弹窗 + HTMX 刷新 `#property-list-container` 以重新渲染列
- **HTMX 属性**
```html
<form hx-post="/properties/column-preferences/"
hx-target="#property-list-container"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful){ $dispatch('close-modal'); }">
<input type="hidden" name="columns" :value="JSON.stringify(selectedColumns)">
</form>
```
#### 4.1.5 使用的特殊组件
| 组件名 | 来源 | 用途 |
|---|---|---|
| Modal Dialog | §7 Modal Dialog | 弹窗容器,`max-w-2xl` |
Alpine.js 管理:`selectedColumns`(有序数组)、`availableColumns`(剩余可选)、`dragging` 状态;拖拽通过 Alpine.js + SortableJS 实现。
---
## 5. 交互状态规范
### 5.1 全局状态机
**房源状态流转**
```
for_sale出售 ──→ suspended暂缓 ──→ for_sale重新挂牌
──→ sold_elsewhere他售
──→ sold成交
for_rent出租 ──→ suspended暂缓 ──→ for_rent重新挂牌
──→ rented_elsewhere他租
──→ sold成交
unlisted未挂牌──→ for_sale / for_rent挂牌
suspended暂缓 ──→ for_sale / for_rent恢复挂牌
sold_elsewhere / rented_elsewhere ──→ for_sale / for_rent重新挂牌
```
**状态在列表页的视觉标记**
- `for_sale` / `for_rent`:正常行背景
- `suspended` / `sold_elsewhere` / `rented_elsewhere`:正常行背景,状态 Badge 有区分
- `sold`:行文字颜色轻微降低(`text-neutral-500`),提示该房源已成交
### 5.2 权限控制矩阵
| 操作 | 经纪人 | 店长 | 管理员 |
|---|---|---|---|
| 查看房源列表 | ✅(自己名下 + 公盘) | ✅(本门店 + 公盘) | ✅(全部) |
| 新增房源 | ✅ | ✅ | ✅ |
| 批量收藏/取消收藏 | ✅ | ✅ | ✅ |
| 设置保护房 | ❌(需权限) | ✅ | ✅ |
| 批量修改相关方 | ❌(需权限) | ✅(本门店) | ✅ |
| 批量删除 | ✅(自己名下) | ✅(本门店) | ✅ |
| 导出 | ✅(自己数据) | ✅(本门店) | ✅(全量) |
| 查看业主电话 | ❌(打码,需点击查看) | 同经纪人 | ✅ |
| 重复房源检测 | ✅ | ✅ | ✅ |
### 5.3 HTMX 请求规范
| 操作 | hx-trigger | hx-get/post | hx-target | hx-swap | Loading |
|---|---|---|---|---|---|
| Tab 切换(出售/出租等) | `click` | `hx-get="/properties/"` | `#property-list-container` | `innerHTML` | 骨架屏 |
| 关键词搜索输入 | `keyup changed delay:300ms, search` | `hx-get="/properties/"` | `#property-list-container` | `innerHTML` | 骨架屏 |
| 筛选 Checkbox 变更 | `change` | `hx-get="/properties/"` | `#property-list-container` | `innerHTML` | 骨架屏 |
| 筛选 Tag 按钮点击 | `click`Alpine.js 更新 hidden input→ `change` | `hx-get="/properties/"` | `#property-list-container` | `innerHTML` | 骨架屏 |
| 查询按钮点击 | `click` | `hx-get="/properties/"` | `#property-list-container` | `innerHTML` | 骨架屏 |
| 重置按钮 | `click` | `hx-get="/properties/"` | `#property-list-container` | `innerHTML` | 骨架屏 |
| 排序列头点击 | `click` | `hx-get="/properties/"` | `#property-list-container` | `innerHTML` | 骨架屏 |
| 分页页码点击 | `click` | `hx-get="/properties/"` | `#property-list-container` | `innerHTML` | 骨架屏 |
| 每页条数变更 | `change` | `hx-get="/properties/"` | `#property-list-container` | `innerHTML` | 骨架屏 |
| 导出按钮 | `click` | `hx-post="/properties/export/"` | `#export-status-area` | `innerHTML` | Toast 提示「导出任务已提交」 |
| 自定义列保存 | `submit` | `hx-post="/properties/column-preferences/"` | `#property-list-container` | `innerHTML` | Modal 内 Loading Spinner |
**Alpine.js 状态管理分工**
| 状态/行为 | 管理方式 | 说明 |
|---|---|---|
| 一级 Tab 激活状态(`activeTab` | Alpine.js | 用于控制售价/租价列的显示/隐藏 |
| 筛选区展开/收起(`showFilters` | Alpine.js | 控制筛选区域整体折叠 |
| 展开更多筛选(`showMoreFilters` | Alpine.js | 控制下方展开筛选行的显示 |
| 价格模式切换(`priceMode`| Alpine.js | 售价/单价 Tab 切换 |
| 区域多选(`selectedDistricts[]` | Alpine.js | 维护已选行政区数组 |
| 表格行选中(`selected[]` | Alpine.js | 维护已勾选行的 ID 数组,驱动批量操作按钮激活 |
| 已选条数(`selectedCount` | Alpine.js 计算属性 | `selected.length` |
| 自定义列弹窗开关(`columnSettingsOpen` | Alpine.js | 弹窗的 open/close 状态 |
| 数据加载/筛选/分页/排序 | HTMX | 所有后端数据请求 |
| 表单提交(批量操作/导出/保存列设置) | HTMX | 后端交互 |
---
## 6. 关键数据字段说明
| 字段名(英文) | 显示名 | 数据类型 | 说明 |
|---|---|---|---|
| `id` | 房源 ID | UUID | 主键,用于行跳转链接 |
| `property_type` | 房源类型 | VARCHAR(20) | residential / villa / shop 等 |
| `status` | 状态 | VARCHAR(20) | for_sale / for_rent / suspended 等 |
| `attribute` | 属性 | VARCHAR(20) | public / private / special / sealed |
| `complex_name` | 小区名称 | 关联查询 | 来自 `complexes.name` |
| `block_no` | 楼栋号 | VARCHAR(30) | 栋/幢/弄号 |
| `unit_no` | 单元号 | VARCHAR(30) | — |
| `room_no` | 房号 | VARCHAR(30) | 门牌号 |
| `floor` | 所在楼层 | SMALLINT | — |
| `total_floors` | 总楼层 | SMALLINT | — |
| `bedroom_count` | 室数 | SMALLINT | 户型组合 |
| `living_room_count` | 厅数 | SMALLINT | 户型组合 |
| `bathroom_count` | 卫数 | SMALLINT | 户型组合 |
| `area` | 建筑面积 | NUMERIC(8,2) | m²保留1位小数显示 |
| `sale_price` | 挂牌售价 | NUMERIC(12,2) | 万元 |
| `sale_unit_price` | 单价 | 计算字段 | `sale_price * 10000 / area`,元/m² |
| `rent_price` | 挂牌租价 | NUMERIC(10,2) | 元/月 |
| `orientation` | 朝向 | VARCHAR(10) | east / south 等,需翻译 |
| `decoration` | 装修 | VARCHAR(10) | rough / fine 等,需翻译 |
| `has_elevator` | 是否有电梯 | BOOLEAN | 用于标签显示 |
| `ownership_years` | 房本年限 | VARCHAR(30) | 满2年/满5年等用于「满五」标签 |
| `grade` | 等级 | VARCHAR(10) | A_urgent / A / B / C / D |
| `listed_at` | 最近挂牌时间 | TIMESTAMPTZ | 显示为 `YYYY-MM-DD` |
| `last_followed_at` | 最后跟进时间 | TIMESTAMPTZ | 超30天标红显示为 `YYYY-MM-DD` |
| `completeness_score` | 维护完成度 | SMALLINT | 0-100Celery 异步计算 |
| `seller_agent_name` | 出售方经纪人 | 关联查询 | 来自 `staff.name` |
| `first_recorder_name` | 首录方 | 关联查询 | 来自 `staff.name` |
| `district` | 行政区 | 关联查询 | 来自 `complexes → districts` |
| `business_area` | 商圈 | 关联查询 | 来自 `complexes → business_areas` |
| `tags` | 标签列表 | 关联查询 | 来自 `property_tag_relations → property_tags` |
| `is_favorited` | 是否已收藏 | BOOLEAN | 当前用户收藏状态,来自 `property_favorites` |
| `latest_price_direction` | 最近调价方向 | 计算字段 | `up` / `down` / `null`,来自最近一条 `price_changes` 记录 |
---
## 7. 竞品截图对应关系
| 截图路径 | 对应功能 | 对应文档章节 | 采纳的设计要点 |
|---|---|---|---|
| `Project/fonrey/screenshots/房源/房源列表.png` | 出售 Tab 列表视图(主参考) | §2.1.2 布局、§3.1 列定义、§3.2 状态变体 | 列布局(楼栋/单元/房号/区域板块/状态/售价/单价/面积/户型/楼层/朝向/挂牌/跟进日);交易类型 Badge橙色买卖/蓝色租赁);多维度筛选区展开方式;工具栏布局(批量操作+导出+自定义分页样式每页20条+跳页) |
| `Project/fonrey/screenshots/房源/全部房源.png` | 全部房源 Tab 视图 | §2.1.3 区域详细规范、§3.1 列定义 | 全部房源同时含售价/租价/面积列;出售+出租混合显示方式;房源名称行显示多个附加标签(满五/电梯/视频/私盘等);价格列下方显示趋势小箭头 |
**与竞品截图差异说明PRD 优先)**
1. **主色差异**:竞品使用橙色(`#F97316`)作为主按钮色和 Tab 激活色Fonrey 使用 Teal`#0F766E` = `primary-600`)。所有激活状态、主按钮均使用 Teal。
2. **按钮颜色**竞品「新增房源」为橙色大按钮Fonrey 改为 `bg-primary-600` Teal 色。
3. **区域筛选位置**:竞品截图中区域筛选(宝山/嘉定等)放在「区域」行独立显示,与 PRD 一致,本文档采纳。
4. **右侧悬浮栏**:竞品截图中页面右侧有悬浮操作栏(增客/增房/发审批等);该功能属于全局导航组件,不在本列表页 UI 文档范围内,由全局布局文档单独定义。
---
## 8. 实现优先级与工期估算
| 页面/功能 | 优先级 | 特殊组件复杂度 | 工期估算(前端) |
|---|---|---|---|
| 一级 Tab 导航 + 页面框架 | P0 🔴 | 低 | 0.5 天 |
| 搜索区域(关键词 + 楼栋/单元/房号) | P0 🔴 | 低 | 0.5 天 |
| 范围/区域/价格/面积/房型筛选行 | P0 🔴 | 中(多 Tag 按钮联动) | 1.5 天 |
| 楼层/标签/筛选/维护筛选行 | P0 🔴 | 中(多下拉联动) | 1 天 |
| 工具栏(批量操作 + 总条数) | P0 🔴 | 低 | 0.5 天 |
| 数据表格(核心列定义 + 状态 Badge + 价格箭头) | P0 🔴 | 中(多列状态变体) | 1.5 天 |
| 分页栏 | P0 🔴 | 低 | 0.25 天 |
| 空状态 + Loading 骨架屏 | P0 🔴 | 低 | 0.25 天 |
| HTMX 请求整合(所有筛选联动) | P0 🔴 | 中 | 1 天 |
| Alpine.js 状态管理整合 | P0 🔴 | 中 | 0.5 天 |
| 重复房源检测 + 疑似号码入口 | P1 🟡 | 低 | 0.25 天 |
| 已存搜索条件保存/调用 | P1 🟡 | 中 | 1 天 |
| 导出功能Celery 异步) | P1 🟡 | 低(复用客源导出) | 0.5 天 |
| 自定义列表弹窗(含拖拽排序) | P1 🟡 | 高SortableJS 集成) | 1.5 天 |
| 智能排序P1 | P1 🟡 | 低 | 0.25 天 |
| 海报视图切换P1 | P1 🟡 | 高(卡片布局全新设计) | 2 天 |
| 地图找房P2 | P2 ⚫ | 高(地图组件集成) | — |
**P0 总估算**:约 7.5 天前端工时
**P1 总估算**:约 5.5 天前端工时
---
## 9. 开放问题(待决策)
| # | 问题 | 影响范围 | 待确认方 |
|---|---|---|---|
| 1 | 「超时未跟进房源」的超时阈值是多少天PRD 未明确指定具体天数 | 范围筛选行、超时标红逻辑 | 产品经理 |
| 2 | 「与我相关」的精确范围定义:是仅 `seller_agent_id = me`,还是包含 `first_recorder_id / number_holder_id` | 筛选逻辑、后端查询 | 产品经理 + 后端 |
| 3 | 价格趋势箭头是显示具体降幅数字如「↓5万」还是仅显示方向箭头竞品截图中仅有箭头图标 | §3.2 价格趋势箭头渲染 | 产品经理 |
| 4 | 房源名称列下方标签组的完整枚举:「满五/电梯/视频/私盘/速销/独家」之外还有哪些?是否全部来自 `property_tags` 还是有些来自字段推导? | §3.2 房源附加标签 | 产品经理 + 后端 |
| 5 | 「海报视图」的具体布局设计尚未定义,仅知道是列表/海报切换;海报单卡片展示哪些字段? | 工具栏切换按钮、海报卡片组件 | 产品经理 |
| 6 | 地铁筛选是否需要在 MVPP0阶段实现PRD 中列出但 MVP 优先级矩阵中未单独标注 | 区域筛选行地铁入口 | 产品经理 |
| 7 | 「关注小区配置提示条」是否在列表页默认显示?关闭后是否持久化(即刷新后不再显示)? | 搜索区关注小区提示条 | 产品经理 |
| 8 | 全部房源 Tab 下,出售价和租价同时展示时,对于只有租价的房源,售价列显示 `-` 还是直接隐藏该列? | §3.1 全部房源 Tab 列定义 | 产品经理 |
| 9 | 「一键装换」和「一般委托」标签筛选的具体业务含义?在数据模型中对应哪个字段或条件? | 标签筛选行 | 产品经理 + 后端 |
| 10 | 列表默认展示的 Tab 是「出售」还是「全部房源」?用户未操作时哪个 Tab 默认激活? | 一级 Tab 初始状态 | 产品经理 |