feat(bitable): Twenty 风格工具栏、视图切换器与视图配置

- BitableFileDetailView 重构顶部工具栏:视图切换器、记录数徽章、新建记录/筛选/排序/选项
- ViewSwitcher 从标签页改为下拉菜单
- bitable store 新增 createRecord 方法
- ViewConfigPanel 支持 initialTab 从按钮直达对应配置页
- 新增 DateDisplay 组件与 bitable-grid-guidelines UX 规范
This commit is contained in:
Chiguyong 2026-07-06 18:43:49 +08:00
parent 96c15146d5
commit 729047433d
7 changed files with 473 additions and 137 deletions

View File

@ -0,0 +1,196 @@
# Bitable 多维表格 UI/UX 规范
> 适用范围:所有基于 vxe-table 的 bitable 视图(`BitableGrid.vue` 及其变体)。
> 目标:对齐 Twenty / Notion / Airtable 风格,消除「不像表格」「没有分隔线」「标题与数据对不齐」等基础 UI/UX 问题。
## 1. 布局原则
### 1.1 表格高度必须随内容(`height: auto`
- **禁止**强制 `height: 100%``flex: 1` 撑满父容器。
- 少量数据时,表格底部应紧贴最后一行,而不是拉伸出巨大空白。
- 滚动条应出现在表格内容**真正超出视口**时,而不是因为容器被强制撑高。
```css
/* Good */
.bitable-grid-scope { height: auto; }
/* Bad — causes the gap between header and data rows */
.bitable-grid-scope { height: 100%; }
```
### 1.2 父容器不得挤压表格
表格父容器应使用 `flex: 0 0 auto``height: auto`,避免剩余空间被表格占据。
```css
/* Good */
.bitable-file-detail-view__grid-container { flex: 0 0 auto; overflow: visible; }
/* Bad — stretches the grid body */
.bitable-file-detail-view__grid-container { flex: 1; overflow: hidden; }
```
## 2. 边框与分隔
### 2.1 必须提供列分隔线
所有数据单元格和列头单元格必须有右侧竖线(`border-right`)。
```css
.vxe-body--column,
.vxe-header--column { border-right: 1px solid var(--bitable-color-border); }
```
- 最右侧一列不显示右边界(`last-child { border-right: 0 }`),避免边界悬空。
- 行分隔线使用更浅的 `--bitable-color-border`1px
- 列头下边界使用更深的 `--bitable-color-border-hover`2px与行分隔形成层次。
### 2.2 禁止无意义的双重/三重分隔线
- 标题区、视图切换区、列头区只能有**一条**主分隔线。
- 如果父容器已经画了分隔线,子容器不要再重复画。
## 3. 对齐与间距
### 3.1 标题与数据单元格必须对齐
- 列头单元格 `padding` 与数据单元格 `padding` 必须一致。
- 所有单元格使用相同的垂直对齐方式(默认居中或居顶,但不能混用)。
```css
.vxe-header--column,
.vxe-body--column { padding: var(--bitable-spacing-sm) var(--bitable-spacing-md); }
```
### 3.2 字号规范
| 元素 | Token | 说明 |
|------|-------|------|
| 列头文字 | `--bitable-font-sm` (13px) | 略小于正文,表达层级 |
| 数据单元格 | `--bitable-font-md` (14px) | 与 antd Table 正文一致 |
| 空状态/提示 | `--bitable-font-sm` (13px) | 占位提示 |
### 3.3 行高Twenty 风格)
| 元素 | 高度 | 说明 |
|------|------|------|
| 数据行 | 44px | 比传统表格更舒展,便于放置 chip/头像 |
| 列头行 | 40px | 略低于数据行,表达层级 |
| 行选择列 | 44px | 与数据行对齐 |
- 禁止 36px 以下行高,避免 chip/多选标签被截断。
- 列头与数据行高度不同是刻意设计,但差距必须 ≤ 4px。
## 4. 视觉层次
### 4.1 列头必须可识别Twenty 风格)
- 背景色使用 `--bitable-color-bg-secondary`
- 列头下边界 **1px** `var(--bitable-color-border-hover)`,比行分隔略重即可,禁止 2px 粗线。
- 列头文字 `font-weight: 500`,颜色 `--bitable-color-text-secondary`,表达 muted 层级。
- 列头必须显示字段类型图标(如文本用 FileText、日期用 Calendar、多选用 Tags图标颜色 `--bitable-color-text-tertiary`
- **禁止**把字段类型图标做成「编辑」样式,避免用户误以为点击图标就是编辑。
### 4.2 标题区、视图区、表格区的层次Twenty 风格)
从上到下:
1. **顶部文件标题栏** — 文件名称 + 返回按钮(最轻)
2. **对象/视图工具栏** — 左侧「对象图标 + 当前视图下拉 · 记录数徽章」,右侧「+ 新建记录 / 筛选 / 排序 / 选项」
3. **列头** — 1px 下边界 + 背景色区分
4. **数据行** — 1px 行分隔 + 1px 列分隔 + hover 高亮
5. **占位行** — 虚线底边 + hover 高亮
「选项」下拉收纳低频操作(字段管理、刷新、视图配置、删除视图);「新建视图」统一放在当前视图下拉菜单底部。
## 5. 新增记录交互
### 5.1 新增记录入口Twenty 风格)
- **主要入口**:工具栏右侧放置显眼的「+ 新建记录」主按钮Twenty 的 `+ New`)。
- **次要入口**:始终在数据列表末尾渲染一行**占位行**pseudo-row点击任意单元格也触发新增。
- 占位行样式:
- 虚线底边(`border-bottom: 1px dashed`
- pointer 光标
- hover 时背景变为 `--bitable-color-bg-tertiary`
### 5.2 代码实现要点
- 占位行的 `_rowId` 使用固定哨兵值(如 `__placeholder__``_recordId` 为空字符串。
- 在 `cell-click` 事件中优先检测占位行,再处理普通行的其他交互。
- 只要表格非分组模式,就追加占位行,保证空表也有可点击目标。
## 6. 暗色/浅色主题
- 所有颜色必须使用 `--bitable-*` token禁止硬编码 hex。
- 边框颜色在暗色模式下应自动跟随全局 `--border-color`
- 避免使用 `box-shadow` 创造分隔(在暗色主题下容易发灰、不自然)。
### 6.1 必须覆盖 vxe-table 默认 CSS 变量
vxe-table 的默认变量是亮色主题值(`#fff`、`#e8eaec` 等)。即使全局 `data-theme="dark"` 已设置vxe-table 仍需要显式重映射变量,否则会出现白底、淡边框等「刷新后无变化」的现象。
```css
.bitable-grid-scope :deep(.vxe-table) {
--vxe-ui-layout-background-color: var(--bitable-color-bg);
--vxe-ui-table-header-background-color: var(--bitable-color-bg-secondary);
--vxe-ui-table-border-color: var(--bitable-color-border);
--vxe-ui-font-color: var(--bitable-color-text);
--vxe-ui-table-row-hover-background-color: var(--bitable-color-bg-tertiary);
--vxe-ui-table-row-striped-background-color: var(--bitable-color-bg-secondary);
}
```
- 在表格根节点 `.vxe-table` 上重映射,作用域最小。
- 同时保持 `data-vxe-ui-theme``data-theme` 同步(全局兜底)。
- 任何新增 vxe-table 实例都必须执行此重映射,禁止依赖 vxe-table 默认主题。
## 7. 单元格渲染
### 7.1 select / multiselect 必须渲染为 pill chip
```css
.select-display__chip {
border-radius: 999px;
padding: 2px 10px;
border: 1px solid;
}
```
- 禁止使用直角或 4px 小圆角 chip避免像 antd Tag 而非 CRM 风格。
- 多选标签之间保留小间距,允许换行,但单元格内最多两行。
### 7.2 date 字段必须格式化显示
- 后端存储 ISO-8601前端显示 `YYYY-MM-DD`(中文 locale
- 禁止在单元格中直接显示原始时间戳或带 T/Z 的字符串。
### 7.3 行选择与行打开方式
- 必须提供 `type: 'checkbox'` 列作为首列。
- 行选择不影响单元格编辑或打开详情抽屉;点击复选框只切换选中态。
- **不再使用单独的行号列(#**Twenty 风格下首列即复选框,第一数据列是主字段列。
- 点击**主字段列单元格**打开记录详情抽屉;其他可编辑列保持单击进入单元格编辑。
## 8. 检查清单Code Review 用)
提交/ review bitable grid 相关改动时,逐项确认:
- [ ] 表格高度是否随内容,无强制撑高
- [ ] 父容器是否未使用 `flex: 1` 拉伸表格
- [ ] 列头与数据单元格是否有竖向分隔线
- [ ] 列头下边界是否为 1px 且比行分隔略重
- [ ] 列头 padding 是否与数据单元格一致
- [ ] 列头是否显示字段类型图标
- [ ] 数据行高是否为 44px、列头行高是否为 40px
- [ ] 是否有行选择复选框列(无 # 行号列)
- [ ] 主字段列点击是否打开记录详情抽屉
- [ ] 最后一列是否无右侧悬浮边界线
- [ ] 字号是否使用 `--bitable-font-sm` / `--bitable-font-md`
- [ ] select 字段是否渲染为 pill chip
- [ ] date 字段是否格式化显示
- [ ] 新增记录是否通过末尾占位行实现
- [ ] 颜色是否全部使用 `--bitable-*` token
- [ ] 是否覆盖 vxe-table 默认 CSS 变量以适配暗色主题
- [ ] 是否未引入无意义的双重/三重水平线

View File

@ -94,6 +94,7 @@ declare module 'vue' {
ConditionNode: typeof import('./src/components/workflow/ConditionNode.vue')['default']
ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default']
DashboardOverview: typeof import('./src/components/evolution/DashboardOverview.vue')['default']
DateDisplay: typeof import('./src/components/bitable/DateDisplay.vue')['default']
DebateArgumentCard: typeof import('./src/components/chat/messages/DebateArgumentCard.vue')['default']
DebateBannerCard: typeof import('./src/components/chat/messages/DebateBannerCard.vue')['default']
DebateConclusionCard: typeof import('./src/components/chat/messages/DebateConclusionCard.vue')['default']

View File

@ -0,0 +1,29 @@
<template>
<span v-if="value" class="date-display">{{ formatted }}</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
value: string | null | undefined
}>()
const formatted = computed(() => {
if (!props.value) return ''
const d = new Date(props.value)
if (Number.isNaN(d.getTime())) return String(props.value)
return d.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
})
</script>
<style scoped>
.date-display {
color: var(--bitable-color-text-secondary);
font-size: var(--bitable-font-sm);
}
</style>

View File

@ -111,6 +111,7 @@ const props = defineProps<{
open: boolean
fields: IBitableField[]
view: IBitableView | null
initialTab?: 'filter' | 'sort' | 'hidden' | 'grouping' | 'conditional-format'
}>()
const emit = defineEmits<{
@ -127,7 +128,17 @@ const { isMobile } = useResponsiveBreakpoint()
const drawerPlacement = computed(() => (isMobile.value ? 'bottom' : 'right'))
const drawerWidth = computed(() => (isMobile.value ? '100%' : 520))
const activeTab = ref('filter')
const activeTab = ref(props.initialTab ?? 'filter')
watch(
() => props.open,
(isOpen) => {
if (isOpen && props.initialTab) {
activeTab.value = props.initialTab
}
},
)
const filterRef = ref<InstanceType<typeof FilterBuilder> | null>(null)
// Current config from view

View File

@ -1,94 +1,53 @@
<template>
<div class="view-switcher">
<a-tabs
v-model:activeKey="activeKey"
type="editable-card"
size="small"
:hide-add="true"
@change="onSwitch"
>
<a-tab-pane
v-for="v in views"
:key="v.id"
:tab="v.name"
:closable="false"
/>
</a-tabs>
<!-- U4: "新建视图" is a dropdown exposing all 5 view types. Only `grid`
is enabled in v1; the rest are disabled with a "规划中" tooltip. -->
<a-dropdown :trigger="['click']" placement="bottomLeft">
<a-button
type="text"
size="small"
:icon="h(PlusOutlined)"
:loading="creating"
class="view-switcher__trigger"
:disabled="creating"
>
新建视图
<TableOutlined class="view-switcher__icon" />
<span class="view-switcher__name">{{ currentView?.name ?? '全部数据' }}</span>
<DownOutlined class="view-switcher__caret" />
</a-button>
<template #overlay>
<a-menu @click="handleTypeClick">
<a-menu>
<a-menu-item
v-for="meta in VIEW_TYPE_LIST"
:key="meta.viewType"
:disabled="meta.disabled"
:title="meta.tooltip"
v-for="v in views"
:key="v.id"
:class="{ 'view-switcher__active': v.id === activeViewId }"
@click="emit('switch', v.id)"
>
<span class="view-switcher__type-item">
<component :is="meta.icon" />
<span>{{ meta.label }}</span>
<span class="view-switcher__item">
<CheckOutlined
v-if="v.id === activeViewId"
class="view-switcher__check"
/>
<span v-else class="view-switcher__check-placeholder" />
{{ v.name }}
</span>
</a-menu-item>
<a-menu-divider />
<a-menu-item :disabled="creating" @click="emit('create', 'grid')">
<PlusOutlined /> 新建视图
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button
v-if="activeKey"
type="text"
size="small"
:icon="h(FilterOutlined)"
@click="emit('config')"
>
配置
</a-button>
<!-- U6: delete the active view. Wrapped in a-popconfirm per spec.
Disabled when only one view remains backend rejects with 409
(last view), so we preempt in the UI to avoid a wasted round-trip.
ponytail: single delete button for the active view (not per-tab)
matches the existing "配置" pattern and keeps the tab header clean. -->
<a-popconfirm
v-if="activeKey && views.length > 1"
title="确认删除此视图?"
ok-text="删除"
ok-type="danger"
cancel-text="取消"
@confirm="handleDelete"
>
<a-button
type="text"
size="small"
danger
:icon="h(DeleteOutlined)"
>
删除
</a-button>
</a-popconfirm>
</div>
</template>
<script setup lang="ts">
import { ref, watch, h } from 'vue'
import { computed } from 'vue'
import { Button as AButton } from 'ant-design-vue'
import {
Tabs as ATabs,
Button as AButton,
Popconfirm as APopconfirm,
} from 'ant-design-vue'
import { PlusOutlined, FilterOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import type { IBitableView, ViewType } from '@/api/bitable'
import { VIEW_TYPE_LIST } from '@/helpers/viewSwitcherUtils'
TableOutlined,
DownOutlined,
CheckOutlined,
PlusOutlined,
} from '@ant-design/icons-vue'
import type { IBitableView } from '@/api/bitable'
const props = withDefaults(
defineProps<{
@ -102,57 +61,64 @@ const props = withDefaults(
const emit = defineEmits<{
(e: 'switch', viewId: string): void
(e: 'create', viewType: ViewType): void
(e: 'config'): void
(e: 'delete', viewId: string): void
(e: 'create', viewType: 'grid'): void
}>()
// antd Tabs activeKey is string | number | undefined; bridge to/from null
const activeKey = ref<string | undefined>(props.activeViewId ?? undefined)
watch(
() => props.activeViewId,
(val) => {
activeKey.value = val ?? undefined
},
const currentView = computed(() =>
props.views.find((v) => v.id === props.activeViewId),
)
function onSwitch(key: string | number): void {
emit('switch', String(key))
}
// U6: emit delete for the active view. The v-if guard on the popconfirm
// already ensures activeKey is set and views.length > 1.
function handleDelete(): void {
if (activeKey.value) {
emit('delete', activeKey.value)
}
}
// a-menu only emits click for enabled items; disabled items are skipped, so
// no extra guard is needed the "" tooltip is shown via `title`.
function handleTypeClick({ key }: { key: string }): void {
emit('create', key as ViewType)
}
</script>
<style scoped>
.view-switcher {
display: flex;
display: inline-flex;
align-items: center;
gap: var(--bitable-spacing-sm);
padding: 0 var(--bitable-spacing-lg);
border-bottom: 1px solid var(--bitable-color-border);
}
.view-switcher :deep(.ant-tabs) {
flex: 1;
min-width: 0;
.view-switcher__trigger {
display: inline-flex;
align-items: center;
gap: var(--bitable-spacing-xs);
padding: 0 var(--bitable-spacing-sm);
color: var(--bitable-color-text);
}
.view-switcher__type-item {
.view-switcher__icon {
font-size: var(--bitable-font-sm);
color: var(--bitable-color-text-secondary);
}
.view-switcher__name {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
font-size: var(--bitable-font-sm);
}
.view-switcher__caret {
font-size: 10px;
color: var(--bitable-color-text-secondary);
}
.view-switcher__item {
display: inline-flex;
align-items: center;
gap: var(--bitable-spacing-xs);
}
.view-switcher__check {
font-size: 12px;
color: var(--bitable-color-primary);
}
.view-switcher__check-placeholder {
display: inline-block;
width: 12px;
}
.view-switcher__active {
background-color: var(--bitable-color-bg-tertiary);
}
</style>

View File

@ -285,6 +285,26 @@ export const useBitableStore = defineStore('bitable', () => {
}
}
/** Create a new empty record in the current table */
async function createRecord(): Promise<string | null> {
if (!currentTable.value) return null
try {
const resp = await bitableApi.createRecords(currentTable.value.id, [{}])
const record = resp.records[0]
if (record) {
records.value.unshift(record)
return record.id
}
return null
} catch (err) {
notification.error({
message: '创建记录失败',
description: err instanceof Error ? err.message : String(err),
})
return null
}
}
/** Add a new field */
async function addField(
name: string,
@ -738,6 +758,7 @@ export const useBitableStore = defineStore('bitable', () => {
selectTable,
loadMoreRecords,
updateCell,
createRecord,
addField,
createTable,
updateField,

View File

@ -50,26 +50,72 @@
</div>
<template v-else>
<div class="bitable-file-detail-view__grid-header">
<h3 class="bitable-file-detail-view__table-name">
{{ store.currentTable.name }}
</h3>
<span class="bitable-file-detail-view__field-count">
{{ store.fields.length }} 个字段 · {{ store.records.length }} 条记录
</span>
<!-- Twenty-style toolbar -->
<div class="bitable-file-detail-view__toolbar">
<div class="bitable-file-detail-view__toolbar-left">
<ViewSwitcher
:views="store.views"
:active-view-id="store.currentView?.id ?? null"
:creating="viewCreating"
@switch="handleSwitchView"
@create="handleCreateView"
/>
<a-tag class="bitable-file-detail-view__record-count" size="small" bordered>
{{ store.records.length }}
</a-tag>
</div>
<div class="bitable-file-detail-view__toolbar-right">
<a-button
type="primary"
size="small"
:icon="h(PlusOutlined)"
@click="handleAddRecord"
>
新建记录
</a-button>
<a-button
size="small"
:icon="h(FilterOutlined)"
@click="openViewConfig('filter')"
>
筛选
</a-button>
<a-button
size="small"
:icon="h(SortAscendingOutlined)"
@click="openViewConfig('sort')"
>
排序
</a-button>
<a-dropdown placement="bottomRight">
<a-button size="small" :icon="h(MoreOutlined)">
选项
</a-button>
<template #overlay>
<a-menu @click="handleOptionsClick">
<a-menu-item key="field-manage">
<SettingOutlined /> 字段管理
</a-menu-item>
<a-menu-item key="refresh">
<ReloadOutlined /> 刷新
</a-menu-item>
<a-menu-item key="view-config">
<FilterOutlined /> 视图配置
</a-menu-item>
<a-menu-divider />
<a-menu-item
key="delete-view"
danger
:disabled="!store.currentView || store.views.length <= 1"
>
<DeleteOutlined /> 删除视图
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<!-- View switcher -->
<ViewSwitcher
:views="store.views"
:active-view-id="store.currentView?.id ?? null"
:creating="viewCreating"
@switch="handleSwitchView"
@create="handleCreateView"
@config="viewConfigOpen = true"
@delete="handleDeleteView"
/>
<div class="bitable-file-detail-view__grid-container">
<BitableGrid
:fields="visibleFields"
@ -112,6 +158,7 @@
:open="viewConfigOpen"
:fields="store.fields"
:view="store.currentView"
:initial-tab="viewConfigTab"
@close="viewConfigOpen = false"
/>
</div>
@ -127,6 +174,11 @@ import {
LoadingOutlined,
TableOutlined,
SettingOutlined,
PlusOutlined,
FilterOutlined,
SortAscendingOutlined,
MoreOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import { useBitableStore } from '@/stores/bitable'
import type { IBitableField, ViewType } from '@/api/bitable'
@ -145,6 +197,7 @@ const store = useBitableStore()
const createModalOpen = ref(false)
const fieldPanelOpen = ref(false)
const viewConfigOpen = ref(false)
const viewConfigTab = ref<'filter' | 'sort' | 'hidden' | 'grouping' | 'conditional-format'>('filter')
// U4: createView POST in-flight flag disables the "" button + shows
// a spinner to prevent duplicate submits.
const viewCreating = ref(false)
@ -218,6 +271,14 @@ function handleRefresh(): void {
store.refreshRecords()
}
function handleAddRecord(): void {
// ponytail: open the detail drawer for a new record.
// Future: inline row creation.
store.createRecord().then((recordId) => {
if (recordId) store.openRecordDetail(recordId)
})
}
async function handleEditCell(payload: {
recordId: string
fieldId: string
@ -230,10 +291,20 @@ function handleSwitchView(viewId: string): void {
store.switchView(viewId)
}
// U6: delete the active view. The ViewSwitcher's a-popconfirm already asked
// for confirmation; the store handles the 409 last-view case with a warning.
async function handleDeleteView(viewId: string): Promise<void> {
await store.deleteView(viewId)
// U6: delete the active view. The options menu asks for confirmation here;
// the store handles the 409 last-view case with a warning.
async function handleDeleteView(): Promise<void> {
if (!store.currentView) return
AModal.confirm({
title: '删除视图',
content: `确定删除视图「${store.currentView.name}」吗?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
await store.deleteView(store.currentView!.id)
},
})
}
async function handleCreateView(viewType: ViewType = 'grid'): Promise<void> {
@ -262,6 +333,28 @@ async function handleCreateView(viewType: ViewType = 'grid'): Promise<void> {
})
}
function openViewConfig(tab: 'filter' | 'sort' | 'hidden' | 'grouping' | 'conditional-format'): void {
viewConfigTab.value = tab
viewConfigOpen.value = true
}
function handleOptionsClick({ key }: { key: string }): void {
switch (key) {
case 'field-manage':
fieldPanelOpen.value = true
break
case 'refresh':
handleRefresh()
break
case 'view-config':
openViewConfig('hidden')
break
case 'delete-view':
handleDeleteView()
break
}
}
// U4: column header menu handlers (events from BitableGrid)
function handleAddFieldFromGrid(): void {
// Open the field manage panel which has the add-field form
@ -364,24 +457,43 @@ async function handleDeleteField(field: IBitableField): Promise<void> {
color: var(--bitable-color-text-placeholder);
}
.bitable-file-detail-view__grid-header {
/* Twenty-style toolbar */
.bitable-file-detail-view__toolbar {
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: var(--bitable-spacing-md);
padding: var(--bitable-spacing-md) var(--bitable-spacing-lg);
padding: var(--bitable-spacing-sm) var(--bitable-spacing-lg);
border-bottom: 1px solid var(--bitable-color-border);
flex-shrink: 0;
flex-wrap: wrap;
}
.bitable-file-detail-view__table-name {
margin: 0;
font-size: var(--bitable-font-lg);
font-weight: 600;
.bitable-file-detail-view__toolbar-left {
display: flex;
align-items: center;
gap: var(--bitable-spacing-sm);
min-width: 0;
}
.bitable-file-detail-view__field-count {
.bitable-file-detail-view__toolbar-right {
display: flex;
align-items: center;
gap: var(--bitable-spacing-sm);
flex-wrap: wrap;
justify-content: flex-end;
}
.bitable-file-detail-view__record-count {
font-size: var(--bitable-font-xs);
color: var(--bitable-color-text-secondary);
background-color: transparent;
border: 1px solid var(--bitable-color-border-hover);
white-space: nowrap;
flex-shrink: 0;
line-height: 1.4;
padding: 0 6px;
border-radius: 999px;
}
.bitable-file-detail-view__grid-container {