72 KiB
72 KiB
Fonrey 组件规范设计文档
版本:v1.0 · 日期:2026-04-25
依赖基准:UI_SYSTEM.md v1.1
技术栈:Tailwind CSS + HTMX + Alpine.js + Django 模板(非 JSX)
设计语言:专业克制高密度;主色 Teal#0F766E;圆角rounded-lg(8px);桌面优先 ≥1280px
目录
- Data Table — 可排序多选数据表格
- Pagination — 分页组件
- Column Visibility Panel — 自定义列显示
- Toolbar — 操作工具栏
- Export Button — 导出按钮
- Smart Sort — 智能排序切换
- Modal Dialog — 模态对话框
- Tree Select — 树形下拉选择器
- Date Range Picker — 日期范围选择器
- Tab Navigation — 标签页导航
- Collapsible Card Grid — 可折叠卡片网格
- Photo Gallery Manager — 相册管理器
- Image Lightbox Viewer — 全屏图片灯箱
- Accordion Progress Panel — 折叠进度检查面板
- Inline Edit Mode — 页面级读写切换
- Drawer / Slide-over Panel — 右侧抽屉面板
- Multi-select Tag Input — 多选标签选择器
- Dynamic Form Table — 动态可增删行表格
- Sortable Table with Drag Handle — 带拖拽手柄排序表格
- 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 结构
<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 数据结构
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 排序集成
<!-- 表格外层容器绑定 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 结构
<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 后端辅助函数
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 结构
<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 数据结构
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 结构
<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 结构
<!-- 同步导出(小数据量) -->
<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 结构
<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 结构
<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 数据结构
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 节点):后端一次性返回完整 JSON,Alpine.js 前端递归渲染
方案二(数据量大):HTMX 懒加载子节点(点击展开时 hx-get 请求子数据)
8.3 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 数据结构
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,无框架依赖),不手写。
<!-- 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 初始化配置
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)
/* 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 结构
<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 结构
<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 子组件
<!-- 活动记录时间线 -->
<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 结构
<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
<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 上传初始化
// 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,无框架依赖)覆盖缩放/旋转/全屏/翻页/缩略图条。
<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 初始化
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 触发按钮集成
<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 结构
<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 结构
<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 数据结构
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 结构
<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 结构
<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 数据结构
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 结构
<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 结构
<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 初始化
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) })
})
}
}
}
/* 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 结构
<!-- 顶部搜索(刷新全部表格) -->
<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 模板(可复用)
{# templates/partials/pagination.html #}
{# 参数:target(HTMX 目标)、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 生成