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:
chiguyong 2026-07-03 15:12:17 +08:00
parent e1cf073693
commit f0c993a0d9
7 changed files with 781 additions and 17 deletions

View File

@ -145,4 +145,144 @@ test.describe('Bitable Field Operations E2E', () => {
// the select editor renders when editing a select-type cell
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()
})
})

View File

@ -36,18 +36,35 @@
:images="(row[f.id] as IAttachmentMeta[] | null | undefined)"
/>
</template>
<!-- Column header dropdown menus (U4) -->
<!-- Column header dropdown menus (U4) + inline field config (U2) -->
<template
v-for="f in fields"
:key="`hdr_${f.id}`"
#[`header_${f.id}`]
>
<a-popover
:open="editingFieldId === f.id"
:trigger="[]"
placement="bottomLeft"
overlay-class-name="bitable-inline-config-popover"
:overlay-style="{ width: '340px' }"
>
<ColumnHeaderMenu
:field="f"
@edit="emit('config-field', $event)"
@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>
<!-- Select / Multiselect edit slots (U5) -->
<template
@ -87,7 +104,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
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 type { VxeGridProps, VxeGridEvents } from 'vxe-table'
import type {
@ -101,6 +118,7 @@ import ImageCell from './ImageCell.vue'
import ColumnHeaderMenu from './ColumnHeaderMenu.vue'
import SelectCellEditor from './SelectCellEditor.vue'
import SelectDisplay from './SelectDisplay.vue'
import InlineFieldConfigurator from './InlineFieldConfigurator.vue'
import type { ISelectOption } from './SelectCellEditor.vue'
type GridRow = Record<string, unknown> & { _rowId: string; _recordId: string }
@ -131,6 +149,28 @@ const emit = defineEmits<{
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)
const attachmentFields = computed(() =>
props.fields.filter(

View File

@ -1,14 +1,26 @@
<template>
<a-dropdown :trigger="['click']" placement="bottomLeft">
<div class="column-header-menu" @click.stop>
<a-dropdown v-model:open="open" :trigger="['click']" placement="bottomLeft">
<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>
<DownOutlined class="column-header-menu__arrow" />
</div>
<template #overlay>
<a-menu @click="handleMenuClick">
<a-menu-item key="edit">
<a-menu-item key="edit-inline">
<EditOutlined /> 编辑字段
</a-menu-item>
<a-menu-item key="open-batch-panel">
<AppstoreOutlined /> 批量管理
</a-menu-item>
<a-menu-item key="hide">
<EyeInvisibleOutlined /> 隐藏字段
</a-menu-item>
@ -22,11 +34,13 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import {
DownOutlined,
EditOutlined,
EyeInvisibleOutlined,
DeleteOutlined,
AppstoreOutlined,
} from '@ant-design/icons-vue'
import type { IBitableField } from '@/api/bitable'
@ -35,15 +49,26 @@ const props = defineProps<{
}>()
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: '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 {
// 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) {
case 'edit':
emit('edit', props.field)
case 'edit-inline':
emit('edit-inline', props.field)
break
case 'open-batch-panel':
emit('open-batch-panel', props.field)
break
case 'hide':
emit('hide', props.field.id)
@ -64,6 +89,8 @@ function handleMenuClick({ key }: { key: string }): void {
width: 100%;
height: 100%;
user-select: none;
outline: none;
border-radius: var(--bitable-radius-sm);
}
.column-header-menu__title {
@ -86,4 +113,8 @@ function handleMenuClick({ key }: { key: string }): void {
.column-header-menu:hover .column-header-menu__arrow {
opacity: 1;
}
.column-header-menu:focus-visible {
box-shadow: 0 0 0 2px var(--bitable-color-primary) inset;
}
</style>

View File

@ -6,6 +6,16 @@
:width="480"
@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 -->
<div class="field-manage-panel__list">
<div
@ -68,7 +78,7 @@
<script setup lang="ts">
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 type { IBitableField, FieldType } from '@/api/bitable'
import { useBitableStore } from '@/stores/bitable'
@ -197,6 +207,11 @@ function typeColor(t: FieldType): string {
</script>
<style scoped>
.field-manage-panel__hint {
margin-bottom: var(--bitable-spacing-md);
border-radius: var(--bitable-radius-md);
}
.field-manage-panel__list {
display: flex;
flex-direction: column;

View File

@ -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>

View File

@ -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}`,
}
}

View File

@ -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(
fieldId: string,
data: { name?: string; config?: Record<string, unknown> },
data: { name?: string; field_type?: FieldType; config?: Record<string, unknown> },
): Promise<IBitableField | null> {
try {
const resp = await bitableApi.updateField(fieldId, data)