DataList 数据列表

统一条目排版、列表与卡片布局、分页及加载状态的数据视图。

3 条作品

  • 夏日口袋
    Summer Pockets

    Key / 株式会社ビジュアルアーツ

  • CLANNAD

    Key / 株式会社ビジュアルアーツ

  • 亚托莉 -我挚爱的时光-
    ATRI -My Dear Moments-

    Frontwing Co., Ltd. / 枕 / ANIPLEX.EXE / iMel / Syawase Works / JAST USA

<script setup lang="ts">
  import { DataList, Image, Stack, Text, Tooltip } from '@hina-ui/vue'
  import { dataListDemo } from '../../data-list'
  const items = dataListDemo('zh-CN')
</script>
<template>
  <DataList
    :items="items.slice(0, 3)"
    item-key="id"
    item-title="title"
    item-description="subtitle"
    :media-ratio="3 / 4"
    layout-toggle
    grid-min="10rem"
    label="作品"
    class="max-w-2xl"
  >
    <template #header><Text size="sm" tone="muted">3 条作品</Text></template>
    <template #media="{ item }">
      <Image :src="item.cover.src" alt="" fit="cover" class="size-full" />
    </template>
    <template #meta="{ item }">
      <Stack gap="xs" class="min-w-0 w-full">
        <Tooltip :content="item.developer">
          <Text size="xs" tone="muted" truncate class="w-fit max-w-full">{{ item.developer }}</Text>
        </Tooltip>
        <Text as="time" :datetime="item.released" size="xs" tone="muted">{{ item.released }}</Text>
      </Stack>
    </template>
  </DataList>
</template>
vue

使用

DataList 提供媒体、标题、描述、元信息和操作区的条目结构。同一份内容可以切换为横向列表或纵向卡片;分页、加载占位和空态由组件协调。它保留列表语义,不附加整行点击或选择行为。需要表头和列对齐时使用 DataTable。

传入 items、唯一且稳定的 itemKey 和标题字段 itemTitle,即可渲染基本列表。标题和描述也接受函数。

<DataList :items="items" item-key="id" item-title="name" item-description="description" />
vue

按需提供 #media、#title、#description、#meta 和 #actions。这些插槽接收 { item, index, key, layout },保留完整条目类型。开启分页时,index 包含当前页偏移量。提供默认插槽可替换整个条目内容,同时保留列表容器、分页和状态管理。

示例使用 Hikarinagi 的公开作品数据快照(2026-09-18),包含名称、开发商、发行日期与封面;详情链接指向原始条目。分页、筛选和远程分页示例使用 1,000 条,虚拟滚动示例使用 5,000 条;排版示例展示其中少量条目。封面由 Image 展示,操作使用 Button,长文本提示使用 Tooltip。

示例

条目结构与布局

layout-toggle 显示内置布局切换器,也可以通过 v-model:layout 或状态插槽的 setLayout 控制。切换时保留仍在渲染范围内的条目节点。

列表中媒体位于文字前方,操作区在宽容器中靠后、窄容器中移至文字下方;卡片中媒体在上,操作区在下。标题和描述默认完整换行,元信息自动换行。需要截断时,可在对应插槽中使用 Text 的截断能力。

mediaRatio 统一两种布局的媒体比例,省略时列表默认正方形,卡片默认 16:10。gridMin 设置最小卡片宽度,容器不足时使用单列;gridGap 设置间距。示例通过 Link 自定义标题,通过 Toggle 添加操作。

3 条作品

  • 終のステラ

    Key

  • planetarian ~ちいさなほしのゆめ~

    Key

  • STEINS;GATE

    ニトロプラス / MAGES. / 株式会社MAGES. / 株式会社角川書店 / スパイク・チュンソフト / 5pb.

<script setup lang="ts">
  import { reactive } from 'vue'
  import { Bookmark } from '@lucide/vue'
  import { DataList, Image, Link, Stack, Text, Toggle, Tooltip } from '@hina-ui/vue'
  import { dataListDemo } from '../../data-list'
  const items = dataListDemo('zh-CN')
  const saved = reactive<Record<number, boolean>>({})
</script>
<template>
  <DataList
    :items="items.slice(3, 6)"
    item-key="id"
    item-description="subtitle"
    layout="grid"
    layout-toggle
    grid-min="10rem"
    label="自定义条目"
    class="max-w-2xl"
  >
    <template #header><Text size="sm" tone="muted">3 条作品</Text></template>
    <template #title="{ item }">
      <Link :href="item.url" target="_blank" rel="noopener noreferrer" tone="neutral">
        {{ item.title }}
      </Link>
    </template>
    <template #media="{ item }">
      <Image :src="item.cover.src" alt="" fit="cover" class="size-full" />
    </template>
    <template #meta="{ item }">
      <Stack gap="xs" class="min-w-0 w-full">
        <Tooltip :content="item.developer">
          <Text size="xs" tone="muted" truncate class="w-fit max-w-full">{{ item.developer }}</Text>
        </Tooltip>
        <Text as="time" :datetime="item.released" size="xs" tone="muted">{{ item.released }}</Text>
      </Stack>
    </template>
    <template #actions="{ item }">
      <Toggle v-model="saved[item.id]" size="sm" :label="`标记 ${item.title}`" :tooltip="false">
        <template #icon="{ pressed }">
          <Bookmark :class="pressed ? 'fill-current' : undefined" />
        </template>
        {{ saved[item.id] ? '已标记' : '标记' }}
      </Toggle>
    </template>
  </DataList>
</template>
vue

本地分页

开启 pagination 后传入完整数组。组件截取当前页,并使用 Pagination 渲染分页。通过 v-model:page 和 v-model:page-size 控制页码与每页条数;数据减少导致页码越界时,自动回到最后一页。只有一页或没有数据时,默认分页自动隐藏;自定义 #pagination 不受此限制。

#footer 与分页共用底部一行,空间不足时换行。翻页只重置列表自身的滚动位置,不滚动外层页面。

状态插槽提供 setPage 和 setPageSize,处理加载状态和页码边界;修改每页条数会回到第一页。示例使用 Select 修改条数。

共 1000 条

  • 夏日口袋
    Summer Pockets

    2018-06-29

  • CLANNAD

    2004-04-28

  • 亚托莉 -我挚爱的时光-
    ATRI -My Dear Moments-

    2020-06-19

  • 星之终途
    終のステラ

    2022-09-30

  • 星之梦
    planetarian ~ちいさなほしのゆめ~

    2004-11-29

  • 命运石之门
    STEINS;GATE

    2009-10-15

  • Summer Pockets REFLECTION BLUE

    2020-06-26

  • Harmonia -ハルモニア-

    2016-09-23

  • 时廻者
    LOOPERS -ルーパーズ-

    2021-05-28

  • 命运石之门 0
    STEINS;GATE 0

    2015-12-10

<script setup lang="ts">
  import { ref } from 'vue'
  import { DataList, Select, Text } from '@hina-ui/vue'
  import { dataListDemo } from '../../data-list'
  const items = dataListDemo('zh-CN')
  const page = ref(1)
  const pageSize = ref(10)
  const sizes = [10, 20, 50].map(value => ({ value, label: `${value} 条 / 页` }))
</script>
<template>
  <DataList
    v-model:page="page"
    v-model:page-size="pageSize"
    :items="items"
    item-key="id"
    item-title="title"
    item-description="subtitle"
    pagination
    class="max-w-2xl"
  >
    <template #header="{ pageSize: size, setPageSize }">
      <Text size="sm" tone="muted">共 {{ items.length }} 条</Text>
      <Select
        :model-value="size"
        :options="sizes"
        size="sm"
        class="w-36 max-w-full"
        aria-label="每页条数"
        @update:model-value="value => setPageSize(Number(value))"
      />
    </template>
    <template #meta="{ item }">
      <Text size="xs" tone="muted">{{ item.released }}</Text>
    </template>
  </DataList>
</template>
vue

筛选与排序

筛选和排序由调用方计算,再将结果传给 items。查询变化时重置页码。示例组合 SearchInput 与 Select。

  • 战国恋姬BRAVE二 ~战乱的九州、岛津篇~
    戦国†恋姫BRAVE弐 ~戦乱の九州、島津編~

    2026-08-28

  • ユリアリス

    2026-08-16

  • 両腕が折れたのでHなお世話をされる話

    2026-07-31

  • ネモフィリア-We pass each other-

    2026-07-31

  • ぼっちな魔王と俺の塔

    2026-07-31

  • お隣の天使様にいつの間にか駄目人間にされていた件 Memorial Vacation

    2026-07-23

  • 年上彼女2

    2026-06-26

  • as:9-nine- ARTEISIA

    2026-06-26

  • マガルミナ

    2026-06-26

  • Relirium - レリリウム - 遺跡と出逢いと冒険と

    2026-05-29

共 1000 条结果

<script setup lang="ts">
  import { computed, ref, watch } from 'vue'
  import { Button, DataList, Empty, SearchInput, Select, Text } from '@hina-ui/vue'
  import { dataListDemo } from '../../data-list'
  const items = dataListDemo('zh-CN')
  const query = ref('')
  const order = ref<string | number | null>('release')
  const page = ref(1)
  const options = [
    { value: 'release', label: '发行时间降序' },
    { value: 'title', label: '名称排序' },
  ]
  const filtered = computed(() =>
    items
      .filter(item =>
        `${item.title} ${item.originalTitle} ${item.developer}`
          .toLocaleLowerCase()
          .includes(query.value.trim().toLocaleLowerCase()),
      )
      .toSorted((a, b) =>
        order.value === 'release'
          ? b.released.localeCompare(a.released)
          : a.title.localeCompare(b.title),
      ),
  )
  watch([query, order], () => {
    page.value = 1
  })
</script>
<template>
  <DataList
    v-model:page="page"
    :items="filtered"
    item-key="id"
    item-title="title"
    item-description="subtitle"
    pagination
    :page-size="10"
    label="筛选结果"
    class="max-w-2xl"
  >
    <template #header>
      <SearchInput
        v-model="query"
        placeholder="搜索名称或开发商"
        aria-label="搜索"
        class="w-full @lg/hn-data-list:min-w-0 @lg/hn-data-list:flex-1"
      />
      <Select
        v-model="order"
        :options="options"
        aria-label="排序方式"
        class="w-full @lg/hn-data-list:w-44"
      />
    </template>
    <template #meta="{ item }">
      <Text size="xs" tone="muted">{{ item.released }}</Text>
    </template>
    <template #empty>
      <Empty title="没有匹配的作品" size="sm" :icon="false">
        <template #actions>
          <Button variant="outline" tone="neutral" size="sm" @click="query = ''">清空搜索</Button>
        </template>
      </Empty>
    </template>
    <template #footer="{ total }">
      <Text size="xs" tone="muted">共 {{ total }} 条结果</Text>
    </template>
  </DataList>
</template>
vue

远程分页

同时开启 manual 和 pagination 时,items 是服务端返回的当前页,组件不会再次切片。提供 total 显示完整分页;总数未知时省略它,并传入 hasNextPage,分页将只显示上一页、下一页和当前页码。

示例实际请求按页保存的静态 JSON 快照,可运行于静态部署;它不实时查询作品库,也没有人为等待。使用 Switch 切换已知/未知总数。请求期间保留上一页,取消过期请求;失败时通过 Empty 显示重试入口。

加载中
<script setup lang="ts">
  import { onMounted, onScopeDispose, ref, watch } from 'vue'
  import { useRuntimeConfig } from '#app'
  import { Button, DataList, Empty, Switch, Text } from '@hina-ui/vue'
  import type { DataListDemoItem } from '../../data-list'

  const baseURL = useRuntimeConfig().app.baseURL
  const page = ref(1)
  const rows = ref<DataListDemoItem[]>([])
  const total = ref<number>()
  const loading = ref(true)
  const knownTotal = ref(true)
  const hasNextPage = ref(false)
  const failed = ref(false)
  let controller: AbortController | undefined
  async function load() {
    controller?.abort()
    const request = new AbortController()
    controller = request
    loading.value = true
    failed.value = false
    try {
      const response = await fetch(`${baseURL}demo/data-list/page-${page.value}.json`, {
        signal: request.signal,
      })
      if (!response.ok) throw new Error(String(response.status))
      const data = (await response.json()) as {
        items: Omit<DataListDemoItem, 'subtitle'>[]
        total: number
        hasNextPage: boolean
      }
      if (request.signal.aborted) return
      rows.value = data.items.map(item => ({
        ...item,
        title: item.title,
        subtitle: item.title === item.originalTitle ? '' : item.originalTitle,
      }))
      total.value = data.total
      hasNextPage.value = data.hasNextPage
    } catch {
      if (!request.signal.aborted) {
        rows.value = []
        failed.value = true
      }
    } finally {
      if (!request.signal.aborted) loading.value = false
    }
  }
  onMounted(load)
  watch(page, load)
  onScopeDispose(() => controller?.abort())
</script>
<template>
  <DataList
    v-model:page="page"
    :items="rows"
    item-key="id"
    item-title="title"
    item-description="subtitle"
    pagination
    manual
    :page-size="10"
    :total="knownTotal ? total : undefined"
    :has-next-page="hasNextPage"
    :loading="loading"
    :min-height="400"
    class="max-w-2xl"
    label="远程条目"
  >
    <template #header>
      <Switch v-model="knownTotal" :disabled="loading">已知总条数</Switch>
      <Button variant="ghost" tone="neutral" size="sm" :disabled="loading" @click="load">
        刷新
      </Button>
    </template>
    <template #meta="{ item }">
      <Text size="xs" tone="muted">{{ item.released }}</Text>
    </template>
    <template #empty>
      <Empty :title="failed ? '加载失败' : '暂无数据'" size="sm">
        <template v-if="failed" #actions><Button size="sm" @click="load">重试</Button></template>
      </Empty>
    </template>
  </DataList>
</template>
vue

加载与空态

首次加载默认使用与条目结构对应的 Skeleton,placeholderCount 控制数量。默认只为已配置的内容区生成占位;虚拟模式默认渲染 3 个占位,普通分页默认与每页条数一致。自定义整个条目时,可用 #placeholder="{ index, layout }" 提供对应骨架,不需要伪造业务数据。

已有条目时,loading 会保留内容、禁止内容交互和翻页,并通过 LoadingOverlay 延迟显示加载提示。#loading 可替换整片首次加载内容以及刷新提示;#empty 或 emptyText 自定义空态。

minHeight 为内容区预留最小高度,自动高度列表在清空数据加载时保留上一轮高度;height 则固定内容区高度,并在内容超出时使用 ScrollArea。

加载中
<script setup lang="ts">
  import { computed, ref } from 'vue'
  import { DataList, Image, SegmentedControl, Stack, Text } from '@hina-ui/vue'
  import { dataListDemo } from '../../data-list'
  const items = dataListDemo('zh-CN')
  const state = ref('initial')
  const options = [
    { value: 'initial', label: '首次加载' },
    { value: 'ready', label: '内容' },
    { value: 'refresh', label: '刷新' },
    { value: 'empty', label: '空态' },
  ]
  const rows = computed(() =>
    state.value === 'initial' || state.value === 'empty' ? [] : items.slice(0, 2),
  )
</script>
<template>
  <Stack class="w-full max-w-2xl">
    <SegmentedControl v-model="state" :options="options" size="sm" aria-label="列表状态" />
    <DataList
      :items="rows"
      item-key="id"
      item-title="title"
      item-description="subtitle"
      :loading="state === 'initial' || state === 'refresh'"
      :placeholder-count="2"
      :media-ratio="3 / 4"
      :height="280"
      label="条目"
    >
      <template #media="{ item }">
        <Image :src="item.cover.src" alt="" fit="cover" class="size-full" />
      </template>
      <template #meta="{ item }">
        <Text size="xs" tone="muted">{{ item.released }}</Text>
      </template>
    </DataList>
  </Stack>
</template>
vue

自定义分页

#pagination 接收与头部、页脚相同的状态和操作方法,可以接入 Pagination 的附属选项。

<DataList :items="items" item-key="id" item-title="name" pagination>
  <template #pagination="{ page, pageSize, total, loading, setPage, setPageSize }">
    <Pagination
      :model-value="page"
      :page-size="pageSize"
      :total="total ?? 0"
      :pending="loading"
      :page-size-options="[10, 20, 50]"
      show-info
      @change="value => value.pageSize === pageSize ? setPage(value.page) : setPageSize(value.pageSize)"
    />
  </template>
</DataList>
vue

虚拟滚动

virtualize 支持列表和网格,与 VirtualList 复用窗口计算能力。网格按行虚拟化,以该行最高条目作为行高,列数随容器宽度调整。estimateSize 是列表行或网格整行的估算高度;overscan 是窗口前后额外保留的行数。高度会在渲染后动态测量。

height 指定滚动视口高度,虚拟模式默认 320px;百分比高度需要父容器具有明确高度。与分页组合时只虚拟化当前页。需要跨卸载保留的条目状态应按 ID 存在组件外部。

下方使用 5,000 条不同的真实条目,在列表与网格间切换,并显示当前可见范围。仅渲染当前窗口及缓冲区内的条目。

  • 夏日口袋
    Summer Pockets

    2018-06-29

  • CLANNAD

    2004-04-28

  • 亚托莉 -我挚爱的时光-
    ATRI -My Dear Moments-

    2020-06-19

  • 星之终途
    終のステラ

    2022-09-30

  • 星之梦
    planetarian ~ちいさなほしのゆめ~

    2004-11-29

可见范围:1–4 / 5000

<script setup lang="ts">
  import { ref } from 'vue'
  import { DataList, Image, Text, type DataListLayout } from '@hina-ui/vue'
  import { dataListVirtualDemo } from '../../data-list-virtual'
  const items = dataListVirtualDemo('zh-CN')
  const layout = ref<DataListLayout>('list')
  const range = ref({ startIndex: -1, endIndex: -1 })
</script>
<template>
  <DataList
    v-model:layout="layout"
    :items="items"
    item-key="id"
    item-title="title"
    item-description="subtitle"
    layout-toggle
    :media-ratio="layout === 'grid' ? 16 / 10 : 3 / 4"
    grid-min="12rem"
    :height="400"
    :virtualize="{ estimateSize: layout === 'grid' ? 280 : 128, overscan: 1 }"
    body-class="border-line rounded-lg border"
    content-class="p-4"
    label="虚拟条目"
    class="max-w-2xl"
    @range-change="range = $event"
  >
    <template #media="{ item }">
      <Image :src="item.cover.src" alt="" fit="cover" class="size-full" />
    </template>
    <template #meta="{ item }">
      <Text size="xs" tone="muted">{{ item.released }}</Text>
    </template>
    <template #footer>
      <Text size="xs" tone="muted">
        可见范围:{{ range.startIndex + 1 }}–{{ range.endIndex + 1 }} / {{ items.length }}
      </Text>
    </template>
  </DataList>
</template>
vue

SSR

列表、卡片、分页和首次加载骨架都支持服务端渲染。虚拟模式输出可读的初始窗口,挂载后校准视口和行高,不需要 client-only 包装。

虚拟网格可通过 initialColumns 指定服务端估算列数,默认 1。服务端和客户端初始值必须一致;实际列数由 CSS 和容器宽度决定。初始窗口之外的条目仍需在滚动后渲染。

API

属性

属性
类型
默认值
说明
items
readonly T[]
必填
完整数组;手动模式下为当前页
itemKey
键字段或 (item, index) => string | number
必填
唯一、稳定的键
itemTitle / itemDescription
文本字段或 (item, index) => string | number | null | undefined
—
标题/描述;对应插槽优先
mediaRatio
number
—
媒体宽高比
layoutToggle
boolean
false
显示布局切换器
gridMin
string
'14rem'
期望的网格最小列宽
gridGap
'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
'md'
网格间距
size
'sm' | 'md' | 'lg'
'md'
列表媒体大小、条目间距及卡片内边距
divided
boolean
true
列表分隔线
pagination
boolean
false
显示分页
manual
boolean
false
当前页数据不再切片
total
number
—
远程总条数;本地模式使用数组长度
hasNextPage
boolean
false
总数未知时是否有下一页
loading
boolean
false
加载状态,禁止内容交互和分页
placeholderCount
number
3 或每页条数
首次加载骨架数量
emptyText
string
语言包
默认空态文字
label
string
—
列表与滚动区的可访问名称
virtualize
boolean | DataListVirtualOptions
false
启用列表或网格虚拟化
height
number | string
自动;虚拟模式 320
内容区固定高度
minHeight
number | string
160
未设置固定高度时的内容区最小高度
class
string
—
根节点类名
bodyClass
string
—
内容区外框,涵盖加载与空态
contentClass
string
—
所有布局和渲染模式下均作用于条目列表容器
itemClass
string | ((item, index) => string | undefined)
—
单个条目类名

DataListVirtualOptions 包含 estimateSize(列表默认 112px、网格默认 280px)、overscan(默认 3 行)和 initialColumns(默认 1)。其他属性,包括 dir 和 style,透传到根节点。

双向绑定

模型
类型
默认值
layout
'list' | 'grid'
'list'
page
number
1
pageSize
number
10

插槽

插槽
参数
说明
media / title / description / meta / actions
DataListItemSlot<T>
条目对应区域
default
DataListItemSlot<T>
替换整个条目内容
placeholder
{ index, layout }
单个首次加载骨架
header / footer
DataListState<T>
列表上方/底部与分页同一行
pagination
DataListState<T>
替换分页
empty
DataListState<T>
非加载状态下的空态
loading
DataListState<T>
整片首次加载内容/刷新指示

DataListState<T> 包含当前页 items、page、pageSize、total、pageCount、layout、loading、refreshing、hasPreviousPage、hasNextPage、setPage(page)、setPageSize(size) 和 setLayout(layout)。未知总数时 total 和 pageCount 为 undefined。

事件与实例

名称参数/类型
说明
pageChange{ page, pageSize }
分页操作或越界修正
rangeChange{ startIndex, endIndex }
虚拟模式可见范围,包含分页偏移;无范围时为 -1
viewportHTMLElement | undefined
设置高度或虚拟化时的滚动容器
scrollToIndex(index, options?)align?: 'start' | 'center' | 'end' | 'auto'; behavior?: ScrollBehavior
滚动到当前页内的条目,index 使用全局下标

scrollToIndex 在存在内部滚动容器时只滚动该容器;没有内部容器时,这个显式调用才会滚动外层祖先,让目标条目进入视口。

直接修改分页模型不会再次触发 pageChange;远程请求应监听模型。所有数据类型均从包根导出,包括 DataListProps<T>、DataListItemSlot<T>、DataListState<T>、DataListVirtualOptions 和 DataListExpose。