Sync: add prd and ui system notes
This commit is contained in:
280
Project/fonrey/PRD/PRD_MVP.md
Normal file
280
Project/fonrey/PRD/PRD_MVP.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# Fonrey 房睿 — MVP 范围书
|
||||
|
||||
**Status**: Draft
|
||||
**Author**: Product Team
|
||||
**Last Updated**: 2026-04-24
|
||||
**Version**: 1.0
|
||||
|
||||
> **For AI assistants**: 本文件定义 Phase 1(MVP)的边界。在任何功能实现前,先对照本文确认是否在范围内。范围外的功能禁止在 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** | 正式发布 | 新房模块、合同/财务模块路线图确认 |
|
||||
987
Project/fonrey/UI&UX/UI_SYSTEM.md
Normal file
987
Project/fonrey/UI&UX/UI_SYSTEM.md
Normal 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 条,第 1–25 条"
|
||||
|
||||
**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.
|
||||
Reference in New Issue
Block a user