diff --git a/src/agentkit/server/frontend/e2e/bitable-record-drawer.spec.ts b/src/agentkit/server/frontend/e2e/bitable-record-drawer.spec.ts new file mode 100644 index 0000000..cf8faa5 --- /dev/null +++ b/src/agentkit/server/frontend/e2e/bitable-record-drawer.spec.ts @@ -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 +} + +// 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, +): Promise { + 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 { + 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 { + 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 + 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 }) + }) +}) diff --git a/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue b/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue index 5a72d52..824473e 100644 --- a/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue +++ b/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue @@ -17,6 +17,7 @@ autoClear: false, }" @edit-closed="onEditClosed" + @cell-click="onCellClick" > + + + @@ -113,12 +124,14 @@ import type { IAttachmentMeta, FieldType, } from '@/api/bitable' +import { useBitableStore } from '@/stores/bitable' import AttachmentCell from './AttachmentCell.vue' 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 RecordDetailDrawer from './RecordDetailDrawer.vue' import type { ISelectOption } from './SelectCellEditor.vue' type GridRow = Record & { _rowId: string; _recordId: string } @@ -149,6 +162,31 @@ const emit = defineEmits<{ const gridRef = ref | 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. // The InlineFieldConfigurator renders inside an a-popover anchored to the // column header (see header slot above). The store mutation (store.updateField) @@ -348,6 +386,13 @@ defineExpose({ 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 { display: flex; align-items: center; diff --git a/src/agentkit/server/frontend/src/components/bitable/RecordDetailDrawer.vue b/src/agentkit/server/frontend/src/components/bitable/RecordDetailDrawer.vue new file mode 100644 index 0000000..523410d --- /dev/null +++ b/src/agentkit/server/frontend/src/components/bitable/RecordDetailDrawer.vue @@ -0,0 +1,385 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/helpers/recordDrawerUtils.ts b/src/agentkit/server/frontend/src/helpers/recordDrawerUtils.ts new file mode 100644 index 0000000..9652793 --- /dev/null +++ b/src/agentkit/server/frontend/src/helpers/recordDrawerUtils.ts @@ -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 + 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. diff --git a/src/agentkit/server/frontend/src/stores/bitable.ts b/src/agentkit/server/frontend/src/stores/bitable.ts index 6b9e2fc..fde29b8 100644 --- a/src/agentkit/server/frontend/src/stores/bitable.ts +++ b/src/agentkit/server/frontend/src/stores/bitable.ts @@ -36,6 +36,16 @@ export const useBitableStore = defineStore('bitable', () => { const nextCursor = ref(null) 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(null) + const currentRecord = ref(null) + const recordDetailLoading = ref(false) + const recordDetailError = ref(null) + const recordDetailNotFound = ref(false) + // Polling timer for formula recalc status let _pollTimer: ReturnType | null = null 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 { + 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, + ): Promise { + const base = currentRecord.value?.values ?? {} + const merged: Record = { ...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) --- /** Create a new view for the current table */ @@ -521,6 +655,12 @@ export const useBitableStore = defineStore('bitable', () => { error, nextCursor, recalcPendingCount, + // U3: record detail drawer state + currentRecordId, + currentRecord, + recordDetailLoading, + recordDetailError, + recordDetailNotFound, // Getters formulaFields, hasFormulaFields, @@ -543,6 +683,11 @@ export const useBitableStore = defineStore('bitable', () => { deleteField, hideField, refreshRecords, + // U3: record detail drawer actions + openRecordDetail, + closeRecordDetail, + fetchRecordDetail, + updateRecordFields, createView, updateView, switchView,