diff --git a/src/agentkit/server/frontend/e2e/bitable-field-ops.spec.ts b/src/agentkit/server/frontend/e2e/bitable-field-ops.spec.ts
index 533b57a..5333f4c 100644
--- a/src/agentkit/server/frontend/e2e/bitable-field-ops.spec.ts
+++ b/src/agentkit/server/frontend/e2e/bitable-field-ops.spec.ts
@@ -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()
+ })
})
diff --git a/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue b/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue
index c72fb85..5a72d52 100644
--- a/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue
+++ b/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue
@@ -36,18 +36,35 @@
:images="(row[f.id] as IAttachmentMeta[] | null | undefined)"
/>
-
+
-
+
+
+
+
+
+
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 & { _rowId: string; _recordId: string }
@@ -131,6 +149,28 @@ const emit = defineEmits<{
const gridRef = ref | 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(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(
diff --git a/src/agentkit/server/frontend/src/components/bitable/ColumnHeaderMenu.vue b/src/agentkit/server/frontend/src/components/bitable/ColumnHeaderMenu.vue
index 83bd1d3..bbf5eef 100644
--- a/src/agentkit/server/frontend/src/components/bitable/ColumnHeaderMenu.vue
+++ b/src/agentkit/server/frontend/src/components/bitable/ColumnHeaderMenu.vue
@@ -1,14 +1,26 @@
-
-
+
+
{{ field.name }}
-
+
编辑字段
+
+ 批量管理
+
隐藏字段
@@ -22,11 +34,13 @@
diff --git a/src/agentkit/server/frontend/src/helpers/fieldRenderUtils.ts b/src/agentkit/server/frontend/src/helpers/fieldRenderUtils.ts
new file mode 100644
index 0000000..e6f50a8
--- /dev/null
+++ b/src/agentkit/server/frontend/src/helpers/fieldRenderUtils.ts
@@ -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 = 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}`,
+ }
+}
diff --git a/src/agentkit/server/frontend/src/stores/bitable.ts b/src/agentkit/server/frontend/src/stores/bitable.ts
index 1f40ef2..6b9e2fc 100644
--- a/src/agentkit/server/frontend/src/stores/bitable.ts
+++ b/src/agentkit/server/frontend/src/stores/bitable.ts
@@ -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 },
+ data: { name?: string; field_type?: FieldType; config?: Record },
): Promise {
try {
const resp = await bitableApi.updateField(fieldId, data)