feat(bitable): U2 inline field configuration in column header menu
- Add InlineFieldConfigurator.vue (inline panel reusing FieldConfigForm logic) - Add fieldRenderUtils.ts (type conversion compatibility check) - Refactor ColumnHeaderMenu: edit -> inline expand, batch -> open FieldManagePanel - Integrate InlineFieldConfigurator in BitableGrid header slot - Add batch-management banner to FieldManagePanel - Add submitting loading state to prevent duplicate clicks - Extend e2e/bitable-field-ops.spec.ts with inline edit scenarios Closes R1 (P0): column header menu inline edit, no more drawer jump. Refs: docs/plans/2026-07-03-001-feat-bitable-p0-ux-and-agent-parity-plan.md U2
This commit is contained in:
parent
e1cf073693
commit
f0c993a0d9
|
|
@ -145,4 +145,144 @@ test.describe('Bitable Field Operations E2E', () => {
|
||||||
// the select editor renders when editing a select-type cell
|
// the select editor renders when editing a select-type cell
|
||||||
await page.waitForTimeout(500)
|
await page.waitForTimeout(500)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── U2: inline field configuration in column header menu ──────────────
|
||||||
|
|
||||||
|
test('C6: edit menu opens InlineFieldConfigurator inline (no drawer)', async ({ page }) => {
|
||||||
|
await setupBitableWithTable(page, 'E2E内联编辑', '测试表')
|
||||||
|
|
||||||
|
// Open the first column header dropdown
|
||||||
|
const headerMenu = page.locator('.column-header-menu').first()
|
||||||
|
await headerMenu.click()
|
||||||
|
|
||||||
|
// Click "编辑字段" — should open the inline configurator, NOT the drawer
|
||||||
|
await page.getByText('编辑字段').click()
|
||||||
|
|
||||||
|
// Inline configurator renders inside a popover (role=dialog)
|
||||||
|
await expect(page.locator('.inline-field-configurator')).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The right-side drawer must NOT have opened
|
||||||
|
await expect(page.locator('.ant-drawer-content')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('C7: rename field via inline config updates the grid label', async ({ page }) => {
|
||||||
|
await setupBitableWithTable(page, 'E2E重命名', '测试表')
|
||||||
|
|
||||||
|
const headerMenu = page.locator('.column-header-menu').first()
|
||||||
|
await headerMenu.click()
|
||||||
|
await page.getByText('编辑字段').click()
|
||||||
|
|
||||||
|
// The first input in the inline configurator is the field name
|
||||||
|
const nameInput = page.locator('.inline-field-configurator input').first()
|
||||||
|
await expect(nameInput).toBeVisible({ timeout: 5_000 })
|
||||||
|
await nameInput.fill('')
|
||||||
|
await nameInput.fill('重命名后字段')
|
||||||
|
|
||||||
|
// Save
|
||||||
|
await page.locator('.inline-field-configurator').getByRole('button', { name: '保存' }).click()
|
||||||
|
|
||||||
|
// The grid header should now show the new label
|
||||||
|
await expect(
|
||||||
|
page.locator('.column-header-menu__title', { hasText: '重命名后字段' }),
|
||||||
|
).toBeVisible({ timeout: 10_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('C8: incompatible type change (text -> number) blocks submit', async ({ page }) => {
|
||||||
|
await setupBitableWithTable(page, 'E2E类型转换', '测试表')
|
||||||
|
|
||||||
|
// Pick a text column with non-numeric values (the default "名称" field).
|
||||||
|
const headerMenu = page
|
||||||
|
.locator('.column-header-menu')
|
||||||
|
.filter({ hasText: '名称' })
|
||||||
|
.first()
|
||||||
|
await headerMenu.click()
|
||||||
|
await page.getByText('编辑字段').click()
|
||||||
|
|
||||||
|
// Switch type to number
|
||||||
|
await page.locator('.inline-field-configurator .ant-select').first().click()
|
||||||
|
await page.getByRole('option', { name: '数字' }).click()
|
||||||
|
|
||||||
|
// Compatibility warning must appear
|
||||||
|
await expect(
|
||||||
|
page.locator('.inline-field-configurator__warning, .inline-field-configurator .ant-alert-warning'),
|
||||||
|
).toBeVisible({ timeout: 5_000 })
|
||||||
|
|
||||||
|
// Save button must be disabled
|
||||||
|
const saveBtn = page
|
||||||
|
.locator('.inline-field-configurator')
|
||||||
|
.getByRole('button', { name: '保存' })
|
||||||
|
await expect(saveBtn).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('C9: select option management inline updates chips after save', async ({ page }) => {
|
||||||
|
await setupBitableWithTable(page, 'E2E选项管理', '测试表')
|
||||||
|
|
||||||
|
// The "状态" field is a select field. Open its inline editor.
|
||||||
|
const statusHeader = page
|
||||||
|
.locator('.column-header-menu')
|
||||||
|
.filter({ hasText: '状态' })
|
||||||
|
.first()
|
||||||
|
await statusHeader.click()
|
||||||
|
await page.getByText('编辑字段').click()
|
||||||
|
|
||||||
|
// Add a new option
|
||||||
|
await page
|
||||||
|
.locator('.inline-field-configurator')
|
||||||
|
.getByRole('button', { name: /添加选项/ })
|
||||||
|
.click()
|
||||||
|
const optionInputs = page.locator('.inline-field-configurator__option-row input')
|
||||||
|
const newOption = optionInputs.last()
|
||||||
|
await newOption.fill('新选项值')
|
||||||
|
|
||||||
|
// Save
|
||||||
|
await page.locator('.inline-field-configurator').getByRole('button', { name: '保存' }).click()
|
||||||
|
|
||||||
|
// Inline configurator closes after save
|
||||||
|
await expect(page.locator('.inline-field-configurator')).toHaveCount(0, { timeout: 10_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('C10: keyboard nav — Tab to header, Enter opens inline editor', async ({ page }) => {
|
||||||
|
await setupBitableWithTable(page, 'E2E键盘导航', '测试表')
|
||||||
|
|
||||||
|
// Tab until focus reaches the first column header menu (role=button)
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await page.keyboard.press('Tab')
|
||||||
|
const focused = await page.locator(':focus').evaluate((el) => ({
|
||||||
|
cls: el.className,
|
||||||
|
tag: el.tagName,
|
||||||
|
}))
|
||||||
|
if (focused.cls.includes('column-header-menu')) break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter opens the dropdown menu
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
await expect(page.getByText('编辑字段')).toBeVisible({ timeout: 5_000 })
|
||||||
|
|
||||||
|
// Activate "编辑字段" via keyboard (a-menu supports arrow + Enter)
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
await expect(page.locator('.inline-field-configurator')).toBeVisible({ timeout: 5_000 })
|
||||||
|
|
||||||
|
// Focus should be inside the inline configurator's first field
|
||||||
|
await expect(page.locator('.inline-field-configurator input').first()).toBeFocused()
|
||||||
|
|
||||||
|
// Esc closes the inline configurator
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await expect(page.locator('.inline-field-configurator')).toHaveCount(0, { timeout: 5_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('C11: batch management entry still opens FieldManagePanel', async ({ page }) => {
|
||||||
|
await setupBitableWithTable(page, 'E2E批量管理', '测试表')
|
||||||
|
|
||||||
|
const headerMenu = page.locator('.column-header-menu').first()
|
||||||
|
await headerMenu.click()
|
||||||
|
|
||||||
|
// Click "批量管理" — should open the right-side drawer
|
||||||
|
await page.getByText('批量管理').click()
|
||||||
|
await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 5_000 })
|
||||||
|
|
||||||
|
// The batch-management hint banner must be present
|
||||||
|
await expect(page.locator('.field-manage-panel__hint')).toBeVisible()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -36,18 +36,35 @@
|
||||||
:images="(row[f.id] as IAttachmentMeta[] | null | undefined)"
|
:images="(row[f.id] as IAttachmentMeta[] | null | undefined)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<!-- Column header dropdown menus (U4) -->
|
<!-- Column header dropdown menus (U4) + inline field config (U2) -->
|
||||||
<template
|
<template
|
||||||
v-for="f in fields"
|
v-for="f in fields"
|
||||||
:key="`hdr_${f.id}`"
|
:key="`hdr_${f.id}`"
|
||||||
#[`header_${f.id}`]
|
#[`header_${f.id}`]
|
||||||
>
|
>
|
||||||
<ColumnHeaderMenu
|
<a-popover
|
||||||
:field="f"
|
:open="editingFieldId === f.id"
|
||||||
@edit="emit('config-field', $event)"
|
:trigger="[]"
|
||||||
@hide="emit('hide-field', $event)"
|
placement="bottomLeft"
|
||||||
@delete="emit('delete-field', $event)"
|
overlay-class-name="bitable-inline-config-popover"
|
||||||
/>
|
:overlay-style="{ width: '340px' }"
|
||||||
|
>
|
||||||
|
<ColumnHeaderMenu
|
||||||
|
:field="f"
|
||||||
|
@edit-inline="startInlineEdit(f.id)"
|
||||||
|
@open-batch-panel="emit('config-field', $event)"
|
||||||
|
@hide="emit('hide-field', $event)"
|
||||||
|
@delete="emit('delete-field', $event)"
|
||||||
|
/>
|
||||||
|
<template #content>
|
||||||
|
<InlineFieldConfigurator
|
||||||
|
v-if="editingFieldId === f.id"
|
||||||
|
:field="f"
|
||||||
|
@saved="onInlineSaved"
|
||||||
|
@cancel="onInlineCancel"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</a-popover>
|
||||||
</template>
|
</template>
|
||||||
<!-- Select / Multiselect edit slots (U5) -->
|
<!-- Select / Multiselect edit slots (U5) -->
|
||||||
<template
|
<template
|
||||||
|
|
@ -87,7 +104,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { VxeGrid } from 'vxe-table'
|
import { VxeGrid } from 'vxe-table'
|
||||||
import { Empty as AEmpty } from 'ant-design-vue'
|
import { Empty as AEmpty, Popover as APopover } from 'ant-design-vue'
|
||||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||||
import type { VxeGridProps, VxeGridEvents } from 'vxe-table'
|
import type { VxeGridProps, VxeGridEvents } from 'vxe-table'
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -101,6 +118,7 @@ import ImageCell from './ImageCell.vue'
|
||||||
import ColumnHeaderMenu from './ColumnHeaderMenu.vue'
|
import ColumnHeaderMenu from './ColumnHeaderMenu.vue'
|
||||||
import SelectCellEditor from './SelectCellEditor.vue'
|
import SelectCellEditor from './SelectCellEditor.vue'
|
||||||
import SelectDisplay from './SelectDisplay.vue'
|
import SelectDisplay from './SelectDisplay.vue'
|
||||||
|
import InlineFieldConfigurator from './InlineFieldConfigurator.vue'
|
||||||
import type { ISelectOption } from './SelectCellEditor.vue'
|
import type { ISelectOption } from './SelectCellEditor.vue'
|
||||||
|
|
||||||
type GridRow = Record<string, unknown> & { _rowId: string; _recordId: string }
|
type GridRow = Record<string, unknown> & { _rowId: string; _recordId: string }
|
||||||
|
|
@ -131,6 +149,28 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const gridRef = ref<InstanceType<typeof VxeGrid> | null>(null)
|
const gridRef = ref<InstanceType<typeof VxeGrid> | null>(null)
|
||||||
|
|
||||||
|
// U2: inline field config — only one column edits at a time. null = none open.
|
||||||
|
// The InlineFieldConfigurator renders inside an a-popover anchored to the
|
||||||
|
// column header (see header slot above). The store mutation (store.updateField)
|
||||||
|
// updates store.fields -> parent visibleFields -> this component's `fields`
|
||||||
|
// prop, so the grid re-renders the new label/column config on the next frame.
|
||||||
|
const editingFieldId = ref<string | null>(null)
|
||||||
|
|
||||||
|
function startInlineEdit(fieldId: string): void {
|
||||||
|
editingFieldId.value = fieldId
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInlineSaved(_field: IBitableField): void {
|
||||||
|
editingFieldId.value = null
|
||||||
|
// Refresh vxe-table column rendering so a type change (e.g. text -> number)
|
||||||
|
// picks up the new editRender. Label updates flow through props naturally.
|
||||||
|
gridRef.value?.refreshColumn?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInlineCancel(): void {
|
||||||
|
editingFieldId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// Fields that use custom slot renderers (attachment/image)
|
// Fields that use custom slot renderers (attachment/image)
|
||||||
const attachmentFields = computed(() =>
|
const attachmentFields = computed(() =>
|
||||||
props.fields.filter(
|
props.fields.filter(
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,26 @@
|
||||||
<template>
|
<template>
|
||||||
<a-dropdown :trigger="['click']" placement="bottomLeft">
|
<a-dropdown v-model:open="open" :trigger="['click']" placement="bottomLeft">
|
||||||
<div class="column-header-menu" @click.stop>
|
<div
|
||||||
|
class="column-header-menu"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
:aria-label="`字段 ${field.name} 菜单`"
|
||||||
|
:aria-expanded="open"
|
||||||
|
@click.stop
|
||||||
|
@keydown.enter.prevent="open = true"
|
||||||
|
@keydown.space.prevent="open = true"
|
||||||
|
>
|
||||||
<span class="column-header-menu__title">{{ field.name }}</span>
|
<span class="column-header-menu__title">{{ field.name }}</span>
|
||||||
<DownOutlined class="column-header-menu__arrow" />
|
<DownOutlined class="column-header-menu__arrow" />
|
||||||
</div>
|
</div>
|
||||||
<template #overlay>
|
<template #overlay>
|
||||||
<a-menu @click="handleMenuClick">
|
<a-menu @click="handleMenuClick">
|
||||||
<a-menu-item key="edit">
|
<a-menu-item key="edit-inline">
|
||||||
<EditOutlined /> 编辑字段
|
<EditOutlined /> 编辑字段
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
|
<a-menu-item key="open-batch-panel">
|
||||||
|
<AppstoreOutlined /> 批量管理
|
||||||
|
</a-menu-item>
|
||||||
<a-menu-item key="hide">
|
<a-menu-item key="hide">
|
||||||
<EyeInvisibleOutlined /> 隐藏字段
|
<EyeInvisibleOutlined /> 隐藏字段
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
|
|
@ -22,11 +34,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
EyeInvisibleOutlined,
|
EyeInvisibleOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import type { IBitableField } from '@/api/bitable'
|
import type { IBitableField } from '@/api/bitable'
|
||||||
|
|
||||||
|
|
@ -35,15 +49,26 @@ const props = defineProps<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'edit', field: IBitableField): void
|
(e: 'edit-inline', field: IBitableField): void
|
||||||
|
(e: 'open-batch-panel', field: IBitableField): void
|
||||||
(e: 'hide', fieldId: string): void
|
(e: 'hide', fieldId: string): void
|
||||||
(e: 'delete', field: IBitableField): void
|
(e: 'delete', field: IBitableField): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Controlled open state so Enter/Space on the focusable header opens the menu
|
||||||
|
// (U2 scenario 5: Tab to header -> Enter opens menu).
|
||||||
|
const open = ref(false)
|
||||||
|
|
||||||
function handleMenuClick({ key }: { key: string }): void {
|
function handleMenuClick({ key }: { key: string }): void {
|
||||||
|
// Close the menu immediately after a choice (a-menu does not auto-close on
|
||||||
|
// programmatic emit when controlled via v-model:open).
|
||||||
|
open.value = false
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'edit':
|
case 'edit-inline':
|
||||||
emit('edit', props.field)
|
emit('edit-inline', props.field)
|
||||||
|
break
|
||||||
|
case 'open-batch-panel':
|
||||||
|
emit('open-batch-panel', props.field)
|
||||||
break
|
break
|
||||||
case 'hide':
|
case 'hide':
|
||||||
emit('hide', props.field.id)
|
emit('hide', props.field.id)
|
||||||
|
|
@ -64,6 +89,8 @@ function handleMenuClick({ key }: { key: string }): void {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
outline: none;
|
||||||
|
border-radius: var(--bitable-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-header-menu__title {
|
.column-header-menu__title {
|
||||||
|
|
@ -86,4 +113,8 @@ function handleMenuClick({ key }: { key: string }): void {
|
||||||
.column-header-menu:hover .column-header-menu__arrow {
|
.column-header-menu:hover .column-header-menu__arrow {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.column-header-menu:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px var(--bitable-color-primary) inset;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,16 @@
|
||||||
:width="480"
|
:width="480"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
|
<!-- U2: positioning hint — single-field edits now happen inline via the
|
||||||
|
column header menu; this drawer remains the batch-management entry. -->
|
||||||
|
<a-alert
|
||||||
|
class="field-manage-panel__hint"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="批量管理入口"
|
||||||
|
description="单字段编辑请使用列头菜单中的「编辑字段」。此处用于批量管理字段。"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Field list -->
|
<!-- Field list -->
|
||||||
<div class="field-manage-panel__list">
|
<div class="field-manage-panel__list">
|
||||||
<div
|
<div
|
||||||
|
|
@ -68,7 +78,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, h } from 'vue'
|
import { ref, h } from 'vue'
|
||||||
import { Modal as AModal, Drawer as ADrawer, Button as AButton, Tag as ATag, Empty as AEmpty } from 'ant-design-vue'
|
import { Modal as AModal, Drawer as ADrawer, Button as AButton, Tag as ATag, Empty as AEmpty, Alert as AAlert } from 'ant-design-vue'
|
||||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||||
import type { IBitableField, FieldType } from '@/api/bitable'
|
import type { IBitableField, FieldType } from '@/api/bitable'
|
||||||
import { useBitableStore } from '@/stores/bitable'
|
import { useBitableStore } from '@/stores/bitable'
|
||||||
|
|
@ -197,6 +207,11 @@ function typeColor(t: FieldType): string {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.field-manage-panel__hint {
|
||||||
|
margin-bottom: var(--bitable-spacing-md);
|
||||||
|
border-radius: var(--bitable-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
.field-manage-panel__list {
|
.field-manage-panel__list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,388 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="inline-field-configurator"
|
||||||
|
tabindex="-1"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="内联编辑字段"
|
||||||
|
@keydown.esc="onCancel"
|
||||||
|
>
|
||||||
|
<div class="inline-field-configurator__header">
|
||||||
|
<FieldTypeIcon :type="localType" />
|
||||||
|
<span class="inline-field-configurator__title">编辑字段</span>
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
:icon="h(CloseOutlined)"
|
||||||
|
aria-label="关闭"
|
||||||
|
@click="onCancel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-form layout="vertical" size="small">
|
||||||
|
<a-form-item label="字段名称">
|
||||||
|
<a-input
|
||||||
|
ref="nameInputRef"
|
||||||
|
v-model:value="localName"
|
||||||
|
:maxlength="100"
|
||||||
|
placeholder="字段名称"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="字段类型">
|
||||||
|
<a-select v-model:value="localType" @change="onTypeChange">
|
||||||
|
<a-select-option value="text">文本</a-select-option>
|
||||||
|
<a-select-option value="number">数字</a-select-option>
|
||||||
|
<a-select-option value="date">日期</a-select-option>
|
||||||
|
<a-select-option value="select">单选</a-select-option>
|
||||||
|
<a-select-option value="multiselect">多选</a-select-option>
|
||||||
|
<a-select-option value="formula">公式</a-select-option>
|
||||||
|
<a-select-option value="attachment">附件</a-select-option>
|
||||||
|
<a-select-option value="image">图片</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<!-- Select / Multiselect: options editor -->
|
||||||
|
<template v-if="localType === 'select' || localType === 'multiselect'">
|
||||||
|
<a-form-item label="选项列表">
|
||||||
|
<div
|
||||||
|
v-for="(_, idx) in selectOptions"
|
||||||
|
:key="idx"
|
||||||
|
class="inline-field-configurator__option-row"
|
||||||
|
>
|
||||||
|
<a-input
|
||||||
|
v-model:value="selectOptions[idx]"
|
||||||
|
placeholder="选项值"
|
||||||
|
:maxlength="200"
|
||||||
|
/>
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
:icon="h(DeleteOutlined)"
|
||||||
|
aria-label="删除选项"
|
||||||
|
@click="removeOption(idx)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<a-button
|
||||||
|
type="dashed"
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
:icon="h(PlusOutlined)"
|
||||||
|
@click="addOption"
|
||||||
|
>
|
||||||
|
添加选项
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Formula: expression editor with live validation -->
|
||||||
|
<template v-if="localType === 'formula'">
|
||||||
|
<a-form-item label="公式表达式">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="formulaExpr"
|
||||||
|
placeholder="例如: {field_id_1} + {field_id_2} 或 SUM({field_id})"
|
||||||
|
:rows="3"
|
||||||
|
:maxlength="2000"
|
||||||
|
/>
|
||||||
|
<div class="inline-field-configurator__formula-hint">
|
||||||
|
用 {字段ID} 引用其他字段,支持 SUM/AVG/COUNT/MIN/MAX/ABS/ROUND/IF/LEN/CONCAT
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="formulaExpr" label="语法校验">
|
||||||
|
<a-alert
|
||||||
|
v-if="formulaValid === true"
|
||||||
|
type="success"
|
||||||
|
message="公式语法正确"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<a-alert
|
||||||
|
v-else-if="formulaValid === false"
|
||||||
|
type="error"
|
||||||
|
:message="formulaError || '公式语法错误'"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<a-alert v-else type="info" message="校验中..." show-icon />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Date: format -->
|
||||||
|
<template v-if="localType === 'date'">
|
||||||
|
<a-form-item label="日期格式">
|
||||||
|
<a-select v-model:value="dateFormat">
|
||||||
|
<a-select-option value="YYYY-MM-DD">YYYY-MM-DD</a-select-option>
|
||||||
|
<a-select-option value="YYYY-MM-DD HH:mm">YYYY-MM-DD HH:mm</a-select-option>
|
||||||
|
<a-select-option value="YYYY/MM/DD">YYYY/MM/DD</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<!-- Type-change compatibility warning (blocks submit) -->
|
||||||
|
<a-alert
|
||||||
|
v-if="compatWarning"
|
||||||
|
class="inline-field-configurator__warning"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
message="无法转换字段类型"
|
||||||
|
:description="compatWarning"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Submit error with retry -->
|
||||||
|
<ErrorState
|
||||||
|
v-if="submitError"
|
||||||
|
class="inline-field-configurator__error"
|
||||||
|
message="保存失败"
|
||||||
|
:description="submitError"
|
||||||
|
:retryable="true"
|
||||||
|
:retrying="submitting"
|
||||||
|
@retry="onSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="inline-field-configurator__actions">
|
||||||
|
<a-button size="small" :disabled="submitting" @click="onCancel">
|
||||||
|
取消
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="onSubmit"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, h } from 'vue'
|
||||||
|
import {
|
||||||
|
Button as AButton,
|
||||||
|
Input as AInput,
|
||||||
|
Select as ASelect,
|
||||||
|
Alert as AAlert,
|
||||||
|
} from 'ant-design-vue'
|
||||||
|
import {
|
||||||
|
CloseOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import type { IBitableField, FieldType } from '@/api/bitable'
|
||||||
|
import { useBitableStore } from '@/stores/bitable'
|
||||||
|
import { checkTypeCompatibility } from '@/helpers/fieldRenderUtils'
|
||||||
|
import FieldTypeIcon from './FieldTypeIcon.vue'
|
||||||
|
import ErrorState from './ErrorState.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
field: IBitableField
|
||||||
|
// ponytail: tableId is reserved for future table-scoped operations; the
|
||||||
|
// PATCH /fields/{id} endpoint only needs fieldId. Kept per U2 spec contract.
|
||||||
|
tableId?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'saved', field: IBitableField): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useBitableStore()
|
||||||
|
|
||||||
|
const localName = ref(props.field.name)
|
||||||
|
const localType = ref<FieldType>(props.field.field_type)
|
||||||
|
const selectOptions = ref<string[]>(
|
||||||
|
(props.field.config?.options as string[]) ?? [],
|
||||||
|
)
|
||||||
|
const formulaExpr = ref((props.field.config?.formula_expr as string) ?? '')
|
||||||
|
const dateFormat = ref((props.field.config?.format as string) ?? 'YYYY-MM-DD')
|
||||||
|
|
||||||
|
// Formula validation state (debounced API check, same as FieldConfigForm)
|
||||||
|
const formulaValid = ref<boolean | null>(null)
|
||||||
|
const formulaError = ref<string | null>(null)
|
||||||
|
let validateTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
const submitError = ref<string | null>(null)
|
||||||
|
const nameInputRef = ref<InstanceType<typeof AInput> | null>(null)
|
||||||
|
|
||||||
|
// Existing values for the field across all loaded records (for compat check)
|
||||||
|
const existingValues = computed<unknown[]>(() =>
|
||||||
|
store.records.map((r) => r.values[props.field.id]),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compatibility result when type changes
|
||||||
|
const compat = computed(() =>
|
||||||
|
checkTypeCompatibility(
|
||||||
|
props.field.field_type,
|
||||||
|
localType.value,
|
||||||
|
existingValues.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const compatWarning = computed<string | null>(() =>
|
||||||
|
compat.value.compatible ? null : (compat.value.reason ?? '当前数据不支持该类型转换'),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Debounced formula validation
|
||||||
|
watch(formulaExpr, (val) => {
|
||||||
|
if (!val.trim()) {
|
||||||
|
formulaValid.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formulaValid.value = null
|
||||||
|
if (validateTimer) clearTimeout(validateTimer)
|
||||||
|
validateTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { bitableApi } = await import('@/api/bitable')
|
||||||
|
const result = await bitableApi.validateFormula(val)
|
||||||
|
formulaValid.value = result.valid
|
||||||
|
formulaError.value = result.error ?? null
|
||||||
|
} catch (err) {
|
||||||
|
formulaValid.value = false
|
||||||
|
formulaError.value = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onTypeChange(): void {
|
||||||
|
// Reset type-specific config when type changes (mirrors FieldConfigForm)
|
||||||
|
selectOptions.value = []
|
||||||
|
formulaExpr.value = ''
|
||||||
|
formulaValid.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOption(): void {
|
||||||
|
selectOptions.value.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOption(idx: number): void {
|
||||||
|
selectOptions.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfig(): Record<string, unknown> {
|
||||||
|
const config: Record<string, unknown> = {}
|
||||||
|
if (localType.value === 'select' || localType.value === 'multiselect') {
|
||||||
|
config.options = selectOptions.value.filter((o) => o.trim())
|
||||||
|
}
|
||||||
|
if (localType.value === 'formula') {
|
||||||
|
config.formula_expr = formulaExpr.value
|
||||||
|
}
|
||||||
|
if (localType.value === 'date') {
|
||||||
|
config.format = dateFormat.value
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFormulaStateValid = computed(() => {
|
||||||
|
if (localType.value !== 'formula') return true
|
||||||
|
if (!formulaExpr.value.trim()) return false
|
||||||
|
return formulaValid.value === true
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
if (submitting.value) return false
|
||||||
|
if (!localName.value.trim()) return false
|
||||||
|
if (!compat.value.compatible) return false
|
||||||
|
return isFormulaStateValid.value
|
||||||
|
})
|
||||||
|
|
||||||
|
function onCancel(): void {
|
||||||
|
if (submitting.value) return
|
||||||
|
submitError.value = null
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(): Promise<void> {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
submitError.value = null
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const patch: {
|
||||||
|
name: string
|
||||||
|
field_type?: FieldType
|
||||||
|
config: Record<string, unknown>
|
||||||
|
} = {
|
||||||
|
name: localName.value.trim(),
|
||||||
|
config: buildConfig(),
|
||||||
|
}
|
||||||
|
// Only send field_type when it actually changes (avoid no-op PATCH semantics)
|
||||||
|
if (localType.value !== props.field.field_type) {
|
||||||
|
patch.field_type = localType.value
|
||||||
|
}
|
||||||
|
const updated = await store.updateField(props.field.id, patch)
|
||||||
|
if (!updated) {
|
||||||
|
// store.updateField surfaces errors via notification; show inline retry too
|
||||||
|
submitError.value = '服务器返回空响应,请重试'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('saved', updated)
|
||||||
|
} catch (err) {
|
||||||
|
submitError.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Move focus to the first field for keyboard accessibility (U2 scenario 5).
|
||||||
|
// a-input exposes focus() at runtime; cast to a minimal structural type
|
||||||
|
// because InstanceType<typeof AInput> doesn't surface the imperative method.
|
||||||
|
const inputEl = nameInputRef.value as { focus?: () => void } | null
|
||||||
|
inputEl?.focus?.()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.inline-field-configurator {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--bitable-spacing-sm);
|
||||||
|
padding: var(--bitable-spacing-sm) 0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-field-configurator__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--bitable-spacing-xs);
|
||||||
|
padding-bottom: var(--bitable-spacing-xs);
|
||||||
|
border-bottom: 1px solid var(--bitable-color-border-split);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-field-configurator__title {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--bitable-font-md);
|
||||||
|
color: var(--bitable-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-field-configurator__option-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--bitable-spacing-xs);
|
||||||
|
margin-bottom: var(--bitable-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-field-configurator__formula-hint {
|
||||||
|
margin-top: var(--bitable-spacing-xs);
|
||||||
|
font-size: var(--bitable-font-xs);
|
||||||
|
color: var(--bitable-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-field-configurator__warning {
|
||||||
|
margin-top: var(--bitable-spacing-xs);
|
||||||
|
border-radius: var(--bitable-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-field-configurator__error {
|
||||||
|
margin-top: var(--bitable-spacing-xs);
|
||||||
|
border-radius: var(--bitable-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-field-configurator__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--bitable-spacing-xs);
|
||||||
|
margin-top: var(--bitable-spacing-xs);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
/**
|
||||||
|
* Bitable field render / type-conversion utilities (U2).
|
||||||
|
*
|
||||||
|
* Pure functions — no Vue, no store, no I/O. Safe to unit-test in isolation.
|
||||||
|
*
|
||||||
|
* `checkTypeCompatibility` implements the conversion matrix from the U2 spec:
|
||||||
|
* - text <-> select/multiselect: compatible (values double as options)
|
||||||
|
* - number -> text: compatible
|
||||||
|
* - text -> number: compatible only if every non-empty value parses as a number
|
||||||
|
* - date -> text: compatible (ISO string)
|
||||||
|
* - text -> date: compatible only if every non-empty value is ISO-date-shaped
|
||||||
|
* - attachment/image/formula/lookup -> anything: NOT compatible (structured data)
|
||||||
|
* - anything -> attachment/image/formula/lookup: NOT compatible (can't synthesize)
|
||||||
|
* - select <-> multiselect: compatible (shared option list, single value <-> [single])
|
||||||
|
*
|
||||||
|
* ponytail: unlisted pairs are blocked conservatively. Ceiling: a few safe pairs
|
||||||
|
* (e.g. number -> select) are also blocked to keep the matrix small and explicit;
|
||||||
|
* upgrade path = extend COMPATIBLE_PAIRS / add a guard below with a test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FieldType } from '@/api/bitable'
|
||||||
|
|
||||||
|
export interface CompatibilityResult {
|
||||||
|
compatible: boolean
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO-8601 date / date-time. Tolerant: date-only, with/without time + TZ.
|
||||||
|
const ISO_DATE_RE =
|
||||||
|
/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/
|
||||||
|
|
||||||
|
const STRUCTURED_OR_CALC: ReadonlySet<FieldType> = new Set([
|
||||||
|
'attachment',
|
||||||
|
'image',
|
||||||
|
'formula',
|
||||||
|
'lookup',
|
||||||
|
])
|
||||||
|
|
||||||
|
function isEmpty(v: unknown): boolean {
|
||||||
|
return v == null || v === ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNumeric(v: unknown): boolean {
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v)
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim()
|
||||||
|
if (s === '') return false
|
||||||
|
// ponytail: Number() accepts hex ('0xFF') and ('' ) — guard explicitly.
|
||||||
|
if (s.startsWith('0x') || s.startsWith('0X')) return false
|
||||||
|
const n = Number(s)
|
||||||
|
return !Number.isNaN(n) && Number.isFinite(n)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isISODate(v: unknown): boolean {
|
||||||
|
if (typeof v !== 'string') return false
|
||||||
|
const s = v.trim()
|
||||||
|
if (!ISO_DATE_RE.test(s)) return false
|
||||||
|
const t = Date.parse(s)
|
||||||
|
return !Number.isNaN(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether existing field values can be carried over when the field's type
|
||||||
|
* changes from `oldType` to `newType`.
|
||||||
|
*
|
||||||
|
* @param existingValues raw values collected from records for this field
|
||||||
|
* (may include nulls / empty strings; they are ignored).
|
||||||
|
*/
|
||||||
|
export function checkTypeCompatibility(
|
||||||
|
oldType: FieldType,
|
||||||
|
newType: FieldType,
|
||||||
|
existingValues: unknown[],
|
||||||
|
): CompatibilityResult {
|
||||||
|
if (oldType === newType) return { compatible: true }
|
||||||
|
|
||||||
|
// Structured / calculated source types cannot be converted away.
|
||||||
|
if (STRUCTURED_OR_CALC.has(oldType)) {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
reason: `${oldType} 字段不支持类型转换(数据结构差异较大)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cannot synthesize structured / calculated target types from plain values.
|
||||||
|
if (STRUCTURED_OR_CALC.has(newType)) {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
reason: `不支持转换到 ${newType} 字段(无法由现有值构造)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonEmpty = existingValues.filter((v) => !isEmpty(v))
|
||||||
|
|
||||||
|
// text <-> select / multiselect
|
||||||
|
if (
|
||||||
|
(oldType === 'text' && (newType === 'select' || newType === 'multiselect')) ||
|
||||||
|
((oldType === 'select' || oldType === 'multiselect') && newType === 'text')
|
||||||
|
) {
|
||||||
|
return { compatible: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// select <-> multiselect (shared option list)
|
||||||
|
if (
|
||||||
|
(oldType === 'select' && newType === 'multiselect') ||
|
||||||
|
(oldType === 'multiselect' && newType === 'select')
|
||||||
|
) {
|
||||||
|
return { compatible: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// number -> text
|
||||||
|
if (oldType === 'number' && newType === 'text') {
|
||||||
|
return { compatible: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// text -> number: all non-empty values must parse as numbers
|
||||||
|
if (oldType === 'text' && newType === 'number') {
|
||||||
|
if (!nonEmpty.every(isNumeric)) {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
reason: '存在非数字文本值,无法转换为数字类型',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { compatible: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// date -> text (ISO string)
|
||||||
|
if (oldType === 'date' && newType === 'text') {
|
||||||
|
return { compatible: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// text -> date: all non-empty values must be ISO dates
|
||||||
|
if (oldType === 'text' && newType === 'date') {
|
||||||
|
if (!nonEmpty.every(isISODate)) {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
reason: '存在非 ISO 日期格式的文本值,无法转换为日期类型',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { compatible: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// number -> date / date -> number: ambiguous (epoch vs calendar) — block.
|
||||||
|
// select -> number / number -> select already covered where applicable above;
|
||||||
|
// any unlisted pair falls through to the conservative block below.
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
reason: `不支持从 ${oldType} 转换到 ${newType}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -317,10 +317,10 @@ export const useBitableStore = defineStore('bitable', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update an existing field */
|
/** Update an existing field (U2: field_type added for inline type change) */
|
||||||
async function updateField(
|
async function updateField(
|
||||||
fieldId: string,
|
fieldId: string,
|
||||||
data: { name?: string; config?: Record<string, unknown> },
|
data: { name?: string; field_type?: FieldType; config?: Record<string, unknown> },
|
||||||
): Promise<IBitableField | null> {
|
): Promise<IBitableField | null> {
|
||||||
try {
|
try {
|
||||||
const resp = await bitableApi.updateField(fieldId, data)
|
const resp = await bitableApi.updateField(fieldId, data)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue