feat: Bitable P0 UX Polish + Agent Parity #23
|
|
@ -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,
|
||||
}"
|
||||
@edit-closed="onEditClosed"
|
||||
@cell-click="onCellClick"
|
||||
>
|
||||
<template #empty>
|
||||
<a-empty :description="emptyText" />
|
||||
|
|
@ -98,6 +99,16 @@
|
|||
</div>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
|
@ -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<string, unknown> & { _rowId: string; _recordId: string }
|
||||
|
|
@ -149,6 +162,31 @@ const emit = defineEmits<{
|
|||
|
||||
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.
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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 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
|
||||
let _pollTimer: ReturnType<typeof setInterval> | 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<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) ---
|
||||
|
||||
/** 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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue