1993 lines
72 KiB
Markdown
1993 lines
72 KiB
Markdown
# 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 节点)**:后端一次性返回完整 JSON,Alpine.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 #}
|
||
{# 参数: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 生成*
|