68 KiB
房源列表 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.2 页面清单
- 1.3 用户角色与权限差异
- 页面设计规范
- 2.1 房源列表主页
- Data Table 规范
- 3.1 列定义
- 3.2 列状态变体
- 3.3 操作列
- 3.4 表格交互状态
- 弹窗设计规范(列表页)
- 4.1 自定义列弹窗
- 交互状态规范
- 5.1 房源状态机
- 5.2 权限控制矩阵
- 5.3 HTMX 请求规范
- 关键数据字段说明
- 竞品截图对应关系
- 实现优先级与工期估算
- 开放问题(待决策)
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,cursor,q,sort,order等;分页采用 Keyset,详见ADR-20260604-001) - 访问入口:顶部全局导航栏「房源」菜单 → 默认进入全部房源列表
- 页面职责:展示经纪人名下(或门店/全司)的房源列表,支持多维度搜索筛选、批量操作、状态快览
- 竞品参考截图:
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 Navigation),underline 变体 |
| 位置 | 页面最顶部,紧贴全局顶导下方,无额外卡片容器 |
| Tab 项 | 出售(含数量 Badge)/ 出租(含数量 Badge)/ 未挂牌 / 成交房源 / 全部房源 |
| 激活样式 | border-b-2 border-primary-600 text-primary-600 font-medium |
| 非激活样式 | text-neutral-500 hover:text-neutral-700 |
| 右侧内容 | 「更多 ▾」下拉菜单 + 「重复房源」链接 + 「疑似问题号码房源」链接 + 「+ 新增房源」主按钮 |
右侧操作区(Tab 栏右侧,绝对定位):
<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 阶段置灰不可点击 |
<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(具体阈值待产品确认) |
<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 |
| 地铁 | 下拉 ▾ | 选择地铁线 + 站点(二级联动),来自楼盘数据 |
<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 控制显示部分预设区间 |
<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) |
底部操作行:
<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 🟡) |
<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='{"cursor": LAST_ID}' hx-target="#property-list-container" hx-swap="innerHTML" hx-include="closest form"(Keyset 分页,LAST_ID 为当前页最后一条 id;详见 ADR-20260604-001。MVP 过渡期允许保留页码 UI 表现,但 cursor 是后端主参数) |
| 当前页 | 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 | 批量操作 + 导出 + 统计 | 批量按钮默认 disabled,selectedCount > 0 激活 |
| Export Button | §5 Export Button | 导出 Excel(P1) | HTMX hx-post 异步触发 Celery 任务 |
| Tab Navigation | §10 Tab Navigation | 一级 Tab(出售/出租等)切换 | Underline 变体,右侧含操作区 |
| Date Range Picker | §9 Date Range Picker | 挂牌日期、跟进时间筛选 | 集成 Flatpickr,range 模式 |
| Multi-select Tag Input | §17 Multi-select Tag Input | 相关方、学校等多选筛选 | 展开更多筛选行内使用 |
| Modal Dialog | §7 Modal Dialog | 自定义列弹窗 | 见 §4.1 规范 |
2.1.5 空状态设计
筛选无结果(最常见场景):
<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>
首次进入无数据(无任何房源):
<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 内显示骨架屏:
<!-- 骨架屏: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 | 面积(m²) | 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 结构(关键片段):
<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 |
价格趋势箭头(售价列):
<!-- 价格下降(最近一次调价为降价) -->
<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上箭头;未变化 → 不显示箭头。
排序列头(售价/单价/面积/挂牌日期/最后跟进日):
<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>
行选中态:
<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 |
| Loading(HTMX 请求中) | 筛选/分页/排序触发 | 骨架屏覆盖 #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²)、租价(元/月)、面积(m²)、户型、楼层、朝向、挂牌日期、最后跟进日、等级、属性、装修、建成年代、来源、维护完成度、出售方、首录方
4.1.4 提交行为
- 提交方式:
hx-post="/properties/column-preferences/" - 成功响应:关闭弹窗 + HTMX 刷新
#property-list-container以重新渲染列 - HTMX 属性:
<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-100,Celery 异步计算 |
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 优先):
- 主色差异:竞品使用橙色(
#F97316)作为主按钮色和 Tab 激活色;Fonrey 使用 Teal(#0F766E=primary-600)。所有激活状态、主按钮均使用 Teal。 - 按钮颜色:竞品「新增房源」为橙色大按钮;Fonrey 改为
bg-primary-600Teal 色。 - 区域筛选位置:竞品截图中区域筛选(宝山/嘉定等)放在「区域」行独立显示,与 PRD 一致,本文档采纳。
- 右侧悬浮栏:竞品截图中页面右侧有悬浮操作栏(增客/增房/发审批等);该功能属于全局导航组件,不在本列表页 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 | 地铁筛选是否需要在 MVP(P0)阶段实现?PRD 中列出但 MVP 优先级矩阵中未单独标注 | 区域筛选行地铁入口 | 产品经理 |
| 7 | 「关注小区配置提示条」是否在列表页默认显示?关闭后是否持久化(即刷新后不再显示)? | 搜索区关注小区提示条 | 产品经理 |
| 8 | 全部房源 Tab 下,出售价和租价同时展示时,对于只有租价的房源,售价列显示 - 还是直接隐藏该列? |
§3.1 全部房源 Tab 列定义 | 产品经理 |
| 9 | 「一键装换」和「一般委托」标签筛选的具体业务含义?在数据模型中对应哪个字段或条件? | 标签筛选行 | 产品经理 + 后端 |
| 10 | 列表默认展示的 Tab 是「出售」还是「全部房源」?用户未操作时哪个 Tab 默认激活? | 一级 Tab 初始状态 | 产品经理 |