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

72 KiB
Raw Blame History

Fonrey 组件规范设计文档

版本v1.0 · 日期2026-04-25
依赖基准UI_SYSTEM.md v1.1
技术栈Tailwind CSS + HTMX + Alpine.js + Django 模板(非 JSX
设计语言:专业克制高密度;主色 Teal #0F766E;圆角 rounded-lg8px桌面优先 ≥1280px


目录

  1. Data Table — 可排序多选数据表格
  2. Pagination — 分页组件
  3. Column Visibility Panel — 自定义列显示
  4. Toolbar — 操作工具栏
  5. Export Button — 导出按钮
  6. Smart Sort — 智能排序切换
  7. Modal Dialog — 模态对话框
  8. Tree Select — 树形下拉选择器
  9. Date Range Picker — 日期范围选择器
  10. Tab Navigation — 标签页导航
  11. Collapsible Card Grid — 可折叠卡片网格
  12. Photo Gallery Manager — 相册管理器
  13. Image Lightbox Viewer — 全屏图片灯箱
  14. Accordion Progress Panel — 折叠进度检查面板
  15. Inline Edit Mode — 页面级读写切换
  16. Drawer / Slide-over Panel — 右侧抽屉面板
  17. Multi-select Tag Input — 多选标签选择器
  18. Dynamic Form Table — 动态可增删行表格
  19. Sortable Table with Drag Handle — 带拖拽手柄排序表格
  20. Multi-Table Independent Pagination — 同页多表格独立分页

1. Data Table

正式名称Sortable Data Table with Column Visibility Control

1.1 视觉规格

属性
行高 56pxh-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 Dropdownellipsis-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-14border-b border-neutral-200
Footer 高度 h-16border-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 节点):后端一次性返回完整 JSONAlpine.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 技术选型

使用 FlatpickrCDN~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.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 FilepondCDN~50KB
Drag-to-Reorder SortableJSCDN~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.jsCDN~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-0duration-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 技术选型

SortableJSCDN~3KB与 Alpine.js 集成,处理拖拽排序。

19.2 视觉规格

属性
拖拽手柄 cursor-grab active:cursor-grabbing text-neutral-300 hover:text-neutral-500
拖拽中行 .sortable-chosenbg-primary-50 shadow-md
放置占位行 .sortable-ghostborder-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 #}
{# 参数targetHTMX 目标、url_base接口基础 URL、page_obj #}
<nav class="inline-flex items-center gap-1" aria-label="分页">
  <button class="w-8 h-8 flex items-center justify-center rounded-md text-neutral-600 hover:bg-neutral-100 disabled:opacity-40"
          {% if not page_obj.has_previous %}disabled{% endif %}
          hx-get="{{ url_base }}?page={{ page_obj.previous_page_number }}"
          hx-target="{{ target }}"
          hx-swap="innerHTML">
    <svg class="w-4 h-4" aria-hidden="true"><!-- chevron-left --></svg>
  </button>

  {% for num in page_range %}
    {% if num == page_obj.number %}
      <span class="w-8 h-8 flex items-center justify-center rounded-md bg-primary-600 text-white text-sm font-medium">{{ num }}</span>
    {% elif num == '…' %}
      <span class="px-1 text-neutral-400"></span>
    {% else %}
      <button class="w-8 h-8 flex items-center justify-center rounded-md text-sm text-neutral-600 hover:bg-neutral-100"
              hx-get="{{ url_base }}?page={{ num }}"
              hx-target="{{ target }}"
              hx-swap="innerHTML">{{ num }}</button>
    {% endif %}
  {% endfor %}

  <button class="w-8 h-8 flex items-center justify-center rounded-md text-neutral-600 hover:bg-neutral-100 disabled:opacity-40"
          {% if not page_obj.has_next %}disabled{% endif %}
          hx-get="{{ url_base }}?page={{ page_obj.next_page_number }}"
          hx-target="{{ target }}"
          hx-swap="innerHTML">
    <svg class="w-4 h-4" aria-hidden="true"><!-- chevron-right --></svg>
  </button>
</nav>

附录 A第三方库清单

版本 用途 引入方式 使用组件
Flatpickr latest 日期范围选择 CDN Date Range Picker
SortableJS latest 拖拽排序 CDN Sortable Table、Photo Gallery
Filepond + ImagePreview 插件 latest 图片上传 + 预览 CDN Photo Gallery Manager
Viewer.js latest 图片灯箱预览 CDN Image Lightbox
@alpinejs/collapse latest 折叠高度动画 CDN Accordion、Collapsible Card
@alpinejs/focus latest 焦点锁定 CDN Modal、Drawer

以上库均为无框架依赖纯 JS 工具库,与 HTMX + Alpine.js + Tailwind 技术栈完全兼容。


附录 B组件难度分级

难度 组件
简单(<30 行 JS Toggle Switch、Tab Navigation、Collapsible Card、Accordion Progress Panel、Toolbar
中等30~80 行 JS Data Table、Pagination、Column Visibility Panel、Inline Edit Mode、Drawer、Multi-select Tag Input、Dynamic Form Table
较难(需外部库或复杂数据结构) Tree Select、Sortable Table、Photo Gallery Manager、Image Lightbox、Date Range Picker、Multi-Table Pagination

附录 C关联文档

  • UI_SYSTEM/UI_SYSTEM.md — 设计 Token、颜色系统、基础组件规范权威基准
  • UI_SYSTEM/组件清单.md — 组件可行性分析与实现建议(本文源材料)
  • TECH_STACK/TECH_STACK.md — 技术选型总纲
  • PRD/* — 各业务模块产品需求

文档版本 v1.0 · 2026-04-25 · 基于 UI_SYSTEM.md v1.1 生成