feat(bitable): Twenty 风格工具栏、视图切换器与视图配置
- BitableFileDetailView 重构顶部工具栏:视图切换器、记录数徽章、新建记录/筛选/排序/选项 - ViewSwitcher 从标签页改为下拉菜单 - bitable store 新增 createRecord 方法 - ViewConfigPanel 支持 initialTab 从按钮直达对应配置页 - 新增 DateDisplay 组件与 bitable-grid-guidelines UX 规范
This commit is contained in:
parent
96c15146d5
commit
729047433d
|
|
@ -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 变量以适配暗色主题
|
||||
- [ ] 是否未引入无意义的双重/三重水平线
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue