feat(bitable): U3 record detail drawer with full field type rendering

- Add RecordDetailDrawer.vue (480px/640px drawer, sticky header, full field type render)
- Add recordDrawerUtils.ts (value formatter, attachment/image extractors, drawer width calc)
- Add currentRecord state + openRecordDetail/closeRecordDetail/fetchRecordDetail actions to store
- Wire BitableGrid row click to open drawer
- Add e2e/bitable-record-drawer.spec.ts with 7 scenarios
- Loading/Error/404/empty states use U1 LoadingState/ErrorState per Open Question
- useResponsiveBreakpoint consumed: isMobile -> 100vw full-screen overlay
- user-owned fields editable, agent-owned fields read-only, upsert preserves agent columns

Closes R2 (P0): grid row click -> detail drawer with all field types visualized.

Refs: docs/plans/2026-07-03-001-feat-bitable-p0-ux-and-agent-parity-plan.md U3
This commit is contained in:
chiguyong 2026-07-03 15:57:33 +08:00
parent f0c993a0d9
commit 5baaeb489d
5 changed files with 1060 additions and 0 deletions

View File

@ -0,0 +1,316 @@
/**
* E2E tests for the Bitable record detail drawer (U3 / R2).
*
* Flow: login create file create table seed a record via API
* click the row's seq cell assert drawer opens with all field types.
*
* ponytail: record + extra-field seeding goes through the REST API (via
* page.evaluate fetch) rather than UI clicks setup is not the thing under
* test, and API seeding is ~10x faster + more deterministic than driving the
* add-field / add-record UI for 11+ fields. Ceiling: API path couples the
* test to the /tables/{id}/fields + /tables/{id}/records contract; if that
* contract shifts, setup breaks (acceptable the same contract is exercised
* by the grid itself).
*
* Requires: running backend with PostgreSQL. Skips gracefully if unreachable.
*/
import { test, expect, type Page } from '@playwright/test'
import { TEST_USER, clearAuth, waitForServer, API_BASE } from './helpers'
interface IField {
id: string
name: string
field_type: string
owner: string
}
interface IRecord {
id: string
values: Record<string, unknown>
}
// API_BASE is an absolute URL (http://127.0.0.1:PORT/api/v1) — passed into
// page.evaluate as an arg because the browser context cannot close over
// Node-scope variables.
const API_BASE_STR = API_BASE
async function loginAndCreateTable(
page: Page,
fileName: string,
tableName: string,
): Promise<{ tableId: string; fields: IField[] }> {
await page.goto('/login')
await clearAuth(page)
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
await page.getByRole('button', { name: /登\s*录/ }).click()
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
await page.getByRole('button', { name: '多维表格' }).click()
await expect(page).toHaveURL(/\/bitable$/, { timeout: 15_000 })
// Create file
await page.getByRole('button', { name: /新建文件/ }).click()
await page.getByPlaceholder('请输入文件名').fill(fileName)
await page.getByRole('button', { name: /确\s*定/ }).click()
await expect(page).toHaveURL(/\/bitable\/[^/]+/, { timeout: 10_000 })
// Create table
await page.locator('.table-view-list__header .ant-btn').click()
await page.getByPlaceholder('请输入表名').fill(tableName)
await page.getByRole('button', { name: /确\s*定/ }).click()
await expect(page.locator('.bitable-file-detail-view__table-name')).toContainText(
tableName,
{ timeout: 10_000 },
)
// Fetch table id + fields via API (auth token is in localStorage after UI login).
// apiBase is passed as an arg — browser context cannot close over Node vars.
const { tableId, fields } = await page.evaluate(async (apiBase: string) => {
const token = localStorage.getItem('agentkit.access_token') ?? ''
const tablesResp = await fetch(`${apiBase}/bitable/tables`, {
headers: { Authorization: `Bearer ${token}` },
})
const tablesJson = (await tablesResp.json()) as { tables: { id: string; name: string }[] }
const table = tablesJson.tables[0]
const fieldsResp = await fetch(`${apiBase}/bitable/tables/${table.id}/fields`, {
headers: { Authorization: `Bearer ${token}` },
})
const fieldsJson = (await fieldsResp.json()) as { fields: IField[] }
return { tableId: table.id, fields: fieldsJson.fields }
}, API_BASE_STR)
return { tableId, fields }
}
async function createRecordViaApi(
page: Page,
tableId: string,
values: Record<string, unknown>,
): Promise<IRecord> {
return await page.evaluate(
async ({ tableId, values, apiBase }) => {
const token = localStorage.getItem('agentkit.access_token') ?? ''
const resp = await fetch(`${apiBase}/bitable/tables/${tableId}/records`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ records: [values] }),
})
const json = (await resp.json()) as { records: IRecord[] }
return json.records[0]
},
{ tableId, values, apiBase: API_BASE_STR },
)
}
async function createFieldViaApi(
page: Page,
tableId: string,
name: string,
fieldType: string,
owner: string = 'user',
): Promise<IField> {
return await page.evaluate(
async ({ tableId, name, fieldType, owner, apiBase }) => {
const token = localStorage.getItem('agentkit.access_token') ?? ''
const resp = await fetch(`${apiBase}/bitable/tables/${tableId}/fields`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, field_type: fieldType, owner }),
})
const json = (await resp.json()) as { field: IField }
return json.field
},
{ tableId, name, fieldType, owner, apiBase: API_BASE_STR },
)
}
async function reloadGrid(page: Page): Promise<void> {
await page.reload()
await expect(page.locator('.bitable-grid-scope')).toBeVisible({ timeout: 15_000 })
}
test.describe('Bitable Record Detail Drawer E2E (U3)', () => {
test.beforeAll(async () => {
try {
await waitForServer(undefined, 5_000)
} catch {
test.skip(true, 'Backend not running — skipping record drawer E2E')
}
})
test('D1: clicking a row seq cell opens the drawer with all fields', async ({ page }) => {
const { tableId, fields } = await loginAndCreateTable(page, 'E2E抽屉打开', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
await createRecordViaApi(page, tableId, { [titleField.id]: '测试记录1' })
await reloadGrid(page)
// Click the seq cell (row number column) of the first data row.
const seqCell = page.locator('.vxe-body--column.col--seq').first()
await expect(seqCell).toBeVisible({ timeout: 10_000 })
await seqCell.click()
// Drawer opens
await expect(page.locator('.record-detail-drawer')).toBeVisible({ timeout: 5_000 })
// The sticky header shows the record title
await expect(page.locator('.record-detail-drawer__header-title')).toContainText(
'测试记录1',
{ timeout: 5_000 },
)
// Field labels are rendered (default table has 5 fields incl. attachment-free types)
await expect(page.locator('.record-detail-drawer__field')).toHaveCount(5, {
timeout: 5_000,
})
})
test('D2: attachment/image fields render thumbnails, not raw URLs', async ({ page }) => {
const { tableId, fields } = await loginAndCreateTable(page, 'E2E附件图片', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
// Add attachment + image fields
const attachmentField = await createFieldViaApi(page, tableId, '附件', 'attachment')
const imageField = await createFieldViaApi(page, tableId, '图片', 'image')
// Seed a record with attachment/image metadata
await createRecordViaApi(page, tableId, {
[titleField.id]: '带附件记录',
[attachmentField.id]: [{ filename: 'doc.pdf', url: '/files/doc.pdf', size: 1024 }],
[imageField.id]: [{ filename: 'pic.png', url: '/files/pic.png', size: 2048 }],
})
await reloadGrid(page)
await page.locator('.vxe-body--column.col--seq').first().click()
await expect(page.locator('.record-detail-drawer')).toBeVisible({ timeout: 5_000 })
// Attachment cell renders a download link (not a raw URL string)
await expect(page.locator('.record-detail-drawer .attachment-cell__link')).toHaveCount(
1,
{ timeout: 5_000 },
)
// Image cell renders a thumbnail <img>
await expect(page.locator('.record-detail-drawer .image-cell__thumb')).toHaveCount(1, {
timeout: 5_000,
})
})
test('D3: formula field renders read-only (no input control)', async ({ page }) => {
const { tableId, fields } = await loginAndCreateTable(page, 'E2E公式只读', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
const numberField = fields.find((f) => f.field_type === 'number')
// Add a number field + a formula field referencing it
const numField = numberField ?? (await createFieldViaApi(page, tableId, '数值', 'number'))
const formulaField = await createFieldViaApi(
page,
tableId,
'公式',
'formula',
)
await createRecordViaApi(page, tableId, {
[titleField.id]: '公式记录',
[numField.id]: 42,
// formula value is computed server-side; seed a cached value for display
[formulaField.id]: 84,
})
await reloadGrid(page)
await page.locator('.vxe-body--column.col--seq').first().click()
await expect(page.locator('.record-detail-drawer')).toBeVisible({ timeout: 5_000 })
// The formula field's value row must NOT contain an editable input.
const formulaRow = page
.locator('.record-detail-drawer__field')
.filter({ hasText: '公式' })
await expect(formulaRow).toBeVisible({ timeout: 5_000 })
await expect(formulaRow.locator('input')).toHaveCount(0)
// The computed value is shown as read-only text
await expect(formulaRow.locator('.record-detail-drawer__readonly')).toContainText('84')
})
test('D4: editing a user-owned field preserves agent columns', async ({ page }) => {
const { tableId, fields } = await loginAndCreateTable(page, 'E2E保留agent列', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
const agentField = fields.find((f) => f.owner === 'agent')!
await createRecordViaApi(page, tableId, {
[titleField.id]: '编辑前标题',
[agentField.id]: 'AGENT_VALUE',
})
await reloadGrid(page)
await page.locator('.vxe-body--column.col--seq').first().click()
await expect(page.locator('.record-detail-drawer')).toBeVisible({ timeout: 5_000 })
// Edit the user-owned title field
const titleInput = page.locator('.record-detail-drawer__field').first().locator('input')
await titleInput.fill('编辑后标题')
await page.locator('.record-detail-drawer__footer').getByRole('button', { name: '保存' }).click()
// Drawer closes on success
await expect(page.locator('.record-detail-drawer')).toHaveCount(0, { timeout: 10_000 })
// Re-open the drawer — agent column must still show AGENT_VALUE
await page.locator('.vxe-body--column.col--seq').first().click()
await expect(page.locator('.record-detail-drawer')).toBeVisible({ timeout: 5_000 })
const agentRow = page
.locator('.record-detail-drawer__field')
.filter({ hasText: agentField.name })
await expect(agentRow).toContainText('AGENT_VALUE', { timeout: 5_000 })
})
test('D5: drawer width is 480px when field count <= 10', async ({ page }) => {
const { tableId, fields } = await loginAndCreateTable(page, 'E2E宽度480', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
await createRecordViaApi(page, tableId, { [titleField.id]: '宽度测试' })
await reloadGrid(page)
await page.locator('.vxe-body--column.col--seq').first().click()
const drawer = page.locator('.record-detail-drawer')
await expect(drawer).toBeVisible({ timeout: 5_000 })
// Default table has 5 fields (<=10). Resolved width should be 480px.
const width = await drawer.evaluate((el) => {
// The a-drawer width is applied to the .ant-drawer-content wrapper.
const content = el.querySelector('.ant-drawer-content') ?? el
return getComputedStyle(content).width
})
expect(width).toBe('480px')
})
test('D6: drawer width is 640px when field count > 10', async ({ page }) => {
const { tableId, fields } = await loginAndCreateTable(page, 'E2E宽度640', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
// Default table has 5 fields; add 7 more to exceed 10.
for (let i = 0; i < 7; i++) {
await createFieldViaApi(page, tableId, `扩展字段${i}`, 'text')
}
await createRecordViaApi(page, tableId, { [titleField.id]: '宽屏测试' })
await reloadGrid(page)
await page.locator('.vxe-body--column.col--seq').first().click()
const drawer = page.locator('.record-detail-drawer')
await expect(drawer).toBeVisible({ timeout: 5_000 })
const width = await drawer.evaluate((el) => {
const content = el.querySelector('.ant-drawer-content') ?? el
return getComputedStyle(content).width
})
expect(width).toBe('640px')
})
test('D7: Esc closes the drawer', async ({ page }) => {
const { tableId, fields } = await loginAndCreateTable(page, 'E2E Esc关闭', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
await createRecordViaApi(page, tableId, { [titleField.id]: 'Esc测试' })
await reloadGrid(page)
await page.locator('.vxe-body--column.col--seq').first().click()
await expect(page.locator('.record-detail-drawer')).toBeVisible({ timeout: 5_000 })
await page.keyboard.press('Escape')
await expect(page.locator('.record-detail-drawer')).toHaveCount(0, { timeout: 5_000 })
})
})

View File

@ -17,6 +17,7 @@
autoClear: false, autoClear: false,
}" }"
@edit-closed="onEditClosed" @edit-closed="onEditClosed"
@cell-click="onCellClick"
> >
<template #empty> <template #empty>
<a-empty :description="emptyText" /> <a-empty :description="emptyText" />
@ -98,6 +99,16 @@
</div> </div>
</template> </template>
</vxe-grid> </vxe-grid>
<!-- U3: Record detail drawer opened by clicking the row's seq cell.
Rendered here so the grid is self-contained; reads state from the
bitable store (currentRecordId / currentRecord / fields). -->
<RecordDetailDrawer
:record-id="store.currentRecordId"
:open="!!store.currentRecordId"
@close="store.closeRecordDetail"
@retry="onDrawerRetry"
/>
</div> </div>
</template> </template>
@ -113,12 +124,14 @@ import type {
IAttachmentMeta, IAttachmentMeta,
FieldType, FieldType,
} from '@/api/bitable' } from '@/api/bitable'
import { useBitableStore } from '@/stores/bitable'
import AttachmentCell from './AttachmentCell.vue' import AttachmentCell from './AttachmentCell.vue'
import ImageCell from './ImageCell.vue' 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 InlineFieldConfigurator from './InlineFieldConfigurator.vue'
import RecordDetailDrawer from './RecordDetailDrawer.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 }
@ -149,6 +162,31 @@ const emit = defineEmits<{
const gridRef = ref<InstanceType<typeof VxeGrid> | null>(null) const gridRef = ref<InstanceType<typeof VxeGrid> | null>(null)
// U3: bitable store used only for the record detail drawer (currentRecordId
// + closeRecordDetail). The grid itself remains a controlled component
// (fields/records via props); the drawer is the one side-effect surface.
const store = useBitableStore()
// U3: clicking the row's seq cell (#) opens the record detail drawer.
// ponytail: data cells are NOT triggers vxe-table's edit-config trigger:'click'
// already owns the click-to-edit behaviour on data cells, so using the seq
// column avoids a conflict. Ceiling: users must click the row number to open
// the drawer (clicking a data cell enters edit mode instead). This matches
// Feishu/Twenty's row-number detail affordance. Upgrade path: add a
// dedicated expand affordance if user testing shows discoverability issues.
const onCellClick: VxeGridEvents.CellClick = (params) => {
const { row, column } = params
// column.type === 'seq' identifies the row-number column.
if (!column || column.type !== 'seq') return
const recordId = (row as GridRow)._recordId
if (!recordId) return
store.openRecordDetail(recordId)
}
function onDrawerRetry(recordId: string): void {
store.fetchRecordDetail(recordId)
}
// U2: inline field config only one column edits at a time. null = none open. // U2: inline field config only one column edits at a time. null = none open.
// The InlineFieldConfigurator renders inside an a-popover anchored to the // The InlineFieldConfigurator renders inside an a-popover anchored to the
// column header (see header slot above). The store mutation (store.updateField) // column header (see header slot above). The store mutation (store.updateField)
@ -348,6 +386,13 @@ defineExpose({
color: var(--bitable-color-primary); color: var(--bitable-color-primary);
} }
/* U3: seq column (#) is the row-detail affordance pointer cursor signals
clickability without a dedicated expand icon. */
.bitable-grid-scope :deep(.vxe-header--column.col--seq),
.bitable-grid-scope :deep(.vxe-body--column.col--seq) {
cursor: pointer;
}
.bitable-grid-scope__add-col { .bitable-grid-scope__add-col {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -0,0 +1,385 @@
<template>
<a-drawer
:open="open"
:width="drawerWidth"
:keyboard="true"
:mask-closable="true"
:body-style="{ padding: 0 }"
placement="right"
class="record-detail-drawer"
:class="{ 'record-detail-drawer--wide': isWideDrawer }"
@close="onClose"
>
<template #title>
<span class="record-detail-drawer__title">
{{ headerTitle }}
</span>
</template>
<!-- Loading: skeleton (U1 LoadingState, Open Question P1) -->
<LoadingState v-if="store.recordDetailLoading" :rows="6" :title="true" />
<!-- 404: record not found / concurrently deleted -->
<div
v-else-if="store.recordDetailNotFound"
class="record-detail-drawer__notfound"
>
<a-empty description="记录不存在或已被删除" />
<a-button type="primary" @click="onClose">关闭</a-button>
</div>
<!-- Error: 5xx / network ErrorState + retry (U1 ErrorState) -->
<ErrorState
v-else-if="store.recordDetailError"
:message="store.recordDetailError"
:retrying="store.recordDetailLoading"
@retry="retry"
/>
<!-- Ready: render fields -->
<template v-else-if="record">
<!-- Empty: 0 fields (Open Question P2) -->
<div v-if="fields.length === 0" class="record-detail-drawer__empty">
<a-empty description="该记录暂无字段数据" />
</div>
<template v-else>
<!-- Sticky header: record title -->
<div class="record-detail-drawer__header">
<span class="record-detail-drawer__header-title">{{ recordTitle }}</span>
</div>
<!-- Body: vertical field list -->
<a-form
layout="vertical"
class="record-detail-drawer__form"
@submit.prevent="onSubmit"
>
<a-form-item
v-for="f in fields"
:key="f.id"
class="record-detail-drawer__field"
>
<template #label>
<span class="record-detail-drawer__field-label">
<FieldTypeIcon :type="f.field_type" />
{{ f.name }}
</span>
</template>
<!-- Read-only renders -->
<template v-if="!isEditable(f)">
<SelectDisplay
v-if="f.field_type === 'select' || f.field_type === 'multiselect'"
:value="(record.values[f.id] as string | string[] | null | undefined)"
:options="(f.config.options as ISelectOption[] | string[] | undefined)"
:multiple="f.field_type === 'multiselect'"
/>
<AttachmentCell
v-else-if="f.field_type === 'attachment'"
:files="(record.values[f.id] as IAttachmentMeta[] | null | undefined)"
/>
<ImageCell
v-else-if="f.field_type === 'image'"
:images="(record.values[f.id] as IAttachmentMeta[] | null | undefined)"
/>
<span v-else class="record-detail-drawer__readonly">
{{ formatFieldValue(record.values[f.id], f.field_type) }}
</span>
<span v-if="isAgentOwned(f)" class="record-detail-drawer__owner-tag">
系统字段
</span>
</template>
<!-- Editable inputs (user-owned, non-computed).
:value + @update:value with explicit casts avoids `any`
while satisfying each component's prop type. -->
<template v-else>
<a-input
v-if="f.field_type === 'text'"
:value="(editBuffer[f.id] as string | undefined)"
:disabled="submitting"
placeholder="请输入"
@update:value="editBuffer[f.id] = $event"
/>
<a-input-number
v-else-if="f.field_type === 'number'"
:value="(editBuffer[f.id] as number | undefined)"
:disabled="submitting"
style="width: 100%"
placeholder="请输入数字"
@update:value="editBuffer[f.id] = $event"
/>
<a-date-picker
v-else-if="f.field_type === 'date'"
:value="(editBuffer[f.id] as string | undefined)"
value-format="YYYY-MM-DD"
:disabled="submitting"
style="width: 100%"
placeholder="请选择日期"
@update:value="editBuffer[f.id] = $event"
/>
<a-select
v-else-if="f.field_type === 'select'"
:value="(editBuffer[f.id] as string | undefined)"
:options="toSelectOptions(f.config.options)"
:disabled="submitting"
:allow-clear="true"
placeholder="请选择"
@update:value="editBuffer[f.id] = $event"
/>
<a-select
v-else-if="f.field_type === 'multiselect'"
:value="(editBuffer[f.id] as string[] | undefined)"
:options="toSelectOptions(f.config.options)"
:disabled="submitting"
:allow-clear="true"
mode="multiple"
placeholder="请选择"
@update:value="editBuffer[f.id] = $event"
/>
</template>
</a-form-item>
</a-form>
</template>
</template>
<!-- Footer: save button (only when ready + has editable fields) -->
<template v-if="showFooter" #footer>
<div class="record-detail-drawer__footer">
<a-button :disabled="submitting" @click="onClose">取消</a-button>
<a-button
type="primary"
:loading="submitting"
:disabled="submitting"
@click="onSubmit"
>
保存
</a-button>
</div>
</template>
</a-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import {
Drawer as ADrawer,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
DatePicker as ADatePicker,
Select as ASelect,
Button as AButton,
Empty as AEmpty,
} from 'ant-design-vue'
import type { IAttachmentMeta, IBitableField, IBitableRecord } from '@/api/bitable'
import { useBitableStore } from '@/stores/bitable'
import { useResponsiveBreakpoint } from '@/composables/useResponsiveBreakpoint'
import LoadingState from './LoadingState.vue'
import ErrorState from './ErrorState.vue'
import FieldTypeIcon from './FieldTypeIcon.vue'
import SelectDisplay from './SelectDisplay.vue'
import AttachmentCell from './AttachmentCell.vue'
import ImageCell from './ImageCell.vue'
import type { ISelectOption } from './SelectCellEditor.vue'
import {
formatFieldValue,
getDrawerWidth,
getRecordTitle,
isFieldEditable,
} from '@/helpers/recordDrawerUtils'
const props = defineProps<{
/** Record id to display. When non-null + open, the drawer fetches the record. */
recordId: string | null
/** Open signal — typically `!!store.currentRecordId`. */
open: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'retry', recordId: string): void
}>()
const store = useBitableStore()
const { isMobile } = useResponsiveBreakpoint()
const submitting = ref(false)
// Edit buffer: keyed by field id, only populated for editable fields.
// Reset whenever the loaded record changes (watch below).
const editBuffer = reactive<Record<string, unknown>>({})
const record = computed<IBitableRecord | null>(() => store.currentRecord)
const fields = computed<IBitableField[]>(() => store.fields)
const isWideDrawer = computed(() => fields.value.length > 10)
const drawerWidth = computed(() => getDrawerWidth(fields.value.length, isMobile.value))
const recordTitle = computed(() =>
record.value ? getRecordTitle(record.value, fields.value) : '记录详情',
)
const headerTitle = computed(() => {
if (store.recordDetailLoading) return '加载中...'
if (store.recordDetailNotFound) return '记录不存在'
if (store.recordDetailError) return '加载失败'
return recordTitle.value
})
const showFooter = computed(
() =>
!!record.value &&
fields.value.length > 0 &&
fields.value.some(isEditable),
)
function isEditable(f: IBitableField): boolean {
return isFieldEditable(f)
}
function isAgentOwned(f: IBitableField): boolean {
return f.owner === 'agent'
}
function toSelectOptions(
raw: unknown,
): { label: string; value: string }[] {
if (!Array.isArray(raw)) return []
return raw.map((opt) => {
if (typeof opt === 'string') return { label: opt, value: opt }
const o = opt as ISelectOption
return { label: o.label ?? o.value, value: o.value }
})
}
// Reset + hydrate the edit buffer when a new record loads.
watch(
record,
(rec) => {
// Clear stale edits
for (const k of Object.keys(editBuffer)) {
delete editBuffer[k]
}
if (!rec) return
for (const f of fields.value) {
if (!isEditable(f)) continue
// Clone primitive values; clone arrays so multiselect edits don't
// mutate the store's record.
const v = rec.values[f.id]
editBuffer[f.id] = Array.isArray(v) ? [...v] : (v ?? undefined)
}
},
{ immediate: true },
)
// If the drawer is re-opened for the same record id while the store still
// holds it, the watch above won't fire (record ref unchanged). Re-hydrate
// on open transition too.
watch(
() => props.open,
(isOpen) => {
if (isOpen && record.value) {
// Re-trigger hydration by reassigning via the record watch.
for (const k of Object.keys(editBuffer)) delete editBuffer[k]
for (const f of fields.value) {
if (!isEditable(f)) continue
const v = record.value.values[f.id]
editBuffer[f.id] = Array.isArray(v) ? [...v] : (v ?? undefined)
}
}
},
)
function onClose(): void {
emit('close')
}
function retry(): void {
if (props.recordId) emit('retry', props.recordId)
}
async function onSubmit(): Promise<void> {
if (!record.value || !props.recordId) return
submitting.value = true
try {
// Only submit editable user-owned fields. Agent columns are merged back
// from currentRecord.values inside updateRecordFields (preserves them).
const edited: Record<string, unknown> = {}
for (const f of fields.value) {
if (!isEditable(f)) continue
if (f.id in editBuffer) edited[f.id] = editBuffer[f.id]
}
const ok = await store.updateRecordFields(props.recordId, edited)
if (ok) {
emit('close')
}
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.record-detail-drawer__title {
font-size: var(--bitable-font-md);
font-weight: 600;
color: var(--bitable-color-text);
}
.record-detail-drawer__header {
position: sticky;
top: 0;
z-index: 2;
background: var(--bitable-color-bg);
border-bottom: 1px solid var(--bitable-color-border);
padding: var(--bitable-spacing-md) var(--bitable-spacing-lg);
}
.record-detail-drawer__header-title {
font-size: var(--bitable-font-lg);
font-weight: 600;
color: var(--bitable-color-text);
word-break: break-all;
}
.record-detail-drawer__form {
padding: var(--bitable-spacing-md) var(--bitable-spacing-lg);
}
.record-detail-drawer__field {
margin-bottom: var(--bitable-spacing-lg);
}
.record-detail-drawer__readonly {
color: var(--bitable-color-text);
word-break: break-word;
white-space: pre-wrap;
}
.record-detail-drawer__owner-tag {
display: inline-block;
margin-left: var(--bitable-spacing-sm);
padding: 0 var(--bitable-spacing-xs);
font-size: var(--bitable-font-xs);
color: var(--bitable-color-text-tertiary);
background: var(--bitable-color-bg-tertiary);
border-radius: var(--bitable-radius-sm);
}
.record-detail-drawer__notfound,
.record-detail-drawer__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--bitable-spacing-md);
padding: var(--bitable-spacing-xl) var(--bitable-spacing-lg);
}
.record-detail-drawer__footer {
display: flex;
justify-content: flex-end;
gap: var(--bitable-spacing-sm);
}
</style>

View File

@ -0,0 +1,169 @@
/**
* RecordDetailDrawer pure helpers (U3 / R2).
*
* Pure functions no Vue, no store, no I/O. Safe to unit-test in isolation.
*
* Responsibilities:
* - formatFieldValue: text/number/date/select/multiselect/formula/lookup display string
* - getAttachmentFileList / getImageThumbnailList: normalize attachment/image
* field values (which may be IAttachmentMeta[] or stale legacy shapes) into
* a uniform {name, url} / {url, alt} list for the drawer.
* - getRecordTitle: first text-type field value, fallback "未命名记录".
* - isFieldEditable: user-owned fields are editable; agent-owned + formula +
* lookup are read-only (formula/lookup are computed, not user data).
* - getDrawerWidth: '100vw' on mobile, else 480px (10 fields) / 640px (>10).
*
* ponytail: width returns a CSS var() reference, not a hardcoded px value, so
* the design token in bitable-tokens.css stays the single source of truth.
* Ceiling: the field-count threshold (10) is hardcoded per KTD4 spec; if the
* token name or threshold changes, update both here and the spec.
*/
import type { FieldType, IAttachmentMeta, IBitableField, IBitableRecord } from '@/api/bitable'
const DRAWER_FIELD_COUNT_THRESHOLD = 10
export interface IAttachmentFileView {
name: string
url: string
size?: number
}
export interface IImageView {
url: string
alt?: string
}
function isEmptyValue(v: unknown): boolean {
return v == null || v === '' || (Array.isArray(v) && v.length === 0)
}
/**
* Format a field value for read-only display in the drawer.
*
* - text/number/formula/lookup: coerce to string, empty '—'
* - date: pass through (ISO string); the drawer may format further, but we
* return the raw stored value so callers can apply a consistent formatter.
* ponytail: no dayjs dependency here the drawer renders the ISO string
* directly to avoid pulling a date lib into a pure helper. Upgrade path:
* if a shared date formatter exists, call it from the drawer component.
* - select/multiselect: return label joined by ' / '; empty '—'
* - attachment/image: return `${n} 个文件` summary (the drawer renders the
* file list separately via getAttachmentFileList / getImageThumbnailList)
*/
export function formatFieldValue(
value: unknown,
fieldType: FieldType,
options?: { labelOf?: (v: string) => string },
): string {
if (isEmptyValue(value)) return '—'
switch (fieldType) {
case 'text':
case 'number':
case 'formula':
case 'lookup':
return String(value)
case 'date':
return typeof value === 'string' ? value : String(value)
case 'select': {
const v = value as string
return options?.labelOf ? options.labelOf(v) : v
}
case 'multiselect': {
const arr = value as string[]
const labels = arr.map((v) => (options?.labelOf ? options.labelOf(v) : v))
return labels.join(' / ')
}
case 'attachment':
case 'image': {
const arr = value as IAttachmentMeta[]
return `${arr.length} 个文件`
}
default:
return String(value)
}
}
/**
* Normalize an attachment field value into a uniform file view list.
*
* Accepts IAttachmentMeta[] (the canonical shape). Defensively tolerates
* legacy / malformed entries (missing url skipped, missing filename
* fallback to stored_name or '未命名文件').
*/
export function getAttachmentFileList(value: unknown): IAttachmentFileView[] {
if (!Array.isArray(value)) return []
const out: IAttachmentFileView[] = []
for (const raw of value) {
if (!raw || typeof raw !== 'object') continue
const m = raw as Partial<IAttachmentMeta>
if (!m.url) continue
out.push({
name: m.filename || m.stored_name || '未命名文件',
url: m.url,
size: typeof m.size === 'number' ? m.size : undefined,
})
}
return out
}
/**
* Normalize an image field value into a uniform thumbnail view list.
*
* Reuses getAttachmentFileList logic; image thumbnails use the same
* IAttachmentMeta shape (url + filename). `alt` falls back to filename.
*/
export function getImageThumbnailList(value: unknown): IImageView[] {
return getAttachmentFileList(value).map((f) => ({
url: f.url,
alt: f.name,
}))
}
/**
* Return the record's title the value of the first text-type field, or
* "未命名记录" if none exists / value is empty.
*/
export function getRecordTitle(record: IBitableRecord, fields: IBitableField[]): string {
const firstText = fields.find((f) => f.field_type === 'text')
if (!firstText) return '未命名记录'
const v = record.values[firstText.id]
if (isEmptyValue(v)) return '未命名记录'
return String(v)
}
/**
* A field is editable in the drawer iff:
* - owner === 'user' (agent-owned columns are managed by the backend / agents)
* - field_type is NOT formula (computed) or lookup (derived from another table)
*
* attachment/image are NOT inline-editable from the drawer in P0 (file upload
* flow is out of scope for U3 ponytail: ceiling noted, upgrade path = add
* an upload control in a follow-up U-ID).
*/
export function isFieldEditable(field: IBitableField): boolean {
if (field.owner !== 'user') return false
if (field.field_type === 'formula' || field.field_type === 'lookup') return false
if (field.field_type === 'attachment' || field.field_type === 'image') return false
return true
}
/**
* Compute the drawer width.
*
* @param fieldCount number of fields rendered in the body
* @param isMobile from useResponsiveBreakpoint full-screen overlay when true
* @returns a CSS value string. On desktop/tablet, returns a var() reference
* to --bitable-drawer-width (10 fields) or --bitable-drawer-width-wide
* (>10 fields). On mobile, returns '100vw'.
*/
export function getDrawerWidth(fieldCount: number, isMobile: boolean): string {
if (isMobile) return '100vw'
return fieldCount > DRAWER_FIELD_COUNT_THRESHOLD
? 'var(--bitable-drawer-width-wide)'
: 'var(--bitable-drawer-width)'
}
// ponytail self-check: width threshold + var() reference. The two var() names
// must match bitable-tokens.css exactly. Trivial string logic, no test file.

View File

@ -36,6 +36,16 @@ export const useBitableStore = defineStore('bitable', () => {
const nextCursor = ref<string | null>(null) const nextCursor = ref<string | null>(null)
const recalcPendingCount = ref(0) const recalcPendingCount = ref(0)
// U3: RecordDetailDrawer state. `currentRecordId` is the drawer's open
// signal — non-null means the drawer is open. `currentRecord` is the
// fully-hydrated record fetched by record_id (so the drawer sees agent
// columns even if the grid view hides them).
const currentRecordId = ref<string | null>(null)
const currentRecord = ref<IBitableRecord | null>(null)
const recordDetailLoading = ref(false)
const recordDetailError = ref<string | null>(null)
const recordDetailNotFound = ref(false)
// Polling timer for formula recalc status // Polling timer for formula recalc status
let _pollTimer: ReturnType<typeof setInterval> | null = null let _pollTimer: ReturnType<typeof setInterval> | null = null
const POLL_INTERVAL = 2000 // 2s per plan const POLL_INTERVAL = 2000 // 2s per plan
@ -391,6 +401,130 @@ export const useBitableStore = defineStore('bitable', () => {
} }
} }
// --- U3: Record detail drawer ---
/**
* Open the record detail drawer for a given record id.
*
* Sets the drawer open signal and triggers an async fetch of the full
* record (by record_id) so the drawer sees all fields including agent-
* owned columns that may be hidden in the grid view.
*
* ponytail: fetch is fire-and-forget `currentRecord` is null until the
* request resolves, which the drawer renders as a skeleton (LoadingState).
* Ceiling: no debounce; rapid row-clicks fire one fetch per click. The
* drawer's watch on `currentRecordId` short-circuits stale fetches by
* checking the id still matches when the response arrives.
*/
function openRecordDetail(recordId: string): void {
if (currentRecordId.value === recordId) return
currentRecordId.value = recordId
currentRecord.value = null
recordDetailLoading.value = true
recordDetailError.value = null
recordDetailNotFound.value = false
void fetchRecordDetail(recordId)
}
/** Close the drawer and clear all drawer state. */
function closeRecordDetail(): void {
currentRecordId.value = null
currentRecord.value = null
recordDetailLoading.value = false
recordDetailError.value = null
recordDetailNotFound.value = false
}
/**
* Fetch a single record by id.
*
* The bitable list endpoint doesn't expose a single-record GET, so this
* reuses `listRecords` and searches the result by id. 404 / empty result
* notFound state; network error error state (drawer shows ErrorState +
* retry).
*
* ponytail: fast path the record is in the currently-loaded page
* (`records.value`), no network round-trip. Slow path re-query the first
* 100 records and search. Ceiling: a record on a later page (cursor > 100)
* would 404-false. Acceptable for P0 because the drawer is opened from a
* visible grid row, so the record is always in the loaded page. Upgrade
* path: add GET /records/{id} when cross-page detail access is needed.
*/
async function fetchRecordDetail(recordId: string): Promise<void> {
if (!currentTable.value) {
recordDetailLoading.value = false
recordDetailError.value = '未选择数据表'
return
}
recordDetailLoading.value = true
recordDetailError.value = null
recordDetailNotFound.value = false
try {
let found: IBitableRecord | null = null
// Fast path: record is in the currently-loaded page.
const local = records.value.find((r) => r.id === recordId)
if (local) {
found = local
} else if (currentTable.value) {
// Slow path: re-query the first 100 records and search by id.
// (See function docstring — ceiling noted.)
const resp = await bitableApi.listRecords(currentTable.value.id, { limit: 100 })
found = (resp.records || []).find((r) => r.id === recordId) ?? null
}
// Stale-response guard: if the user clicked another row while this
// fetch was in flight, drop the result.
if (currentRecordId.value !== recordId) return
if (!found) {
recordDetailNotFound.value = true
} else {
currentRecord.value = found
}
} catch (err) {
if (currentRecordId.value !== recordId) return
recordDetailError.value = err instanceof Error ? err.message : '加载记录详情失败'
} finally {
if (currentRecordId.value === recordId) {
recordDetailLoading.value = false
}
}
}
/**
* Save user-edited field values from the drawer.
*
* Merges the edited user-owned fields with the EXISTING agent-owned field
* values from `currentRecord` before PATCHing. This preserves agent
* columns: the backend's update_record_values does a full-replace of the
* values dict, so we send the agent columns back as-is rather than
* dropping them. (Spec: "upsert 保留 agent 列" implemented client-side
* to avoid adding a new backend endpoint in U3.)
*
* Returns true on success. On success, both `currentRecord` and the
* matching entry in `records` are updated with the server response.
*/
async function updateRecordFields(
recordId: string,
editedFields: Record<string, unknown>,
): Promise<boolean> {
const base = currentRecord.value?.values ?? {}
const merged: Record<string, unknown> = { ...base, ...editedFields }
try {
const resp = await bitableApi.updateRecord(recordId, merged)
currentRecord.value = resp.record
const idx = records.value.findIndex((r) => r.id === recordId)
if (idx >= 0) records.value[idx] = resp.record
return true
} catch (err) {
notification.error({
message: '保存失败',
description: err instanceof Error ? err.message : String(err),
})
return false
}
}
// --- View management (U5c) --- // --- View management (U5c) ---
/** Create a new view for the current table */ /** Create a new view for the current table */
@ -521,6 +655,12 @@ export const useBitableStore = defineStore('bitable', () => {
error, error,
nextCursor, nextCursor,
recalcPendingCount, recalcPendingCount,
// U3: record detail drawer state
currentRecordId,
currentRecord,
recordDetailLoading,
recordDetailError,
recordDetailNotFound,
// Getters // Getters
formulaFields, formulaFields,
hasFormulaFields, hasFormulaFields,
@ -543,6 +683,11 @@ export const useBitableStore = defineStore('bitable', () => {
deleteField, deleteField,
hideField, hideField,
refreshRecords, refreshRecords,
// U3: record detail drawer actions
openRecordDetail,
closeRecordDetail,
fetchRecordDetail,
updateRecordFields,
createView, createView,
updateView, updateView,
switchView, switchView,