Sync: add prd and ui system notes

This commit is contained in:
2026-04-24 21:43:10 +08:00
parent 31d316b096
commit d54fdb2d26
6 changed files with 1326 additions and 1 deletions

View File

@@ -0,0 +1,280 @@
# Fonrey 房睿 — MVP 范围书
**Status**: Draft
**Author**: Product Team
**Last Updated**: 2026-04-24
**Version**: 1.0
> **For AI assistants**: 本文件定义 Phase 1MVP的边界。在任何功能实现前先对照本文确认是否在范围内。范围外的功能禁止在 MVP 阶段实现。
---
## 1. 产品背景与目标
**Fonrey房睿** 是一套面向中小型房产经纪公司的 B2B SaaS 管理平台,解决以下核心痛点:
- 房源/客源信息散乱,全靠人工记录
- 跟进记录缺失,数据流失严重
- 重复录入浪费大量经纪人时间
- 无法支撑 89,000+ 数据量级下的高效房客匹配
**MVP 目标**:在一家种子客户(单租户)环境下,完整跑通"录入房源 → 录入客源 → 匹配带看 → 成交"的核心业务链路。
---
## 2. MVP 核心功能清单Phase 1 必须实现)
### 2.1 优先级定义
| 优先级 | 含义 |
|--------|------|
| **P0** | MVP 上线前必须完成,阻断核心业务链路 |
| **P1** | MVP 上线后第一个迭代周期内完成 |
| **P2** | 已规划,列入路线图但不阻断上线 |
---
### 2.2 模块优先级矩阵
#### 🏠 房源管理
| 功能 | 优先级 | 说明 |
|------|--------|------|
| 录入住宅(二手出售/出租) | **P0** | 核心业务入口 |
| 房源列表(二手&租赁) | **P0** | 含筛选、排序、分页 |
| 房源详情页 | **P0** | 含基本信息、产证、交易信息展示 |
| 跟进记录(全部/写入/修改/其他) | **P0** | 含钥匙、委托、实勘 |
| 图片管理(相册上传/分类/排序) | **P0** | 核心房源内容 |
| 业主联系人管理 | **P0** | 含新增/编辑/查看同业主房源 |
| 价格调整(调价/调价记录) | **P0** | 核心运营操作 |
| 房源状态变更(在售/暂缓/成交/下架) | **P0** | 状态机核心 |
| 房源维护完成度(诊断面板) | **P1** | 提升数据质量 |
| 敏感信息跟进(查看权限控制) | **P1** | 需配合权限模块 |
| 附件管理 | **P1** | 非阻断性 |
| 市场报盘 | **P1** | 运营辅助功能 |
| 价格解读 | **P1** | 分析辅助 |
| 录入别墅/商铺/商住/写字楼/其他 | **P2** | 住宅优先,商业类低频 |
| 全部商铺列表 / 全部写字楼列表 | **P2** | 配合 P2 录入功能 |
| 房源广场 | **P2** | 跨租户/公共池功能 |
#### 🏙️ 楼盘管理
| 功能 | 优先级 | 说明 |
|------|--------|------|
| 楼盘列表 + 楼盘详情(楼盘信息/楼栋/结构) | **P0** | 房源数据底座,必须先行 |
| 区域管理(城区/商圈) | **P0** | 房源关联必须 |
| 楼盘照片管理 | **P1** | 数据完善 |
| 楼盘价格走势 | **P1** | 分析辅助 |
| 周边配套(学校管理) | **P1** | 补充信息 |
| 应用数据标准 | **P2** | 明确不做 |
#### 👥 客源管理
| 功能 | 优先级 | 说明 |
|------|--------|------|
| 录入私客(求购/求租) | **P0** | 核心业务 |
| 私客列表(全部/求购/求租) | **P0** | 含筛选、排序 |
| 私客详情(基本信息/需求信息) | **P0** | |
| 跟进记录(全部/写入/修改/其他) | **P0** | |
| 带看管理(预约带看/新增带看) | **P0** | 房客匹配核心 |
| 联系人管理 | **P0** | |
| 客源状态变更(改等级/改状态) | **P0** | |
| 转公客 / 转成交 / 转无效 | **P0** | 生命周期核心 |
| 二手配房(智能匹配) | **P1** | 核心价值,但可后续迭代 |
| 客源解读 | **P1** | AI 辅助分析 |
| 客源信息概览 | **P1** | 汇总视图 |
| 客源收藏夹 | **P1** | 辅助功能 |
| 公客管理 | **P2** | 私客优先 |
| 成交客管理 | **P2** | |
| 暂缓私客 | **P2** | |
#### 🏢 组织人事
| 功能 | 优先级 | 说明 |
|------|--------|------|
| 公司组织结构(部门/门店树) | **P0** | 权限系统基础 |
| 员工列表/员工详情 | **P0** | |
| 员工入职/账号创建 | **P0** | |
| 员工离职 / 调动 | **P1** | |
| 员工通讯录 | **P1** | |
| 异动记录 | **P1** | |
| 奖惩记录 | **P2** | |
| 职务管理 | **P1** | |
| 门店分布地图 | **P2** | |
#### 🔐 权限管理
| 功能 | 优先级 | 说明 |
|------|--------|------|
| 角色管理(预设角色 + 自定义角色) | **P0** | 权限基础 |
| 人员权限列表 | **P0** | |
| 角色批量分配 | **P0** | |
| 功能权限(菜单级) | **P0** | |
| 数据权限(部门/个人/全司) | **P0** | |
| 字段级权限(敏感字段可见性) | **P1** | 配合房源/客源敏感信息 |
| 个人特定权限覆盖 | **P1** | |
#### 🔑 用户登录
| 功能 | 优先级 | 说明 |
|------|--------|------|
| 账号密码登录 | **P0** | |
| 多租户识别(子域名/域名) | **P0** | |
| Token 管理 / 会话超时 | **P0** | |
| 短信验证码登录 | **P1** | |
| 密码重置 | **P1** | |
| 记住登录状态 | **P1** | |
#### ⚙️ 系统配置
| 功能 | 优先级 | 说明 |
|------|--------|------|
| 首页设置 | **P1** | |
| 房源设置(字段必填/自定义字段/标签) | **P0** | 影响录入表单 |
| 相关方设置 | **P1** | |
| 客源设置(基本配置/参数配置) | **P1** | |
| 人事OA设置 | **P2** | |
| 交易设置 | **P2** | |
| 财务设置 | **P2** | |
| 合同设置 | **P2** | |
#### 🖥️ 系统管理(运营后台)
| 功能 | 优先级 | 说明 |
|------|--------|------|
| 租户管理(开通/暂停/配置) | **P1** | 单租户种子阶段可手动 |
| 系统健康监控 | **P1** | |
| 操作审计日志 | **P2** | |
| 灰度发布 / 滚动升级 | **P2** | |
#### 💻 客户端发布
| 功能 | 优先级 | 说明 |
|------|--------|------|
| Windows 桌面客户端(内置浏览器) | **P1** | 种子客户使用 Web 端可先行 |
| 自动更新机制 | **P1** | 配合客户端 |
---
## 3. 非目标Out of Scope — MVP 阶段绝对不做)
以下功能在 MVP 阶段**明确不实现**AI 生成代码时不得为这些功能预留接口或引入相关依赖:
| 功能 | 原因 |
|------|------|
| 移动端适配 | v2 规划 |
| 新房模块(新房管理/新房设置) | 独立模块,后续版本 |
| 合同管理模块 | 独立模块,后续版本 |
| 财务管理/提成结算 | 独立模块,后续版本 |
| 三网发布(安居客/链家/贝壳对接) | 独立模块,后续版本 |
| 数据报表/行程量化 | 独立模块,后续版本 |
| 在线充值/增值服务 | 独立模块,后续版本 |
| 任务管理OA任务/入职祝福) | 低优先 |
| 考勤管理 | 独立 HR 模块 |
| 审批流程 | 独立 OA 模块 |
| 智慧大屏 / VR换装 | 增值产品 |
| 房源广场(跨租户公共池) | 多租户复杂场景 |
---
## 4. 用户故事MVP 核心路径)
### Story 1 — 经纪人录入房源
> As a **一线经纪人**,
> I want to **快速录入一套二手住宅并上传图片和业主联系方式**,
> So that **这套房源的信息能被团队所有成员找到和跟进**.
**验收标准**
- 可在 3 分钟内完成住宅基本信息录入
- 上传图片后自动按分类展示
- 录入后即刻出现在房源列表
---
### Story 2 — 经纪人跟进房源
> As a **一线经纪人**,
> I want to **对我负责的房源记录每次跟进(面访/电话/钥匙/实勘)**,
> So that **我的跟进历史有据可查,团队不会重复联系同一业主**.
**验收标准**
- 跟进记录按时间线倒序展示
- 支持写入跟进、修改跟进、其他跟进(钥匙/委托/实勘)
- 敏感信息跟进只对有权限的人员可见
---
### Story 3 — 经纪人录入客源
> As a **一线经纪人**,
> I want to **录入意向购房/租房客户并跟进其需求变化**,
> So that **我能在合适时机将客户与合适房源匹配**.
**验收标准**
- 区分求购/求租两种意向
- 支持跟进记录
- 可安排带看并记录带看结果
---
### Story 4 — 转成交
> As a **一线经纪人**,
> I want to **将已达成交易的客源标记为"成交"并关联成交房源**,
> So that **成交数据进入系统留存,房源状态自动更新**.
**验收标准**
- 转成交时必须选择关联房源
- 成交后客源状态自动变为"成交客"
- 关联房源状态建议变更为"成交"(可手动确认)
---
### Story 5 — 店长查看团队数据
> As a **门店店长**,
> I want to **查看本门店所有员工的房源和客源列表**,
> So that **我能掌握团队整体情况并合理分配资源**.
**验收标准**
- 数据权限按部门隔离,店长可见本门店数据
- 可筛选查看特定员工的房源/客源
- 无法看到其他门店的数据
---
## 5. MVP 技术边界
| 约束 | 决策 |
|------|------|
| 租户数 | **单租户**种子阶段,多租户架构已就位但不激活多租户切换 UI |
| 数据量 | 目标支撑 **89,000 条**房源,测试阶段以 10,000 条压测 |
| 浏览器支持 | Chrome 最新版 / Edge 最新版,不支持 IE |
| 语言 | 简体中文,不做国际化 |
| 移动端 | **不做**Web 端 Desktop-first |
| 导出 | Excel/CSV 导出通过 Celery 异步,不超时 |
---
## 6. MVP 交付检查清单
在 MVP 正式上线前,以下项目必须全部勾选:
- [ ] 房源录入(住宅)完整流程可用
- [ ] 房源列表可筛选/排序/分页
- [ ] 客源录入(求购/求租)完整流程可用
- [ ] 带看创建与记录可用
- [ ] 转成交流程可用
- [ ] 楼盘数据可录入(为房源提供底座)
- [ ] 员工账号可创建/分配角色
- [ ] 权限隔离:经纪人只能看自己数据,店长能看本店数据
- [ ] 89,000 条数据量下列表查询 < 2 秒(含索引优化)
- [ ] 图片上传到 Cloudflare R2 可用
- [ ] 多租户 Schema 隔离验证通过
---
## 7. 版本路线图
| 版本 | 目标 | 核心功能 |
|------|------|---------|
| **v0.1 MVP** | 单租户种子验证 | P0 功能全部上线 |
| **v0.2** | 功能完善 | P1 功能上线,开始多租户测试 |
| **v0.3** | 商业化就绪 | Windows 客户端、多租户正式开放、系统配置完善 |
| **v1.0** | 正式发布 | 新房模块、合同/财务模块路线图确认 |

View File

@@ -0,0 +1,987 @@
> **For AI assistants**: Read this entire file before writing any frontend code or template. All decisions here are final. When in doubt about styling, spacing, color, or component behavior — the answer is in this document. Do not invent values.
---
## Design Philosophy
**Core aesthetic**: Clean, functional, low-friction.
We build tools, not experiences. Every UI element must earn its place.
**Principles** (in priority order):
1. **Clarity over cleverness** — if you have to explain the UI, it failed
2. **Density over whitespace** — our users are power users; do not waste screen space
3. **Consistency over novelty** — use existing patterns before inventing new ones
4. **Motion is functional** — animate only to communicate state change, never for decoration
**Anti-patterns we actively avoid**:
- Skeleton loaders for data that loads in < 300ms (use a spinner instead)
- Modal dialogs for destructive actions that are easily reversible
- Infinite scroll (we use pagination; users need to share URLs to specific pages)
- Tooltips on mobile
- Full-width buttons on desktop (max-width: 320px for standalone CTAs)
- Mixing card and table layouts in the same list view
- Auto-submitting forms on change without explicit confirmation
- Generic error messages ("Something went wrong" is never acceptable)
- `window.alert` — always use our Dialog/Toast components
---
## Design Tokens
All colors, spacing, radius, and shadow values must come from these tokens. **Never write raw hex values or arbitrary px values in components.**
### Color System
We use CSS custom properties (variables). Define these in `base.css` under `:root`.
```css
:root {
/* Backgrounds */
--color-bg-base: #ffffff; /* page background */
--color-bg-subtle: #f9fafb; /* card, sidebar, panel backgrounds */
--color-bg-muted: #f3f4f6; /* disabled states, placeholders, table zebra */
/* Text */
--color-text-primary: #111827; /* body text */
--color-text-secondary:#6b7280; /* labels, captions, helper text */
--color-text-disabled: #9ca3af; /* disabled text */
--color-text-inverse: #ffffff; /* text on dark/colored backgrounds */
/* Borders */
--color-border: #e5e7eb; /* default borders */
--color-border-strong: #d1d5db; /* focused, emphasized borders */
/* Brand / Accent — orange as primary action color */
--color-accent: #f97316; /* primary actions, links, active states */
--color-accent-hover: #ea6c0a; /* hover state of accent */
--color-accent-subtle: #fff7ed; /* light tint for accent backgrounds */
/* Semantic */
--color-success: #16a34a; /* confirmations, completed states */
--color-success-bg: #f0fdf4;
--color-warning: #d97706; /* non-blocking alerts */
--color-warning-bg: #fffbeb;
--color-danger: #dc2626; /* destructive actions, errors */
--color-danger-bg: #fef2f2;
--color-info: #2563eb; /* informational, neutral alerts */
--color-info-bg: #eff6ff;
}
```
**Mapping to Tailwind**: Configure `tailwind.config.js` to extend colors using these CSS variables so you can write `text-accent`, `bg-bg-subtle`, etc. If that is not yet configured, use the following Tailwind equivalents as a temporary fallback — but never use arbitrary hex:
| Token | Tailwind equivalent |
|---|---|
| `--color-accent` | `text-orange-500` / `bg-orange-500` |
| `--color-accent-hover` | `hover:bg-orange-600` |
| `--color-text-primary` | `text-gray-900` |
| `--color-text-secondary` | `text-gray-500` |
| `--color-text-disabled` | `text-gray-400` |
| `--color-border` | `border-gray-200` |
| `--color-border-strong` | `border-gray-300` |
| `--color-bg-subtle` | `bg-gray-50` |
| `--color-bg-muted` | `bg-gray-100` |
| `--color-danger` | `text-red-600` / `bg-red-600` |
| `--color-success` | `text-green-600` |
**Rule**: If you find yourself writing `text-gray-500`, stop. Ask: is this a label, a caption, or secondary content? Then use the semantic token. If you find yourself writing `text-gray-400`, that is disabled text.
---
### Spacing Scale
We use a **4px base grid**. Only these values are permitted:
| px | Tailwind |
|---|---|
| 4px | `p-1` / `m-1` / `gap-1` |
| 8px | `p-2` / `m-2` / `gap-2` |
| 12px | `p-3` / `m-3` / `gap-3` |
| 16px | `p-4` / `m-4` / `gap-4` |
| 24px | `p-6` / `m-6` / `gap-6` |
| 32px | `p-8` / `m-8` / `gap-8` |
| 48px | `p-12` / `m-12` / `gap-12` |
| 64px | `p-16` / `m-16` / `gap-16` |
| 96px | `p-24` / `m-24` / `gap-24` |
**Never use**: `p-5`, `p-7`, `p-9`, `p-10`, `p-11` — these break the grid.
---
### Border Radius
```
--radius-sm: 4px → rounded-sm (inputs, badges, table cells)
--radius-md: 8px → rounded (cards, buttons) ← default
--radius-lg: 12px → rounded-xl (modals, drawers, panels)
--radius-full: 9999px → rounded-full (avatars, pill badges, toggles)
```
**Rule**: Never mix radius sizes within the same component. A card with `rounded` should not have children with `rounded-xl`.
---
### Elevation / Shadow
```
--shadow-sm → shadow-sm (subtle card lift, focused inputs)
--shadow-md → shadow-md (dropdowns, popovers, floating panels)
--shadow-lg → shadow-xl (modals, drawers, dialogs)
```
**Rules**:
- Never use `drop-shadow` filter — use `box-shadow` (`shadow-*`) only
- Never use `shadow-2xl``shadow-xl` is the maximum
---
## Typography
**Font stack**:
- UI: `Inter` (variable weight, loaded via `<link>` from self-hosted or CDN)
- Code / monospace data: `JetBrains Mono` (used for code blocks and numeric data columns only)
- Never import fonts via Google Fonts `@import` inside CSS — use `<link>` in `<head>`
**Type scale** — use only these sizes, no arbitrary values:
| Tailwind class | Size | Weight | Line-height | Usage |
|---|---|---|---|---|
| `text-xs` | 12px | 400 | 1.5 | Labels, badges, metadata, table captions |
| `text-sm` | 14px | 400 | 1.5 | Body text, secondary content, form helpers |
| `text-base` | 16px | 400 | 1.6 | Primary body text |
| `text-lg` | 18px | 500 | 1.4 | Section headings, drawer titles |
| `text-xl` | 20px | 600 | 1.3 | Page sub-headings |
| `text-2xl` | 24px | 700 | 1.2 | Page titles |
| `text-3xl` | 30px | 700 | 1.1 | Hero headings only — never in app UI |
**Rules**:
- Max 2 font sizes per component
- `font-medium` (500) is the minimum weight for anything interactive (buttons, links, tab labels)
- Never use `text-3xl` in application views — only marketing/landing pages
- Body text line length: max 72 characters (`max-w-prose`)
- Numbers in tables: use `font-mono` (JetBrains Mono) and `tabular-nums`
---
## Page Layout
### App Shell
The standard application layout is a **fixed sidebar + scrollable main content** pattern.
```
┌─────────────────────────────────────────────────────┐
│ Top Nav Bar (h-14, fixed, z-30) │
├──────────────┬──────────────────────────────────────┤
│ │ Page Header (breadcrumb + actions) │
│ Sidebar ├──────────────────────────────────────┤
│ (w-56, │ │
│ fixed, │ Main Content Area │
│ z-20) │ (overflow-y-auto, flex-1) │
│ │ │
│ │ │
└──────────────┴──────────────────────────────────────┘
```
- **Top Nav**: `h-14`, `bg-white`, `border-b border-gray-200`, `fixed top-0 inset-x-0 z-30`
- **Sidebar**: `w-56`, `bg-gray-50`, `border-r border-gray-200`, `fixed left-0 top-14 bottom-0 z-20`, `overflow-y-auto`
- **Main**: `ml-56 mt-14`, `min-h-screen`, `bg-white`
- **Page content padding**: `p-6` on all sides
### Page Header
Every page has a header section directly below the top nav, inside the main area:
```html
<div class="px-6 pt-6 pb-4 border-b border-gray-200">
<!-- Breadcrumb -->
<nav class="text-sm text-gray-500 mb-1">
<span>房源管理</span>
<span class="mx-1">/</span>
<span class="text-gray-900">住宅出售</span>
</nav>
<!-- Page title + primary actions -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">住宅出售</h1>
<div class="flex items-center gap-2">
<!-- Primary action buttons here -->
</div>
</div>
</div>
```
### Sidebar Navigation
- Active item: `bg-orange-50 text-orange-600 font-medium border-r-2 border-orange-500`
- Inactive item: `text-gray-600 hover:bg-gray-100`
- Section label: `text-xs font-semibold text-gray-400 uppercase tracking-wide px-3 mt-4 mb-1`
- Item height: `h-9` (`36px`)
- Item padding: `px-3`
- Icon size: `w-4 h-4`, `mr-2`
- Second-level items: `pl-9`
---
## Core Component Specs
### Button
**Variants**:
| Variant | Tailwind classes | Use case | Never use for |
|---|---|---|---|
| `primary` | `bg-orange-500 hover:bg-orange-600 text-white font-medium rounded` | Single main CTA per view | Destructive actions |
| `secondary` | `bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 font-medium rounded` | Secondary actions | Main CTA |
| `ghost` | `bg-transparent hover:bg-gray-100 text-gray-600 font-medium rounded` | Toolbar actions, low-priority | Standalone CTAs |
| `danger` | `bg-red-600 hover:bg-red-700 text-white font-medium rounded` | Irreversible destructive actions only | Anything reversible |
| `link` | `text-orange-500 hover:text-orange-600 underline-offset-2 hover:underline` | Inline navigation links only | Form submissions |
**Sizes**:
| Size | Height | Padding | Font |
|---|---|---|---|
| `sm` | `h-7` (28px) | `px-3` | `text-xs` |
| `md` | `h-9` (36px) | `px-4` | `text-sm` — default |
| `lg` | `h-11` (44px) | `px-6` | `text-base` |
**States** (all must be handled in every button):
- `default` — base styles above
- `hover` — defined in variant above
- `active``active:scale-95 active:opacity-90`
- `focus-visible``focus-visible:outline-2 focus-visible:outline-orange-500 focus-visible:outline-offset-2`
- `disabled``disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none`
- `loading` — spinner icon replaces or precedes label; button is disabled
**Loading state rule**: Show spinner and disable button immediately on click. Never allow double-submission.
```html
<!-- Correct: HTMX handles loading state automatically with hx-indicator -->
<button hx-post="/api/save/"
hx-disabled-elt="this"
class="btn-primary"
aria-label="保存">
<span class="htmx-indicator">
<svg class="animate-spin w-4 h-4 mr-2" ...></svg>
</span>
保存
</button>
<!-- For Alpine.js-controlled loading -->
<button @click="submit()"
:disabled="loading"
:class="loading ? 'opacity-50 cursor-not-allowed' : ''"
class="btn-primary">
<svg x-show="loading" class="animate-spin w-4 h-4 mr-2" ...></svg>
<span x-text="loading ? '保存中...' : '保存'"></span>
</button>
```
**Icon buttons**: Always include `aria-label`. Never use icon-only buttons as the sole primary CTA.
---
### Form Inputs
**Anatomy** (always in this order, no exceptions):
```
[Label] ← always visible, never placeholder-only
[Input field]
[Helper text] ← optional, describes expected format
[Error message] ← replaces helper text on error
```
**Base input class**:
```
block w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900
placeholder:text-gray-400
focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500
disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed
```
**States**:
| State | Additional classes |
|---|---|
| `default` | `border-gray-300` |
| `focus` | `ring-2 ring-orange-500 border-orange-500` |
| `error` | `border-red-500 ring-2 ring-red-500 focus:ring-red-500` |
| `disabled` | `bg-gray-100 text-gray-400 cursor-not-allowed` |
| `readonly` | `bg-gray-50 text-gray-600 cursor-default` |
**Label**:
```html
<label for="field-id" class="block text-sm font-medium text-gray-700 mb-1">
字段名称 <span class="text-red-500">*</span> <!-- required indicator -->
</label>
```
**Helper text**:
```html
<p class="mt-1 text-xs text-gray-500">格式说明文字</p>
```
**Error message**:
```html
<p class="mt-1 text-xs text-red-600" id="field-id-error" role="alert">
<svg class="inline w-3 h-3 mr-1" ...></svg>
具体的错误原因,可操作的
</p>
```
**Rules**:
- Label always above input, never to the side (exception: checkbox and radio)
- Placeholder text is NOT a label — both must exist
- Error messages: specific and actionable ("请输入有效的手机号", not "格式错误")
- Required fields: mark with `*` next to label; explain at top of form ("* 为必填项")
- Never disable a submit button to prevent submission — show errors inline instead
- `aria-describedby` must link input to its error element when error is shown
**Select / Dropdown**:
```
block w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900
focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500
```
**Textarea**: same as input, add `resize-y min-h-[80px]`. Always include character counter when there is a max length.
---
### Data Table
**Structure**:
```
[Toolbar: bulk actions + filter chips + column visibility + export]
[Table: sticky header, checkbox col, data cols, actions col]
[Pagination: count + page controls + per-page selector + jump-to]
```
**Table base classes**:
```html
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 sticky top-0 z-10">
<tr>
<!-- Checkbox column — leftmost -->
<th class="w-10 px-3 py-3">
<input type="checkbox" class="rounded border-gray-300 text-orange-500 focus:ring-orange-500">
</th>
<!-- Sortable column -->
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide cursor-pointer select-none hover:bg-gray-100"
hx-get="?sort=field&order=asc" hx-target="#table-body">
列名 <svg class="inline w-3 h-3 ml-1">...</svg>
</th>
</tr>
</thead>
<tbody id="table-body" class="divide-y divide-gray-100 bg-white">
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-3 py-3">...</td>
<!-- Actions column — rightmost, visible on row hover only -->
<td class="px-3 py-3 opacity-0 group-hover:opacity-100 transition-opacity text-right">
...
</td>
</tr>
</tbody>
</table>
</div>
```
Add `group` class to `<tr>` to enable `group-hover` on the actions column.
**Column rules**:
- Numbers: `text-right font-mono tabular-nums`
- Dates: relative time for < 7 days ("2小时前"), absolute date for older ("2025-10-15")
- Status: always a colored badge — never plain text
- Long text: `truncate max-w-[200px]` with `title` attribute showing full value
**Default page size**: 25 rows. Options: 10 / 25 / 50 / 100.
**Pagination display**: Always show total count — "共 3,629 条,第 125 条"
**Empty state** (never just "暂无数据"):
```html
<tr>
<td colspan="[N]" class="py-16 text-center">
<div class="flex flex-col items-center gap-3">
<svg class="w-12 h-12 text-gray-300" ...></svg> <!-- relevant icon -->
<p class="text-base font-medium text-gray-500">暂无房源</p>
<p class="text-sm text-gray-400">符合条件的房源将出现在这里</p>
<a href="/property/add/" class="btn-primary btn-sm mt-2">新增房源</a>
</div>
</td>
</tr>
```
---
### Status Badge
Status must always be communicated with **color + icon + text** (never color alone).
```html
<!-- Base badge structure -->
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium">
<svg class="w-3 h-3" ...></svg>
状态文字
</span>
```
**Variant classes** (add to base):
| Status | Class |
|---|---|
| Active / 在售 | `bg-green-100 text-green-700` |
| Warning / 即将过期 | `bg-yellow-100 text-yellow-700` |
| Danger / 已删除 | `bg-red-100 text-red-700` |
| Neutral / 已下架 | `bg-gray-100 text-gray-600` |
| Info / 跟进中 | `bg-blue-100 text-blue-700` |
| Brand / 出售 | `bg-orange-100 text-orange-700` |
---
### Modal / Dialog
**Size variants**:
| Size | Max-width | Use case |
|---|---|---|
| `sm` | `max-w-sm` (400px) | Confirmation dialogs, simple alerts |
| `md` | `max-w-lg` (560px) | Forms with ≤ 5 fields |
| `lg` | `max-w-2xl` (720px) | Complex forms, detail previews |
| `xl` | `max-w-4xl` (960px) | Multi-step flows, wide content |
**Base modal structure**:
```html
<div x-data="{ open: false }">
<!-- Backdrop -->
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="open = false"
class="fixed inset-0 bg-black/50 z-40"
aria-hidden="true">
</div>
<!-- Panel -->
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
role="dialog"
aria-modal="true"
x-trap="open"
class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg flex flex-col max-h-[90vh]">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
<h2 class="text-lg font-semibold text-gray-900">弹窗标题</h2>
<button @click="open = false" aria-label="关闭" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" ...></svg>
</button>
</div>
<!-- Scrollable body -->
<div class="px-6 py-4 overflow-y-auto flex-1">
<!-- content -->
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-2 px-6 py-4 border-t border-gray-200 shrink-0">
<button @click="open = false" class="btn-secondary">取消</button>
<button class="btn-primary">确定</button>
<!-- Destructive: danger button on RIGHT, cancel on LEFT — as shown above -->
</div>
</div>
</div>
</div>
```
**Rules**:
- Always trap focus inside modal (`x-trap` from Alpine.js Focus plugin)
- ESC key always closes (Alpine handles this automatically with `x-trap`)
- Click outside backdrop closes — unless form has unsaved changes → show confirmation
- Never nest modals — use a multi-step flow instead
- Destructive confirm dialogs: danger button on RIGHT, cancel on LEFT
- Never auto-close a modal after an async action — wait for user to dismiss
---
### Drawer / Slide-over Panel
Used for editing content with many fields where the main page should remain visible for reference.
**When to use Drawer vs Modal**:
| Scenario | Component |
|---|---|
| Many fields, needs scrolling | Drawer |
| Few fields (≤ 5), simple confirm | Modal |
| User needs to reference main page data while editing | Drawer |
**Standard drawer** (slides in from the right):
```html
<div x-data="{ open: false }">
<!-- Backdrop -->
<div x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
@click="open = false"
class="fixed inset-0 bg-black/30 z-40">
</div>
<!-- Drawer panel -->
<div x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
role="dialog"
aria-modal="true"
class="fixed right-0 top-0 h-full w-[480px] bg-white z-50 shadow-xl flex flex-col">
<!-- Fixed header -->
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between shrink-0">
<h2 class="text-lg font-semibold text-gray-900">抽屉标题</h2>
<button @click="open = false" aria-label="关闭" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" ...></svg>
</button>
</div>
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto px-6 py-4">
<!-- content -->
</div>
<!-- Fixed footer -->
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-2 shrink-0">
<button @click="open = false" class="btn-secondary">取消</button>
<button class="btn-primary">确定</button>
</div>
</div>
</div>
```
**Width**: `w-[480px]` default; `w-[640px]` for wide content (image management, multi-column settings).
---
### Tab Navigation
**Standard tabs** (underline style):
```html
<div x-data="{ activeTab: 'info' }">
<!-- Tab bar -->
<div class="flex border-b border-gray-200">
<button @click="activeTab = 'info'"
:class="activeTab === 'info'
? 'border-b-2 border-orange-500 text-orange-600 font-medium'
: 'text-gray-500 hover:text-gray-700'"
class="px-4 py-3 text-sm -mb-px">
基本信息
</button>
<!-- more tabs -->
</div>
<!-- Tab panels — use HTMX for content that requires server data -->
<div x-show="activeTab === 'info'" class="pt-4">
...
</div>
</div>
```
**Tab + HTMX** (for server-rendered tab content):
```html
<button hx-get="/property/123/tab/followup/"
hx-target="#tab-content"
hx-swap="innerHTML"
@click="activeTab = 'followup'"
:class="activeTab === 'followup' ? 'border-b-2 border-orange-500 text-orange-600 font-medium' : 'text-gray-500 hover:text-gray-700'"
class="px-4 py-3 text-sm -mb-px">
跟进记录
</button>
<div id="tab-content" class="pt-4">...</div>
```
**URL-syncing tabs**: Use `hx-push-url="true"` on HTMX tab requests to make tabs bookmarkable.
---
### Toggle Switch
```html
<button @click="val = !val"
:aria-checked="val.toString()"
role="switch"
:class="val ? 'bg-orange-500' : 'bg-gray-300'"
:disabled="disabled"
class="relative w-10 h-5 rounded-full transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-orange-500 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed">
<span :class="val ? 'translate-x-5' : 'translate-x-0.5'"
class="absolute top-0.5 left-0 w-4 h-4 bg-white rounded-full shadow transition-transform duration-200">
</span>
</button>
```
---
### Collapsible / Accordion
```html
<div x-data="{ open: false }">
<!-- Header row — clickable -->
<div @click="open = !open"
class="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-gray-50 select-none">
<span class="text-sm font-medium text-gray-700">分组标题</span>
<svg :class="open ? 'rotate-180' : ''"
class="w-4 h-4 text-gray-400 transition-transform duration-200" ...></svg>
</div>
<!-- Collapsible body — use x-collapse plugin for smooth height animation -->
<div x-show="open" x-collapse class="px-4 pb-4">
<!-- content -->
</div>
</div>
```
Requires Alpine.js `@alpinejs/collapse` plugin (official, ~1KB).
---
### Tree Select
For hierarchical data selection (org unit, staff assignment):
- **Small datasets** (< 200 nodes): Alpine.js renders full JSON tree client-side
- **Large datasets**: HTMX lazy-loads child nodes on expand
Alpine.js data structure:
```javascript
{
open: false,
query: '',
selected: null,
nodes: [/* tree JSON from Django */],
toggle(node) { node.expanded = !node.expanded },
select(node) { this.selected = node; this.open = false },
filteredNodes() {
if (!this.query) return this.nodes
// Recursive filter — preserves ancestors of matching nodes
return this.filterTree(this.nodes, this.query.toLowerCase())
}
}
```
---
### Multi-select Tag Input
For multi-value fields (e.g., property status, amenities):
```html
<div x-data="multiSelect(options)"
@click.away="open = false"
class="relative">
<!-- Tag container / trigger -->
<div @click="open = true"
:class="open ? 'ring-2 ring-orange-500 border-orange-500' : 'border-gray-300'"
class="flex flex-wrap gap-1 min-h-[36px] border rounded px-2 py-1.5 cursor-text bg-white">
<template x-for="item in selected" :key="item.value">
<span class="inline-flex items-center gap-1 bg-gray-100 text-gray-700 text-xs px-2 py-0.5 rounded">
<span x-text="item.label"></span>
<button @click.stop="remove(item)" aria-label="移除" class="text-gray-400 hover:text-gray-600">×</button>
</span>
</template>
<input x-model="query" class="outline-none text-sm flex-1 min-w-[60px] bg-transparent" placeholder="搜索或选择">
</div>
<!-- Dropdown -->
<div x-show="open" class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded shadow-md max-h-48 overflow-y-auto">
<template x-for="option in filteredOptions()" :key="option.value">
<div @click="toggle(option)"
class="flex items-center justify-between px-4 py-2 text-sm hover:bg-gray-50 cursor-pointer">
<span x-text="option.label"></span>
<svg x-show="isSelected(option)" class="w-4 h-4 text-orange-500" ...></svg>
</div>
</template>
<div x-show="filteredOptions().length === 0" class="px-4 py-3 text-sm text-gray-400">暂无匹配选项</div>
</div>
</div>
```
---
### Date Range Picker
Use **Flatpickr** (CDN, ~16KB, zero framework dependency):
```javascript
flatpickr("#date-range-input", {
mode: "range",
showMonths: 2,
dateFormat: "Y-m-d",
locale: "zh",
});
```
Override Flatpickr default styles with Tailwind to match our design system. Never build a date picker from scratch.
---
### Photo Gallery / Image Management
- Upload: **Filepond** (~50KB, zero framework dependency) — drag-and-drop, preview, progress, multi-file queue
- Drag-to-reorder: **SortableJS** (~3KB) — use `handle: '.drag-handle'`
- Lightbox preview: **Viewer.js** (~5KB) — zoom, rotate, thumbnail strip
All three are framework-free pure JS libraries, fully compatible with HTMX + Alpine.js.
---
## State & Feedback Patterns
### Loading States
| Duration | Pattern |
|---|---|
| < 300ms | Nothing — avoid flash of spinner |
| 300ms 1s | Inline spinner (`animate-spin`) |
| 1s 3s | Spinner + "加载中..." text |
| > 3s | Progress bar + estimated time |
| Background Celery task | Subtle pulsing indicator in top nav |
HTMX automatically adds `htmx-request` class during requests — use it to show/hide indicators:
```html
<div class="htmx-indicator">
<svg class="animate-spin w-4 h-4 text-orange-500" ...></svg>
</div>
```
---
### Empty States
Every list, table, and data view must handle the empty state. Required elements:
1. Relevant icon (not a generic "no data" icon — use something contextually relevant)
2. Friendly headline ("暂无房源")
3. Explanation ("符合当前筛选条件的房源将出现在这里")
4. CTA if the user can fix it ("新增第一条房源 →")
```html
<div class="flex flex-col items-center justify-center py-16 text-center">
<svg class="w-12 h-12 text-gray-300 mb-4" ...></svg>
<p class="text-base font-medium text-gray-500 mb-1">暂无房源</p>
<p class="text-sm text-gray-400 mb-4">符合当前筛选条件的房源将出现在这里</p>
<a href="/property/add/" class="btn-primary btn-sm">新增房源</a>
</div>
```
---
### Toast Notifications
| Type | When to use | Duration | Classes |
|---|---|---|---|
| `success` | Async action completed | 3s auto-dismiss | `bg-green-50 border-green-200 text-green-800` |
| `error` | Action failed, user must retry | Persistent (manual dismiss) | `bg-red-50 border-red-200 text-red-800` |
| `warning` | Completed with caveats | 5s | `bg-yellow-50 border-yellow-200 text-yellow-700` |
| `info` | Background process started | 3s | `bg-blue-50 border-blue-200 text-blue-700` |
**Rules**:
- Max 3 toasts visible at once (queue the rest)
- Never show success toast for page navigations
- Error toasts must include a retry action button when possible
- Never use toast for validation errors — show inline instead
- Position: `fixed bottom-4 right-4 z-50 flex flex-col gap-2`
Trigger via HTMX response header: `HX-Trigger: {"showToast": {"type": "success", "message": "保存成功"}}`
---
### Inline Edit (Read/Edit Mode Toggle)
For settings pages and detail views that support in-place editing:
```html
<div x-data="{ editing: false, snapshot: null }"
@keydown.escape="editing = false; restoreSnapshot()">
<button x-show="!editing" @click="editing = true; snapshot = JSON.parse(JSON.stringify(data))"
class="btn-secondary btn-sm">编辑</button>
<div x-show="editing" class="flex gap-2">
<button @click="editing = false; restoreSnapshot()" class="btn-secondary btn-sm">取消</button>
<button hx-post="/settings/save/" hx-vals="js:data" @click="editing = false" class="btn-primary btn-sm">保存</button>
</div>
<!-- Each field toggles between read and edit mode -->
<div class="flex justify-between py-3 border-b border-gray-100">
<span class="text-sm text-gray-500">工龄计算方式</span>
<span x-show="!editing" class="text-sm text-gray-900" x-text="data.tenureBasis"></span>
<select x-show="editing" x-model="data.tenureBasis" class="input-sm">
<option>从首次入职开始计算</option>
</select>
</div>
</div>
```
**Cancel rule**: Always snapshot data before entering edit mode. On cancel, restore from snapshot (3 lines). Never leave the user with unsaved partial edits on cancel.
---
## Responsive Breakpoints
**Strategy**: Desktop-first (target users are ≥ 85% desktop/Windows Electron client).
| Breakpoint | Tailwind prefix | Viewport | Target |
|---|---|---|---|
| Desktop | (base, no prefix) | > 1280px | Primary design target |
| Laptop | `lg:` | ≥ 1024px | Minor layout adjustments |
| Tablet | `md:` | ≥ 768px | Collapsed sidebar |
| Mobile | `sm:` | ≥ 640px | Single column, no tables |
**Component-specific rules**:
- Data tables → `overflow-x-auto` on `md` and below
- Sidebar → collapses to icon-only or hidden on `md`; bottom nav on `sm`
- Modals → full-screen (`inset-0 rounded-none`) on `sm`
- Multi-column forms → single column on `md` and below (`md:grid-cols-1`)
- Drawers → full-width on `sm` (`sm:w-full`)
**Never**:
- Hide critical functionality on mobile — adapt it, do not remove it
- Use fixed px widths for layout containers (use `max-w-*` instead)
- Assume touch input on desktop
---
## Motion & Animation
**Principle**: Motion communicates state, it does not decorate.
**Duration scale**:
| Token | Duration | Use for |
|---|---|---|
| `duration-100` | 100ms | Micro-interactions (checkbox tick, toggle) |
| `duration-200` | 200ms | Most transitions (hover, focus, fade) — default |
| `duration-300` | 300ms | Larger elements (modal enter, drawer slide) |
| `duration-500` | 500ms | Page-level transitions only |
**Easing**:
- Default UI transitions: `ease-out` (enter) / `ease-in` (leave)
- Playful/spring: avoid in this product — we are a business tool
- Progress bars: `linear`
**What to animate**:
- `opacity` — enter/exit fades
- `transform: translateY / translateX` — panel slide-in
- `max-height` with `x-collapse` — accordion expand
**Never animate**:
- `width` or `height` directly — use `max-height` with `x-collapse` or `transform`
- `top / left / right / bottom` — use `transform: translate` instead
- `box-shadow` on hover — use opacity-layered pseudo-element trick instead
- Anything if `prefers-reduced-motion: reduce` is set:
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
---
## Accessibility (Non-negotiable)
**Every component must**:
- Meet WCAG 2.1 AA contrast ratios (4.5:1 for body text, 3:1 for large text ≥ 18px)
- Be fully keyboard navigable (Tab, Shift+Tab, Enter, Space, Escape)
- Have visible focus indicators — never `outline: none` without a custom replacement using `focus-visible:`
- Work with screen readers (proper ARIA roles and labels)
**Required patterns**:
| Element | Requirement |
|---|---|
| Icon-only buttons | `aria-label="操作名称"` always |
| Form inputs | `id` attribute + `<label for="...">` pairing always |
| Images | `alt` text always (empty `alt=""` for decorative images) |
| Modals | `role="dialog"` + `aria-modal="true"` + focus trap (`x-trap`) |
| Loading states | `aria-busy="true"` on the loading container |
| Error messages | `aria-describedby` linking input `id` to error `id` |
| Toggle switches | `role="switch"` + `aria-checked` |
| Status indicators | color + icon + text (never color alone) |
**Color alone is never enough**:
- Status badges: color background + icon + text label
- Form errors: red border + error icon + text message below field
- Charts: color + pattern or direct label
---
## UI Anti-patterns — Never Do These
**Layout**:
- Body text wider than 72 characters without `max-w-prose`
- Full-width buttons on desktop (use `max-w-xs` or `w-auto`)
- Mixing card and table layouts in the same list view
**Interaction**:
- Double-click to perform actions — single click only
- Drag-and-drop as the *only* way to reorder — always provide an alternative
- Hover-only affordances (invisible until hovered)
- Auto-submitting forms on field change without explicit "保存" action
**Feedback**:
- Generic error messages: "出错了" or "Something went wrong" — always be specific
- Success messages that do not tell the user what happened ("已保存" with no context)
- Blocking the UI with a modal spinner for optimistic actions
- Using `window.alert()`, `window.confirm()`, or `window.prompt()` — use our Dialog component
**Content**:
- Lorem ipsum in any committed code or template
- Hardcoded user names, emails, or phone numbers in components
- Placeholder images — use the Avatar initials fallback component
**Spacing**:
- `p-5`, `p-7`, `p-9`, `p-10`, `p-11` — off-grid values, never use
- Arbitrary values like `p-[13px]` — always round to the nearest grid value
---
## Third-party Libraries Approved for Use
The following libraries are pre-approved. Do not introduce any library not on this list without updating this document.
| Library | Version | Purpose | CDN size |
|---|---|---|---|
| HTMX | 2.x | Partial DOM updates, server interactions | ~14KB |
| Alpine.js | 3.x | Frontend state management | ~15KB |
| Alpine `@alpinejs/collapse` | official | Smooth accordion height animation | ~1KB |
| Alpine `@alpinejs/focus` | official | Focus trapping in modals | ~3KB |
| Tailwind CSS | 3.x | Utility-first styling | (purged) |
| Flatpickr | 4.x | Date range picker | ~16KB |
| Filepond | 4.x | File upload with preview | ~50KB |
| SortableJS | 1.x | Drag-to-reorder lists and grids | ~3KB |
| Viewer.js | 1.x | Image lightbox preview | ~5KB |
**Never introduce**: React, Vue, Angular, jQuery, Lodash, Moment.js, Bootstrap, or any component library built for a JS framework.