---
title: CMS 内容管理
description: 带状态筛选、批量操作与抽屉编辑的内容后台
category: TemplateContent
status: beta
since: 0.6.0
tags: [template, cms, table, form, drawer]
syncStatus: reviewed
verified: false
---

## 用法

### 内容工作台
:::TuffDemoWrapper{demo="TemplateCmsDemo" code-lang="vue" description="筛选、排序与翻页模拟取数；点一行打开编辑抽屉。图中为示例数据。"}
---
code: |
  <script setup lang="ts">
  import { computed, ref } from 'vue'
  import TemplateFrame from './TemplateFrame.vue'

  // TxDataTable 只排序、只全选它拿到的那一页：
  // 宿主先对过滤后的全部文章排序，再切出当前页。
  const pageRows = computed(() => articles.value
    .filter(article => matches(article, query.value))
    .sort((a, b) => compare(a, b, query.value.sort))
    .slice((page.value - 1) * pageSize.value, page.value * pageSize.value))

  // 翻页、换筛选后清空选择：表头的全选只覆盖当前页
  function commit(next: Query) {
    if (next.page !== query.value.page || next.status !== query.value.status)
      selected.value = []
    query.value = next
  }
  </script>

  <template>
    <TemplateFrame title="CMS" :height="580">
      <TxFilterChips v-model="status" :items="statusItems" aria-label="按状态筛选" />

      <TxDataTable
        v-model:selected-keys="selected"
        :columns="columns"
        :data="pageRows"
        :sort="sort"
        :sort-on-client="false"
        row-key="id"
        selectable
        highlight-selected
        sticky-header
        table-layout="fixed"
        @update:sort="onSort"
        @row-click="({ row }) => openEditor(row)"
      >
        <template #cell-status="{ row }">
          <TxStatusBadge size="sm" :text="row.statusLabel" :status="row.tone" :icon="row.icon" />
        </template>
        <template #cell-actions="{ row }">
          <!-- 单元格里的点击会冒泡成 row-click，要在这里拦下 -->
          <span @click.stop>
            <TxDropdownMenu placement="bottom-end">…</TxDropdownMenu>
          </span>
        </template>
      </TxDataTable>

      <TxPagination v-model:current-page="page" :page-size="pageSize" :total="total" />

      <TxDrawer v-model:visible="editing" title="编辑文章" size="min(560px, 94vw)">
        <TxForm ref="form" :model="draft" :rules="rules" label-position="top">…</TxForm>
        <template #footer>
          <TxSplitButton variant="primary" menu-icon="i-carbon-chevron-down" @click="save()">
            保存
            <template #menu="{ close }">…</template>
          </TxSplitButton>
        </template>
      </TxDrawer>

      <TxToastPanel :open="toast.open" side="above" :stack="0">
        {{ toast.text }}
        <button type="button" @click="undo">撤销</button>
      </TxToastPanel>
    </TemplateFrame>
  </template>
---
:::

### 看板排期
按流转看同一批文章的四列看板，适合每周排期会看全局，不适合逐条改字段。
:::TuffDemoWrapper{demo="TemplateCmsBoardDemo" code-lang="vue" description="拖动卡片排序或换列，展开后可拖到本周某天排期。图中为示例数据。"}
---
code: |
  <script setup lang="ts">
  import { reactive } from 'vue'
  import TemplateFrame from './TemplateFrame.vue'

  const board = reactive({ draft: [], review: [], scheduled: [], published: [] })
  let pendingMove = null

  // TxSortableList 的拖拽状态是实例私有的，别的列根本不接受放下。
  // 宿主在列容器上放行 dragover，drop 时只记下目标……
  function onColumnDragOver(event: DragEvent, column: ColumnKey) {
    if (!drag.id || column === drag.from)
      return // 本列内的排序交给 TxSortableList
    event.preventDefault()
    drag.index = insertionIndex(event.currentTarget, event.clientY)
  }
  function onColumnDrop(event: DragEvent, column: ColumnKey) {
    if (drag.id && column !== drag.from)
      pendingMove = { id: drag.id, to: column, index: drag.index }
  }

  // ……等源列表在它自己的 dragend 里收尾，再在看板根节点上真正移动
  function onBoardDragEnd() {
    if (pendingMove)
      applyMove(pendingMove)
    pendingMove = null
  }
  </script>

  <template>
    <TemplateFrame title="看板排期" :height="580" @enter="play">
      <div class="board" @dragstart="onBoardDragStart" @dragend="onBoardDragEnd" @keydown="moveWithArrows">
        <section
          v-for="column in columns"
          :key="column"
          @dragover="onColumnDragOver($event, column)"
          @drop="onColumnDrop($event, column)"
        >
          <TxBadge :value="board[column].length" :variant="overLimit(column) ? 'warning' : 'default'" />
          <TxSortableList v-model="board[column]" :item-label="card => card.title" :labels="sortLabels">
            <template #item="{ item }">
              <div class="card" @click="openDetail(item)">
                <!-- 列表把行内任何位置的 Enter / 空格都当成「拿起」，菜单要拦下 -->
                <span @click.stop @keydown.enter.stop @keydown.space.stop>
                  <TxDropdownMenu>…</TxDropdownMenu>
                </span>
              </div>
            </template>
          </TxSortableList>
        </section>
      </div>

      <TxModal v-model="detailOpen" :title="detail.title">
        <TxFlatRadio v-model="detailColumn">…</TxFlatRadio>
        <TxDatePicker v-model="publishDate" variant="field" />
      </TxModal>
    </TemplateFrame>
  </template>
---
:::

## 概述

Tuff 文档与博客的内容工作台：按状态找稿件、批量发布或归档、在抽屉里编辑，每次改动都可撤销。

- 筛选、搜索、排序与翻页各模拟约 350ms 的取数：表格先留旧数据，超过 120ms 才换成同版式的骨架行，版面不跳。
- 排序作用于过滤后的全部文章，再切出当前页；翻页或换筛选会清空选择，因为表头全选只覆盖当前页。
- 编辑、发布、归档与删除立即生效，提示停留约 5 秒可「撤销」；抽屉只在保存时校验。
- 列内点行直接打开抽屉；展开后（≥ 960px）增加三列与详情栏；窄于 640px 时只留标题列。
- 看板排期：列内拖动重排，拖到另一列即换状态，展开后拖到本周某天即排期；每次移动都可撤销。
- 看板排期：键盘上每列一个停靠点，空格拿起、↑ / ↓ 移动、← / → 换列、Esc 取消；窄于 640px 时一次只显示一列。
- 看板排期：进入视口后有协作者上线并移动一张卡片；点击或按键即停止；减少动态效果时直接显示最终状态。

## 自定义

- 接真实接口时，用请求替换模拟取数，并在下一次查询发出时取消上一次。
- 后端分页时保持关闭 `sort-on-client`，把排序、筛选与页码一起交给接口。
- 新字段优先放进抽屉与详情栏；窄容器里按优先级删列，不横向滚动。
- 需要所见即所得时可换成 TxMarkdownEditor，前提是宿主能生成它的工具栏图标类。
- 看板排期：跨列拖放由宿主处理（放行 `dragover`、`drop` 只记目标、根节点 `dragend` 时改数据）；卡片内的按钮要拦下 Enter 与空格。

## 相关组件

| 区域 | 组件 | 作用 |
|---|---|---|
| 顶栏 | [TxSearchInput](/docs/dev/components/search-input)、[TxButton](/docs/dev/components/button) | 搜索与新建文章 |
| 状态筛选 | [TxFilterChips](/docs/dev/components/filter-chips) | 单选状态与计数 |
| 文章表格 | [TxDataTable](/docs/dev/components/data-table)、[TxStatusBadge](/docs/dev/components/status-badge)、[TxTag](/docs/dev/components/tag)、[TxAvatar](/docs/dev/components/avatar) | 多选、受控排序与吸顶表头 |
| 行菜单 | [TxDropdownMenu](/docs/dev/components/dropdown-menu)、[TxIconButton](/docs/dev/components/button) | 单行编辑、发布与删除 |
| 加载与空态 | [TxSkeleton](/docs/dev/components/skeleton)、[TxSearchEmpty](/docs/dev/components/search-empty) | 骨架行与清除筛选 |
| 底栏 | [TxPagination](/docs/dev/components/pagination)、[TxButton](/docs/dev/components/button) | 分页与批量操作 |
| 结果提示 | [TxToastPanel](/docs/dev/components/toast-panel) | 可撤销的结果提示 |
| 详情栏（展开后） | [TxCellLink](/docs/dev/components/cell-link)、[TxMarkdownView](/docs/dev/components/markdown-view)、[TxNoSelection](/docs/dev/components/no-selection) | 摘要、元数据与正文预览 |
| 编辑抽屉 | [TxDrawer](/docs/dev/components/drawer)、[TxForm](/docs/dev/components/form)、[TxInput](/docs/dev/components/input)、[TxSelect](/docs/dev/components/select)、[TxTagInput](/docs/dev/components/tag-input)、[TxDatePicker](/docs/dev/components/date-picker)、[TxFlatRadio](/docs/dev/components/flat-radio)、[TuffSwitch](/docs/dev/components/switch)、[TxTextarea](/docs/dev/components/textarea)、[TxSplitButton](/docs/dev/components/button) | 带校验的编辑表单 |
| 顶栏（看板排期） | [TxAvatarGroup / TxAvatar](/docs/dev/components/avatar) | 在线协作者 |
| 快捷筛选（看板排期） | [TxFilterChips](/docs/dev/components/filter-chips)、[TxDropdownMenu](/docs/dev/components/dropdown-menu) | 淡化不匹配的卡片 |
| 看板列（看板排期） | [TxSortableList](/docs/dev/components/sortable-list)、[TxBadge](/docs/dev/components/badge)、[TxTooltip](/docs/dev/components/tooltip) | 列内拖动与在制上限 |
| 卡片（看板排期） | [TxTag](/docs/dev/components/tag)、[TxAvatar](/docs/dev/components/avatar)、[TxDropdownMenu / TxDropdownSubmenu](/docs/dev/components/dropdown-menu)、[TxProgressBar](/docs/dev/components/progress-bar) | 栏目、作者、时间与菜单 |
| 本周排期（看板排期，展开后） | [TxIconButton](/docs/dev/components/button)、[TxButton](/docs/dev/components/button) | 拖到某天即排期 |
| 卡片详情（看板排期） | [TxModal](/docs/dev/components/modal)、[TxFlatRadio](/docs/dev/components/flat-radio)、[TxDatePicker](/docs/dev/components/date-picker)、[TxCheckbox](/docs/dev/components/checkbox)、[TxStatusBadge](/docs/dev/components/status-badge)、[TxInput](/docs/dev/components/input) | 阶段、日期、清单与评论 |
