Files
nexus/Project/fonrey/UI_SYSTEM/组件规范设计.md

1993 lines
72 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Fonrey 组件规范设计文档
> **版本**v1.0 · **日期**2026-04-25
> **依赖基准**`UI_SYSTEM.md v1.1`
> **技术栈**Tailwind CSS + HTMX + Alpine.js + Django 模板(非 JSX
> **设计语言**:专业克制高密度;主色 Teal `#0F766E`;圆角 `rounded-lg`8px桌面优先 ≥1280px
---
## 目录
1. [Data Table — 可排序多选数据表格](#1-data-table)
2. [Pagination — 分页组件](#2-pagination)
3. [Column Visibility Panel — 自定义列显示](#3-column-visibility-panel)
4. [Toolbar — 操作工具栏](#4-toolbar)
5. [Export Button — 导出按钮](#5-export-button)
6. [Smart Sort — 智能排序切换](#6-smart-sort)
7. [Modal Dialog — 模态对话框](#7-modal-dialog)
8. [Tree Select — 树形下拉选择器](#8-tree-select)
9. [Date Range Picker — 日期范围选择器](#9-date-range-picker)
10. [Tab Navigation — 标签页导航](#10-tab-navigation)
11. [Collapsible Card Grid — 可折叠卡片网格](#11-collapsible-card-grid)
12. [Photo Gallery Manager — 相册管理器](#12-photo-gallery-manager)
13. [Image Lightbox Viewer — 全屏图片灯箱](#13-image-lightbox-viewer)
14. [Accordion Progress Panel — 折叠进度检查面板](#14-accordion-progress-panel)
15. [Inline Edit Mode — 页面级读写切换](#15-inline-edit-mode)
16. [Drawer / Slide-over Panel — 右侧抽屉面板](#16-drawer--slide-over-panel)
17. [Multi-select Tag Input — 多选标签选择器](#17-multi-select-tag-input)
18. [Dynamic Form Table — 动态可增删行表格](#18-dynamic-form-table)
19. [Sortable Table with Drag Handle — 带拖拽手柄排序表格](#19-sortable-table-with-drag-handle)
20. [Multi-Table Independent Pagination — 同页多表格独立分页](#20-multi-table-independent-pagination)
---
## 1. Data Table
**正式名称**Sortable Data Table with Column Visibility Control
### 1.1 视觉规格
| 属性 | 值 |
|---|---|
| 行高 | 56px`h-14` |
| 表头背景 | `bg-neutral-50` |
| 表头文字 | `text-xs font-semibold text-neutral-500 uppercase tracking-wide` |
| 行分割线 | `divide-y divide-neutral-100` |
| 悬停行 | `hover:bg-neutral-50` |
| 选中行高亮 | `bg-primary-50` |
| 圆角容器 | `rounded-lg border border-neutral-200 overflow-hidden` |
### 1.2 子特性规范
| 子特性 | Token / 类名 | 说明 |
|---|---|---|
| 排序箭头(未排序) | `text-neutral-300` chevron-up-down icon | 双向箭头,`w-4 h-4` |
| 排序箭头(升序激活) | `text-primary-600` chevron-up icon | 单向箭头 |
| 排序箭头(降序激活) | `text-primary-600` chevron-down icon | 单向箭头 |
| Checkbox 多选 | `w-4 h-4 rounded accent-primary-600` | Alpine.js 管理 `selected[]` 数组 |
| 状态 Tag出售 | `bg-primary-50 text-primary-700 text-xs px-2 py-0.5 rounded-full` | |
| 状态 Tag出租 | `bg-info-50 text-info-600 text-xs px-2 py-0.5 rounded-full` | |
| 状态 Tag待核验 | `bg-warning-50 text-warning-600 text-xs px-2 py-0.5 rounded-full` | |
| 状态 Tag已下架 | `bg-neutral-100 text-neutral-500 text-xs px-2 py-0.5 rounded-full` | |
| 价格趋势箭头(上涨) | `text-success-600` arrow-up icon `w-3 h-3` | Alpine.js 条件渲染 |
| 价格趋势箭头(下跌) | `text-danger-600` arrow-down icon `w-3 h-3` | |
| 横向滚动 | `overflow-x-auto` 包裹 `<table>` | 配合列 `min-w-[120px]` |
### 1.3 HTML 结构
```html
<div x-data="dataTable()" class="rounded-lg border border-neutral-200 overflow-hidden">
<!-- 表格容器:横向滚动 -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-neutral-200">
<!-- 表头 -->
<thead class="bg-neutral-50">
<tr>
<!-- 全选 Checkbox -->
<th scope="col" class="w-10 px-4 py-3">
<input type="checkbox"
class="w-4 h-4 rounded accent-primary-600"
@change="toggleAll($event.target.checked)"
:checked="allSelected">
</th>
<!-- 可排序列头 -->
<th scope="col"
class="px-4 py-3 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide cursor-pointer select-none"
@click="sort('title')">
<span class="inline-flex items-center gap-1">
房源标题
<!-- 排序图标:默认双向,激活后单向 -->
<svg class="w-4 h-4"
:class="sortKey==='title' ? 'text-primary-600' : 'text-neutral-300'"
aria-hidden="true"><!-- chevron-up-down / chevron-up / chevron-down --></svg>
</span>
</th>
<!-- 不可排序列头 -->
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
状态
</th>
</tr>
</thead>
<!-- 表体 -->
<tbody class="bg-white divide-y divide-neutral-100">
<tr class="hover:bg-neutral-50 transition-colors"
:class="selected.includes(row.id) ? 'bg-primary-50' : ''"
x-for="row in rows" :key="row.id">
<!-- 行 Checkbox -->
<td class="px-4 py-3">
<input type="checkbox"
class="w-4 h-4 rounded accent-primary-600"
:value="row.id"
x-model="selected">
</td>
<!-- 房源标题 -->
<td class="px-4 py-3 text-sm text-neutral-800 font-medium whitespace-nowrap">
<span x-text="row.title"></span>
</td>
<!-- 状态 Tag -->
<td class="px-4 py-3">
<span class="text-xs px-2 py-0.5 rounded-full"
:class="{
'bg-primary-50 text-primary-700': row.status==='在售',
'bg-warning-50 text-warning-600': row.status==='待核验',
'bg-neutral-100 text-neutral-500': row.status==='已下架',
'bg-info-50 text-info-600': row.status==='成交'
}"
x-text="row.status">
</span>
</td>
<!-- 价格 + 趋势箭头 -->
<td class="px-4 py-3 text-sm tabular-nums">
<span class="inline-flex items-center gap-1">
<span x-text="row.price + ' 万'"></span>
<svg x-show="row.trend==='up'" class="w-3 h-3 text-success-600" aria-hidden="true"><!-- arrow-up --></svg>
<svg x-show="row.trend==='down'" class="w-3 h-3 text-danger-600" aria-hidden="true"><!-- arrow-down --></svg>
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
```
### 1.4 Alpine.js 数据结构
```javascript
function dataTable() {
return {
rows: [], // 由 Django 后端渲染 JSON 或 HTMX 注入
selected: [], // 选中行 ID 数组
sortKey: '',
sortDir: 'asc',
get allSelected() {
return this.rows.length > 0 && this.selected.length === this.rows.length
},
toggleAll(checked) {
this.selected = checked ? this.rows.map(r => r.id) : []
},
sort(key) {
if (this.sortKey === key) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc'
} else {
this.sortKey = key
this.sortDir = 'asc'
}
// 触发 HTMX 请求,携带排序参数
htmx.trigger('#table-container', 'sort-change',
{ key: this.sortKey, dir: this.sortDir })
}
}
}
```
### 1.5 HTMX 排序集成
```html
<!-- 表格外层容器绑定 HTMX排序/筛选通过 hx-get 重刷表体 -->
<div id="table-container"
hx-get="/properties/"
hx-trigger="sort-change"
hx-target="#table-body"
hx-swap="innerHTML"
hx-include="[name='sort_key'],[name='sort_dir'],[name='q']">
```
---
## 2. Pagination
### 2.1 视觉规格
| 属性 | 值 |
|---|---|
| 容器布局 | `flex items-center justify-between px-4 py-3 border-t border-neutral-200` |
| 总条数文字 | `text-sm text-neutral-500` |
| 页码按钮尺寸 | `w-8 h-8` `rounded-md` `text-sm` |
| 当前页 | `bg-primary-600 text-white font-medium` |
| 普通页码 | `text-neutral-600 hover:bg-neutral-100` |
| 禁用状态 | `opacity-40 cursor-not-allowed` |
| 省略号 | `text-neutral-400 px-1` |
### 2.2 HTML 结构
```html
<div class="flex items-center justify-between px-4 py-3 border-t border-neutral-200 bg-white">
<!-- 左侧:总条数 -->
<span class="text-sm text-neutral-500">
<span class="font-medium text-neutral-800">{{ paginator.count }}</span>
</span>
<!-- 中间:页码 -->
<nav class="inline-flex items-center gap-1" aria-label="分页">
<!-- 上一页 -->
<button class="w-8 h-8 flex items-center justify-center rounded-md text-neutral-600 hover:bg-neutral-100 disabled:opacity-40 disabled:cursor-not-allowed"
{% if not page_obj.has_previous %}disabled{% endif %}
hx-get="?page={{ page_obj.previous_page_number }}"
hx-target="#table-body"
hx-push-url="true"
aria-label="上一页">
<svg class="w-4 h-4" aria-hidden="true"><!-- chevron-left --></svg>
</button>
<!-- 页码列表Django 后端生成) -->
{% for num in page_range %}
{% if num == page_obj.number %}
<button class="w-8 h-8 flex items-center justify-center rounded-md bg-primary-600 text-white text-sm font-medium"
aria-current="page">{{ num }}</button>
{% elif num == '…' %}
<span class="px-1 text-neutral-400"></span>
{% else %}
<button class="w-8 h-8 flex items-center justify-center rounded-md text-sm text-neutral-600 hover:bg-neutral-100"
hx-get="?page={{ num }}"
hx-target="#table-body"
hx-push-url="true">{{ num }}</button>
{% endif %}
{% endfor %}
<!-- 下一页 -->
<button class="w-8 h-8 flex items-center justify-center rounded-md text-neutral-600 hover:bg-neutral-100 disabled:opacity-40 disabled:cursor-not-allowed"
{% if not page_obj.has_next %}disabled{% endif %}
hx-get="?page={{ page_obj.next_page_number }}"
hx-target="#table-body"
hx-push-url="true"
aria-label="下一页">
<svg class="w-4 h-4" aria-hidden="true"><!-- chevron-right --></svg>
</button>
</nav>
<!-- 右侧:每页条数 + 跳至 -->
<div class="flex items-center gap-3 text-sm text-neutral-500">
<!-- 每页条数 -->
<div x-data="{ open: false }" class="relative">
<button @click="open = !open"
@click.away="open = false"
class="flex items-center gap-1 px-2 py-1 rounded border border-neutral-200 hover:bg-neutral-50">
<span>{{ page_size }}条/页</span>
<svg class="w-3 h-3" aria-hidden="true"><!-- chevron-down --></svg>
</button>
<div x-show="open"
class="absolute right-0 bottom-full mb-1 bg-white border border-neutral-200 rounded-lg shadow-lg py-1 w-24 z-10">
{% for size in [10, 20, 50, 100] %}
<button class="w-full text-left px-3 py-1.5 text-sm hover:bg-neutral-50"
hx-get="?page_size={{ size }}&page=1"
hx-target="#table-body"
@click="open = false">{{ size }}条/页</button>
{% endfor %}
</div>
</div>
<!-- 跳至 -->
<div class="flex items-center gap-1.5">
<span>跳至</span>
<input type="number" min="1" max="{{ paginator.num_pages }}"
class="w-12 px-2 py-1 text-center rounded border border-neutral-200 text-sm focus:outline-none focus:ring-2 focus:ring-primary-600/40"
@keydown.enter="htmx.ajax('GET', '?page=' + $el.value, {target: '#table-body', pushUrl: true})">
<span></span>
</div>
</div>
</div>
```
### 2.3 Django 后端辅助函数
```python
def get_page_range(page_obj, on_each_side=2, on_ends=1):
"""生成含省略号的页码列表,供模板渲染"""
paginator = page_obj.paginator
current = page_obj.number
total = paginator.num_pages
result = []
left = set(range(1, on_ends + 1))
right = set(range(total - on_ends + 1, total + 1))
middle = set(range(current - on_each_side, current + on_each_side + 1))
visible = sorted(left | right | middle)
prev = None
for num in visible:
if 1 <= num <= total:
if prev and num - prev > 1:
result.append('')
result.append(num)
prev = num
return result
```
---
## 3. Column Visibility Panel
### 3.1 视觉规格
| 属性 | 值 |
|---|---|
| 触发按钮 | Secondary 按钮,`adjustments-horizontal` 图标 |
| 面板宽度 | `w-64` |
| 面板位置 | 右对齐弹出,`absolute right-0 top-full mt-1` |
| 面板层级 | `z-20` |
| 列项高度 | `h-9` |
| Checkbox 颜色 | `accent-primary-600` |
### 3.2 HTML 结构
```html
<div x-data="columnVisibility()" class="relative">
<!-- 触发按钮 -->
<button @click="open = !open"
@click.away="open = false"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-neutral-700
border border-neutral-200 rounded-md bg-white hover:bg-neutral-50
focus-visible:ring-2 focus-visible:ring-primary-600/40">
<svg class="w-4 h-4" aria-hidden="true"><!-- adjustments-horizontal --></svg>
自定义列
</button>
<!-- 下拉面板 -->
<div x-show="open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="absolute right-0 top-full mt-1 w-64 bg-white rounded-lg border border-neutral-200 shadow-lg z-20">
<!-- 面板标题 -->
<div class="px-3 py-2.5 border-b border-neutral-100 flex items-center justify-between">
<span class="text-sm font-semibold text-neutral-700">显示列</span>
<button @click="resetToDefault()" class="text-xs text-primary-600 hover:text-primary-700">恢复默认</button>
</div>
<!-- 列列表 -->
<div class="py-1 max-h-72 overflow-y-auto">
<template x-for="col in columns" :key="col.key">
<label class="flex items-center gap-2.5 px-3 h-9 hover:bg-neutral-50 cursor-pointer"
:class="col.locked ? 'opacity-50 cursor-not-allowed' : ''">
<input type="checkbox"
class="w-4 h-4 rounded accent-primary-600"
x-model="col.visible"
:disabled="col.locked"
@change="savePreferences()">
<span class="text-sm text-neutral-700" x-text="col.label"></span>
<span x-show="col.locked" class="ml-auto text-xs text-neutral-400">固定</span>
</label>
</template>
</div>
</div>
</div>
```
### 3.3 Alpine.js 数据结构
```javascript
function columnVisibility() {
const STORAGE_KEY = 'fonrey:table:property:cols'
return {
open: false,
columns: [
{ key: 'checkbox', label: '多选', visible: true, locked: true },
{ key: 'code', label: '房源编号', visible: true, locked: true },
{ key: 'title', label: '房源标题', visible: true, locked: false },
{ key: 'status', label: '状态', visible: true, locked: false },
{ key: 'price', label: '价格', visible: true, locked: false },
{ key: 'area', label: '面积', visible: true, locked: false },
{ key: 'district', label: '商圈', visible: false, locked: false },
{ key: 'agent', label: '经纪人', visible: true, locked: false },
{ key: 'created', label: '录入时间', visible: false, locked: false },
],
init() {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const savedCols = JSON.parse(saved)
this.columns = this.columns.map(col => {
const s = savedCols.find(c => c.key === col.key)
return s ? { ...col, visible: s.visible } : col
})
}
},
savePreferences() {
localStorage.setItem(STORAGE_KEY,
JSON.stringify(this.columns.map(c => ({ key: c.key, visible: c.visible })))
)
},
resetToDefault() {
localStorage.removeItem(STORAGE_KEY)
this.columns.forEach(col => { if (!col.locked) col.visible = true })
}
}
}
```
---
## 4. Toolbar
### 4.1 视觉规格
| 属性 | 值 |
|---|---|
| 容器 | `flex items-center gap-2 py-2` |
| 批量按钮(未选中) | `opacity-50 cursor-not-allowed pointer-events-none` |
| 批量按钮(已选中) | Secondary 按钮正常态 |
| 选中计数 badge | `bg-primary-600 text-white text-xs px-1.5 py-0.5 rounded-full min-w-[20px] text-center` |
| "更多"下拉 | `w-40` Dropdown`ellipsis-horizontal` 图标 |
### 4.2 HTML 结构
```html
<div x-data="toolbar()" class="flex items-center gap-2 py-2">
<!-- 总条数 -->
<span class="text-sm text-neutral-500 mr-2">
<span class="font-medium text-neutral-800">{{ total_count }}</span>
</span>
<!-- 选中计数提示(有选中时出现) -->
<template x-if="selectedCount > 0">
<span class="text-sm text-primary-600 font-medium">
已选 <span x-text="selectedCount"></span>
</span>
</template>
<!-- 批量操作按钮(依赖选中状态) -->
<button class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md border transition-colors"
:class="selectedCount > 0
? 'border-neutral-200 text-neutral-700 bg-white hover:bg-neutral-50'
: 'border-neutral-100 text-neutral-300 bg-neutral-50 cursor-not-allowed'"
:disabled="selectedCount === 0"
hx-post="/properties/bulk-share/"
:hx-vals="JSON.stringify({ ids: selectedIds })">
<svg class="w-4 h-4" aria-hidden="true"><!-- share --></svg>
批量分享
</button>
<button class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md border transition-colors"
:class="selectedCount > 0
? 'border-neutral-200 text-neutral-700 bg-white hover:bg-neutral-50'
: 'border-neutral-100 text-neutral-300 bg-neutral-50 cursor-not-allowed'"
:disabled="selectedCount === 0">
<svg class="w-4 h-4" aria-hidden="true"><!-- star --></svg>
批量收藏
</button>
<!-- 更多下拉 -->
<div x-data="{ open: false }" class="relative">
<button @click="open = !open"
@click.away="open = false"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md border border-neutral-200 text-neutral-700 bg-white hover:bg-neutral-50">
更多
<svg class="w-4 h-4" aria-hidden="true"><!-- chevron-down --></svg>
</button>
<div x-show="open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="absolute left-0 top-full mt-1 w-40 bg-white rounded-lg border border-neutral-200 shadow-lg py-1 z-20">
<button class="w-full flex items-center gap-2 px-3 py-2 text-sm text-neutral-700 hover:bg-neutral-50">
<svg class="w-4 h-4" aria-hidden="true"><!-- key --></svg>
设置保护房
</button>
<button class="w-full flex items-center gap-2 px-3 py-2 text-sm text-danger-600 hover:bg-danger-50">
<svg class="w-4 h-4" aria-hidden="true"><!-- trash --></svg>
批量删除
</button>
</div>
</div>
</div>
```
---
## 5. Export Button
### 5.1 两种模式
| 模式 | 触发条件 | 实现方式 |
|---|---|---|
| 同步导出 | 数据量 ≤5000 条 | Django 视图直接返回 `FileResponse`,浏览器触发下载 |
| 异步导出 | 数据量 >5000 条 | Celery 任务异步生成,完成后推送 Toast 通知(含下载链接) |
### 5.2 HTML 结构
```html
<!-- 同步导出(小数据量) -->
<a href="/properties/export/?{{ query_string }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md
border border-neutral-200 text-neutral-700 bg-white hover:bg-neutral-50">
<svg class="w-4 h-4" aria-hidden="true"><!-- arrow-down-tray --></svg>
导出
</a>
<!-- 异步导出大数据量HTMX 触发任务) -->
<button hx-post="/properties/export-async/"
hx-target="#toast-container"
hx-swap="beforeend"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md
border border-neutral-200 text-neutral-700 bg-white hover:bg-neutral-50">
<svg class="w-4 h-4" aria-hidden="true"><!-- arrow-down-tray --></svg>
导出
</button>
```
---
## 6. Smart Sort
### 6.1 视觉规格
| 状态 | 样式 |
|---|---|
| 未激活 | `border border-neutral-200 text-neutral-600 bg-white` |
| 激活 | `border border-primary-600 text-primary-600 bg-primary-50` |
### 6.2 HTML 结构
```html
<div x-data="{ smartSort: false }" class="flex items-center gap-1">
<button @click="smartSort = !smartSort"
:class="smartSort
? 'border-primary-600 text-primary-600 bg-primary-50'
: 'border-neutral-200 text-neutral-600 bg-white hover:bg-neutral-50'"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md border transition-colors"
hx-get="/properties/"
:hx-vals="JSON.stringify({ smart_sort: smartSort })"
hx-target="#table-body">
<svg class="w-4 h-4" aria-hidden="true"><!-- sparkles --></svg>
智能排序
</button>
</div>
```
---
## 7. Modal Dialog
### 7.1 视觉规格
| 属性 | 值 |
|---|---|
| 遮罩 | `fixed inset-0 bg-black/50 z-40` |
| 面板宽度 | `w-full max-w-lg`(默认)/ `max-w-2xl`(宽型) |
| 面板圆角 | `rounded-xl` |
| 面板阴影 | `shadow-2xl` |
| Header 高度 | `h-14``border-b border-neutral-200` |
| Footer 高度 | `h-16``border-t border-neutral-200` |
| 拖拽手柄 | `cursor-grab active:cursor-grabbing text-neutral-300 hover:text-neutral-500` |
### 7.2 HTML 结构
```html
<div x-data="modal()" x-on:keydown.escape.window="close()">
<!-- 触发按钮 -->
<button @click="open()" class="...">编辑价格</button>
<!-- 遮罩 + 面板 -->
<template x-teleport="body">
<div x-show="isOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4"
@click.self="close()">
<div x-show="isOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="relative w-full max-w-lg bg-white rounded-xl shadow-2xl flex flex-col max-h-[90vh]"
@click.stop>
<!-- 拖拽手柄(可选功能,需 @alpinejs/drag -->
<div class="absolute top-3 left-3 cursor-grab active:cursor-grabbing text-neutral-300 hover:text-neutral-500"
aria-hidden="true">
<svg class="w-5 h-5"><!-- grip-vertical --></svg>
</div>
<!-- Header -->
<div class="flex items-center justify-between h-14 px-6 border-b border-neutral-200 shrink-0">
<h2 class="text-base font-semibold text-neutral-800" id="modal-title">编辑房源价格</h2>
<button @click="close()"
class="p-1 rounded-md text-neutral-400 hover:text-neutral-600 hover:bg-neutral-100
focus-visible:ring-2 focus-visible:ring-primary-600/40"
aria-label="关闭">
<svg class="w-5 h-5" aria-hidden="true"><!-- x-mark --></svg>
</button>
</div>
<!-- Body可滚动 -->
<div class="flex-1 overflow-y-auto px-6 py-5">
<!-- 表单字段:带后缀单位输入框 -->
<div class="space-y-4">
<div>
<label for="price" class="block text-sm font-medium text-neutral-700 mb-1">
售价 <span class="text-danger-600">*</span>
</label>
<div class="flex rounded-md border border-neutral-200 focus-within:ring-2 focus-within:ring-primary-600/40">
<input type="number" id="price" name="price"
class="flex-1 min-w-0 px-3 py-2 text-sm bg-transparent outline-none"
placeholder="请输入售价">
<span class="flex items-center px-3 text-sm text-neutral-500 bg-neutral-50 border-l border-neutral-200 rounded-r-md"></span>
</div>
</div>
<!-- 多行文本域 + 字数统计 -->
<div>
<label for="reason" class="block text-sm font-medium text-neutral-700 mb-1">更改理由</label>
<div class="relative">
<textarea id="reason" name="reason" rows="3"
class="w-full px-3 py-2 text-sm border border-neutral-200 rounded-md
focus:outline-none focus:ring-2 focus:ring-primary-600/40 resize-none"
maxlength="200"
x-model="reason"
placeholder="请说明价格调整原因"></textarea>
<span class="absolute bottom-2 right-2 text-xs text-neutral-400"
x-text="reason.length + '/200'"></span>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-2 h-16 px-6 border-t border-neutral-200 shrink-0">
<button @click="close()"
class="px-4 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-200
rounded-md hover:bg-neutral-50 focus-visible:ring-2 focus-visible:ring-primary-600/40">
取消
</button>
<button type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md
hover:bg-primary-700 focus-visible:ring-2 focus-visible:ring-primary-600/40">
确认
</button>
</div>
</div>
</div>
</template>
</div>
```
### 7.3 Alpine.js 数据结构
```javascript
function modal() {
return {
isOpen: false,
reason: '',
open() { this.isOpen = true },
close() { this.isOpen = false; this.reason = '' }
}
}
```
---
## 8. Tree Select
### 8.1 视觉规格
| 属性 | 值 |
|---|---|
| 触发框 | `h-9 px-3 text-sm border border-neutral-200 rounded-md` |
| 面板宽度 | `w-72` |
| 父节点行高 | `h-9` |
| 子节点行高 | `h-9 pl-8`(左缩进) |
| 展开箭头 | `w-4 h-4 transition-transform`,展开时 `rotate-90` |
| 已选节点 | `bg-primary-50 text-primary-700` |
| 带头像叶节点 | `w-6 h-6 rounded-full bg-primary-100 text-primary-700 text-xs flex items-center justify-center` |
| Badge关闭状态 | `bg-warning-50 text-warning-600 text-xs px-1.5 py-0.5 rounded-full` |
| 底部操作行 | `sticky bottom-0 border-t border-neutral-100 bg-white px-3 py-2` |
### 8.2 推荐实现方案
**方案一(默认,数据量 ≤200 节点)**:后端一次性返回完整 JSONAlpine.js 前端递归渲染
**方案二(数据量大)**HTMX 懒加载子节点(点击展开时 `hx-get` 请求子数据)
### 8.3 HTML 结构(方案一)
```html
<div x-data="treeSelect()" class="relative" @click.away="open = false">
<!-- 触发框 -->
<button @click="open = !open"
class="w-full flex items-center justify-between h-9 px-3 text-sm border border-neutral-200
rounded-md bg-white hover:border-neutral-300 focus-visible:ring-2 focus-visible:ring-primary-600/40">
<span :class="selected ? 'text-neutral-800' : 'text-neutral-400'"
x-text="selected ? selected.label : '请选择经纪人'"></span>
<svg class="w-4 h-4 text-neutral-400" :class="open ? 'rotate-180' : ''" aria-hidden="true"><!-- chevron-down --></svg>
</button>
<!-- 下拉面板 -->
<div x-show="open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="absolute left-0 top-full mt-1 w-72 bg-white border border-neutral-200 rounded-lg shadow-lg z-30 flex flex-col max-h-72">
<!-- 搜索框 -->
<div class="px-2 py-2 border-b border-neutral-100 shrink-0">
<input x-model="query"
type="text"
placeholder="搜索员工/门店"
class="w-full h-7 px-2.5 text-sm border border-neutral-200 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-600/40"
@click.stop>
</div>
<!-- 树形节点列表 -->
<div class="flex-1 overflow-y-auto py-1">
<template x-for="group in filteredTree" :key="group.id">
<div x-data="{ groupOpen: true }">
<!-- 父节点(门店/公司) -->
<button @click="groupOpen = !groupOpen"
class="w-full flex items-center gap-2 h-9 px-3 hover:bg-neutral-50 text-sm text-neutral-700 font-medium">
<svg class="w-4 h-4 text-neutral-400 transition-transform shrink-0"
:class="groupOpen ? 'rotate-90' : ''" aria-hidden="true"><!-- chevron-right --></svg>
<span x-text="group.label"></span>
<!-- Badge如"关闭"状态) -->
<span x-show="group.badge"
class="ml-auto text-xs px-1.5 py-0.5 rounded-full bg-warning-50 text-warning-600"
x-text="group.badge"></span>
</button>
<!-- 子节点 -->
<div x-show="groupOpen" x-collapse>
<template x-for="leaf in group.children" :key="leaf.id">
<button @click="selectLeaf(leaf)"
class="w-full flex items-center gap-2 h-9 pl-8 pr-3 hover:bg-neutral-50 text-sm"
:class="selected?.id === leaf.id ? 'bg-primary-50 text-primary-700' : 'text-neutral-700'">
<!-- 头像(字符头像) -->
<span class="w-6 h-6 rounded-full bg-primary-100 text-primary-700 text-xs
flex items-center justify-center shrink-0 font-medium"
x-text="leaf.label[0]"></span>
<span x-text="leaf.label" class="flex-1 text-left truncate"></span>
<span class="text-xs text-neutral-400" x-text="leaf.code"></span>
</button>
</template>
</div>
</div>
</template>
<!-- 空状态 -->
<div x-show="filteredTree.length === 0" class="py-8 text-center text-sm text-neutral-400">
暂无匹配结果
</div>
</div>
<!-- 底部操作行 -->
<div class="sticky bottom-0 border-t border-neutral-100 bg-white px-3 py-2 shrink-0">
<label class="flex items-center gap-2 text-xs text-neutral-500 cursor-pointer">
<input type="checkbox" x-model="hideInactive" class="w-3.5 h-3.5 rounded accent-primary-600">
隐藏离职员工
</label>
</div>
</div>
</div>
```
### 8.4 Alpine.js 数据结构
```javascript
function treeSelect() {
return {
open: false,
query: '',
selected: null,
hideInactive: false,
tree: [], // 从 Django 后端注入:[{ id, label, badge, children: [{id, label, code}] }]
get filteredTree() {
if (!this.query) return this.tree
const q = this.query.toLowerCase()
return this.tree
.map(group => ({
...group,
children: group.children.filter(leaf =>
leaf.label.includes(q) || leaf.code?.includes(q)
)
}))
.filter(group => group.children.length > 0)
},
selectLeaf(leaf) {
this.selected = leaf
this.open = false
this.query = ''
}
}
}
```
---
## 9. Date Range Picker
### 9.1 技术选型
**使用 Flatpickr**CDN~16KB无框架依赖不手写。
```html
<!-- CDN 引入 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/zh.js"></script>
```
### 9.2 初始化配置
```javascript
flatpickr('#dateRange', {
mode: 'range',
showMonths: 2,
locale: 'zh',
dateFormat: 'Y-m-d',
allowInput: true,
onReady(_sel, _str, fp) {
fp.calendarContainer.classList.add('fonrey-calendar')
}
})
```
### 9.3 样式覆盖(配合主色 Teal
```css
/* static/css/flatpickr-overrides.css */
.flatpickr-day.selected,
.flatpickr-day.startRange,
.flatpickr-day.endRange {
background: #0F766E;
border-color: #0F766E;
}
.flatpickr-day.inRange {
background: #CCFBF1;
border-color: #CCFBF1;
color: #115E59;
}
.flatpickr-day.today {
border-color: #D97706;
}
```
### 9.4 HTML 结构
```html
<div class="relative">
<label class="block text-sm font-medium text-neutral-700 mb-1">日期范围</label>
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none"
aria-hidden="true"><!-- calendar --></svg>
<input type="text" id="dateRange" name="date_range"
placeholder="开始日期 → 结束日期"
class="w-full pl-9 pr-3 py-2 text-sm border border-neutral-200 rounded-md
focus:outline-none focus:ring-2 focus:ring-primary-600/40"
readonly>
</div>
</div>
```
---
## 10. Tab Navigation
### 10.1 视觉规格
| 属性 | 值 |
|---|---|
| Tab 容器 | `border-b border-neutral-200` |
| Tab 按钮(未激活) | `h-10 px-4 text-sm text-neutral-500 hover:text-neutral-700` |
| Tab 按钮(激活) | `h-10 px-4 text-sm text-primary-600 font-medium border-b-2 border-primary-600` |
| 内容区顶部间距 | `mt-4` |
### 10.2 HTML 结构
```html
<div x-data="{ activeTab: 'all' }">
<!-- Tab 标签栏 -->
<div class="flex border-b border-neutral-200" role="tablist">
<template x-for="tab in tabs" :key="tab.key">
<button @click="activeTab = tab.key"
:class="activeTab === tab.key
? 'h-10 px-4 text-sm text-primary-600 font-medium border-b-2 border-primary-600 -mb-px'
: 'h-10 px-4 text-sm text-neutral-500 hover:text-neutral-700'"
class="transition-colors"
role="tab"
:aria-selected="activeTab === tab.key"
x-text="tab.label">
</button>
</template>
</div>
<!-- Tab 内容区HTMX 懒加载) -->
<div class="mt-4">
<template x-for="tab in tabs" :key="tab.key">
<div x-show="activeTab === tab.key"
:id="'tab-panel-' + tab.key"
role="tabpanel"
hx-get="{{ tab.url }}"
hx-trigger="intersect once"
hx-target="this"
hx-swap="innerHTML">
<!-- Loading 骨架屏 -->
<div class="animate-pulse space-y-3">
<div class="h-4 bg-neutral-100 rounded w-3/4"></div>
<div class="h-4 bg-neutral-100 rounded w-1/2"></div>
</div>
</div>
</template>
</div>
</div>
```
### 10.3 Timeline 子组件
```html
<!-- 活动记录时间线 -->
<div class="relative pl-6">
<!-- 竖线 -->
<div class="absolute left-2.5 top-0 bottom-0 w-px bg-neutral-200"></div>
<div class="space-y-4" id="log-list">
{% for log in logs %}
<div class="relative">
<!-- 圆点 -->
<div class="absolute -left-[14px] top-1.5 w-3 h-3 rounded-full border-2 border-primary-600 bg-white"></div>
<!-- 内容 -->
<div class="text-sm">
<span class="font-medium text-neutral-800">{{ log.operator }}</span>
<span class="text-neutral-500 ml-1">{{ log.action }}</span>
<span class="text-neutral-400 ml-2 text-xs">{{ log.created_at }}</span>
</div>
<div class="text-sm text-neutral-600 mt-0.5">{{ log.detail }}</div>
</div>
{% endfor %}
</div>
<!-- 加载更多 -->
{% if has_more %}
<button class="mt-4 text-sm text-primary-600 hover:text-primary-700"
hx-get="/logs/?page={{ next_page }}"
hx-target="#log-list"
hx-swap="beforeend">
查看全部跟进
</button>
{% endif %}
</div>
```
---
## 11. Collapsible Card Grid
### 11.1 视觉规格
| 属性 | 值 |
|---|---|
| 外层卡片 | `bg-white rounded-lg border border-neutral-200` |
| Section Header | `flex items-center justify-between px-4 py-3 border-b border-neutral-100` |
| 网格列数 | `grid grid-cols-3 gap-4` |
| 员工卡片 | `p-3 rounded-lg border border-neutral-100 hover:border-neutral-200` |
| 头像尺寸 | `w-10 h-10 rounded-full` |
| 展开按钮 | `w-full flex items-center justify-center h-9 text-sm text-neutral-500 hover:bg-neutral-50 border-t border-neutral-100` |
### 11.2 HTML 结构
```html
<div x-data="{ expanded: false }" class="bg-white rounded-lg border border-neutral-200">
<!-- Section Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-neutral-100">
<h3 class="text-sm font-semibold text-neutral-800">相关员工</h3>
<button class="text-sm text-primary-600 hover:text-primary-700">编辑</button>
</div>
<!-- 内容区(折叠控制) -->
<div :class="expanded ? '' : 'max-h-48 overflow-hidden'"
class="p-4 transition-all duration-300">
<div class="grid grid-cols-3 gap-4">
<!-- 员工卡片 -->
{% for member in members %}
<div class="p-3 rounded-lg border border-neutral-100 hover:border-neutral-200 transition-colors">
<div class="flex items-start gap-2.5">
<!-- 头像 -->
<img src="{{ member.avatar }}" alt="{{ member.name }}"
class="w-10 h-10 rounded-full object-cover shrink-0">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-neutral-800 truncate">{{ member.name }}</div>
<div class="text-xs text-neutral-500 truncate">{{ member.store }}</div>
<div class="text-xs text-neutral-400 mt-0.5">{{ member.phone }}</div>
</div>
<!-- 更多按钮 -->
<div x-data="{ open: false }" class="relative shrink-0">
<button @click="open = !open"
@click.away="open = false"
class="text-neutral-400 hover:text-neutral-600 text-base leading-none">···</button>
<div x-show="open"
class="absolute right-0 top-full mt-1 w-28 bg-white border border-neutral-200 rounded-lg shadow-lg py-1 z-10">
<button class="w-full text-left px-3 py-1.5 text-xs text-neutral-700 hover:bg-neutral-50">查看主页</button>
<button class="w-full text-left px-3 py-1.5 text-xs text-danger-600 hover:bg-danger-50">移除</button>
</div>
</div>
</div>
<!-- 角色标签 -->
<div class="mt-2 text-xs text-primary-600">{{ member.role }}</div>
</div>
{% endfor %}
<!-- 空状态占位格 -->
{% if members|length < 3 %}
<div class="p-3 rounded-lg border border-dashed border-neutral-200 flex items-center justify-center">
<span class="text-xs text-neutral-400">暂未分配</span>
</div>
{% endif %}
</div>
</div>
<!-- 展开/收起按钮 -->
<button @click="expanded = !expanded"
class="w-full flex items-center justify-center gap-1 h-9 text-sm text-neutral-500
hover:bg-neutral-50 border-t border-neutral-100 transition-colors">
<span x-text="expanded ? '收起' : '展开全部'"></span>
<svg class="w-4 h-4 transition-transform" :class="expanded ? 'rotate-180' : ''" aria-hidden="true"><!-- chevron-down --></svg>
</button>
</div>
```
---
## 12. Photo Gallery Manager
### 12.1 组件构成
| 子组件 | 实现方式 |
|---|---|
| Scrollable Tab Bar分类标签 | Tailwind `overflow-x-auto flex` + Alpine.js 激活态 |
| Image Grid with Checkbox | Tailwind `grid grid-cols-6 gap-2` + Alpine.js 多选 |
| Batch Action Toolbar | Alpine.js `:disabled` 状态控制 |
| Drag-and-Drop Upload | **Filepond**CDN~50KB |
| Drag-to-Reorder | **SortableJS**CDN~3KB |
### 12.2 图片网格 HTML
```html
<div x-data="photoGallery()" class="space-y-4">
<!-- 分类 Tab 横向滚动 -->
<div class="flex gap-1 overflow-x-auto pb-1 border-b border-neutral-200">
<template x-for="cat in categories" :key="cat.id">
<button @click="activeCategory = cat.id"
:class="activeCategory === cat.id
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white text-neutral-600 border-neutral-200 hover:bg-neutral-50'"
class="shrink-0 px-3 h-7 text-xs font-medium border rounded-md transition-colors"
x-text="cat.label">
</button>
</template>
</div>
<!-- 批量操作栏 -->
<div class="flex items-center gap-2 h-9">
<label class="flex items-center gap-2 text-sm text-neutral-600 cursor-pointer">
<input type="checkbox" class="w-4 h-4 rounded accent-primary-600"
@change="toggleSelectAll($event.target.checked)"
:checked="allSelected">
全选
</label>
<span x-show="selectedPhotos.length > 0" class="text-sm text-primary-600 ml-2">
已选 <span x-text="selectedPhotos.length"></span>
</span>
<div class="ml-auto flex items-center gap-2">
<button :disabled="selectedPhotos.length === 0"
:class="selectedPhotos.length > 0 ? 'text-neutral-700 border-neutral-200 hover:bg-neutral-50' : 'text-neutral-300 border-neutral-100 cursor-not-allowed'"
class="px-3 h-7 text-xs border rounded-md">
批量修改类别
</button>
<button :disabled="selectedPhotos.length === 0"
:class="selectedPhotos.length > 0 ? 'text-danger-600 border-danger-200 hover:bg-danger-50' : 'text-neutral-300 border-neutral-100 cursor-not-allowed'"
class="px-3 h-7 text-xs border rounded-md">
批量删除
</button>
</div>
</div>
<!-- 图片网格 -->
<div id="photo-grid" class="grid grid-cols-6 gap-2">
<template x-for="photo in filteredPhotos" :key="photo.id">
<div class="relative group aspect-square rounded-lg overflow-hidden bg-neutral-100 cursor-pointer">
<!-- 图片 -->
<img :src="photo.url" :alt="photo.alt"
class="w-full h-full object-cover">
<!-- Checkbox悬停/选中时显示) -->
<div class="absolute top-1.5 left-1.5"
:class="selectedPhotos.includes(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'">
<input type="checkbox"
class="w-4 h-4 rounded accent-primary-600"
:value="photo.id"
x-model="selectedPhotos"
@click.stop>
</div>
<!-- 封面角标 -->
<div x-show="photo.is_cover"
class="absolute top-1.5 right-1.5 bg-danger-600 text-white text-xs px-1.5 py-0.5 rounded">
封面
</div>
<!-- 底部信息条 -->
<div class="absolute bottom-0 inset-x-0 bg-black/60 px-2 py-1">
<div class="text-white text-xs truncate" x-text="photo.category"></div>
</div>
</div>
</template>
</div>
</div>
```
### 12.3 Filepond 上传初始化
```javascript
// static/js/filepond-init.js
FilePond.registerPlugin(FilePondPluginImagePreview)
FilePond.setOptions({
server: {
process: '/api/photos/upload/',
headers: { 'X-CSRFToken': getCookie('csrftoken') }
},
labelIdle: '拖拽图片到此处,或 <span class="filepond--label-action">点击选择</span>',
acceptedFileTypes: ['image/*'],
allowMultiple: true,
maxFiles: 50,
})
```
---
## 13. Image Lightbox Viewer
### 13.1 技术选型
**使用 Viewer.js**CDN~5KB无框架依赖覆盖缩放/旋转/全屏/翻页/缩略图条。
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/viewerjs/dist/viewer.min.css">
<script src="https://cdn.jsdelivr.net/npm/viewerjs/dist/viewer.min.js"></script>
```
### 13.2 初始化
```javascript
const gallery = document.getElementById('photo-gallery')
const viewer = new Viewer(gallery, {
toolbar: {
zoomIn: true, zoomOut: true, oneToOne: true,
reset: true, rotateLeft: true, rotateRight: true, download: true,
},
navbar: true,
title: (image) => `${image.alt} · ${image.naturalWidth} × ${image.naturalHeight}`,
url: 'data-src', // 使用 data-src 存储原图 URL
})
```
### 13.3 触发按钮集成
```html
<div id="photo-gallery" class="hidden">
{% for photo in photos %}
<img data-src="{{ photo.original_url }}" alt="{{ photo.title }}"
src="{{ photo.thumbnail_url }}">
{% endfor %}
</div>
<!-- 点击缩略图触发灯箱 -->
<button @click="viewer.show(); viewer.view({{ forloop.counter0 }})"
class="...">
<img src="{{ photo.thumbnail_url }}" alt="{{ photo.title }}">
</button>
```
---
## 14. Accordion Progress Panel
### 14.1 视觉规格
| 属性 | 值 |
|---|---|
| 进度条轨道 | `h-2 bg-neutral-100 rounded-full` |
| 进度条填充(正常) | `bg-warning-600 rounded-full transition-all` |
| 进度条填充(满分) | `bg-success-600` |
| 父行(折叠头) | `flex justify-between items-center h-10 px-4 cursor-pointer hover:bg-neutral-50` |
| 子行 | `flex justify-between items-center h-9 pl-8 pr-4 text-sm` |
| 分数(正常) | `text-sm text-neutral-600 tabular-nums` |
| 分数0分/未达标) | `text-sm text-danger-600 font-medium tabular-nums` |
| 操作链接 | `text-xs text-primary-600 hover:text-primary-700 ml-2` |
### 14.2 HTML 结构
```html
<div class="bg-white rounded-lg border border-neutral-200">
<!-- 标题 + 总进度 -->
<div class="px-4 py-4 border-b border-neutral-100">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-semibold text-neutral-800">信息完整度</h3>
<span class="text-2xl font-bold text-warning-600">69%</span>
</div>
<!-- 进度条 -->
<div class="h-2 bg-neutral-100 rounded-full overflow-hidden">
<div class="h-full bg-warning-600 rounded-full transition-all" style="width: 69%"></div>
</div>
<p class="mt-2 text-xs text-neutral-500">
完善信息可提升房源曝光率。
<a href="#" class="text-primary-600 hover:text-primary-700">了解更多</a>
</p>
</div>
<!-- 可折叠分组列表 -->
<div class="divide-y divide-neutral-100">
{% for group in progress_groups %}
{% if group.is_collapsible %}
<!-- 可折叠行 -->
<div x-data="{ open: true }">
<button @click="open = !open"
class="w-full flex justify-between items-center h-10 px-4 hover:bg-neutral-50 transition-colors">
<span class="text-sm font-medium text-neutral-700">{{ group.label }}</span>
<div class="flex items-center gap-3">
<span class="text-sm tabular-nums" :class="">{{ group.score }}% / {{ group.max_score }}%</span>
<svg class="w-4 h-4 text-neutral-400 transition-transform" :class="open ? 'rotate-0' : 'rotate-180'" aria-hidden="true"><!-- chevron-up --></svg>
</div>
</button>
<div x-show="open" x-collapse>
{% for item in group.items %}
<div class="flex justify-between items-center h-9 pl-8 pr-4 text-sm hover:bg-neutral-50">
<span class="text-neutral-600">{{ item.label }}</span>
<div class="flex items-center">
<span class="tabular-nums {% if item.score == 0 %}text-danger-600 font-medium{% else %}text-neutral-500{% endif %}">
{{ item.score }}% / {{ item.max_score }}%
</span>
{% if item.action %}
<a href="{{ item.action_url }}" class="text-xs text-primary-600 hover:text-primary-700 ml-2">{{ item.action }}</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<!-- 普通不可折叠行 -->
<div class="flex justify-between items-center h-10 px-4 text-sm">
<span class="text-neutral-700">{{ group.label }}</span>
<div class="flex items-center">
<span class="tabular-nums {% if group.score == 0 %}text-danger-600 font-medium{% else %}text-neutral-500{% endif %}">
{{ group.score }}% / {{ group.max_score }}%
</span>
{% if group.action %}
<a href="{{ group.action_url }}" class="text-xs text-primary-600 hover:text-primary-700 ml-2">{{ group.action }}</a>
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
```
> **注意**:需引入 Alpine.js 官方 `x-collapse` 插件1KB处理高度动画。
---
## 15. Inline Edit Mode
### 15.1 视觉规格
| 状态 | 区分方式 |
|---|---|
| 只读态 | 纯文本 `<span>`,无边框 |
| 编辑态 | `<input>` / `<select>``border border-neutral-200 rounded-md` |
| 页面编辑按钮(只读) | Secondary 按钮,`pencil-square` 图标 |
| 保存按钮(编辑中) | Primary 按钮 |
| 取消按钮(编辑中) | Ghost 按钮 |
| Toggle 开关(只读禁用) | `opacity-50 cursor-not-allowed` |
| Toggle 开关(编辑激活) | 完整交互态 |
| 左侧分组竖线 | `border-l-4 border-primary-600 pl-3` |
### 15.2 HTML 结构
```html
<div x-data="inlineEdit()" class="...">
<!-- 顶部编辑/保存按钮 -->
<div class="flex justify-end gap-2 mb-4">
<button x-show="!editing" @click="startEdit()"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md border border-neutral-200 text-neutral-700 bg-white hover:bg-neutral-50">
<svg class="w-4 h-4" aria-hidden="true"><!-- pencil-square --></svg>
编辑
</button>
<template x-if="editing">
<div class="flex gap-2">
<button @click="cancelEdit()"
class="px-3 py-1.5 text-sm font-medium rounded-md border border-neutral-200 text-neutral-700 bg-white hover:bg-neutral-50">
取消
</button>
<button @click="saveEdit()"
hx-post="/settings/org/"
hx-vals="js:getFormData()"
hx-target="#feedback"
class="px-3 py-1.5 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700">
保存
</button>
</div>
</template>
</div>
<!-- 设置项行 -->
<div class="space-y-0 divide-y divide-neutral-100">
<!-- 文字类设置项 -->
<div class="flex items-center justify-between h-12 px-4">
<span class="text-sm text-neutral-600">工龄计算方式</span>
<div class="text-sm">
<!-- 只读态 -->
<span x-show="!editing" class="text-neutral-800" x-text="settings.seniorityRule"></span>
<!-- 编辑态 -->
<select x-show="editing"
x-model="settings.seniorityRule"
class="px-2 py-1 text-sm border border-neutral-200 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-600/40">
<option value="first_entry">从首次入职开始计算</option>
<option value="current_entry">从本次入职开始计算</option>
</select>
</div>
</div>
<!-- Toggle 类设置项 -->
<div class="flex items-center justify-between h-12 px-4">
<span class="text-sm text-neutral-600">自动生成员工编号</span>
<!-- Toggle 开关(只读禁用 / 编辑可操作) -->
<button @click="if(editing) settings.autoId = !settings.autoId"
:disabled="!editing"
:class="[
settings.autoId ? 'bg-primary-600' : 'bg-neutral-300',
!editing ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
]"
class="relative w-10 h-5 rounded-full transition-colors"
:aria-checked="settings.autoId"
role="switch">
<span :class="settings.autoId ? 'translate-x-5' : 'translate-x-0.5'"
class="absolute top-0.5 w-4 h-4 bg-white rounded-full shadow-xs transition-transform">
</span>
</button>
</div>
</div>
</div>
```
### 15.3 Alpine.js 数据结构
```javascript
function inlineEdit() {
return {
editing: false,
settings: {},
_snapshot: null,
init() {
// 从 Django 注入的 JSON 初始化
this.settings = JSON.parse(document.getElementById('settings-data').textContent)
},
startEdit() {
this._snapshot = JSON.parse(JSON.stringify(this.settings))
this.editing = true
},
cancelEdit() {
this.settings = JSON.parse(JSON.stringify(this._snapshot))
this.editing = false
},
saveEdit() {
this.editing = false
},
getFormData() {
return JSON.stringify(this.settings)
}
}
}
```
---
## 16. Drawer / Slide-over Panel
### 16.1 视觉规格
| 属性 | 值 |
|---|---|
| 遮罩 | `fixed inset-0 bg-black/30 z-40` |
| 面板宽度(默认) | `w-[480px]` |
| 面板宽度(宽型) | `w-[640px]` |
| 面板层级 | `z-50` |
| 入场动画 | `translate-x-full → translate-x-0``duration-300 ease-out` |
| Header 高度 | `h-14 border-b border-neutral-200` |
| Footer 高度 | `h-16 border-t border-neutral-200` |
| 内容区 | `flex-1 overflow-y-auto` |
### 16.2 HTML 结构
```html
<div x-data="{ drawerOpen: false }">
<!-- 触发按钮 -->
<button @click="drawerOpen = true" class="...">字段设置</button>
<template x-teleport="body">
<!-- 遮罩 -->
<div x-show="drawerOpen"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="drawerOpen = false"
class="fixed inset-0 bg-black/30 z-40">
</div>
<!-- 抽屉面板 -->
<div x-show="drawerOpen"
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"
@keydown.escape.window="drawerOpen = false"
class="fixed right-0 top-0 h-full w-[480px] bg-white z-50 shadow-2xl flex flex-col"
role="dialog"
aria-modal="true"
x-trap.noscroll="drawerOpen">
<!-- Header -->
<div class="flex items-center justify-between h-14 px-6 border-b border-neutral-200 shrink-0">
<h2 class="text-base font-semibold text-neutral-800">字段填写要求设置</h2>
<button @click="drawerOpen = false"
class="p-1 rounded-md text-neutral-400 hover:text-neutral-600 hover:bg-neutral-100
focus-visible:ring-2 focus-visible:ring-primary-600/40"
aria-label="关闭">
<svg class="w-5 h-5" aria-hidden="true"><!-- x-mark --></svg>
</button>
</div>
<!-- Body可滚动内容区 -->
<div class="flex-1 overflow-y-auto px-6 py-4">
<!-- 内容由 HTMX 注入 -->
<div hx-get="/settings/fields/"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<!-- Loading 骨架 -->
<div class="animate-pulse space-y-3">
<div class="h-9 bg-neutral-100 rounded"></div>
<div class="h-9 bg-neutral-100 rounded"></div>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-2 h-16 px-6 border-t border-neutral-200 shrink-0">
<button @click="drawerOpen = false"
class="px-4 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-200
rounded-md hover:bg-neutral-50">
取消
</button>
<button hx-post="/settings/fields/"
hx-include="#drawer-form"
hx-on::after-request="drawerOpen = false"
class="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700">
确定
</button>
</div>
</div>
</template>
</div>
```
> **注意**:需引入 Alpine.js 官方 `@alpinejs/focus` 插件以支持 `x-trap`(焦点锁定在 Drawer 内)。
---
## 17. Multi-select Tag Input
### 17.1 视觉规格
| 属性 | 值 |
|---|---|
| 容器(默认) | `flex flex-wrap gap-1 min-h-[36px] px-2 py-1 border border-neutral-200 rounded-md` |
| 容器(激活) | `+ ring-2 ring-primary-600/40 border-primary-400` |
| Tag/Chip | `inline-flex items-center gap-1 bg-primary-50 text-primary-700 text-xs px-2 py-0.5 rounded-full` |
| Tag 删除按钮 | `text-primary-400 hover:text-primary-700` |
| 下拉选项(未选) | `px-3 h-9 text-sm text-neutral-700 hover:bg-neutral-50` |
| 下拉选项(已选) | `+ bg-primary-50 text-primary-700` + `check` 图标 |
### 17.2 HTML 结构
```html
<div x-data="multiSelectTag()" class="relative" @click.away="open = false">
<!-- Tag 输入容器 -->
<div @click="open = true; $nextTick(() => $refs.input.focus())"
class="flex flex-wrap gap-1 min-h-[36px] px-2 py-1 border rounded-md cursor-text transition-all"
:class="open ? 'ring-2 ring-primary-600/40 border-primary-400' : 'border-neutral-200 hover:border-neutral-300'">
<!-- 已选 Tags -->
<template x-for="item in selected" :key="item">
<span class="inline-flex items-center gap-1 bg-primary-50 text-primary-700 text-xs px-2 py-0.5 rounded-full">
<span x-text="item"></span>
<button @click.stop="remove(item)"
class="text-primary-400 hover:text-primary-700"
:aria-label="'移除 ' + item">
<svg class="w-3 h-3" aria-hidden="true"><!-- x-mark --></svg>
</button>
</span>
</template>
<!-- 搜索输入框 -->
<input x-ref="input"
x-model="query"
class="outline-none flex-1 min-w-[80px] text-sm py-0.5"
placeholder="">
</div>
<!-- 下拉选项列表 -->
<div x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="absolute left-0 top-full mt-1 w-full bg-white border border-neutral-200 rounded-lg shadow-lg py-1 z-20 max-h-52 overflow-y-auto">
<template x-for="option in filteredOptions" :key="option">
<button @click="toggle(option)"
class="w-full flex items-center justify-between px-3 h-9 text-sm hover:bg-neutral-50 transition-colors"
:class="isSelected(option) ? 'bg-primary-50 text-primary-700' : 'text-neutral-700'">
<span x-text="option"></span>
<svg x-show="isSelected(option)" class="w-4 h-4 text-primary-600 shrink-0" aria-hidden="true"><!-- check --></svg>
</button>
</template>
<div x-show="filteredOptions.length === 0" class="px-3 py-6 text-center text-sm text-neutral-400">
暂无匹配选项
</div>
</div>
</div>
```
### 17.3 Alpine.js 数据结构
```javascript
function multiSelectTag() {
return {
open: false,
query: '',
selected: [],
options: ['出售', '出租', '租售', '他售/不售', '他租/不租', '暂缓'],
get filteredOptions() {
if (!this.query) return this.options
return this.options.filter(o => o.includes(this.query))
},
toggle(option) {
const i = this.selected.indexOf(option)
i === -1 ? this.selected.push(option) : this.selected.splice(i, 1)
},
isSelected(option) {
return this.selected.includes(option)
},
remove(option) {
this.selected = this.selected.filter(s => s !== option)
}
}
}
```
---
## 18. Dynamic Form Table
### 18.1 视觉规格
| 属性 | 值 |
|---|---|
| 表格边框 | `border border-neutral-200 rounded-lg overflow-hidden` |
| 表头 | `bg-neutral-50 text-xs font-semibold text-neutral-500 uppercase` |
| 系统预置行 | 字段名称不可编辑,操作列显示 `-``text-neutral-400` |
| 用户自定义行 | 字段名称可编辑 `<input>`,操作列显示"隐藏不使用" |
| 必填 Badge必填 | `bg-primary-600 text-white text-xs px-2 py-0.5 rounded-full` |
| 必填 Badge非必填 | `bg-neutral-200 text-neutral-500 text-xs px-2 py-0.5 rounded-full` |
| Toggle 开关(主色) | `bg-primary-600`(开)/ `bg-neutral-300`(关) |
| 添加行按钮 | `border-t border-dashed border-neutral-200 text-primary-600 hover:bg-primary-50` |
### 18.2 HTML 结构
```html
<div x-data="dynamicFormTable()">
<div class="border border-neutral-200 rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-neutral-200">
<thead class="bg-neutral-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-neutral-500 uppercase w-1/3">字段名称</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-neutral-500 uppercase">字段类型</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-neutral-500 uppercase">可选内容</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-neutral-500 uppercase w-28">是否必填</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-neutral-500 uppercase w-24">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-neutral-100">
<template x-for="row in rows" :key="row.id">
<tr class="hover:bg-neutral-50">
<!-- 字段名称 -->
<td class="px-4 py-3">
<span x-show="row.system" class="text-sm text-neutral-800" x-text="row.name"></span>
<input x-show="!row.system"
x-model="row.name"
class="w-full text-sm px-2 py-1 border border-neutral-200 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-600/40"
placeholder="字段名称">
</td>
<!-- 字段类型 -->
<td class="px-4 py-3 text-sm text-neutral-600" x-text="row.type"></td>
<!-- 可选内容 -->
<td class="px-4 py-3 text-sm text-neutral-500" x-text="row.options"></td>
<!-- 必填 Toggle -->
<td class="px-4 py-3">
<div class="flex items-center justify-center gap-1.5">
<span class="text-xs px-2 py-0.5 rounded-full"
:class="row.required ? 'bg-primary-600 text-white' : 'bg-neutral-200 text-neutral-500'"
x-text="row.required ? '必填' : '非必填'">
</span>
<button @click="if (!row.system) row.required = !row.required"
:disabled="row.system"
:class="[row.required ? 'bg-primary-600' : 'bg-neutral-300',
row.system ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer']"
class="relative w-8 h-4 rounded-full transition-colors shrink-0"
role="switch"
:aria-checked="row.required">
<span :class="row.required ? 'translate-x-4' : 'translate-x-0.5'"
class="absolute top-0.5 w-3 h-3 bg-white rounded-full shadow-xs transition-transform">
</span>
</button>
</div>
</td>
<!-- 操作 -->
<td class="px-4 py-3 text-center">
<span x-show="row.system" class="text-sm text-neutral-300">-</span>
<button x-show="!row.system"
@click="toggleHidden(row)"
class="text-xs"
:class="row.hidden ? 'text-neutral-400 hover:text-neutral-600' : 'text-primary-600 hover:text-primary-700'"
x-text="row.hidden ? '显示使用' : '隐藏不使用'">
</button>
</td>
</tr>
</template>
</tbody>
</table>
<!-- 添加行按钮 -->
<button @click="addRow()"
class="w-full flex items-center justify-center gap-1.5 h-10 text-sm text-primary-600
hover:bg-primary-50 border-t border-dashed border-neutral-200 transition-colors">
<svg class="w-4 h-4" aria-hidden="true"><!-- plus --></svg>
添加字段
</button>
</div>
<!-- 保存按钮 -->
<div class="flex justify-end mt-4">
<button hx-post="/api/field-settings/"
hx-vals="js:{rows: JSON.stringify($data.rows)}"
hx-target="#feedback"
class="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700">
保存设置
</button>
</div>
</div>
```
---
## 19. Sortable Table with Drag Handle
### 19.1 技术选型
**SortableJS**CDN~3KB与 Alpine.js 集成,处理拖拽排序。
### 19.2 视觉规格
| 属性 | 值 |
|---|---|
| 拖拽手柄 | `cursor-grab active:cursor-grabbing text-neutral-300 hover:text-neutral-500` |
| 拖拽中行 | `.sortable-chosen``bg-primary-50 shadow-md` |
| 放置占位行 | `.sortable-ghost``border-2 border-dashed border-primary-300 bg-transparent opacity-50` |
### 19.3 HTML 结构
```html
<div x-data="sortableTable()">
<table class="min-w-full divide-y divide-neutral-200">
<thead class="bg-neutral-50">
<tr>
<th class="w-8 px-2"></th><!-- 手柄列 -->
<th class="px-4 py-3 text-left text-xs font-semibold text-neutral-500 uppercase">字段名称</th>
<!-- 其他列头 -->
</tr>
</thead>
<tbody id="sortable-table" class="bg-white divide-y divide-neutral-100" x-init="initSort()">
<template x-for="row in rows" :key="row.id" :data-id="row.id">
<tr class="hover:bg-neutral-50">
<!-- 拖拽手柄 -->
<td class="px-2 py-3">
<div class="drag-handle cursor-grab active:cursor-grabbing text-neutral-300 hover:text-neutral-500 w-5 h-5 flex items-center justify-center"
aria-hidden="true">
<svg class="w-4 h-4"><!-- bars-3 / grip-vertical --></svg>
</div>
</td>
<td class="px-4 py-3 text-sm text-neutral-800" x-text="row.name"></td>
</tr>
</template>
</tbody>
</table>
</div>
```
### 19.4 Alpine.js + SortableJS 初始化
```javascript
function sortableTable() {
return {
rows: [],
initSort() {
Sortable.create(document.getElementById('sortable-table'), {
handle: '.drag-handle',
animation: 150,
chosenClass: 'bg-primary-50 shadow-md',
ghostClass: 'sortable-ghost',
onEnd: (evt) => {
const moved = this.rows.splice(evt.oldIndex, 1)[0]
this.rows.splice(evt.newIndex, 0, moved)
this.saveOrder()
}
})
},
saveOrder() {
fetch('/api/field-options/reorder/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({ ids: this.rows.map(r => r.id) })
})
}
}
}
```
```css
/* Drop 占位行样式(在 static/css/app.css 中定义,不写 inline */
.sortable-ghost {
border: 2px dashed #5EEAD4; /* primary-300 */
background: transparent !important;
opacity: 0.5;
}
```
---
## 20. Multi-Table Independent Pagination
### 20.1 核心设计原则
每个表格区块拥有**独立的三要素**,通过 HTMX `hx-target` 精准隔离:
```
表格区块 N
├─ id="table-{module}" ← 唯一 DOM 目标
├─ hx-target="#table-{module}" ← 分页只刷新自身
└─ /api/{module}/?page=N ← 独立后端分页接口
```
翻某个表格的页,其他表格 **DOM 完全不变**
### 20.2 HTML 结构
```html
<!-- 顶部搜索(刷新全部表格) -->
<form hx-get="/params/search/"
hx-target="#all-tables"
hx-swap="innerHTML"
class="flex items-center gap-2 mb-4">
<input type="text" name="q" placeholder="搜索参数名称"
class="px-3 h-9 text-sm border border-neutral-200 rounded-md flex-1 focus:outline-none focus:ring-2 focus:ring-primary-600/40">
<button type="submit"
class="px-4 h-9 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700">
搜索
</button>
<button type="button"
hx-get="/params/"
hx-target="#all-tables"
class="px-4 h-9 text-sm font-medium text-neutral-700 border border-neutral-200 rounded-md hover:bg-neutral-50">
重置
</button>
</form>
<!-- 所有表格容器 -->
<div id="all-tables" class="space-y-6">
<!-- 表格区块:业客信息 -->
<section>
<div class="mb-2">
<h3 class="text-sm font-semibold text-neutral-800">业客信息</h3>
<p class="text-xs text-neutral-500 mt-0.5">买卖双方及关联人员信息字段</p>
</div>
<!-- 表格内容区(分页只刷新此 div -->
<div id="table-customer" class="border border-neutral-200 rounded-lg overflow-hidden">
{% include "partials/table_customer.html" %}
</div>
<!-- 独立分页器 -->
<div class="flex justify-end mt-2">
{% include "partials/pagination.html" with target="#table-customer" url_base="/params/customer/" page_obj=customer_page %}
</div>
</section>
<!-- 表格区块:合同应收费用 -->
<section>
<div class="mb-2">
<h3 class="text-sm font-semibold text-neutral-800">合同-应收费用</h3>
</div>
<div id="table-fee" class="border border-neutral-200 rounded-lg overflow-hidden">
{% include "partials/table_fee.html" %}
</div>
<div class="flex justify-end mt-2">
{% include "partials/pagination.html" with target="#table-fee" url_base="/params/fee/" page_obj=fee_page %}
</div>
</section>
</div>
```
### 20.3 分页器 Partial 模板(可复用)
```html
{# templates/partials/pagination.html #}
{# 参数targetHTMX 目标、url_base接口基础 URL、page_obj #}
<nav class="inline-flex items-center gap-1" aria-label="分页">
<button class="w-8 h-8 flex items-center justify-center rounded-md text-neutral-600 hover:bg-neutral-100 disabled:opacity-40"
{% if not page_obj.has_previous %}disabled{% endif %}
hx-get="{{ url_base }}?page={{ page_obj.previous_page_number }}"
hx-target="{{ target }}"
hx-swap="innerHTML">
<svg class="w-4 h-4" aria-hidden="true"><!-- chevron-left --></svg>
</button>
{% for num in page_range %}
{% if num == page_obj.number %}
<span class="w-8 h-8 flex items-center justify-center rounded-md bg-primary-600 text-white text-sm font-medium">{{ num }}</span>
{% elif num == '…' %}
<span class="px-1 text-neutral-400"></span>
{% else %}
<button class="w-8 h-8 flex items-center justify-center rounded-md text-sm text-neutral-600 hover:bg-neutral-100"
hx-get="{{ url_base }}?page={{ num }}"
hx-target="{{ target }}"
hx-swap="innerHTML">{{ num }}</button>
{% endif %}
{% endfor %}
<button class="w-8 h-8 flex items-center justify-center rounded-md text-neutral-600 hover:bg-neutral-100 disabled:opacity-40"
{% if not page_obj.has_next %}disabled{% endif %}
hx-get="{{ url_base }}?page={{ page_obj.next_page_number }}"
hx-target="{{ target }}"
hx-swap="innerHTML">
<svg class="w-4 h-4" aria-hidden="true"><!-- chevron-right --></svg>
</button>
</nav>
```
---
## 附录 A第三方库清单
| 库 | 版本 | 用途 | 引入方式 | 使用组件 |
|---|---|---|---|---|
| Flatpickr | latest | 日期范围选择 | CDN | Date Range Picker |
| SortableJS | latest | 拖拽排序 | CDN | Sortable Table、Photo Gallery |
| Filepond + ImagePreview 插件 | latest | 图片上传 + 预览 | CDN | Photo Gallery Manager |
| Viewer.js | latest | 图片灯箱预览 | CDN | Image Lightbox |
| @alpinejs/collapse | latest | 折叠高度动画 | CDN | Accordion、Collapsible Card |
| @alpinejs/focus | latest | 焦点锁定 | CDN | Modal、Drawer |
> 以上库均为**无框架依赖**纯 JS 工具库,与 HTMX + Alpine.js + Tailwind 技术栈完全兼容。
---
## 附录 B组件难度分级
| 难度 | 组件 |
|---|---|
| ⭐ 简单(<30 行 JS | Toggle Switch、Tab Navigation、Collapsible Card、Accordion Progress Panel、Toolbar |
| ⭐⭐ 中等30~80 行 JS | Data Table、Pagination、Column Visibility Panel、Inline Edit Mode、Drawer、Multi-select Tag Input、Dynamic Form Table |
| ⭐⭐⭐ 较难(需外部库或复杂数据结构) | Tree Select、Sortable Table、Photo Gallery Manager、Image Lightbox、Date Range Picker、Multi-Table Pagination |
---
## 附录 C关联文档
- `UI_SYSTEM/UI_SYSTEM.md` — 设计 Token、颜色系统、基础组件规范权威基准
- `UI_SYSTEM/组件清单.md` — 组件可行性分析与实现建议(本文源材料)
- `TECH_STACK/TECH_STACK.md` — 技术选型总纲
- `PRD/*` — 各业务模块产品需求
---
*文档版本 v1.0 · 2026-04-25 · 基于 UI_SYSTEM.md v1.1 生成*