feat: Bitable P0 UX Polish + Agent Parity #23
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}`]
|
||||
>
|
||||
<ColumnHeaderMenu
|
||||
:field="f"
|
||||
@edit="emit('config-field', $event)"
|
||||
@hide="emit('hide-field', $event)"
|
||||
@delete="emit('delete-field', $event)"
|
||||
/>
|
||||
<a-popover
|
||||
:open="editingFieldId === f.id"
|
||||
:trigger="[]"
|
||||
placement="bottomLeft"
|
||||
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>
|
||||
<!-- 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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue