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:
parent
f0c993a0d9
commit
5baaeb489d
|
|
@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue