Table.vue 5.18 KB
<script setup>
/**
 * 数据表。列宽必须显式声明;表格 `table-layout: fixed`,单元格不折行,超长省略号 + title。
 *
 * @typedef {{ key:string, label:string, width:string, align?:'left'|'right'|'center', mono?:boolean, wrap?:boolean, hint?:string, fixed?:'right', sortable?:boolean }} Column
 */

import { computed } from 'vue'

const props = defineProps({
  /** @type {import('vue').PropType<Column[]>} */
  columns: { type: Array, required: true },
  rows: { type: Array, required: true },
  rowKey: { type: String, default: 'id' },
  /** 整行可点。行内的按钮要自己 @click.stop,否则点按钮会连带触发行点击。 */
  clickable: { type: Boolean, default: false },
  /** 当前排序列。留空则不渲染排序态,`sortable` 列头也不可点——排序是完全opt-in的。 */
  sortKey: { type: String, default: null },
  sortOrder: { type: String, default: 'desc' },
})

const emit = defineEmits(['row-click', 'sort'])

function onHeaderClick(column) {
  if (column.sortable) emit('sort', column.key)
}

for (const column of props.columns) {
  if (!column.width) throw new Error(`Table: 列 "${column.key}" 缺 width`)
}

// 光有 `width: 100%` 时,列宽之和超过容器,浏览器会把每列按比例压小 —— 声明的宽度形同虚设,内容照样被截。
// 钉一个等于列宽之和的 min-width,容器不够就让外层横向滚动。
const minWidth = computed(() => {
  let total = 0
  for (const column of props.columns) total += Number.parseFloat(column.width)
  return `${total}px`
})

const fixedRightOffsets = computed(() => {
  const offsets = new Map()
  let right = 0
  for (let index = props.columns.length - 1; index >= 0; index -= 1) {
    const column = props.columns[index]
    if (column.fixed !== 'right') continue
    offsets.set(column.key, `${right}px`)
    right += Number.parseFloat(column.width)
  }
  return offsets
})

function cellClass(column) {
  return {
    'is-mono': column.mono,
    'is-wrap': column.wrap,
    'is-fixed-right': column.fixed === 'right',
  }
}

function headerClass(column) {
  return {
    ...cellClass(column),
    'is-sortable': column.sortable,
  }
}

function cellStyle(column) {
  return {
    textAlign: column.align || 'left',
    right: fixedRightOffsets.value.get(column.key),
  }
}

function textOf(row, column) {
  const value = row[column.key]
  return value == null ? '' : String(value)
}
</script>

<template>
  <div class="drama-table">
    <table :style="{ minWidth }">
      <colgroup>
        <col
          v-for="column in columns"
          :key="column.key"
          :style="{ width: column.width }"
        />
      </colgroup>
      <thead>
        <tr>
          <th
            v-for="column in columns"
            :key="column.key"
            :class="headerClass(column)"
            :style="cellStyle(column)"
            :title="column.hint"
            @click="onHeaderClick(column)"
          >
            {{ column.label }}<span v-if="column.sortable && sortKey === column.key" class="drama-table__sort-arrow">{{ sortOrder === 'asc' ? '▲' : '▼' }}</span>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr
          v-for="row in rows"
          :key="row[rowKey]"
          :class="{ 'is-clickable': clickable }"
          @click="clickable && emit('row-click', row)"
        >
          <td
            v-for="column in columns"
            :key="column.key"
            :class="cellClass(column)"
            :style="cellStyle(column)"
            :title="$slots[column.key] ? undefined : textOf(row, column)"
          >
            <slot
              :name="column.key"
              :row="row"
            >
              {{ row[column.key] }}
            </slot>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<style scoped>
.drama-table {
  width: 100%;
  overflow-x: auto;
}

table {
  width: 100%;
  table-layout: fixed;
  border-collapse: collapse;
}

th,
td {
  padding: var(--drama-space-md) var(--drama-space-lg);
  border-bottom: 1px solid var(--drama-color-border);
  vertical-align: middle;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

th {
  color: var(--drama-color-text-muted);
  font-size: var(--drama-font-size-sm);
  font-weight: var(--drama-font-weight-medium);
  line-height: var(--drama-line-height-tight);
}

td {
  color: var(--drama-color-text);
  font-size: var(--drama-font-size-sm);
  line-height: var(--drama-line-height-tight);
}

td.is-mono {
  font-family: var(--drama-font-family-mono);
  font-variant-numeric: tabular-nums;
}

td.is-wrap {
  overflow-wrap: anywhere;
  text-overflow: clip;
  white-space: normal;
}

th.is-sortable {
  cursor: pointer;
  user-select: none;
}

th.is-sortable:hover {
  color: var(--drama-color-text);
}

.drama-table__sort-arrow {
  margin-left: var(--drama-space-2xs, 2px);
  font-size: 0.75em;
}

th.is-fixed-right,
td.is-fixed-right {
  position: sticky;
  z-index: 1;
  border-left: 1px solid var(--drama-color-border);
  background-color: var(--drama-color-surface);
}

th.is-fixed-right {
  z-index: 2;
}

tbody tr:last-child td {
  border-bottom: 0;
}

tbody tr:hover td {
  background-color: var(--drama-color-surface-alt);
}

tbody tr.is-clickable {
  cursor: pointer;
}
</style>