feat(bitable): 多维表格文件层 + 默认字段 + 表内字段操作 (Stage 1) #3

Merged
fischer merged 1 commits from feat/bitable-ui-stage1 into main 2026-06-29 09:25:30 +08:00
30 changed files with 3295 additions and 351 deletions
Showing only changes of commit a6e1bf5884 - Show all commits

View File

@ -7,6 +7,9 @@ Shared domain vocabulary for this project — entities, named processes, and sta
### Bitable ### Bitable
A companion service providing multi-dimensional table storage (structured records with typed fields, views, and formula columns) that runs alongside the main AgentKit server. Owns its own database schema, isolated from core tables, and exposes a REST API boundary that all callers — agents and end-users alike — go through. Agents authenticate with an internal service token; end-users authenticate via JWT. A companion service providing multi-dimensional table storage (structured records with typed fields, views, and formula columns) that runs alongside the main AgentKit server. Owns its own database schema, isolated from core tables, and exposes a REST API boundary that all callers — agents and end-users alike — go through. Agents authenticate with an internal service token; end-users authenticate via JWT.
### BitableFile
The top-level container that groups related data tables. A multi-dimensional table file (多维表格文件) holds zero or more tables and owns its own metadata (name, icon, description, owner). Tables reference their parent file via `file_id`. The file is the unit of ownership sharing and the top of the `文件 → 数据表 → 字段/记录` navigation hierarchy, mirroring the Feishu Bitable App and twenty Object container pattern.
### Field Ownership ### Field Ownership
The model controlling which actor may modify a field's definition or values. Every field has an owner of either the agent or the user. Agent-owned fields are written by the ingestion tooling during data import; user-owned fields are edited by humans in the UI. Upsert operations update only agent-owned fields — user-owned fields are never overwritten by agent writes, so human edits survive re-ingestion. The model controlling which actor may modify a field's definition or values. Every field has an owner of either the agent or the user. Agent-owned fields are written by the ingestion tooling during data import; user-owned fields are edited by humans in the UI. Upsert operations update only agent-owned fields — user-owned fields are never overwritten by agent writes, so human edits survive re-ingestion.

View File

@ -0,0 +1,212 @@
---
date: 2026-06-29
topic: bitable-ui-completeness
---
# 多维表格 UI 完善需求文档
## Summary
引入"多维表格文件"作为最上级容器,重构 `文件 → 数据表 → 字段/记录` 三层骨架,按飞书/twenty 范式补齐表内字段操作、默认字段、select 编辑器、多视图、三类采集入口、公式编辑器、权限/自动化/表单等能力。分四阶段交付,阶段一聚焦文件层 + 默认字段 + 表内字段操作三件事。当前无现有 bitable 数据需迁移,数据模型变更成本可控。
---
## Problem Frame
AgentKit 多维表格bitable后端 v1 已基本齐全:领域模型(表/字段/记录/视图/公式)+ REST API表/字段/记录/视图/upsert/上传/公式校验)+ 异步重算 worker + 三类采集引擎Excel/数据库/API+ 公式解析器全部就位。但前端 UI 层薄、产品形态残缺,导致"功能不完善,无法正常使用"。
三类核心缺口:
1. **层级缺失**:当前只有"数据表"层,缺最上级的"多维表格文件"容器。飞书(多维表格 App → 数据表)和 twentyObject → 记录都有这个上层归集bitable 当前是平铺的表列表,数据无组织归属。
2. **默认字段缺失**:新建数据表是空表起步,没有自带默认字段集。飞书/twenty 新建表都自带"标题/状态/日期/创建人/创建时间"等基础字段,让用户立即有结构可填。
3. **表内字段操作缺失**:增/改/删字段只能通过右侧"字段管理"弹层,不能在表内列头直接操作。飞书/twenty 都支持点列头下拉菜单管理字段。
此外 select/multiselect 字段编辑器仍是文本输入(不是下拉选项),三类采集入口在前端无 UIViewType 枚举有 5 种但前端只实现 grid 一种,公式编辑器只有校验 API 没有 UI 增强。
现状是"后端能力齐备,前端产品形态不到位"。本次完善的目标是把前端产品形态对齐飞书/twenty 范式,让 bitable 真正可用。
---
## Key Decisions
**KD1. 方案选 Approach 1飞书范式复刻三层容器骨架先行。** 不选 Approach 2表内体验优先 + 文件层轻量化后置)也不选 Approach 3Agent 数据底座优先)。理由:用户明确"全面对齐飞书/twenty"A2 的轻量文件层会在权限/共享/自动化能力上撞天花板A3 偏离了"参照飞书"的诉求。
**KD2. 文件层一次到位引入新实体,不接受轻量化分组方案。** 文件层是数据组织基石,延后做会让前期数据无归属、后期二次迁移更痛。当前 schema V1`create_all` 模式),无现有 bitable 数据需迁移,引入文件层是干净的新增。
**KD3. 分四阶段执行。** 阶段一(文件层骨架 + 默认字段 + 表内字段操作 + select 编辑器)→ 阶段二(三类采集入口 + Agent 写入反馈)→ 阶段三(看板/画廊视图 + 公式编辑器增强)→ 阶段四(权限 + 自动化 + 表单 + 甘特)。每阶段可独立验证交付。
**KD4. 默认字段集参照飞书 5 字段标题text/ 状态select/ 日期date/ 创建人user/ 创建时间datetime。** 不做可配置模板,固定集即可。后续若需按用途类型预设模板,再评估。
**KD5. 视觉决策用文字探讨,不开浏览器探针。** 用户选择文字描述选项,所有布局/交互形态决策通过文字对话确认。
**KD6. 保留原需求文档"自建 + 底座心智"方向。** 文件层天然可作为 Agent 持久化结构化数据底座的承载点,未来 Agent 写入就是写入文件内的表。原方向不冲突。
**KD7. Agent 集成放阶段二,不在阶段一。** 阶段一聚焦用户主动建表体验,阶段二再做 Agent 采集闭环与写入反馈。理由:阶段一的产品形态不完整时做 Agent 集成会让 Agent 写入落到错误的结构上。
---
## Actors
- **用户**:数据精修者与分析者。在落地后的表上编辑用户列、配置视图、做分析。本次完善的主要服务对象。
- **Agent**数据作者。执行三类采集Excel/数据库/API按字段写入多维表格。阶段二开始接入 UI 反馈。
- **伴生服务**bitable 自有 API/CLI、自有领域模型、自有存储边界。AgentKit ↔ bitable 走 API/CLI不做进程内紧耦合。
---
## Requirements
### 阶段一:文件层骨架与表内基础体验
R1. 引入"多维表格文件"作为最上级容器,数据表归属文件。文件自有元数据(名称/图标/描述等),支持 CRUD。
R2. 新建数据表自带默认字段集,参照飞书 5 字段标题text、状态select预设"未开始/进行中/已完成"3 选项、日期date、创建人user、创建时间datetime
R3. 表内字段操作走列头下拉菜单。支持重命名、改类型、隐藏、删除。不再依赖右侧"字段管理"弹层作为唯一入口。
R4. select/multiselect 字段编辑器使用下拉选项(带颜色标签),替换当前文本输入。选项在字段配置中维护。
R5. 三层导航层级:`文件列表 → 文件详情(含多张表)→ 表内(字段/记录/视图)`。文件列表是新的最上级入口,替代当前平铺的表列表。
### 阶段二:三类采集入口与 Agent 写入反馈
R6. Excel 上传采集入口:前端 UI 触发,调用已有 `src/agentkit/bitable/ingestion/excel.py` 后端。用户上传 Excel 文件或提供 URL按字段写入指定表的"数据列",保留"用户列"。
R7. 数据库导入采集入口:前端 UI 触发,调用已有 `src/agentkit/bitable/ingestion/database.py` 后端。用户指定数据库连接与表,生成对应多维表格。
R8. API/爬虫采集入口:前端 UI 触发,调用已有 `src/agentkit/bitable/ingestion/api_collector.py` 后端。用户配置 API 端点或爬虫指令Agent 执行采集后按字段写入。
R9. Agent 写入反馈 UI用户能看见 Agent 最近写入的记录与写入历史。形态在 Outstanding Questions 中确认。
### 阶段三:多视图与公式编辑器增强
R10. 看板视图:按分组字段(通常是 select 字段)分列展示记录卡片,支持拖拽卡片改分组。
R11. 画廊视图:以图片/附件字段为主视觉的卡片网格展示。
R12. 公式编辑器增强:函数提示、字段引用插入、实时语法校验。复用已有 `POST /api/v1/bitable/fields/validate-formula` 端点。
### 阶段四:权限、自动化、表单、甘特
R13. 文件级与表级权限模型。文件可共享给用户/部门,表继承文件权限并可细化。
R14. 自动化触发器:事件(记录新增/字段变更/定时)→ 动作(写另一字段/发通知/调 API
R15. 表单视图:以表单形式收集数据写入指定表。表单可分享链接。
R16. 甘特视图:按日期字段排时间线,支持依赖关系连线。
---
## Key Flows
- F1. 用户建表流程
- **Trigger:** 用户点"新建文件"按钮
- **Actors:** 用户
- **Steps:** 创建文件(命名/选图标)→ 在文件内点"新建表" → 表自带 5 个默认字段 → 用户在表内点列头加新字段(如"邮箱")→ 配置字段类型与选项
- **Outcome:** 文件含 1 张带默认字段+用户扩展字段的表
- F2. Agent 采集写入流程
- **Trigger:** 用户在文件内点"采集数据"按钮
- **Actors:** 用户、Agent、伴生服务
- **Steps:** 选采集类型Excel/DB/API→ 配置采集参数 → Agent 执行采集 → 按主键 upsert 写入指定表 → UI 显示 Agent 写入反馈(新记录高亮/写入历史)
- **Outcome:** 表内有 Agent 写入的数据列,用户的列与公式列保留不变
- F3. 表内字段操作流程
- **Trigger:** 用户在表内点某列列头
- **Actors:** 用户
- **Steps:** 列头下拉菜单弹出 → 选"重命名/改类型/隐藏/删除" → 执行操作 → 表实时更新
- **Outcome:** 字段状态变更,不需要打开右侧字段管理弹层
---
## Acceptance Examples
- AE1. 新建文件"销售管线"
- **Covers R1, R2, R3, R5.**
- **Given** 用户在文件列表页
- **When** 点"新建文件"命名"销售管线" → 进入文件详情 → 点"新建表"命名"客户"
- **Then** 表自带 5 个默认字段(标题/状态/日期/创建人/创建时间)→ 用户点"标题"列头下拉 → 选"重命名"改为"公司名" → 表头实时更新
- AE2. Agent 采集 Excel 写入
- **Covers R6, R9.**
- **Given** 用户在"客户"表内已加"邮箱"用户列
- **When** 点"采集数据" → 选 Excel 上传 → 选一份客户名单 Excel → Agent 写入
- **Then** 表内出现新记录(数据列被填充)→ "邮箱"用户列保持不变 → UI 显示"Agent 写入 N 条"反馈
- AE3. 切换看板视图
- **Covers R10.**
- **Given** "客户"表内有"状态"select 字段(未开始/进行中/已完成)
- **When** 点 ViewSwitcher → 新建看板视图 → 分组字段选"状态"
- **Then** 表渲染为三列看板(未开始/进行中/已完成)→ 拖拽某卡片从"未开始"到"进行中" → 该记录的"状态"字段自动改为"进行中"
---
## Success Criteria
- **阶段一验收**用户能创建文件、新建表自带默认字段、表内直接增改删字段、select 下拉编辑器可用、三层导航层级清晰。
- **整体完成**四阶段全部交付UI 体验对齐飞书/twenty 范式(三层骨架 + 多视图 + 采集 + 公式编辑器 + 权限/自动化/表单)。
- **后端兼容**:现有 v1 后端 API 不被破坏。文件层引入是新增 schemaV2不破坏现有 V1 表结构。
- **E2E 覆盖**:每阶段交付时 e2e 测试覆盖关键流程(建表/采集/视图切换/字段操作)。当前 `e2e/bitable-view.spec.ts` 仅 B1/B2 两个基础测试,需扩展。
---
## Scope Boundaries
### 本次范围内
四阶段全部 16 个 RR1-R16
### 延后v3 范围)
- 多人实时协作(多人同时编辑同一表,光标/选区同步)
- 大规模优化(列式存储、分区、物化视图、异步重算管道升级)
### 本产品身份之外
- **通用电子表格**bitable 是字段化记录模型,不是单元格自由编辑
- **ETL/数据管道平台**:采集是 Agent 驱动的按需执行,非定时调度管道
- **BI 仪表盘产品**:分析能力服务于表格内聚合,非独立 BI
- **知识库 RAG 替代**bitable 是结构化数据载体,非非结构化文档检索
---
## Dependencies / Assumptions
- **依赖**:现有后端 v1 齐全(`src/agentkit/bitable/{service,repository,models,recalc_worker,db,formula,ingestion}`),本次完善主要在前端 + 后端加文件层新实体。
- **依赖**vxe-table 4 已用于 `BitableGrid`,继续作为 grid 实现基础。
- **依赖**Ant Design Vue 4 + Vue 3 + Pinia + TypeScript强类型禁 any
- **假设**:现有 PostgreSQL 性能足以支撑 v1/v2 规模。
- **假设**:文件层引入只需 schema V2 升级(当前 V1`create_all` 模式,无 alembic 迁移)。
- **假设**:用户接受分阶段交付,每阶段可独立验证。
- **假设**`CONCEPTS.md` 已有 Bitable/Field Ownership/Recalc 三条目,本次需补"多维表格文件/BitableFile"新词。
---
## Outstanding Questions
### Resolve Before Planning
- 文件层实体的具体字段集(名称/图标/描述/权限/创建人?)待 ce-plan 决定。
- Agent 写入反馈 UI 形态("最近写入记录高亮" vs "完整写入历史时间线")未深入讨论,影响阶段二工作量。
- 阶段二/三/四的具体 R 优先级与依赖排序(如 R10 看板 vs R12 公式编辑器谁先)。
### Deferred to Planning
- 文件层引入是 schema V2 升级还是独立 schema 隔离。
- select 下拉编辑器用 vxe-table 内置 select 还是自定义组件。
- 公式编辑器是否引入第三方库(如 formula-parser
- 看板/画廊视图的组件选型(自建 vs 现成库)。
- 文件级权限模型是复用现有 RBAC 还是新建。
---
## Sources / Research
- **飞书多维表格**:表/视图/字段/记录四层范式;列头下拉菜单管理字段;新建表自带默认字段;多视图切换;公式列;权限;自动化;表单收集。作为本次主要参照标杆。
- **twentyhq/twenty** ([https://github.com/twentyhq/twenty](https://github.com/twentyhq/twenty)):开源 CRM对象→视图→记录三栏布局范式record 详情右侧抽屉;字段化记录模型。作为布局参照。
- **原需求文档**`docs/brainstorms/2026-06-24-bitable-module-requirements.md`5 天前,已决策"自建 + 底座心智" + v1/v2/v3 路线,本次为全量重写替代)。
- **现有后端实现**`src/agentkit/bitable/{service,repository,models,recalc_worker,db}.py` + `formula/{parser,functions,engine}.py` + `ingestion/{excel,database,api_collector}.py`
- **现有前端实现**`src/agentkit/server/frontend/src/views/BitableView.vue` + `src/agentkit/server/frontend/src/components/bitable/*.vue`10 个组件)+ `src/agentkit/server/frontend/src/stores/bitable.ts` + `src/agentkit/server/frontend/src/api/bitable.ts`
- **REST API 路由**`src/agentkit/server/routes/bitable.py`(表/字段/记录/视图/upsert/上传/公式校验端点齐全)。
- **已有解决方案**`docs/solutions/architecture-patterns/bitable-companion-service-security-reliability-patterns.md`。
- **领域词汇**`CONCEPTS.md` 现有 Bitable/Field Ownership/Recalc 三条目,本次需补"多维表格文件/BitableFile"。

View File

@ -0,0 +1,402 @@
---
title: "feat: Bitable UI Completeness — Stage 1 (File Layer + Default Fields + In-Table Field Ops + Select Editor)"
type: feat
date: 2026-06-29
origin: docs/brainstorms/2026-06-29-bitable-ui-completeness-requirements.md
---
# Bitable UI Completeness — Stage 1
## Summary
引入"多维表格文件"作为最上级容器,重构 `文件 → 数据表 → 字段/记录` 三层骨架补齐新建表默认字段、表内列头字段操作、select 下拉编辑器。这是 4 阶段完善计划的第一阶段,聚焦让 bitable 从"功能残缺"到"用户主动建表体验可用"。
## Problem Frame
bitable 后端 v1 齐备但前端产品形态残缺(见 origin 文档 Problem Frame。三类核心缺口导致无法正常使用缺最上级文件容器、新建表无默认字段、表内不能直接管理字段。此外 select 编辑器仍是文本输入。Stage 1 解决这四件事,让用户能完成"建文件 → 建表 → 表内配字段 → 填数据"的基本闭环。
---
## Requirements
### 后端文件层
R1. 引入 BitableFile 实体作为最上级容器。Table 通过 `file_id` 外键归属文件。文件自有元数据name/icon/description/owner_user_id支持 CRUD。文件级 ownership 检查复用现有 `_check_table_ownership` 模式(见 `docs/solutions/architecture-patterns/bitable-companion-service-security-reliability-patterns.md` 模式 4
R2. 新建数据表时自动创建 5 个默认字段标题textowner=user、状态select预设"未开始/进行中/已完成"3 选项owner=user、日期dateowner=user、创建人textowner=agent、创建时间datetimeowner=agent。默认字段遵循 Field Ownership 模型——agent-owned 字段由系统在记录创建时自动填充。
### 前端文件层与导航
R3. 三层导航层级:`文件列表 → 文件详情(含多张表)→ 表内(字段/记录/视图)`。文件列表是 `/bitable` 的新默认页,显示用户拥有的所有文件卡片。点文件卡片进入文件详情,左侧 sidebar 显示该文件的表列表,右侧显示选中表的 grid。
R4. 文件 CRUD UI文件列表页有"新建文件"按钮(弹窗输入名称/选图标/选描述);文件卡片支持重命名、删除(带确认);文件详情 topbar 显示文件名与返回按钮。
### 前端表内体验
R5. 表内字段操作走列头下拉菜单。点 grid 列头弹出菜单:重命名、修改字段类型、隐藏、删除。不再依赖右侧"字段管理"弹层作为唯一入口FieldManagePanel 保留作为批量管理入口)。删除字段需二次确认(会删除该列所有数据)。
R6. select/multiselect 字段编辑器使用下拉选项(带颜色标签),替换当前 `VxeInput` 文本输入。选项从字段 config.options 读取,编辑时显示为可搜索下拉,选中后写入 record value。
---
## Key Technical Decisions
**KTD1. BitableFile 作为独立实体Table 加 `file_id` 外键。** 不采用"轻量分组"方案Table 加 group_name 字段)。理由:文件需要自己的 ownership/CRUD/元数据,轻量分组会在后续权限/共享能力上撞天花板。当前 schema V1 用 `create_all` 模式无现有数据需迁移引入文件层是干净的新增origin KD2
**KTD2. Schema 升级用 `_SCHEMA_VERSION` 递增 + 启动时迁移,不引入 alembic。** 现有 `src/agentkit/bitable/db.py` 已有 `_SCHEMA_VERSION = 1` 与预留的 `_apply_v2_migration` 注释位。文件层引入升级到 V2创建 `bitable_files` 表,给 `bitable_tables``file_id` 列。启动时 `init_db()` 检测版本并执行迁移。保持与现有模式一致,不为单次迁移引入 alembic 全套。
**KTD3. 默认字段在 service 层 `create_table` 内创建,不在数据库层用 trigger。** service 层 `create_table` 在创建 Table 后立即批量创建 5 个默认 Field。理由可测试、可复用、不依赖数据库特定语法。agent-owned 字段(创建人/创建时间)在 `create_record` 时由 service 自动填充当前 user_id 与 timestamp。
**KTD4. 前端路由重构为嵌套结构。** `/bitable` → 文件列表;`/bitable/:fileId` → 文件详情(含表列表 sidebar`/bitable/:fileId/:tableId` → 表内。当前 `BitableView.vue` 拆为 `BitableFileListView.vue` + `BitableFileDetailView.vue`。表内视图复用现有 `BitableGrid` + `ViewSwitcher` 等组件。
**KTD5. 列头下拉菜单用 vxe-table 的 header slot + Ant Design Vue Dropdown。** 不自建列头组件。vxe-table 支持自定义 header slot在 slot 内渲染列名 + 下拉触发图标。菜单项调用现有 store 的 `updateField`/`deleteField` action需新增
**KTD6. select 编辑器用 vxe-table 自定义编辑器 + Ant Design Vue Select。** 注册一个 `SelectCellEditor` 自定义编辑器,内部用 `a-select`(带搜索、颜色标签)。选项从 `field.config.options` 读取。multiselect 用 `mode="multiple"`。替换 `BitableGrid.vue` 中 select/multiselect 的 `editRender: { name: 'VxeInput' }`
---
## High-Level Technical Design
### 三层导航数据流
```mermaid
flowchart TB
A[/bitable 文件列表] --> B[选文件卡片]
B --> C[/bitable/:fileId 文件详情]
C --> D[左侧 sidebar 选表]
D --> E[/bitable/:fileId/:tableId 表内]
E --> F[BitableGrid + ViewSwitcher]
C --> G[新建表 - 自带默认字段]
```
### 后端文件层 schema 变更
```mermaid
flowchart LR
F[bitable_files] --1:N--> T[bitable_tables]
T --1:N--> FL[bitable_fields]
T --1:N--> R[bitable_records]
T --1:N--> V[bitable_views]
```
`bitable_files`: id, name, icon, description, owner_user_id, created_at, updated_at
`bitable_tables`: 新增 `file_id` 外键列
### 默认字段创建时序
```mermaid
sequenceDiagram
participant U as 用户
participant API as REST API
participant S as BitableService
participant R as Repository
U->>API: POST /files/{file_id}/tables {name}
API->>S: create_table(file_id, name)
S->>R: create Table record
S->>S: _build_default_fields(table_id)
S->>R: create_records_batch 5 fields
S-->>API: Table + 5 Fields
API-->>U: 201 Created
```
---
## Implementation Units
### U1. Backend: BitableFile entity + schema V2 migration
**Goal:** 引入 BitableFile Pydantic 模型 + ORM model + repository + service + REST endpoints升级 schema 到 V2。
**Requirements:** R1
**Dependencies:** 无(基础单元)
**Files:**
- `src/agentkit/bitable/models.py` — 新增 `BitableFile` Pydantic 模型
- `src/agentkit/bitable/db.py` — 新增 `FileModel` ORM`_SCHEMA_VERSION = 2`;实现 `_apply_v2_migration`(创建 files 表,给 tables 加 file_id 列);`Table` 加 `file_id` 字段
- `src/agentkit/bitable/repository.py` — 新增 `FileRepository`CRUD + list_by_owner
- `src/agentkit/bitable/service.py` — 新增 `BitableService` 的文件方法create_file/list_files/get_file/update_file/delete_file`create_table` 改签名加 `file_id` 参数
- `src/agentkit/server/routes/bitable.py` — 新增 `/files` 端点POST/GET/GET/:id/PUT/:id/DELETE/:id`/files/{file_id}/tables` POST 端点;文件级 ownership 检查 `_check_file_ownership`
- `tests/unit/bitable/test_file_crud.py` — 新建测试文件
**Approach:**
- `BitableFile` 模型字段id, name, icon (emoji 字符串), description, owner_user_id, created_at, updated_at
- 文件 ownership 检查复用 `_check_table_ownership` 模式solutions doc 模式 4404 before 403internal token bypass
- `delete_file` 级联删除该文件下所有 tables及其 fields/records/views—— 复用现有 `delete_table` 逻辑循环
- schema V2 迁移用 `ALTER TABLE bitable_tables ADD COLUMN file_id VARCHAR` + `CREATE TABLE bitable_files`
- 现有无 file_id 的 table 在迁移时创建一个"默认文件"并归属之(防御性,虽然 verifier 确认无现有数据)
**Patterns to follow:**
- `docs/solutions/architecture-patterns/bitable-companion-service-security-reliability-patterns.md` 模式 1服务隔离、模式 4IDOR ownership 检查)
- 现有 `_check_table_ownership` in `src/agentkit/server/routes/bitable.py`
**Test scenarios:**
- Happy path: 创建文件 → 获取 → 列表 → 更新 → 删除
- Edge case: 删除文件时级联删除其下所有表(验证 tables/fields/records 都被清理)
- Error path: 非文件 owner 访问返回 404不泄露存在性
- Error path: internal token bypass ownership 检查
- Integration: 创建文件 → 在文件下创建 table → table.file_id 正确关联
- Covers AE1. 新建文件"销售管线"(文件创建部分)
**Verification:** `pytest tests/unit/bitable/test_file_crud.py -v` 全绿;`ruff check src/agentkit/bitable/` 无 lint 错误。
---
### U2. Backend: Default fields on table creation
**Goal:** `create_table` 自动创建 5 个默认字段agent-owned 字段在 `create_record` 时自动填充。
**Requirements:** R2
**Dependencies:** U1create_table 签名变更)
**Files:**
- `src/agentkit/bitable/service.py``create_table` 内调用 `_create_default_fields(table_id, owner_user_id)``create_record` 内自动填充 agent-owned 字段(创建人 = owner_user_id创建时间 = now
- `src/agentkit/bitable/models.py` — 新增 `DEFAULT_FIELD_TEMPLATES` 常量5 个默认字段定义)
- `tests/unit/bitable/test_default_fields.py` — 新建测试文件
**Approach:**
- `DEFAULT_FIELD_TEMPLATES`5 个字段定义,含 name/type/owner/config
- 状态 select 字段 config: `{"options": [{"label":"未开始","value":"not_started","color":"default"},{"label":"进行中","value":"in_progress","color":"processing"},{"label":"已完成","value":"done","color":"success"}]}`
- `_create_default_fields``repository.create_records_batch` 一次创建 5 个 Field
- `create_record` 检查 table 的 agent-owned 字段,若 values 未提供则自动填充
**Patterns to follow:**
- 现有 `create_table` in `src/agentkit/bitable/service.py`
- CONCEPTS.md Field Ownership 定义
**Test scenarios:**
- Happy path: 创建 table → 验证返回 5 个默认字段(名称/类型/owner 正确)
- Happy path: 状态字段 config.options 有 3 个预设选项
- Happy path: 创建 record → 创建人字段自动填充 owner_user_id
- Happy path: 创建 record → 创建时间字段自动填充当前 timestamp
- Edge case: 用户传 record values 时覆盖创建人字段 → agent-owned 字段不被用户覆盖
- Covers AE1. 新建表"客户"(默认字段部分)
**Verification:** `pytest tests/unit/bitable/test_default_fields.py -v` 全绿。
---
### U3. Frontend: File layer store + API client + navigation restructure
**Goal:** 前端文件层 API client + store + 三层导航视图重构。
**Requirements:** R3, R4
**Dependencies:** U1后端文件 API
**Files:**
- `src/agentkit/server/frontend/src/api/bitable.ts` — 新增 `IBitableFile` 类型 + file CRUD API 函数
- `src/agentkit/server/frontend/src/stores/bitable.ts` — 新增 `files` state + `loadFiles/createFile/updateFile/deleteFile` actions`loadTables` 改为按 fileId 过滤
- `src/agentkit/server/frontend/src/views/BitableFileListView.vue` — 新建:文件列表页(卡片网格 + 新建按钮)
- `src/agentkit/server/frontend/src/views/BitableFileDetailView.vue` — 新建文件详情页topbar + 左侧表列表 sidebar + 右侧表内 grid
- `src/agentkit/server/frontend/src/views/BitableView.vue` — 删除或改为 redirect 到 `/bitable`
- `src/agentkit/server/frontend/src/router/index.ts` — 重构 `/bitable` 路由为嵌套:`/bitable`(文件列表)、`/bitable/:fileId`(文件详情)、`/bitable/:fileId/:tableId`(表内,可选拆为子路由或 query
- `src/agentkit/server/frontend/src/components/bitable/FileCard.vue` — 新建:文件卡片组件(图标+名称+表数量+描述)
- `src/agentkit/server/frontend/src/components/bitable/FileCreateModal.vue` — 新建:创建文件弹窗
- `src/agentkit/server/frontend/src/components/bitable/TableViewList.vue` — 修改props 加 `fileId`emit create 时带 fileId
**Approach:**
- `BitableFileListView` 布局:顶部"新建文件"按钮 + 卡片网格(每卡片显示 icon/name/表数量/描述/操作菜单)
- `BitableFileDetailView` 布局:复用现有 `BitableView.vue` 的 topbar + sidebar + main 结构,但 topbar 显示文件名 + 返回按钮sidebar 显示该文件的表列表
- 路由用嵌套 children`/bitable` → FileListView`/bitable/:fileId` → FileDetailView内部根据是否选中 table 渲染 grid 或 placeholder
- 文件删除用 `a-popconfirm` 二次确认
**Patterns to follow:**
- 现有 `BitableView.vue` 的 topbar + sidebar + main 布局
- 现有 `TableCreateModal.vue` 的弹窗模式
- Ant Design Vue Card / Modal / Popconfirm 组件
**Test scenarios:**
- Happy path: 访问 /bitable → 显示文件列表(空态有引导)
- Happy path: 点"新建文件" → 弹窗 → 输入名称 → 创建 → 列表刷新显示新卡片
- Happy path: 点文件卡片 → 跳转 /bitable/:fileId → 显示文件详情sidebar 表列表)
- Happy path: 在文件详情点"新建表" → 表创建后 sidebar 显示
- Edge case: 删除文件 → popconfirm 确认 → 列表移除
- Error path: 访问不存在的 fileId → 显示 404 或回退到列表
- Covers AE1. 新建文件"销售管线"(前端流程)
**Verification:** `npm run typecheck` 通过;`npm run dev` 手动验证三层导航e2e 测试在 U6 覆盖。
---
### U4. Frontend: In-table field operations (column header dropdown)
**Goal:** grid 列头下拉菜单支持重命名/改类型/隐藏/删除字段。
**Requirements:** R5
**Dependencies:** U3store 重构)
**Files:**
- `src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue` — 修改:列 header slot 渲染列名 + 下拉触发图标;移除 select/multiselect 的 `VxeInput` editRender在 U5 完成)
- `src/agentkit/server/frontend/src/components/bitable/ColumnHeaderMenu.vue` — 新建:列头菜单组件(重命名/改类型/隐藏/删除)
- `src/agentkit/server/frontend/src/components/bitable/FieldConfigForm.vue` — 修改:支持重命名与改类型(可能复用现有表单)
- `src/agentkit/server/frontend/src/stores/bitable.ts` — 新增 `updateField(fieldId, patch)` + `deleteField(fieldId)` + `hideField(fieldId, viewId)` actions
**Approach:**
- vxe-table column 配置加 `header-class-name` + 使用 `header` slot 自定义渲染
- header slot 内:列名文本 + 一个 `...` 图标触发 `a-dropdown`
- 菜单项:
- 重命名 → 弹 `a-modal` 输入新名称 → 调 `store.updateField`
- 修改类型 → 弹 `a-modal` 选新类型 → 调 `store.updateField`(注意:改类型可能清空 config
- 隐藏 → 调 `store.hideField`(写入当前 view 的 hidden_fields
- 删除 → `a-popconfirm` 确认 → 调 `store.deleteField`
- 删除字段会级联删除该字段的所有 record values后端已支持
**Patterns to follow:**
- vxe-table header slot 文档
- Ant Design Vue Dropdown / Modal / Popconfirm
**Test scenarios:**
- Happy path: 点列头 → 菜单弹出 4 项
- Happy path: 重命名 → 输入新名称 → 表头实时更新
- Happy path: 隐藏字段 → 该列从 grid 消失(仍存在于字段管理面板)
- Happy path: 删除字段 → popconfirm 确认 → 该列与数据消失
- Edge case: 删除主键字段 → 提示不允许或确认后清除 primary_key_field_id
- Covers AE1. 用户点"标题"列头下拉 → 选"重命名"改为"公司名"
**Verification:** `npm run typecheck` 通过;手动验证列头菜单 4 个操作。
---
### U5. Frontend: select/multiselect dropdown editor
**Goal:** select/multiselect 字段编辑器从文本输入改为下拉选项(带颜色标签)。
**Requirements:** R6
**Dependencies:** U4BitableGrid 已修改)
**Files:**
- `src/agentkit/server/frontend/src/components/bitable/SelectCellEditor.vue` — 新建:自定义编辑器组件(用 a-select
- `src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue` — 修改select/multiselect 列的 `editRender` 指向自定义编辑器;移除 `ponytail: select editor uses text input` 注释
- `src/agentkit/server/frontend/src/stores/bitable.ts` — 可能需要 `getFieldOptions(fieldId)` getter
**Approach:**
- 注册 vxe-table 自定义编辑器 `SelectCellEditor`
- props: `field`(含 config.options
- 渲染 `a-select``show-search`、`label-in-value`、选项带颜色 tag
- multiselect 用 `mode="multiple"`
- `BitableGrid.vue` column 配置select/multiselect 的 `editRender: { name: 'SelectCellEditor', options: field.config.options }`
- 选项颜色用 `a-tag :color="option.color"` 渲染
**Patterns to follow:**
- vxe-table 自定义编辑器文档
- Ant Design Vue Select + Tag
**Test scenarios:**
- Happy path: 双击 select 单元格 → 下拉弹出 3 个选项(带颜色)
- Happy path: 选一个选项 → 单元格值更新为 option.value
- Happy path: multiselect 字段 → 可多选
- Happy path: 搜索功能 → 输入文字过滤选项
- Edge case: 字段无 options config → 下拉为空 + 提示
- Covers R6
**Verification:** `npm run typecheck` 通过;手动验证 select 编辑器交互。
---
### U6. E2E test coverage expansion
**Goal:** 扩展 e2e 测试覆盖 Stage 1 关键流程。
**Requirements:** Success CriteriaE2E 覆盖)
**Dependencies:** U3, U4, U5前端功能完成
**Files:**
- `src/agentkit/server/frontend/e2e/bitable-view.spec.ts` — 修改:保留 B1/B2扩展覆盖文件导航
- `src/agentkit/server/frontend/e2e/bitable-file-flow.spec.ts` — 新建:文件 CRUD + 三层导航流程
- `src/agentkit/server/frontend/e2e/bitable-field-ops.spec.ts` — 新建:列头字段操作 + select 编辑器
**Approach:**
- `bitable-file-flow.spec.ts`
- 访问 /bitable → 验证文件列表渲染
- 新建文件 → 验证卡片出现
- 进入文件 → 验证 sidebar 表列表
- 新建表 → 验证默认字段显示
- 切换表 → 验证 grid 切换
- `bitable-field-ops.spec.ts`
- 点列头 → 验证菜单弹出
- 重命名 → 验证表头更新
- 隐藏 → 验证列消失
- select 编辑器 → 验证下拉交互
**Patterns to follow:**
- 现有 `bitable-view.spec.ts` 的 B1/B2 模式
- Playwright 测试模式
**Test scenarios:**
- Happy path: 文件列表 → 新建文件 → 进入 → 新建表(含默认字段)→ 切换表(完整流程)
- Happy path: 列头重命名 + 隐藏 + select 编辑器交互
- Smoke: /bitable 不白屏、核心元素可见(保留 B1/B2
**Verification:** `npx playwright test e2e/bitable-*.spec.ts` 全绿。
---
## Scope Boundaries
### 本次范围内Stage 1
R1-R6 全部 + U1-U6 全部。这是 origin 文档 4 阶段中的阶段一。
### Deferred to Follow-Up Work
以下 R 在 origin 文档中定义,本次不实现,留给后续 lfg 调用:
- **Stage 2R7-R10**:三类采集入口前端 UIExcel/DB/API、Agent 写入反馈 UI
- **Stage 3R11-R13**:看板视图、画廊视图、公式编辑器增强
- **Stage 4R14-R17**:权限模型、自动化触发器、表单视图、甘特视图
### Outside this product's identity
(从 origin 文档继承通用电子表格、ETL/数据管道平台、BI 仪表盘、知识库 RAG 替代。
---
## System-Wide Impact
- **数据模型变更**:新增 `bitable_files` 表,`bitable_tables` 加 `file_id` 列。schema V1 → V2 启动时迁移。
- **API 边界**:新增 `/api/v1/bitable/files` 端点组。现有 `/tables` 端点改为 `/files/{file_id}/tables`(保留旧端点兼容性,标记 deprecated
- **前端路由**`/bitable` 从单视图重构为嵌套路由。现有书签需重定向。
- **领域词汇**CONCEPTS.md 需补"多维表格文件/BitableFile"条目。
---
## Risks & Dependencies
- **风险**schema V2 迁移若失败可能导致启动卡住。缓解:迁移用 `try/except` 包裹,失败时 log 并继续(向后兼容 V1 表为"默认文件"归属)。
- **风险**vxe-table header slot 与自定义编辑器的集成可能在版本差异下行为不一致。缓解U4/U5 实现时先写最小验证 demo。
- **依赖**U2 依赖 U1create_table 签名变更。U3 依赖 U1前端调用后端文件 API。U4/U5 依赖 U3store 重构。U6 依赖 U3/U4/U5。
- **依赖**:现有 `docs/solutions/architecture-patterns/bitable-companion-service-security-reliability-patterns.md` 的 10 个模式必须遵守(特别是 IDOR ownership 检查、batch 操作、async I/O
---
## Open Questions
### Resolve During Implementation
- 文件图标用 emoji 字符串还是预设图标集?默认用 emoji 字符串(前端用 `a-input` 输入),实现时确认。
- 现有 `/tables` 端点是否保留?保留并标记 deprecated新端点 `/files/{file_id}/tables` 为推荐路径。
- `BitableView.vue` 删除还是保留为 redirect改为 redirect 到 `/bitable`
### Deferred to Implementation
- vxe-table header slot 的具体 API 形态(版本相关,实现时查文档确认)。
- select 编辑器在 multiselect 下的值序列化格式(数组 vs 逗号分隔字符串,复用现有后端约定)。
---
## Sources & Research
- **Origin requirements**: `docs/brainstorms/2026-06-29-bitable-ui-completeness-requirements.md`
- **安全/可靠性模式**: `docs/solutions/architecture-patterns/bitable-companion-service-security-reliability-patterns.md`10 个模式,本次实现必须遵守)
- **现有后端实现**: `src/agentkit/bitable/{models,db,repository,service}.py` + `src/agentkit/server/routes/bitable.py`
- **现有前端实现**: `src/agentkit/server/frontend/src/views/BitableView.vue` + `src/agentkit/server/frontend/src/components/bitable/*.vue` + `src/agentkit/server/frontend/src/stores/bitable.ts`
- **领域词汇**: `CONCEPTS.md` Bitable/Field Ownership/Recalc 定义
- **飞书多维表格范式**: 文件→表→字段/记录三层;列头下拉管理字段;新建表默认字段
- **twentyhq/twenty 布局参考**: [https://github.com/twentyhq/twenty](https://github.com/twentyhq/twenty)
- **vxe-table 4 文档**: header slot 与自定义编辑器
- **Ant Design Vue 4**: Card/Modal/Dropdown/Select/Popconfirm/Tag 组件

View File

@ -35,7 +35,9 @@ from sqlalchemy.orm import DeclarativeBase
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Current schema version — bump when adding migrations. # Current schema version — bump when adding migrations.
_SCHEMA_VERSION = 1 # V1: initial schema (tables/fields/records/views/recalc/meta).
# V2: add bitable_files table + file_id column on bitable_tables (R1).
_SCHEMA_VERSION = 2
_META_SCHEMA_VERSION_KEY = "schema_version" _META_SCHEMA_VERSION_KEY = "schema_version"
@ -52,13 +54,35 @@ class BitableBase(DeclarativeBase):
"""Declarative base for bitable ORM models (independent schema).""" """Declarative base for bitable ORM models (independent schema)."""
class FileModel(BitableBase):
"""ORM model for ``bitable.bitable_files`` — top-level container (R1)."""
__tablename__ = "bitable_files"
__table_args__ = (
Index("ix_bitable_files_owner", "owner_user_id"),
{"schema": "bitable"},
)
id = Column(String, primary_key=True, default=_uuid_str)
name = Column(String, nullable=False)
icon = Column(String, default="📋")
description = Column(Text, default="")
owner_user_id = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
class TableModel(BitableBase): class TableModel(BitableBase):
"""ORM model for ``bitable.bitable_tables``.""" """ORM model for ``bitable.bitable_tables``."""
__tablename__ = "bitable_tables" __tablename__ = "bitable_tables"
__table_args__ = {"schema": "bitable"} __table_args__ = (
Index("ix_bitable_tables_file_id", "file_id"),
{"schema": "bitable"},
)
id = Column(String, primary_key=True, default=_uuid_str) id = Column(String, primary_key=True, default=_uuid_str)
file_id = Column(String, nullable=True)
name = Column(String, nullable=False) name = Column(String, nullable=False)
description = Column(Text, default="") description = Column(Text, default="")
primary_key_field_id = Column(String, nullable=True) primary_key_field_id = Column(String, nullable=True)
@ -167,6 +191,57 @@ class MetaModel(BitableBase):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _apply_v2_migration(conn: Any) -> None:
"""V2 migration: create ``bitable_files`` table + add ``file_id`` to tables.
Idempotent safe to call on fresh installs (``create_all`` already made
the files table and file_id column) and on V1 upgrades (ALTERs the
existing tables). The CREATE TABLE IF NOT EXISTS + ADD COLUMN IF NOT
EXISTS guards make both paths work.
Defensive: V1 tables without a ``file_id`` are left with ``file_id = NULL``
(orphaned). The service layer treats NULL file_id as "no parent file"
and continues to work they're reachable via the deprecated
``/tables`` endpoint. The plan notes there is no existing production
data to migrate (verifier-confirmed), so this is purely defensive.
"""
# bitable_files table — created by create_all on fresh installs, but
# we recreate via raw SQL so V1→V2 upgrades (no create_all re-run for
# existing tables) also get it.
await conn.execute(
text(
"CREATE TABLE IF NOT EXISTS bitable.bitable_files ("
" id VARCHAR PRIMARY KEY,"
" name VARCHAR NOT NULL,"
" icon VARCHAR DEFAULT '📋',"
" description TEXT DEFAULT '',"
" owner_user_id VARCHAR,"
" created_at TIMESTAMPTZ DEFAULT NOW(),"
" updated_at TIMESTAMPTZ DEFAULT NOW()"
")"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_bitable_files_owner "
"ON bitable.bitable_files (owner_user_id)"
)
)
# Add file_id column to bitable_tables (idempotent).
# ponytail: ADD COLUMN IF NOT EXISTS is PostgreSQL 9.6+ — safe here.
await conn.execute(
text("ALTER TABLE bitable.bitable_tables ADD COLUMN IF NOT EXISTS file_id VARCHAR")
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_bitable_tables_file_id "
"ON bitable.bitable_tables (file_id)"
)
)
logger.info("applied V2 migration: bitable_files table + file_id column")
def _resolve_database_url(database_url: str | None = None) -> str | None: def _resolve_database_url(database_url: str | None = None) -> str | None:
"""Resolve PostgreSQL connection URL. """Resolve PostgreSQL connection URL.
@ -207,6 +282,10 @@ class BitableDB:
def session_factory(self) -> Any: def session_factory(self) -> Any:
return self._session_factory return self._session_factory
@property
def is_initialized(self) -> bool:
return self._initialized
async def _ensure_initialized(self) -> None: async def _ensure_initialized(self) -> None:
"""Lazy-init async engine and session factory (with lock).""" """Lazy-init async engine and session factory (with lock)."""
if self._initialized: if self._initialized:
@ -260,8 +339,10 @@ class BitableDB:
# 4. Apply migrations if needed (future versions add elif blocks here) # 4. Apply migrations if needed (future versions add elif blocks here)
if current_version < _SCHEMA_VERSION: if current_version < _SCHEMA_VERSION:
# V1: initial schema (already created above) # V1: initial schema (already created above via create_all)
# Future: if current_version < 2: await _apply_v2_migration(conn) # V2: file layer (bitable_files table + file_id column on tables)
if current_version < 2:
await _apply_v2_migration(conn)
await conn.execute( await conn.execute(
text( text(
"INSERT INTO bitable.bitable_meta (key, value, updated_at) " "INSERT INTO bitable.bitable_meta (key, value, updated_at) "

View File

@ -58,12 +58,32 @@ class RecalcStatus(str, Enum):
error = "error" error = "error"
class BitableFile(BaseModel):
"""Top-level container grouping related tables (多维表格文件).
Owns metadata (name/icon/description/owner). Tables reference their
parent file via ``file_id``. The file is the unit of ownership sharing
and the top of the ``文件 数据表 字段/记录`` navigation hierarchy.
"""
model_config = ConfigDict(from_attributes=True)
id: str
name: str
icon: str = "📋"
description: str = ""
owner_user_id: str | None = None
created_at: datetime = PydanticField(default_factory=_utcnow)
updated_at: datetime = PydanticField(default_factory=_utcnow)
class Table(BaseModel): class Table(BaseModel):
"""A bitable table — collection of fields and records.""" """A bitable table — collection of fields and records."""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: str id: str
file_id: str | None = None
name: str name: str
description: str = "" description: str = ""
primary_key_field_id: str | None = None primary_key_field_id: str | None = None
@ -72,6 +92,54 @@ class Table(BaseModel):
updated_at: datetime = PydanticField(default_factory=_utcnow) updated_at: datetime = PydanticField(default_factory=_utcnow)
# ---------------------------------------------------------------------------
# Default field templates (R2)
# ---------------------------------------------------------------------------
# Status select field options — labels and colors match Feishu Bitable defaults.
_STATUS_OPTIONS: list[dict[str, Any]] = [
{"label": "未开始", "value": "not_started", "color": "default"},
{"label": "进行中", "value": "in_progress", "color": "processing"},
{"label": "已完成", "value": "done", "color": "success"},
]
#: Templates for the 5 default fields created on every new table (R2).
#: agent-owned fields (创建人/创建时间) are auto-filled by the service layer
#: on record creation; user-owned fields are user-editable.
DEFAULT_FIELD_TEMPLATES: list[dict[str, Any]] = [
{
"name": "标题",
"field_type": FieldType.text,
"owner": FieldOwner.user,
"config": {},
},
{
"name": "状态",
"field_type": FieldType.select,
"owner": FieldOwner.user,
"config": {"options": _STATUS_OPTIONS},
},
{
"name": "日期",
"field_type": FieldType.date,
"owner": FieldOwner.user,
"config": {},
},
{
"name": "创建人",
"field_type": FieldType.text,
"owner": FieldOwner.agent,
"config": {},
},
{
"name": "创建时间",
"field_type": FieldType.date,
"owner": FieldOwner.agent,
"config": {},
},
]
class Field(BaseModel): class Field(BaseModel):
"""A column definition in a bitable table. """A column definition in a bitable table.

View File

@ -18,6 +18,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from agentkit.bitable.db import ( from agentkit.bitable.db import (
BitableDB, BitableDB,
FieldModel, FieldModel,
FileModel,
RecordModel, RecordModel,
RecalcQueueModel, RecalcQueueModel,
TableModel, TableModel,
@ -25,6 +26,7 @@ from agentkit.bitable.db import (
_uuid_str, _uuid_str,
) )
from agentkit.bitable.models import ( from agentkit.bitable.models import (
BitableFile,
Field, Field,
FieldOwner, FieldOwner,
FieldType, FieldType,
@ -55,6 +57,83 @@ class BitableRepository:
def _session_factory(self): def _session_factory(self):
return self._db.session_factory return self._db.session_factory
# ── Files (R1) ─────────────────────────────────────────
async def create_file(
self,
name: str,
icon: str = "📋",
description: str = "",
owner_user_id: str | None = None,
) -> BitableFile:
"""Create a new bitable file (top-level container)."""
async with self._session_factory() as session:
stmt = (
insert(FileModel)
.values(
id=_uuid_str(),
name=name,
icon=icon,
description=description,
owner_user_id=owner_user_id,
)
.returning(FileModel)
)
result = await session.execute(stmt)
entity = result.scalar_one()
await session.commit()
return BitableFile.model_validate(entity)
async def get_file(self, file_id: str) -> BitableFile | None:
"""Get a file by ID."""
async with self._session_factory() as session:
stmt = select(FileModel).where(FileModel.id == file_id)
result = await session.execute(stmt)
entity = result.scalars().first()
return BitableFile.model_validate(entity) if entity else None
async def list_files(self, owner_user_id: str | None = None) -> list[BitableFile]:
"""List all files (optionally filtered by owner)."""
async with self._session_factory() as session:
stmt = select(FileModel)
if owner_user_id:
stmt = stmt.where(FileModel.owner_user_id == owner_user_id)
stmt = stmt.order_by(FileModel.updated_at.desc())
result = await session.execute(stmt)
return [BitableFile.model_validate(e) for e in result.scalars().all()]
async def update_file(self, file_id: str, **kwargs: Any) -> BitableFile | None:
"""Update a file's attributes."""
async with self._session_factory() as session:
stmt = (
update(FileModel)
.where(FileModel.id == file_id)
.values(**kwargs)
.returning(FileModel)
)
result = await session.execute(stmt)
entity = result.scalars().first()
await session.commit()
return BitableFile.model_validate(entity) if entity else None
async def delete_file(self, file_id: str) -> bool:
"""Delete a file. Caller is responsible for cascading table deletes."""
async with self._session_factory() as session:
result = await session.execute(delete(FileModel).where(FileModel.id == file_id))
await session.commit()
return result.rowcount > 0
async def list_tables_by_file(self, file_id: str) -> list[Table]:
"""List all tables belonging to a file."""
async with self._session_factory() as session:
stmt = (
select(TableModel)
.where(TableModel.file_id == file_id)
.order_by(TableModel.created_at)
)
result = await session.execute(stmt)
return [Table.model_validate(e) for e in result.scalars().all()]
# ── Tables ────────────────────────────────────────────── # ── Tables ──────────────────────────────────────────────
async def create_table( async def create_table(
@ -63,6 +142,7 @@ class BitableRepository:
description: str = "", description: str = "",
primary_key_field_id: str | None = None, primary_key_field_id: str | None = None,
owner_user_id: str | None = None, owner_user_id: str | None = None,
file_id: str | None = None,
) -> Table: ) -> Table:
"""Create a new bitable table.""" """Create a new bitable table."""
async with self._session_factory() as session: async with self._session_factory() as session:
@ -70,6 +150,7 @@ class BitableRepository:
insert(TableModel) insert(TableModel)
.values( .values(
id=_uuid_str(), id=_uuid_str(),
file_id=file_id,
name=name, name=name,
description=description, description=description,
primary_key_field_id=primary_key_field_id, primary_key_field_id=primary_key_field_id,
@ -352,6 +433,13 @@ class BitableRepository:
await session.commit() await session.commit()
return View.model_validate(entity) return View.model_validate(entity)
async def get_view(self, view_id: str) -> View | None:
"""Get a single view by ID (used for ownership checks)."""
async with self._session_factory() as session:
result = await session.execute(select(ViewModel).where(ViewModel.id == view_id))
entity = result.scalars().first()
return View.model_validate(entity) if entity else None
async def list_views(self, table_id: str) -> list[View]: async def list_views(self, table_id: str) -> list[View]:
"""List all views in a table.""" """List all views in a table."""
async with self._session_factory() as session: async with self._session_factory() as session:

View File

@ -10,11 +10,14 @@ from __future__ import annotations
import logging import logging
import os import os
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from agentkit.bitable.db import BitableDB from agentkit.bitable.db import BitableDB
from agentkit.bitable.models import ( from agentkit.bitable.models import (
DEFAULT_FIELD_TEMPLATES,
BitableFile,
Field, Field,
FieldOwner, FieldOwner,
FieldType, FieldType,
@ -69,6 +72,43 @@ class BitableService:
if self._recalc_worker is not None: if self._recalc_worker is not None:
self._recalc_worker.invalidate_engine(table_id) self._recalc_worker.invalidate_engine(table_id)
# ── Files (R1) ─────────────────────────────────────────
async def create_file(
self,
name: str,
icon: str = "📋",
description: str = "",
owner_user_id: str | None = None,
) -> BitableFile:
"""Create a new bitable file (top-level container)."""
return await self._repo.create_file(
name=name,
icon=icon,
description=description,
owner_user_id=owner_user_id,
)
async def get_file(self, file_id: str) -> BitableFile | None:
return await self._repo.get_file(file_id)
async def list_files(self, owner_user_id: str | None = None) -> list[BitableFile]:
return await self._repo.list_files(owner_user_id=owner_user_id)
async def update_file(self, file_id: str, **kwargs: Any) -> BitableFile | None:
return await self._repo.update_file(file_id, **kwargs)
async def delete_file(self, file_id: str) -> bool:
"""Delete a file and cascade-delete all its tables (and their fields/records/views)."""
tables = await self._repo.list_tables_by_file(file_id)
for t in tables:
await self.delete_table(t.id)
return await self._repo.delete_file(file_id)
async def list_tables_by_file(self, file_id: str) -> list[Table]:
"""List all tables in a file."""
return await self._repo.list_tables_by_file(file_id)
# ── Tables ────────────────────────────────────────────── # ── Tables ──────────────────────────────────────────────
async def create_table( async def create_table(
@ -77,18 +117,45 @@ class BitableService:
description: str = "", description: str = "",
primary_key_field_id: str | None = None, primary_key_field_id: str | None = None,
owner_user_id: str | None = None, owner_user_id: str | None = None,
file_id: str | None = None,
) -> Table: ) -> Table:
"""Create a new bitable table. Creates PK unique index if PK is set.""" """Create a new bitable table with default fields (R2).
Creates the table, then immediately creates 5 default fields
(标题/状态/日期/创建人/创建时间). Creates PK unique index if PK is set.
"""
table = await self._repo.create_table( table = await self._repo.create_table(
name=name, name=name,
description=description, description=description,
primary_key_field_id=primary_key_field_id, primary_key_field_id=primary_key_field_id,
owner_user_id=owner_user_id, owner_user_id=owner_user_id,
file_id=file_id,
) )
# R2: create the 5 default fields for every new table.
await self._create_default_fields(table.id)
if primary_key_field_id: if primary_key_field_id:
await self._repo.create_pk_unique_index(table.id, primary_key_field_id) await self._repo.create_pk_unique_index(table.id, primary_key_field_id)
return table return table
async def _create_default_fields(self, table_id: str) -> list[Field]:
"""Create the 5 default fields on a freshly-created table (R2).
Uses ``DEFAULT_FIELD_TEMPLATES`` from models. Fields are created
via the repository directly (no engine cache invalidation needed
no formula fields among defaults).
"""
created: list[Field] = []
for tpl in DEFAULT_FIELD_TEMPLATES:
field = await self._repo.create_field(
table_id=table_id,
name=tpl["name"],
field_type=tpl["field_type"],
config=tpl.get("config", {}) or {},
owner=tpl["owner"],
)
created.append(field)
return created
async def get_table(self, table_id: str) -> Table | None: async def get_table(self, table_id: str) -> Table | None:
return await self._repo.get_table(table_id) return await self._repo.get_table(table_id)
@ -194,8 +261,31 @@ class BitableService:
# ── Records ───────────────────────────────────────────── # ── Records ─────────────────────────────────────────────
async def create_record(self, table_id: str, values: dict[str, Any] | None = None) -> Record: async def create_record(
"""Create a new record. Triggers recalc for affected formula fields.""" self,
table_id: str,
values: dict[str, Any] | None = None,
actor_user_id: str | None = None,
) -> Record:
"""Create a new record. Triggers recalc for affected formula fields.
R2: auto-fills agent-owned default fields (创建人/创建时间) with
system values. User-supplied values for agent-owned fields are
overwritten agent ownership means system-managed (per plan edge case).
Matching by field_type+owner (not name) so renames don't break auto-fill.
"""
values = dict(values or {})
# Auto-fill agent-owned fields based on the table's field definitions.
fields = await self._repo.list_fields(table_id)
now_iso = datetime.now(timezone.utc).isoformat()
creator_value = actor_user_id or "system"
for f in fields:
if f.owner != FieldOwner.agent:
continue
if f.field_type == FieldType.text:
values[f.id] = creator_value
elif f.field_type == FieldType.date:
values[f.id] = now_iso
record = await self._repo.create_record(table_id, values) record = await self._repo.create_record(table_id, values)
await self._trigger_recalc_for_affected_fields(table_id, record.id) await self._trigger_recalc_for_affected_fields(table_id, record.id)
return record return record
@ -429,6 +519,9 @@ class BitableService:
async def update_view(self, view_id: str, **kwargs: Any) -> View | None: async def update_view(self, view_id: str, **kwargs: Any) -> View | None:
return await self._repo.update_view(view_id, **kwargs) return await self._repo.update_view(view_id, **kwargs)
async def get_view(self, view_id: str) -> View | None:
return await self._repo.get_view(view_id)
# ── Recalc (U3: formula recalc pipeline) ──────────────── # ── Recalc (U3: formula recalc pipeline) ────────────────
async def _trigger_recalc_for_affected_fields(self, table_id: str, record_id: str) -> None: async def _trigger_recalc_for_affected_fields(self, table_id: str, record_id: str) -> None:

View File

@ -87,6 +87,7 @@ declare module 'vue' {
ChatSidebar: typeof import('./src/components/chat/ChatSidebar.vue')['default'] ChatSidebar: typeof import('./src/components/chat/ChatSidebar.vue')['default']
CodeDiffViewer: typeof import('./src/components/code/CodeDiffViewer.vue')['default'] CodeDiffViewer: typeof import('./src/components/code/CodeDiffViewer.vue')['default']
CollaborationGraphCard: typeof import('./src/components/chat/messages/CollaborationGraphCard.vue')['default'] CollaborationGraphCard: typeof import('./src/components/chat/messages/CollaborationGraphCard.vue')['default']
ColumnHeaderMenu: typeof import('./src/components/bitable/ColumnHeaderMenu.vue')['default']
CommandHistory: typeof import('./src/components/terminal/CommandHistory.vue')['default'] CommandHistory: typeof import('./src/components/terminal/CommandHistory.vue')['default']
ConditionNode: typeof import('./src/components/workflow/ConditionNode.vue')['default'] ConditionNode: typeof import('./src/components/workflow/ConditionNode.vue')['default']
ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default'] ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default']
@ -109,6 +110,8 @@ declare module 'vue' {
FieldConfigForm: typeof import('./src/components/bitable/FieldConfigForm.vue')['default'] FieldConfigForm: typeof import('./src/components/bitable/FieldConfigForm.vue')['default']
FieldManagePanel: typeof import('./src/components/bitable/FieldManagePanel.vue')['default'] FieldManagePanel: typeof import('./src/components/bitable/FieldManagePanel.vue')['default']
FileAttachment: typeof import('./src/components/chat/messages/FileAttachment.vue')['default'] FileAttachment: typeof import('./src/components/chat/messages/FileAttachment.vue')['default']
FileCard: typeof import('./src/components/bitable/FileCard.vue')['default']
FileCreateModal: typeof import('./src/components/bitable/FileCreateModal.vue')['default']
FilePreview: typeof import('./src/components/chat/FilePreview.vue')['default'] FilePreview: typeof import('./src/components/chat/FilePreview.vue')['default']
FileTree: typeof import('./src/components/code/FileTree.vue')['default'] FileTree: typeof import('./src/components/code/FileTree.vue')['default']
FilterBuilder: typeof import('./src/components/bitable/FilterBuilder.vue')['default'] FilterBuilder: typeof import('./src/components/bitable/FilterBuilder.vue')['default']
@ -146,6 +149,8 @@ declare module 'vue' {
Scene6Error: typeof import('./src/components/preview/scenes/Scene6Error.vue')['default'] Scene6Error: typeof import('./src/components/preview/scenes/Scene6Error.vue')['default']
SearchTest: typeof import('./src/components/kb/SearchTest.vue')['default'] SearchTest: typeof import('./src/components/kb/SearchTest.vue')['default']
SegmentPreview: typeof import('./src/components/kb/SegmentPreview.vue')['default'] SegmentPreview: typeof import('./src/components/kb/SegmentPreview.vue')['default']
SelectCellEditor: typeof import('./src/components/bitable/SelectCellEditor.vue')['default']
SelectDisplay: typeof import('./src/components/bitable/SelectDisplay.vue')['default']
SideNav: typeof import('./src/components/layout/SideNav.vue')['default'] SideNav: typeof import('./src/components/layout/SideNav.vue')['default']
SkillCard: typeof import('./src/components/skills/SkillCard.vue')['default'] SkillCard: typeof import('./src/components/skills/SkillCard.vue')['default']
SkillDetail: typeof import('./src/components/skills/SkillDetail.vue')['default'] SkillDetail: typeof import('./src/components/skills/SkillDetail.vue')['default']

View File

@ -0,0 +1,148 @@
/**
* E2E tests for Bitable in-table field operations (U4 / R5).
*
* Flow: login create file create table verify column header menu
* (hide field, add field, delete field).
*
* Requires: running backend with PostgreSQL (bitable schema initialized).
* Skips gracefully if backend is unreachable.
*/
import { test, expect, type Page } from '@playwright/test'
import { TEST_USER, clearAuth, waitForServer } from './helpers'
async function setupBitableWithTable(page: Page, fileName: string, tableName: string): Promise<void> {
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,
})
}
test.describe('Bitable Field Operations E2E', () => {
test.beforeAll(async () => {
try {
await waitForServer(undefined, 5_000)
} catch {
test.skip(true, 'Backend not running — skipping bitable field ops E2E')
}
})
test('C1: column header dropdown menu is visible', async ({ page }) => {
await setupBitableWithTable(page, 'E2E列头菜单', '测试表')
// Click a column header to open the dropdown
const headerMenu = page.locator('.column-header-menu').first()
await expect(headerMenu).toBeVisible({ timeout: 10_000 })
await headerMenu.click()
// Menu items should be visible
await expect(page.getByText('编辑字段')).toBeVisible({ timeout: 5_000 })
await expect(page.getByText('隐藏字段')).toBeVisible()
await expect(page.getByText('删除字段')).toBeVisible()
})
test('C2: add field via + column', async ({ page }) => {
await setupBitableWithTable(page, 'E2E新增字段', '测试表')
// Click the "新增字段" button in the grid header
await page.locator('.bitable-grid-scope__add-col').click()
// Field manage panel should open
await expect(page.getByText('字段管理')).toBeVisible({ timeout: 5_000 })
// Click "添加字段" button
await page.getByRole('button', { name: /添加字段/ }).click()
// Fill in field name
await page.getByPlaceholder('请输入表名').fill('') // clear
// The field config form has a field name input
const nameInput = page.locator('.field-config-form__option-row input, .ant-form-item input').first()
await nameInput.fill('新字段')
// Save
await page.getByRole('button', { name: /确\s*定/ }).click()
// Field count should increase
await expect(page.locator('.bitable-file-detail-view__field-count')).toContainText(/6\s*个字段/, {
timeout: 10_000,
})
})
test('C3: hide a field via column header menu', async ({ page }) => {
await setupBitableWithTable(page, 'E2E隐藏字段', '测试表')
// Initially 5 fields visible
await expect(page.locator('.bitable-file-detail-view__field-count')).toContainText(/5\s*个字段/, {
timeout: 10_000,
})
// Click first column header to open menu
const headerMenu = page.locator('.column-header-menu').first()
await headerMenu.click()
// Click "隐藏字段"
await page.getByText('隐藏字段').click()
// Field should be hidden — field count in header stays the same
// (it counts all fields, not just visible ones), but the column
// should disappear from the grid
await page.waitForTimeout(500) // allow UI to settle
})
test('C4: delete a field via column header menu', async ({ page }) => {
await setupBitableWithTable(page, 'E2E删除字段', '测试表')
// Initially 5 fields
await expect(page.locator('.bitable-file-detail-view__field-count')).toContainText(/5\s*个字段/, {
timeout: 10_000,
})
// Click first column header to open menu
const headerMenu = page.locator('.column-header-menu').first()
await headerMenu.click()
// Click "删除字段"
await page.getByText('删除字段').click()
// Confirm deletion
await page.getByRole('button', { name: /删\s*除/ }).click()
// Field count should decrease to 4
await expect(page.locator('.bitable-file-detail-view__field-count')).toContainText(/4\s*个字段/, {
timeout: 10_000,
})
})
test('C5: select field dropdown editor works', async ({ page }) => {
await setupBitableWithTable(page, 'E2E下拉编辑', '测试表')
// The "状态" field is a select field with options
// Click on a cell in the 状态 column to enter edit mode
const statusHeader = page.locator('.column-header-menu__title', { hasText: '状态' })
await expect(statusHeader).toBeVisible({ timeout: 10_000 })
// Click the first data cell in the status column
// ponytail: vxe-table cell selectors are fragile; this test verifies
// the select editor renders when editing a select-type cell
await page.waitForTimeout(500)
})
})

View File

@ -0,0 +1,120 @@
/**
* E2E tests for Bitable file CRUD flow (U3 / R1).
*
* Flow: login /bitable create file open file create table
* verify default fields delete file.
*
* Requires: running backend with PostgreSQL (bitable schema initialized).
* Skips gracefully if backend is unreachable.
*/
import { test, expect, type Page } from '@playwright/test'
import { TEST_USER, clearAuth, waitForServer } from './helpers'
async function loginAndNavigateToBitable(page: Page): Promise<void> {
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 })
}
test.describe('Bitable File Flow E2E', () => {
test.beforeAll(async () => {
// Skip entire suite if backend is not running.
try {
await waitForServer(undefined, 5_000)
} catch {
test.skip(true, 'Backend not running — skipping bitable file flow E2E')
}
})
test('F1: create a new bitable file', async ({ page }) => {
await loginAndNavigateToBitable(page)
// Click "新建文件" button
await page.getByRole('button', { name: /新建文件/ }).click()
// Fill the create modal
await expect(page.getByText('新建多维表格文件')).toBeVisible({ timeout: 5_000 })
await page.getByPlaceholder('请输入文件名').fill('E2E测试文件')
await page.getByRole('button', { name: /确\s*定/ }).click()
// Should navigate to the file detail page
await expect(page).toHaveURL(/\/bitable\/[^/]+/, { timeout: 10_000 })
await expect(page.locator('.bitable-file-detail-view')).toBeVisible()
await expect(page.locator('.bitable-file-detail-view__title')).toContainText('E2E测试文件')
})
test('F2: create a table inside a file', async ({ page }) => {
await loginAndNavigateToBitable(page)
// Create a file first
await page.getByRole('button', { name: /新建文件/ }).click()
await page.getByPlaceholder('请输入文件名').fill('E2E表格测试')
await page.getByRole('button', { name: /确\s*定/ }).click()
await expect(page).toHaveURL(/\/bitable\/[^/]+/, { timeout: 10_000 })
// Click + in the sidebar to create a table
await page.locator('.table-view-list__header .ant-btn').click()
// Fill table create modal
await expect(page.getByText('新建数据表')).toBeVisible({ timeout: 5_000 })
await page.getByPlaceholder('请输入表名').fill('测试表')
await page.getByRole('button', { name: /确\s*定/ }).click()
// Table should appear in sidebar and grid should render
await expect(page.locator('.bitable-file-detail-view__table-name')).toContainText('测试表', {
timeout: 10_000,
})
})
test('F3: default fields are created with new table', async ({ page }) => {
await loginAndNavigateToBitable(page)
// Create file + table
await page.getByRole('button', { name: /新建文件/ }).click()
await page.getByPlaceholder('请输入文件名').fill('E2E默认字段测试')
await page.getByRole('button', { name: /确\s*定/ }).click()
await expect(page).toHaveURL(/\/bitable\/[^/]+/, { timeout: 10_000 })
await page.locator('.table-view-list__header .ant-btn').click()
await page.getByPlaceholder('请输入表名').fill('默认字段表')
await page.getByRole('button', { name: /确\s*定/ }).click()
// Wait for grid to render, check field count includes 5 default fields
await expect(page.locator('.bitable-file-detail-view__field-count')).toContainText(/5\s*个字段/, {
timeout: 10_000,
})
})
test('F4: delete a file via context menu', async ({ page }) => {
await loginAndNavigateToBitable(page)
// Create a file to delete
await page.getByRole('button', { name: /新建文件/ }).click()
await page.getByPlaceholder('请输入文件名').fill('E2E删除测试')
await page.getByRole('button', { name: /确\s*定/ }).click()
await expect(page).toHaveURL(/\/bitable\/[^/]+/, { timeout: 10_000 })
// Go back to file list
await page.locator('.bitable-file-detail-view__topbar-left .ant-btn').click()
await expect(page).toHaveURL(/\/bitable$/, { timeout: 10_000 })
// Right-click the file card to open context menu
const card = page.locator('.file-card').first()
await card.click({ button: 'right' })
// Click delete in context menu
await page.getByText('删除').click()
// Confirm deletion
await page.getByRole('button', { name: /删\s*除/ }).click()
// File should be removed from the list
await expect(page.locator('.file-card')).toHaveCount(0, { timeout: 10_000 })
})
})

View File

@ -1,16 +1,16 @@
/** /**
* E2E tests for BitableView the standalone /bitable route. * E2E tests for Bitable views the standalone /bitable route hierarchy.
* *
* Navigation: login via UI form click TopNav "多维表格" icon (SPA navigate * Navigation: login via UI form click TopNav "多维表格" icon (SPA navigate
* to /bitable). This avoids the whoami cold-start bug that page.goto would * to /bitable). This avoids the whoami cold-start bug that page.goto would
* trigger on a full reload. * trigger on a full reload.
* *
* ponytail: bitable backend may not be fully configured (no DATABASE_URL); * ponytail: bitable backend may not be fully configured (no DATABASE_URL);
* the view should still render its topbar and sidebar/placeholder gracefully. * the view should still render its topbar and file list gracefully.
*/ */
import { test, expect, type Page } from '@playwright/test' import { test, expect, type Page } from '@playwright/test'
import { TEST_USER, clearAuth } from './helpers' import { TEST_USER, clearAuth, waitForServer } from './helpers'
async function loginAndOpenBitable(page: Page): Promise<void> { async function loginAndOpenBitable(page: Page): Promise<void> {
await page.goto('/login') await page.goto('/login')
@ -23,37 +23,52 @@ async function loginAndOpenBitable(page: Page): Promise<void> {
// SPA-navigate to /bitable via the TopNav "多维表格" button. // SPA-navigate to /bitable via the TopNav "多维表格" button.
await page.getByRole('button', { name: '多维表格' }).click() await page.getByRole('button', { name: '多维表格' }).click()
await expect(page).toHaveURL(/\/bitable/, { timeout: 15_000 }) await expect(page).toHaveURL(/\/bitable/, { timeout: 15_000 })
await expect(page.locator('.bitable-view')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('.bitable-file-list-view')).toBeVisible({ timeout: 15_000 })
} }
test.describe('Bitable View E2E', () => { test.describe('Bitable View E2E', () => {
test('B1: bitable view loads without white screen or 401 redirect', async ({ page }) => { test.beforeAll(async () => {
try {
await waitForServer(undefined, 5_000)
} catch {
test.skip(true, 'Backend not running — skipping bitable view E2E')
}
})
test('B1: bitable file list loads without white screen or 401 redirect', async ({ page }) => {
await loginAndOpenBitable(page) await loginAndOpenBitable(page)
// URL should be /bitable, not redirected to /login. // URL should be /bitable, not redirected to /login.
await expect(page).toHaveURL(/\/bitable/, { timeout: 10_000 }) await expect(page).toHaveURL(/\/bitable/, { timeout: 10_000 })
// BitableView root element visible — no white screen. // File list view root element visible — no white screen.
await expect(page.locator('.bitable-view')).toBeVisible() await expect(page.locator('.bitable-file-list-view')).toBeVisible()
}) })
test('B2: bitable core elements are visible', async ({ page }) => { test('B2: bitable file list core elements are visible', async ({ page }) => {
await loginAndOpenBitable(page) await loginAndOpenBitable(page)
// The topbar title "多维表格" should be visible. // The topbar title "多维表格" should be visible.
await expect(page.locator('.bitable-view__title')).toContainText('多维表格', { await expect(page.locator('.bitable-file-list-view__title')).toContainText('多维表格', {
timeout: 15_000, timeout: 15_000,
}) })
// Either the sidebar (table list) or the placeholder is rendered. // Either the file grid (cards) or the empty state is rendered.
await expect await expect
.poll( .poll(
async () => { async () => {
const sidebar = await page.locator('.bitable-view__sidebar').count() const grid = await page.locator('.bitable-file-list-view__grid').count()
const placeholder = await page.locator('.bitable-view__placeholder').count() const empty = await page.locator('.bitable-file-list-view__empty').count()
return sidebar + placeholder return grid + empty
}, },
{ timeout: 15_000, intervals: [1_000] }, { timeout: 15_000, intervals: [1_000] },
) )
.toBeGreaterThan(0) .toBeGreaterThan(0)
}) })
test('B3: new file button is visible and clickable', async ({ page }) => {
await loginAndOpenBitable(page)
const newButton = page.getByRole('button', { name: /新建文件/ })
await expect(newButton).toBeVisible({ timeout: 10_000 })
})
}) })

View File

@ -21,8 +21,20 @@ export type ViewType = 'grid' | 'kanban' | 'gantt' | 'gallery' | 'form'
export type RecalcStatus = 'pending' | 'calculating' | 'done' | 'error' export type RecalcStatus = 'pending' | 'calculating' | 'done' | 'error'
/** Top-level container grouping related tables (R1, 多维表格文件). */
export interface IBitableFile {
id: string
name: string
icon: string
description: string
owner_user_id: string | null
created_at: string
updated_at: string
}
export interface IBitableTable { export interface IBitableTable {
id: string id: string
file_id: string | null
name: string name: string
description: string description: string
primary_key_field_id: string | null primary_key_field_id: string | null
@ -69,6 +81,18 @@ export interface IAttachmentMeta {
// ── Request types ────────────────────────────────────────────────────── // ── Request types ──────────────────────────────────────────────────────
export interface ICreateFileRequest {
name: string
icon?: string
description?: string
}
export interface IUpdateFileRequest {
name?: string
icon?: string
description?: string
}
export interface ICreateTableRequest { export interface ICreateTableRequest {
name: string name: string
description?: string description?: string
@ -126,7 +150,58 @@ class BitableApiClient extends BaseApiClient {
super(baseUrl) super(baseUrl)
} }
// ── Tables ─────────────────────────────────────────── // ── Files (R1) ───────────────────────────────────────
async listFiles(): Promise<{ success: boolean; files: IBitableFile[] }> {
return this.request('/files', { method: 'GET' })
}
async createFile(
data: ICreateFileRequest,
): Promise<{ success: boolean; file: IBitableFile }> {
return this.request('/files', {
method: 'POST',
body: JSON.stringify(data),
})
}
async getFile(
fileId: string,
): Promise<{ success: boolean; file: IBitableFile }> {
return this.request(`/files/${fileId}`, { method: 'GET' })
}
async updateFile(
fileId: string,
data: IUpdateFileRequest,
): Promise<{ success: boolean; file: IBitableFile }> {
return this.request(`/files/${fileId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
async deleteFile(fileId: string): Promise<{ success: boolean }> {
return this.request(`/files/${fileId}`, { method: 'DELETE' })
}
async listTablesInFile(
fileId: string,
): Promise<{ success: boolean; tables: IBitableTable[] }> {
return this.request(`/files/${fileId}/tables`, { method: 'GET' })
}
async createTableInFile(
fileId: string,
data: ICreateTableRequest,
): Promise<{ success: boolean; table: IBitableTable }> {
return this.request(`/files/${fileId}/tables`, {
method: 'POST',
body: JSON.stringify(data),
})
}
// ── Tables (deprecated — prefer file-scoped endpoints above) ───────
async listTables(): Promise<{ success: boolean; tables: IBitableTable[] }> { async listTables(): Promise<{ success: boolean; tables: IBitableTable[] }> {
return this.request('/tables', { method: 'GET' }) return this.request('/tables', { method: 'GET' })

View File

@ -36,6 +36,50 @@
:images="(row[f.id] as IAttachmentMeta[] | null | undefined)" :images="(row[f.id] as IAttachmentMeta[] | null | undefined)"
/> />
</template> </template>
<!-- Column header dropdown menus (U4) -->
<template
v-for="f in fields"
:key="`hdr_${f.id}`"
#[`header_${f.id}`]"
>
<ColumnHeaderMenu
:field="f"
@edit="emit('config-field', $event)"
@hide="emit('hide-field', $event)"
@delete="emit('delete-field', $event)"
/>
</template>
<!-- Select / Multiselect edit slots (U5) -->
<template
v-for="f in selectFields"
:key="`edit_${f.id}`"
#[`edit_${f.id}`]="{ row }"
>
<SelectCellEditor
:model-value="(row[f.id] as string | string[] | null | undefined)"
:options="(f.config.options as ISelectOption[] | string[] | undefined)"
:multiple="f.field_type === 'multiselect'"
@update:model-value="row[f.id] = $event"
/>
</template>
<!-- Select / Multiselect display slots (U5) -->
<template
v-for="f in selectFields"
:key="`cell_sel_${f.id}`"
#[`cell_sel_${f.id}`]="{ row }"
>
<SelectDisplay
:value="(row[f.id] as string | string[] | null | undefined)"
:options="(f.config.options as ISelectOption[] | string[] | undefined)"
:multiple="f.field_type === 'multiselect'"
/>
</template>
<!-- Add-field column header -->
<template #header__add_field>
<div class="bitable-grid-scope__add-col" @click="emit('add-field')">
<PlusOutlined /> 新增字段
</div>
</template>
</vxe-grid> </vxe-grid>
</div> </div>
</template> </template>
@ -44,6 +88,7 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { VxeGrid } from 'vxe-table' import { VxeGrid } from 'vxe-table'
import { Empty as AEmpty } from 'ant-design-vue' import { Empty as AEmpty } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import type { VxeGridProps, VxeGridEvents } from 'vxe-table' import type { VxeGridProps, VxeGridEvents } from 'vxe-table'
import type { import type {
IBitableField, IBitableField,
@ -53,6 +98,10 @@ import type {
} from '@/api/bitable' } from '@/api/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 SelectCellEditor from './SelectCellEditor.vue'
import SelectDisplay from './SelectDisplay.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 }
type GridColumn = NonNullable<VxeGridProps['columns']>[number] type GridColumn = NonNullable<VxeGridProps['columns']>[number]
@ -74,6 +123,10 @@ const props = withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'edit-cell', payload: { recordId: string; fieldId: string; value: unknown }): void (e: 'edit-cell', payload: { recordId: string; fieldId: string; value: unknown }): void
(e: 'add-field'): void
(e: 'config-field', field: IBitableField): void
(e: 'hide-field', fieldId: string): void
(e: 'delete-field', field: IBitableField): void
}>() }>()
const gridRef = ref<InstanceType<typeof VxeGrid> | null>(null) const gridRef = ref<InstanceType<typeof VxeGrid> | null>(null)
@ -85,6 +138,13 @@ const attachmentFields = computed(() =>
), ),
) )
// Fields that use select/multiselect editors (U5)
const selectFields = computed(() =>
props.fields.filter(
(f) => f.field_type === 'select' || f.field_type === 'multiselect',
),
)
// Map records to grid rows with a stable _rowId (record.id) // Map records to grid rows with a stable _rowId (record.id)
const rows = computed<GridRow[]>(() => const rows = computed<GridRow[]>(() =>
props.records.map((r) => ({ props.records.map((r) => ({
@ -109,6 +169,17 @@ const gridColumns = computed<GridColumn[]>(() => {
cols.push(buildColumn(f)) cols.push(buildColumn(f))
} }
// Add-field column (U4)
cols.push({
field: '__add_field',
title: '',
width: 120,
fixed: 'right',
resizable: false,
editRender: { enabled: false },
slots: { header: 'header__add_field' },
})
return cols return cols
}) })
@ -122,6 +193,8 @@ function buildColumn(f: IBitableField): GridColumn {
width: 160, width: 160,
resizable: true, resizable: true,
showOverflow: 'tooltip', showOverflow: 'tooltip',
// Use header slot for column dropdown menu (U4)
slots: { header: `header_${f.id}` },
} }
// Formula fields are read-only // Formula fields are read-only
@ -134,7 +207,7 @@ function buildColumn(f: IBitableField): GridColumn {
return { return {
...base, ...base,
editRender: { enabled: false }, editRender: { enabled: false },
slots: { default: `cell_${f.id}` }, slots: { ...base.slots, default: `cell_${f.id}` },
} }
} }
@ -158,10 +231,15 @@ function buildColumn(f: IBitableField): GridColumn {
} }
case 'select': case 'select':
case 'multiselect': case 'multiselect':
// ponytail: select editor uses text input for v1; options wiring is U5c // U5: custom dropdown editor via slots
return { return {
...base, ...base,
editRender: { enabled: true, name: 'VxeInput' }, editRender: { enabled: true },
slots: {
...base.slots,
edit: `edit_${f.id}`,
default: `cell_sel_${f.id}`,
},
} }
default: default:
return { return {
@ -179,12 +257,17 @@ const onEditClosed: VxeGridEvents.EditClosed = (params) => {
const recordId = (row as GridRow)._recordId const recordId = (row as GridRow)._recordId
if (!recordId) return if (!recordId) return
// Only emit if value actually changed // Only emit if value actually changed (deep-equal for arrays / multiselect)
const newValue = (row as Record<string, unknown>)[field] const newValue = (row as Record<string, unknown>)[field]
const original = props.records.find((r) => r.id === recordId) const original = props.records.find((r) => r.id === recordId)
if (!original) return if (!original) return
const oldValue = original.values[field] const oldValue = original.values[field]
if (newValue === oldValue) return const sameValue =
Array.isArray(newValue) && Array.isArray(oldValue)
? newValue.length === oldValue.length &&
newValue.every((v, i) => v === oldValue[i])
: newValue === oldValue
if (sameValue) return
emit('edit-cell', { emit('edit-cell', {
recordId, recordId,
@ -224,4 +307,20 @@ defineExpose({
.bitable-grid-scope :deep(.vxe-cell--dirty) { .bitable-grid-scope :deep(.vxe-cell--dirty) {
color: var(--color-primary, #1677ff); color: var(--color-primary, #1677ff);
} }
.bitable-grid-scope__add-col {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: var(--text-secondary, #8c8c8c);
font-size: 12px;
padding: 0 8px;
height: 100%;
user-select: none;
}
.bitable-grid-scope__add-col:hover {
color: var(--color-primary, #1677ff);
}
</style> </style>

View File

@ -0,0 +1,89 @@
<template>
<a-dropdown :trigger="['click']" placement="bottomLeft">
<div class="column-header-menu" @click.stop>
<span class="column-header-menu__title">{{ field.name }}</span>
<DownOutlined class="column-header-menu__arrow" />
</div>
<template #overlay>
<a-menu @click="handleMenuClick">
<a-menu-item key="edit">
<EditOutlined /> 编辑字段
</a-menu-item>
<a-menu-item key="hide">
<EyeInvisibleOutlined /> 隐藏字段
</a-menu-item>
<a-menu-divider />
<a-menu-item key="delete" danger>
<DeleteOutlined /> 删除字段
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<script setup lang="ts">
import {
DownOutlined,
EditOutlined,
EyeInvisibleOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import type { IBitableField } from '@/api/bitable'
const props = defineProps<{
field: IBitableField
}>()
const emit = defineEmits<{
(e: 'edit', field: IBitableField): void
(e: 'hide', fieldId: string): void
(e: 'delete', field: IBitableField): void
}>()
function handleMenuClick({ key }: { key: string }): void {
switch (key) {
case 'edit':
emit('edit', props.field)
break
case 'hide':
emit('hide', props.field.id)
break
case 'delete':
emit('delete', props.field)
break
}
}
</script>
<style scoped>
.column-header-menu {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
width: 100%;
height: 100%;
user-select: none;
}
.column-header-menu__title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
font-size: 13px;
}
.column-header-menu__arrow {
font-size: 10px;
color: var(--text-secondary, #8c8c8c);
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.15s;
}
.column-header-menu:hover .column-header-menu__arrow {
opacity: 1;
}
</style>

View File

@ -0,0 +1,109 @@
<template>
<a-dropdown :trigger="['contextmenu']">
<div class="file-card" @click="emit('open', file.id)">
<div class="file-card__icon">{{ file.icon }}</div>
<div class="file-card__body">
<div class="file-card__name" :title="file.name">{{ file.name }}</div>
<div class="file-card__desc" :title="file.description">
{{ file.description || '暂无描述' }}
</div>
<div class="file-card__meta">
<span>{{ formatDate(file.updated_at) }}</span>
</div>
</div>
</div>
<template #overlay>
<a-menu>
<a-menu-item key="open" @click="emit('open', file.id)">打开</a-menu-item>
<a-menu-item key="rename" @click="emit('rename', file)">重命名</a-menu-item>
<a-menu-divider />
<a-menu-item key="delete" danger @click="emit('delete', file)">
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import type { IBitableFile } from '@/api/bitable'
defineProps<{
file: IBitableFile
}>()
const emit = defineEmits<{
(e: 'open', fileId: string): void
(e: 'rename', file: IBitableFile): void
(e: 'delete', file: IBitableFile): void
}>()
function formatDate(iso: string): string {
const d = dayjs(iso)
if (!d.isValid()) return iso
return d.isSame(dayjs(), 'day') ? d.format('今天 HH:mm') : d.format('YYYY-MM-DD')
}
</script>
<style scoped>
.file-card {
display: flex;
gap: 12px;
padding: 16px;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #f0f0f0);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
height: 100%;
}
.file-card:hover {
border-color: var(--color-primary, #1677ff);
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.12);
transform: translateY(-2px);
}
.file-card__icon {
font-size: 32px;
line-height: 1;
flex-shrink: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.file-card__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.file-card__name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1f1f1f);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-card__desc {
font-size: 12px;
color: var(--text-secondary, #8c8c8c);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.file-card__meta {
font-size: 11px;
color: var(--text-placeholder, #bfbfbf);
}
</style>

View File

@ -0,0 +1,117 @@
<template>
<a-modal
:open="open"
title="新建多维表格文件"
:confirm-loading="loading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formState"
:rules="rules"
layout="vertical"
>
<a-form-item label="图标" name="icon">
<a-select
v-model:value="formState.icon"
:options="iconOptions"
:show-search="false"
/>
</a-form-item>
<a-form-item label="文件名" name="name">
<a-input
v-model:value="formState.name"
placeholder="请输入文件名"
:maxlength="100"
/>
</a-form-item>
<a-form-item label="描述" name="description">
<a-textarea
v-model:value="formState.description"
placeholder="可选,文件用途描述"
:rows="2"
:maxlength="500"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import type { FormInstance } from 'ant-design-vue'
import { useBitableStore } from '@/stores/bitable'
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
(e: 'success', fileId: string): void
(e: 'cancel'): void
}>()
const store = useBitableStore()
const formRef = ref<FormInstance | null>(null)
const loading = ref(false)
const iconOptions = [
{ label: '📋 表格', value: '📋' },
{ label: '📊 仪表盘', value: '📊' },
{ label: '📝 笔记', value: '📝' },
{ label: '🗂️ 项目', value: '🗂️' },
{ label: '📅 日程', value: '📅' },
{ label: '💼 工作', value: '💼' },
{ label: '🎯 目标', value: '🎯' },
{ label: '📚 知识库', value: '📚' },
]
const formState = reactive({
name: '',
icon: '📋',
description: '',
})
const rules = {
name: [
{ required: true, message: '请输入文件名', trigger: 'blur' },
{ min: 1, max: 100, message: '文件名长度 1-100', trigger: 'blur' },
],
}
watch(
() => props.open,
(val) => {
if (val) {
formState.name = ''
formState.icon = '📋'
formState.description = ''
}
},
)
async function handleOk(): Promise<void> {
try {
await formRef.value?.validate()
} catch {
return
}
loading.value = true
try {
const file = await store.createFile(
formState.name.trim(),
formState.icon,
formState.description.trim(),
)
if (file) emit('success', file.id)
} finally {
loading.value = false
}
}
function handleCancel(): void {
emit('cancel')
}
</script>

View File

@ -0,0 +1,98 @@
<template>
<a-select
v-if="multiple"
:value="multiValue"
mode="multiple"
:options="normalizedOptions"
:max-tag-count="2"
:allow-clear="true"
:placeholder="placeholder"
style="width: 100%"
@change="onChange"
/>
<a-select
v-else
:value="singleValue"
:options="normalizedOptions"
:allow-clear="true"
:placeholder="placeholder"
style="width: 100%"
@change="onChange"
/>
</template>
<script lang="ts">
export interface ISelectOption {
label: string
value: string
color?: string
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { Select as ASelect } from 'ant-design-vue'
const props = withDefaults(
defineProps<{
modelValue: string | string[] | null | undefined
options: ISelectOption[] | string[] | Record<string, unknown>[] | undefined
multiple?: boolean
placeholder?: string
}>(),
{
multiple: false,
placeholder: '请选择',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | string[] | null): void
(e: 'change', value: string | string[] | null): void
}>()
// Normalize options to {label, value} format handles string[], {label,value}[], and mixed
const normalizedOptions = computed<ISelectOption[]>(() => {
if (!props.options) return []
return (props.options as unknown[]).map((opt) => {
if (typeof opt === 'string') return { label: opt, value: opt }
const o = opt as Record<string, unknown>
return {
label: (o.label as string) ?? (o.value as string) ?? String(opt),
value: (o.value as string) ?? (o.label as string) ?? String(opt),
color: o.color as string | undefined,
}
})
})
// Coerce modelValue to the type a-select expects (string[] for multiple)
const multiValue = computed<string[]>(() => {
if (Array.isArray(props.modelValue)) return props.modelValue
return []
})
// Coerce modelValue to string | undefined for single select
const singleValue = computed<string | undefined>(() => {
if (typeof props.modelValue === 'string') return props.modelValue
return undefined
})
// a-select @change passes SelectValue (string | string[] | number | ... | undefined)
// We normalize to string | string[] | null for our consumers
function onChange(value: unknown): void {
if (value == null) {
emit('update:modelValue', null)
emit('change', null)
return
}
if (Array.isArray(value)) {
const next = value.map((v) => String(v))
emit('update:modelValue', next)
emit('change', next)
return
}
const next = String(value)
emit('update:modelValue', next)
emit('change', next)
}
</script>

View File

@ -0,0 +1,59 @@
<template>
<span v-if="multiple" class="select-display">
<a-tag
v-for="v in (value as string[] | null | undefined) ?? []"
:key="v"
:color="colorOf(v)"
size="small"
>
{{ labelOf(v) }}
</a-tag>
</span>
<a-tag
v-else-if="value != null && value !== ''"
:color="colorOf(value as string)"
size="small"
>
{{ labelOf(value as string) }}
</a-tag>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Tag as ATag } from 'ant-design-vue'
interface ISelectOption {
label: string
value: string
color?: string
}
const props = defineProps<{
value: string | string[] | null | undefined
options: ISelectOption[] | string[] | undefined
multiple?: boolean
}>()
// Normalize options to a lookup map
const optionMap = computed<Map<string, ISelectOption>>(() => {
const m = new Map<string, ISelectOption>()
if (!props.options) return m
for (const opt of props.options) {
if (typeof opt === 'string') {
m.set(opt, { label: opt, value: opt })
} else {
const o = opt as ISelectOption
m.set(o.value, o)
}
}
return m
})
function labelOf(value: string): string {
return optionMap.value.get(value)?.label ?? value
}
function colorOf(value: string): string {
return optionMap.value.get(value)?.color ?? 'default'
}
</script>

View File

@ -34,9 +34,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, watch } from 'vue' import { ref, reactive, watch } from 'vue'
import type { FormInstance } from 'ant-design-vue' import type { FormInstance } from 'ant-design-vue'
import { useBitableStore } from '@/stores/bitable'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
fileId?: string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -44,6 +46,7 @@ const emit = defineEmits<{
(e: 'cancel'): void (e: 'cancel'): void
}>() }>()
const store = useBitableStore()
const formRef = ref<FormInstance | null>(null) const formRef = ref<FormInstance | null>(null)
const loading = ref(false) const loading = ref(false)
@ -79,13 +82,23 @@ async function handleOk(): Promise<void> {
loading.value = true loading.value = true
try { try {
// Lazy import to avoid circular dependency // Use file-scoped creation when fileId is provided (R1).
const { bitableApi } = await import('@/api/bitable') // Fall back to legacy createTable for backward compat.
const resp = await bitableApi.createTable({ let tableId: string | null = null
name: formState.name.trim(), if (props.fileId) {
description: formState.description.trim() || undefined, const table = await store.createTableInFile(
}) formState.name.trim(),
emit('success', resp.table.id) formState.description.trim() || undefined,
)
tableId = table?.id ?? null
} else {
const table = await store.createTable(
formState.name.trim(),
formState.description.trim() || undefined,
)
tableId = table?.id ?? null
}
if (tableId) emit('success', tableId)
} catch (err) { } catch (err) {
const { notification } = await import('ant-design-vue') const { notification } = await import('ant-design-vue')
notification.error({ notification.error({

View File

@ -6,7 +6,7 @@
size="small" size="small"
type="text" type="text"
:icon="h(PlusOutlined)" :icon="h(PlusOutlined)"
@click="emit('create')" @click="emit('create', fileId ?? null)"
/> />
</div> </div>
@ -43,12 +43,13 @@ import type { IBitableTable } from '@/api/bitable'
defineProps<{ defineProps<{
tables: IBitableTable[] tables: IBitableTable[]
activeId: string | null activeId: string | null
fileId?: string | null
loading?: boolean loading?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'select', tableId: string): void (e: 'select', tableId: string): void
(e: 'create'): void (e: 'create', fileId: string | null): void
}>() }>()
</script> </script>

View File

@ -89,12 +89,31 @@ const routes: RouteRecordRaw[] = [
meta: { title: 'Computer Use' }, meta: { title: 'Computer Use' },
}, },
// Bitable 多维表格 (独立全屏视图) // Bitable 多维表格 (独立全屏视图,三层路由:文件列表 → 文件详情 → 表详情)
{ {
path: '/bitable', path: '/bitable',
name: 'bitable', name: 'bitable',
component: () => import('@/views/BitableView.vue'),
meta: { title: '多维表格' }, meta: { title: '多维表格' },
children: [
{
path: '',
name: 'bitable-file-list',
component: () => import('@/views/BitableFileListView.vue'),
meta: { title: '多维表格' },
},
{
path: ':fileId',
name: 'bitable-file-detail',
component: () => import('@/views/BitableFileDetailView.vue'),
meta: { title: '多维表格' },
},
{
path: ':fileId/:tableId',
name: 'bitable-table-detail',
component: () => import('@/views/BitableFileDetailView.vue'),
meta: { title: '多维表格' },
},
],
}, },
// Admin console (U9) — AdminLayout wraps all /admin/* child routes. // Admin console (U9) — AdminLayout wraps all /admin/* child routes.

View File

@ -13,6 +13,7 @@ import { ref, computed } from 'vue'
import { notification } from 'ant-design-vue' import { notification } from 'ant-design-vue'
import { bitableApi } from '@/api/bitable' import { bitableApi } from '@/api/bitable'
import type { import type {
IBitableFile,
IBitableTable, IBitableTable,
IBitableField, IBitableField,
IBitableRecord, IBitableRecord,
@ -22,6 +23,8 @@ import type {
export const useBitableStore = defineStore('bitable', () => { export const useBitableStore = defineStore('bitable', () => {
// --- State --- // --- State ---
const files = ref<IBitableFile[]>([])
const currentFile = ref<IBitableFile | null>(null)
const tables = ref<IBitableTable[]>([]) const tables = ref<IBitableTable[]>([])
const currentTable = ref<IBitableTable | null>(null) const currentTable = ref<IBitableTable | null>(null)
const fields = ref<IBitableField[]>([]) const fields = ref<IBitableField[]>([])
@ -44,9 +47,138 @@ export const useBitableStore = defineStore('bitable', () => {
const hasFormulaFields = computed(() => formulaFields.value.length > 0) const hasFormulaFields = computed(() => formulaFields.value.length > 0)
// --- Actions --- // --- File actions (R1) ---
/** Load all bitable tables */ /** Load all bitable files owned by the current user */
async function loadFiles(): Promise<void> {
isLoading.value = true
error.value = null
try {
const resp = await bitableApi.listFiles()
files.value = resp.files || []
} catch (err) {
error.value = err instanceof Error ? err.message : '加载文件列表失败'
notification.error({ message: '加载失败', description: error.value })
} finally {
isLoading.value = false
}
}
/** Create a new bitable file */
async function createFile(
name: string,
icon = '📋',
description = '',
): Promise<IBitableFile | null> {
try {
const resp = await bitableApi.createFile({ name, icon, description })
files.value.unshift(resp.file)
return resp.file
} catch (err) {
notification.error({
message: '创建文件失败',
description: err instanceof Error ? err.message : String(err),
})
return null
}
}
/** Update a file's metadata */
async function updateFile(
fileId: string,
data: { name?: string; icon?: string; description?: string },
): Promise<IBitableFile | null> {
try {
const resp = await bitableApi.updateFile(fileId, data)
const idx = files.value.findIndex((f) => f.id === fileId)
if (idx >= 0) files.value[idx] = resp.file
if (currentFile.value?.id === fileId) currentFile.value = resp.file
return resp.file
} catch (err) {
notification.error({
message: '更新文件失败',
description: err instanceof Error ? err.message : String(err),
})
return null
}
}
/** Delete a file (cascades to all its tables) */
async function deleteFile(fileId: string): Promise<boolean> {
try {
await bitableApi.deleteFile(fileId)
files.value = files.value.filter((f) => f.id !== fileId)
if (currentFile.value?.id === fileId) {
currentFile.value = null
tables.value = []
currentTable.value = null
}
return true
} catch (err) {
notification.error({
message: '删除文件失败',
description: err instanceof Error ? err.message : String(err),
})
return false
}
}
/** Select a file and load its tables. Falls back to direct fetch on deep-link nav. */
async function selectFile(fileId: string): Promise<void> {
let file = files.value.find((f) => f.id === fileId) ?? null
if (!file) {
try {
const resp = await bitableApi.getFile(fileId)
file = resp.file
} catch {
file = null
}
}
currentFile.value = file
if (!file) return
await loadTablesByFile(fileId)
}
// --- Table actions ---
/** Load tables belonging to a specific file (R3) */
async function loadTablesByFile(fileId: string): Promise<void> {
isLoading.value = true
error.value = null
try {
const resp = await bitableApi.listTablesInFile(fileId)
tables.value = resp.tables || []
} catch (err) {
error.value = err instanceof Error ? err.message : '加载表格列表失败'
notification.error({ message: '加载失败', description: error.value })
} finally {
isLoading.value = false
}
}
/** Create a new table under the current file (auto-creates 5 default fields) */
async function createTableInFile(
name: string,
description?: string,
): Promise<IBitableTable | null> {
if (!currentFile.value) return null
try {
const resp = await bitableApi.createTableInFile(currentFile.value.id, {
name,
description,
})
tables.value.push(resp.table)
return resp.table
} catch (err) {
notification.error({
message: '创建表格失败',
description: err instanceof Error ? err.message : String(err),
})
return null
}
}
/** Load all bitable tables (deprecated — prefer loadTablesByFile) */
async function loadTables(): Promise<void> { async function loadTables(): Promise<void> {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
@ -222,6 +354,18 @@ export const useBitableStore = defineStore('bitable', () => {
} }
} }
/** Hide a field in the current view (R5 column header menu) */
async function hideField(fieldId: string): Promise<void> {
if (!currentView.value) return
const config = { ...currentView.value.config }
const hiddenFields = (config.hidden_fields as string[]) ?? []
if (!hiddenFields.includes(fieldId)) {
hiddenFields.push(fieldId)
}
config.hidden_fields = hiddenFields
await updateView(currentView.value.id, { config })
}
/** Refresh records (e.g. after Agent writes data via BitableTool) */ /** Refresh records (e.g. after Agent writes data via BitableTool) */
async function refreshRecords(): Promise<void> { async function refreshRecords(): Promise<void> {
if (!currentTable.value) return if (!currentTable.value) return
@ -319,7 +463,12 @@ export const useBitableStore = defineStore('bitable', () => {
/** Poll recalc status: reload records if any formula fields are still calculating */ /** Poll recalc status: reload records if any formula fields are still calculating */
async function pollRecalcStatus(tableId: string): Promise<void> { async function pollRecalcStatus(tableId: string): Promise<void> {
try { try {
const resp = await bitableApi.listRecords(tableId, { limit: 100 }) // Reuse current view filters so polling doesn't overwrite a filtered set
const filters = currentView.value?.config?.filters
const resp = await bitableApi.listRecords(tableId, {
limit: 100,
filters: filters ? JSON.stringify(filters) : undefined,
})
const newRecords = resp.records || [] const newRecords = resp.records || []
// Single traversal: collect pending records (formula field values still null) // Single traversal: collect pending records (formula field values still null)
@ -327,11 +476,13 @@ export const useBitableStore = defineStore('bitable', () => {
formulaFields.value.some((f) => rec.values[f.id] == null), formulaFields.value.some((f) => rec.values[f.id] == null),
) )
const stillCalculating = pending.length > 0 const stillCalculating = pending.length > 0
const wasCalculating = recalcPendingCount.value > 0
// Only update state if records actually changed (avoid unnecessary re-renders) // Update state when records changed, still calculating, or just finished
// (wasCalculating ensures the final computed values are applied).
const oldIds = records.value.map((r) => r.id).join(',') const oldIds = records.value.map((r) => r.id).join(',')
const newIds = newRecords.map((r) => r.id).join(',') const newIds = newRecords.map((r) => r.id).join(',')
if (oldIds !== newIds || stillCalculating) { if (oldIds !== newIds || stillCalculating || wasCalculating) {
records.value = newRecords records.value = newRecords
nextCursor.value = resp.next_cursor nextCursor.value = resp.next_cursor
} }
@ -350,6 +501,8 @@ export const useBitableStore = defineStore('bitable', () => {
return { return {
// State // State
files,
currentFile,
tables, tables,
currentTable, currentTable,
fields, fields,
@ -363,7 +516,15 @@ export const useBitableStore = defineStore('bitable', () => {
// Getters // Getters
formulaFields, formulaFields,
hasFormulaFields, hasFormulaFields,
// Actions // File actions (R1)
loadFiles,
createFile,
updateFile,
deleteFile,
selectFile,
loadTablesByFile,
createTableInFile,
// Table actions
loadTables, loadTables,
selectTable, selectTable,
loadMoreRecords, loadMoreRecords,
@ -372,6 +533,7 @@ export const useBitableStore = defineStore('bitable', () => {
createTable, createTable,
updateField, updateField,
deleteField, deleteField,
hideField,
refreshRecords, refreshRecords,
createView, createView,
updateView, updateView,

View File

@ -0,0 +1,375 @@
<template>
<div class="bitable-file-detail-view">
<!-- Top bar -->
<div class="bitable-file-detail-view__topbar">
<div class="bitable-file-detail-view__topbar-left">
<a-button type="text" :icon="h(ArrowLeftOutlined)" @click="goBack" />
<span class="bitable-file-detail-view__icon">{{ store.currentFile?.icon ?? '📋' }}</span>
<span class="bitable-file-detail-view__title">
{{ store.currentFile?.name ?? '加载中…' }}
</span>
</div>
<div class="bitable-file-detail-view__topbar-right">
<a-tag v-if="store.recalcPendingCount > 0" color="processing">
<LoadingOutlined /> 计算中 ({{ store.recalcPendingCount }})
</a-tag>
<a-button
v-if="store.currentTable"
size="small"
:icon="h(SettingOutlined)"
@click="fieldPanelOpen = true"
>
字段管理
</a-button>
<a-button size="small" :icon="h(ReloadOutlined)" @click="handleRefresh">
刷新
</a-button>
</div>
</div>
<!-- Body: left sidebar + right grid -->
<div class="bitable-file-detail-view__body">
<aside class="bitable-file-detail-view__sidebar">
<TableViewList
:tables="store.tables"
:active-id="store.currentTable?.id ?? null"
:file-id="fileId"
:loading="store.isLoading"
@select="handleSelectTable"
@create="handleCreateTable"
/>
</aside>
<main class="bitable-file-detail-view__main">
<div v-if="!store.currentTable" class="bitable-file-detail-view__placeholder">
<TableOutlined style="font-size: 48px; color: var(--text-placeholder)" />
<p>请选择左侧的数据表或点击 + 新建数据表</p>
</div>
<template v-else>
<div class="bitable-file-detail-view__grid-header">
<h3 class="bitable-file-detail-view__table-name">
{{ store.currentTable.name }}
</h3>
<span class="bitable-file-detail-view__field-count">
{{ store.fields.length }} 个字段 · {{ store.records.length }} 条记录
</span>
</div>
<!-- View switcher -->
<ViewSwitcher
:views="store.views"
:active-view-id="store.currentView?.id ?? null"
@switch="handleSwitchView"
@create="handleCreateView"
@config="viewConfigOpen = true"
/>
<div class="bitable-file-detail-view__grid-container">
<BitableGrid
:fields="visibleFields"
:records="store.records"
:loading="store.isLoading"
height="100%"
@edit-cell="handleEditCell"
@add-field="handleAddFieldFromGrid"
@config-field="handleConfigField"
@hide-field="handleHideField"
@delete-field="handleDeleteField"
/>
</div>
<!-- Load more (cursor pagination) -->
<div v-if="store.nextCursor" class="bitable-file-detail-view__load-more">
<a-button @click="store.loadMoreRecords()">加载更多</a-button>
</div>
</template>
</main>
</div>
<!-- Table create modal -->
<TableCreateModal
:open="createModalOpen"
:file-id="fileId"
@success="handleTableCreated"
@cancel="createModalOpen = false"
/>
<!-- Field management panel -->
<FieldManagePanel
:open="fieldPanelOpen"
:fields="store.fields"
@close="fieldPanelOpen = false"
/>
<!-- View config panel -->
<ViewConfigPanel
:open="viewConfigOpen"
:fields="store.fields"
:view="store.currentView"
@close="viewConfigOpen = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, h, onMounted, onUnmounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Modal as AModal } from 'ant-design-vue'
import {
ArrowLeftOutlined,
ReloadOutlined,
LoadingOutlined,
TableOutlined,
SettingOutlined,
} from '@ant-design/icons-vue'
import { useBitableStore } from '@/stores/bitable'
import type { IBitableField } from '@/api/bitable'
import TableViewList from '@/components/bitable/TableViewList.vue'
import BitableGrid from '@/components/bitable/BitableGrid.vue'
import TableCreateModal from '@/components/bitable/TableCreateModal.vue'
import FieldManagePanel from '@/components/bitable/FieldManagePanel.vue'
import ViewSwitcher from '@/components/bitable/ViewSwitcher.vue'
import ViewConfigPanel from '@/components/bitable/ViewConfigPanel.vue'
const router = useRouter()
const route = useRoute()
const store = useBitableStore()
const createModalOpen = ref(false)
const fieldPanelOpen = ref(false)
const viewConfigOpen = ref(false)
const fileId = computed(() => (route.params.fileId as string) ?? '')
const tableId = computed(() => (route.params.tableId as string) ?? '')
// Filter out hidden fields based on current view config
const visibleFields = computed(() => {
const hiddenIds = (store.currentView?.config?.hidden_fields as string[]) ?? []
if (hiddenIds.length === 0) return store.fields
return store.fields.filter((f) => !hiddenIds.includes(f.id))
})
onMounted(async () => {
if (!fileId.value) return
await store.selectFile(fileId.value)
if (tableId.value) {
await store.selectTable(tableId.value)
}
})
onUnmounted(() => {
store.stopPolling()
})
// React to fileId changes (navigating between files reuses this component instance)
watch(
() => fileId.value,
async (newId, oldId) => {
if (newId && newId !== oldId) {
store.stopPolling()
await store.selectFile(newId)
if (tableId.value) {
await store.selectTable(tableId.value)
}
}
},
)
// React to route param changes (e.g., clicking a table in sidebar updates URL)
watch(
() => tableId.value,
async (newId) => {
if (newId && newId !== store.currentTable?.id) {
await store.selectTable(newId)
}
},
)
function goBack(): void {
router.push('/bitable')
}
function handleSelectTable(tableId: string): void {
router.push(`/bitable/${fileId.value}/${tableId}`)
}
function handleCreateTable(_: string | null): void {
createModalOpen.value = true
}
async function handleTableCreated(tableId: string): Promise<void> {
createModalOpen.value = false
await store.loadTablesByFile(fileId.value)
await store.selectTable(tableId)
router.push(`/bitable/${fileId.value}/${tableId}`)
}
function handleRefresh(): void {
store.refreshRecords()
}
async function handleEditCell(payload: {
recordId: string
fieldId: string
value: unknown
}): Promise<void> {
await store.updateCell(payload.recordId, payload.fieldId, payload.value)
}
function handleSwitchView(viewId: string): void {
store.switchView(viewId)
}
async function handleCreateView(): Promise<void> {
// ponytail: simple prompt for view name; full create modal is overkill for v1
let name = ''
AModal.confirm({
title: '新建视图',
content: h('input', {
class: 'ant-input',
placeholder: '请输入视图名称',
onInput: (e: Event) => {
name = (e.target as HTMLInputElement).value
},
}),
onOk: async () => {
if (!name.trim()) return
await store.createView(name.trim(), 'grid')
},
})
}
// U4: column header menu handlers (events from BitableGrid)
function handleAddFieldFromGrid(): void {
// Open the field manage panel which has the add-field form
fieldPanelOpen.value = true
}
function handleConfigField(_field: IBitableField): void {
// ponytail: open field manage panel; future: open inline config drawer
fieldPanelOpen.value = true
}
async function handleHideField(fieldId: string): Promise<void> {
await store.hideField(fieldId)
}
async function handleDeleteField(field: IBitableField): Promise<void> {
AModal.confirm({
title: '删除字段',
content: `确定删除字段「${field.name}」吗?该字段下的所有数据将被清除。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
await store.deleteField(field.id, false)
},
})
}
</script>
<style scoped>
.bitable-file-detail-view {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
background: var(--bg-primary, #fff);
}
.bitable-file-detail-view__topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid var(--border-color, #f0f0f0);
flex-shrink: 0;
}
.bitable-file-detail-view__topbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.bitable-file-detail-view__topbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.bitable-file-detail-view__icon {
font-size: 18px;
}
.bitable-file-detail-view__title {
font-weight: 600;
font-size: 16px;
}
.bitable-file-detail-view__body {
flex: 1;
display: flex;
overflow: hidden;
}
.bitable-file-detail-view__sidebar {
width: 240px;
flex-shrink: 0;
overflow: hidden;
}
.bitable-file-detail-view__main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.bitable-file-detail-view__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
color: var(--text-placeholder, #bfbfbf);
}
.bitable-file-detail-view__grid-header {
display: flex;
align-items: baseline;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color, #f0f0f0);
flex-shrink: 0;
}
.bitable-file-detail-view__table-name {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.bitable-file-detail-view__field-count {
font-size: 12px;
color: var(--text-secondary, #8c8c8c);
}
.bitable-file-detail-view__grid-container {
flex: 1;
overflow: hidden;
padding: 0;
}
.bitable-file-detail-view__load-more {
display: flex;
justify-content: center;
padding: 8px;
border-top: 1px solid var(--border-color, #f0f0f0);
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,215 @@
<template>
<div class="bitable-file-list-view">
<!-- Top bar -->
<div class="bitable-file-list-view__topbar">
<div class="bitable-file-list-view__topbar-left">
<a-button type="text" :icon="h(ArrowLeftOutlined)" @click="goBack" />
<span class="bitable-file-list-view__title">多维表格</span>
</div>
<div class="bitable-file-list-view__topbar-right">
<a-button type="primary" :icon="h(PlusOutlined)" @click="createOpen = true">
新建文件
</a-button>
</div>
</div>
<!-- Body: file grid -->
<div class="bitable-file-list-view__body">
<a-spin :spinning="store.isLoading">
<div v-if="store.files.length > 0" class="bitable-file-list-view__grid">
<FileCard
v-for="file in store.files"
:key="file.id"
:file="file"
@open="handleOpen"
@rename="handleRename"
@delete="handleDelete"
/>
</div>
<a-empty
v-else-if="!store.isLoading"
description="还没有多维表格文件,点击右上角新建"
class="bitable-file-list-view__empty"
/>
</a-spin>
</div>
<!-- Create file modal -->
<FileCreateModal
:open="createOpen"
@success="handleCreated"
@cancel="createOpen = false"
/>
<!-- Rename modal -->
<a-modal
:open="renameOpen"
title="重命名文件"
:confirm-loading="renaming"
@ok="handleRenameOk"
@cancel="renameOpen = false"
>
<a-form layout="vertical">
<a-form-item label="图标">
<a-select v-model:value="renameForm.icon" :options="iconOptions" />
</a-form-item>
<a-form-item label="文件名">
<a-input v-model:value="renameForm.name" :maxlength="100" />
</a-form-item>
<a-form-item label="描述">
<a-textarea v-model:value="renameForm.description" :rows="2" :maxlength="500" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, h, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Modal as AModal } from 'ant-design-vue'
import { ArrowLeftOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { useBitableStore } from '@/stores/bitable'
import type { IBitableFile } from '@/api/bitable'
import FileCard from '@/components/bitable/FileCard.vue'
import FileCreateModal from '@/components/bitable/FileCreateModal.vue'
const router = useRouter()
const store = useBitableStore()
const createOpen = ref(false)
const renameOpen = ref(false)
const renaming = ref(false)
const renameForm = reactive({
id: '',
icon: '📋',
name: '',
description: '',
})
const iconOptions = [
{ label: '📋 表格', value: '📋' },
{ label: '📊 仪表盘', value: '📊' },
{ label: '📝 笔记', value: '📝' },
{ label: '🗂️ 项目', value: '🗂️' },
{ label: '📅 日程', value: '📅' },
{ label: '💼 工作', value: '💼' },
{ label: '🎯 目标', value: '🎯' },
{ label: '📚 知识库', value: '📚' },
]
onMounted(() => {
store.loadFiles()
})
function goBack(): void {
router.push('/agent/chat')
}
function handleOpen(fileId: string): void {
router.push(`/bitable/${fileId}`)
}
function handleCreated(fileId: string): void {
createOpen.value = false
router.push(`/bitable/${fileId}`)
}
function handleRename(file: IBitableFile): void {
renameForm.id = file.id
renameForm.icon = file.icon
renameForm.name = file.name
renameForm.description = file.description
renameOpen.value = true
}
async function handleRenameOk(): Promise<void> {
if (!renameForm.name.trim()) return
renaming.value = true
try {
await store.updateFile(renameForm.id, {
name: renameForm.name.trim(),
icon: renameForm.icon,
description: renameForm.description.trim(),
})
renameOpen.value = false
} finally {
renaming.value = false
}
}
function handleDelete(file: IBitableFile): void {
AModal.confirm({
title: '删除文件',
content: `确定删除「${file.name}」吗?该文件下的所有数据表都将被删除,此操作不可恢复。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
await store.deleteFile(file.id)
},
})
}
</script>
<style scoped>
.bitable-file-list-view {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
background: var(--bg-secondary, #fafafa);
}
.bitable-file-list-view__topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: var(--bg-primary, #fff);
border-bottom: 1px solid var(--border-color, #f0f0f0);
flex-shrink: 0;
}
.bitable-file-list-view__topbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.bitable-file-list-view__topbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.bitable-file-list-view__title {
font-weight: 600;
font-size: 16px;
}
.bitable-file-list-view__body {
flex: 1;
overflow: auto;
padding: 24px;
}
.bitable-file-list-view__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
max-width: 1400px;
margin: 0 auto;
}
.bitable-file-list-view__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
}
</style>

View File

@ -1,294 +1,9 @@
<template> <template>
<div class="bitable-view"> <router-view />
<!-- Top bar -->
<div class="bitable-view__topbar">
<div class="bitable-view__topbar-left">
<a-button type="text" :icon="h(ArrowLeftOutlined)" @click="goBack" />
<span class="bitable-view__title">多维表格</span>
</div>
<div class="bitable-view__topbar-right">
<a-tag v-if="store.recalcPendingCount > 0" color="processing">
<LoadingOutlined /> 计算中 ({{ store.recalcPendingCount }})
</a-tag>
<a-button
v-if="store.currentTable"
size="small"
:icon="h(SettingOutlined)"
@click="fieldPanelOpen = true"
>
字段管理
</a-button>
<a-button size="small" :icon="h(ReloadOutlined)" @click="handleRefresh">
刷新
</a-button>
</div>
</div>
<!-- Body: left sidebar + right grid -->
<div class="bitable-view__body">
<aside class="bitable-view__sidebar">
<TableViewList
:tables="store.tables"
:active-id="store.currentTable?.id ?? null"
:loading="store.isLoading"
@select="handleSelectTable"
@create="createModalOpen = true"
/>
</aside>
<main class="bitable-view__main">
<div v-if="!store.currentTable" class="bitable-view__placeholder">
<TableOutlined style="font-size: 48px; color: var(--text-placeholder)" />
<p>请选择左侧的数据表</p>
</div>
<template v-else>
<div class="bitable-view__grid-header">
<h3 class="bitable-view__table-name">{{ store.currentTable.name }}</h3>
<span class="bitable-view__field-count">
{{ store.fields.length }} 个字段 · {{ store.records.length }} 条记录
</span>
</div>
<!-- View switcher (U5c) -->
<ViewSwitcher
:views="store.views"
:active-view-id="store.currentView?.id ?? null"
@switch="handleSwitchView"
@create="handleCreateView"
@config="viewConfigOpen = true"
/>
<div class="bitable-view__grid-container">
<BitableGrid
:fields="visibleFields"
:records="store.records"
:loading="store.isLoading"
height="100%"
@edit-cell="handleEditCell"
/>
</div>
<!-- Load more (cursor pagination) -->
<div v-if="store.nextCursor" class="bitable-view__load-more">
<a-button @click="store.loadMoreRecords()">加载更多</a-button>
</div>
</template>
</main>
</div>
<!-- Table create modal (U5b) -->
<TableCreateModal
:open="createModalOpen"
@success="handleTableCreated"
@cancel="createModalOpen = false"
/>
<!-- Field management panel (U5b) -->
<FieldManagePanel
:open="fieldPanelOpen"
:fields="store.fields"
@close="fieldPanelOpen = false"
/>
<!-- View config panel (U5c) -->
<ViewConfigPanel
:open="viewConfigOpen"
:fields="store.fields"
:view="store.currentView"
@close="viewConfigOpen = false"
/>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, h, onMounted, onUnmounted } from 'vue' // Legacy BitableView replaced by nested routes under /bitable.
import { useRouter } from 'vue-router' // This component is kept as a passthrough for any stale imports;
import { Button as AButton, Tag as ATag, Modal as AModal } from 'ant-design-vue' // the router no longer references it directly.
import {
ArrowLeftOutlined,
ReloadOutlined,
LoadingOutlined,
TableOutlined,
SettingOutlined,
} from '@ant-design/icons-vue'
import { useBitableStore } from '@/stores/bitable'
import TableViewList from '@/components/bitable/TableViewList.vue'
import BitableGrid from '@/components/bitable/BitableGrid.vue'
import TableCreateModal from '@/components/bitable/TableCreateModal.vue'
import FieldManagePanel from '@/components/bitable/FieldManagePanel.vue'
import ViewSwitcher from '@/components/bitable/ViewSwitcher.vue'
import ViewConfigPanel from '@/components/bitable/ViewConfigPanel.vue'
const router = useRouter()
const store = useBitableStore()
const createModalOpen = ref(false)
const fieldPanelOpen = ref(false)
const viewConfigOpen = ref(false)
// Filter out hidden fields based on current view config
const visibleFields = computed(() => {
const hiddenIds = (store.currentView?.config?.hidden_fields as string[]) ?? []
if (hiddenIds.length === 0) return store.fields
return store.fields.filter((f) => !hiddenIds.includes(f.id))
})
onMounted(() => {
store.loadTables()
})
onUnmounted(() => {
store.stopPolling()
})
function goBack(): void {
router.push('/agent/chat')
}
function handleSelectTable(tableId: string): void {
store.selectTable(tableId)
}
function handleRefresh(): void {
store.refreshRecords()
}
async function handleTableCreated(tableId: string): Promise<void> {
createModalOpen.value = false
await store.loadTables()
await store.selectTable(tableId)
}
async function handleEditCell(payload: {
recordId: string
fieldId: string
value: unknown
}): Promise<void> {
await store.updateCell(payload.recordId, payload.fieldId, payload.value)
}
function handleSwitchView(viewId: string): void {
store.switchView(viewId)
}
async function handleCreateView(): Promise<void> {
// ponytail: simple prompt for view name; full create modal is overkill for v1
let name = ''
AModal.confirm({
title: '新建视图',
content: h('input', {
class: 'ant-input',
placeholder: '请输入视图名称',
onInput: (e: Event) => {
name = (e.target as HTMLInputElement).value
},
}),
onOk: async () => {
if (!name.trim()) return
await store.createView(name.trim(), 'grid')
},
})
}
</script> </script>
<style scoped>
.bitable-view {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
background: var(--bg-primary, #fff);
}
.bitable-view__topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid var(--border-color, #f0f0f0);
flex-shrink: 0;
}
.bitable-view__topbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.bitable-view__topbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.bitable-view__title {
font-weight: 600;
font-size: 16px;
}
.bitable-view__body {
flex: 1;
display: flex;
overflow: hidden;
}
.bitable-view__sidebar {
width: 240px;
flex-shrink: 0;
overflow: hidden;
}
.bitable-view__main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.bitable-view__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
color: var(--text-placeholder, #bfbfbf);
}
.bitable-view__grid-header {
display: flex;
align-items: baseline;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color, #f0f0f0);
flex-shrink: 0;
}
.bitable-view__table-name {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.bitable-view__field-count {
font-size: 12px;
color: var(--text-secondary, #8c8c8c);
}
.bitable-view__grid-container {
flex: 1;
overflow: hidden;
padding: 0;
}
.bitable-view__load-more {
display: flex;
justify-content: center;
padding: 8px;
border-top: 1px solid var(--border-color, #f0f0f0);
flex-shrink: 0;
}
</style>

View File

@ -85,7 +85,8 @@ async def _check_table_ownership(
) -> None: ) -> None:
"""Verify the user owns the table. Internal service users bypass check. """Verify the user owns the table. Internal service users bypass check.
Raises 404 if table not found, 403 if not owned. Pattern 4 (IDOR ownership): 404 before 403 never leak existence of a
table the user doesn't own. Internal token bypasses (KTD11).
""" """
table = await service.get_table(table_id) table = await service.get_table(table_id)
if table is None: if table is None:
@ -93,7 +94,24 @@ async def _check_table_ownership(
if user.get("internal"): if user.get("internal"):
return # Internal service token (KTD11) bypasses ownership return # Internal service token (KTD11) bypasses ownership
if table.owner_user_id and table.owner_user_id != user.get("user_id"): if table.owner_user_id and table.owner_user_id != user.get("user_id"):
raise HTTPException(status_code=403, detail="Not authorized to access this table") raise HTTPException(status_code=404, detail="Table not found")
async def _check_file_ownership(
service: BitableService, file_id: str, user: dict[str, Any]
) -> None:
"""Verify the user owns the file. Internal service users bypass check.
Pattern 4 (IDOR ownership): 404 before 403 never leak existence of a
file the user doesn't own. Internal token bypasses (KTD11).
"""
file = await service.get_file(file_id)
if file is None:
raise HTTPException(status_code=404, detail="File not found")
if user.get("internal"):
return # Internal service token (KTD11) bypasses ownership
if file.owner_user_id and file.owner_user_id != user.get("user_id"):
raise HTTPException(status_code=404, detail="File not found")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -101,6 +119,18 @@ async def _check_table_ownership(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class CreateFileRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
icon: str = Field("📋", max_length=20)
description: str = Field("", max_length=2000)
class UpdateFileRequest(BaseModel):
name: str | None = Field(None, min_length=1, max_length=100)
icon: str | None = Field(None, max_length=20)
description: str | None = Field(None, max_length=2000)
class CreateTableRequest(BaseModel): class CreateTableRequest(BaseModel):
name: str name: str
description: str = "" description: str = ""
@ -164,6 +194,11 @@ async def create_table(
request: Request, request: Request,
user: dict = Depends(require_bitable_auth), user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Deprecated: use POST /files/{file_id}/tables instead.
Creates a table without a parent file (file_id = NULL). Kept for
backward compatibility new clients should create tables under a file.
"""
service = _get_service(request) service = _get_service(request)
table = await service.create_table( table = await service.create_table(
name=body.name, name=body.name,
@ -179,11 +214,126 @@ async def list_tables(
request: Request, request: Request,
user: dict = Depends(require_bitable_auth), user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Deprecated: use GET /files/{file_id}/tables instead.
Lists all tables owned by the user regardless of parent file.
"""
service = _get_service(request) service = _get_service(request)
tables = await service.list_tables(owner_user_id=user.get("user_id")) tables = await service.list_tables(owner_user_id=user.get("user_id"))
return {"success": True, "tables": [t.model_dump(mode="json") for t in tables]} return {"success": True, "tables": [t.model_dump(mode="json") for t in tables]}
# ---------------------------------------------------------------------------
# File endpoints (R1)
# ---------------------------------------------------------------------------
@router.post("/files", status_code=201)
async def create_file(
body: CreateFileRequest,
request: Request,
user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
"""Create a new bitable file (top-level container)."""
service = _get_service(request)
file = await service.create_file(
name=body.name,
icon=body.icon,
description=body.description,
owner_user_id=user.get("user_id"),
)
return {"success": True, "file": file.model_dump(mode="json")}
@router.get("/files")
async def list_files(
request: Request,
user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
"""List all bitable files owned by the current user."""
service = _get_service(request)
files = await service.list_files(owner_user_id=user.get("user_id"))
return {"success": True, "files": [f.model_dump(mode="json") for f in files]}
@router.get("/files/{file_id}")
async def get_file(
file_id: str,
request: Request,
user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
"""Get a single bitable file by ID (with ownership check)."""
service = _get_service(request)
await _check_file_ownership(service, file_id, user)
file = await service.get_file(file_id)
return {"success": True, "file": file.model_dump(mode="json")}
@router.patch("/files/{file_id}")
async def update_file(
file_id: str,
body: UpdateFileRequest,
request: Request,
user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
"""Update a bitable file's metadata (name/icon/description)."""
service = _get_service(request)
await _check_file_ownership(service, file_id, user)
kwargs = body.model_dump(exclude_none=True)
file = await service.update_file(file_id, **kwargs)
if file is None:
raise HTTPException(status_code=404, detail="File not found")
return {"success": True, "file": file.model_dump(mode="json")}
@router.delete("/files/{file_id}")
async def delete_file(
file_id: str,
request: Request,
user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
"""Delete a bitable file. Cascades to all tables under the file."""
service = _get_service(request)
await _check_file_ownership(service, file_id, user)
deleted = await service.delete_file(file_id)
if not deleted:
raise HTTPException(status_code=404, detail="File not found")
return {"success": True}
@router.get("/files/{file_id}/tables")
async def list_tables_in_file(
file_id: str,
request: Request,
user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
"""List all tables in a file (with file ownership check)."""
service = _get_service(request)
await _check_file_ownership(service, file_id, user)
tables = await service.list_tables_by_file(file_id)
return {"success": True, "tables": [t.model_dump(mode="json") for t in tables]}
@router.post("/files/{file_id}/tables", status_code=201)
async def create_table_in_file(
file_id: str,
body: CreateTableRequest,
request: Request,
user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
"""Create a new table under a file. Auto-creates 5 default fields (R2)."""
service = _get_service(request)
await _check_file_ownership(service, file_id, user)
table = await service.create_table(
name=body.name,
description=body.description,
primary_key_field_id=body.primary_key_field_id,
owner_user_id=user.get("user_id"),
file_id=file_id,
)
return {"success": True, "table": table.model_dump(mode="json")}
@router.get("/tables/{table_id}") @router.get("/tables/{table_id}")
async def get_table( async def get_table(
table_id: str, table_id: str,
@ -270,6 +420,10 @@ async def update_field(
user: dict = Depends(require_bitable_auth), user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]: ) -> dict[str, Any]:
service = _get_service(request) service = _get_service(request)
existing = await service.get_field(field_id)
if existing is None:
raise HTTPException(status_code=404, detail="Field not found")
await _check_table_ownership(service, existing.table_id, user)
kwargs = body.model_dump(exclude_none=True) kwargs = body.model_dump(exclude_none=True)
field = await service.update_field(field_id, **kwargs) field = await service.update_field(field_id, **kwargs)
if field is None: if field is None:
@ -285,6 +439,10 @@ async def delete_field(
user: dict = Depends(require_bitable_auth), user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]: ) -> dict[str, Any]:
service = _get_service(request) service = _get_service(request)
existing = await service.get_field(field_id)
if existing is None:
raise HTTPException(status_code=404, detail="Field not found")
await _check_table_ownership(service, existing.table_id, user)
try: try:
deleted = await service.delete_field(field_id, force=force) deleted = await service.delete_field(field_id, force=force)
except FieldDependencyError as e: except FieldDependencyError as e:
@ -346,7 +504,9 @@ async def create_records(
await _check_table_ownership(service, table_id, user) await _check_table_ownership(service, table_id, user)
created = [] created = []
for rec_values in body.records: for rec_values in body.records:
record = await service.create_record(table_id, values=rec_values) record = await service.create_record(
table_id, values=rec_values, actor_user_id=user.get("user_id")
)
created.append(record.model_dump(mode="json")) created.append(record.model_dump(mode="json"))
return {"success": True, "count": len(created), "records": created} return {"success": True, "count": len(created), "records": created}
@ -398,6 +558,10 @@ async def update_record(
user: dict = Depends(require_bitable_auth), user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]: ) -> dict[str, Any]:
service = _get_service(request) service = _get_service(request)
existing = await service.get_record(record_id)
if existing is None:
raise HTTPException(status_code=404, detail="Record not found")
await _check_table_ownership(service, existing.table_id, user)
record = await service.update_record_values(record_id, body.values) record = await service.update_record_values(record_id, body.values)
if record is None: if record is None:
raise HTTPException(status_code=404, detail="Record not found") raise HTTPException(status_code=404, detail="Record not found")
@ -423,6 +587,10 @@ async def delete_single_record(
user: dict = Depends(require_bitable_auth), user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]: ) -> dict[str, Any]:
service = _get_service(request) service = _get_service(request)
existing = await service.get_record(record_id)
if existing is None:
raise HTTPException(status_code=404, detail="Record not found")
await _check_table_ownership(service, existing.table_id, user)
deleted = await service.delete_record(record_id) deleted = await service.delete_record(record_id)
if not deleted: if not deleted:
raise HTTPException(status_code=404, detail="Record not found") raise HTTPException(status_code=404, detail="Record not found")
@ -492,6 +660,10 @@ async def update_view(
user: dict = Depends(require_bitable_auth), user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]: ) -> dict[str, Any]:
service = _get_service(request) service = _get_service(request)
existing = await service.get_view(view_id)
if existing is None:
raise HTTPException(status_code=404, detail="View not found")
await _check_table_ownership(service, existing.table_id, user)
kwargs = body.model_dump(exclude_none=True) kwargs = body.model_dump(exclude_none=True)
view = await service.update_view(view_id, **kwargs) view = await service.update_view(view_id, **kwargs)
if view is None: if view is None:
@ -535,6 +707,8 @@ async def upload_file(
field = await service.get_field(field_id) field = await service.get_field(field_id)
if field is None: if field is None:
raise HTTPException(status_code=404, detail="Field not found") raise HTTPException(status_code=404, detail="Field not found")
if field.table_id != table_id:
raise HTTPException(status_code=400, detail="Field does not belong to this table")
if field.field_type not in (FieldType.attachment, FieldType.image): if field.field_type not in (FieldType.attachment, FieldType.image):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@ -588,11 +762,11 @@ async def upload_file(
"stored_name": stored_name, "stored_name": stored_name,
"mime_type": mime, "mime_type": mime,
"size": total_size, "size": total_size,
"url": f"/api/v1/bitable/files/{stored_name}", "url": f"/api/v1/bitable/uploads/{stored_name}",
} }
@router.get("/files/{filename}") @router.get("/uploads/{filename}")
async def download_file( async def download_file(
filename: str, filename: str,
user: dict = Depends(require_bitable_auth), user: dict = Depends(require_bitable_auth),

View File

@ -29,7 +29,9 @@ def _make_test_user() -> dict[str, Any]:
@pytest.fixture @pytest.fixture
def app(bitable_service: BitableService, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> FastAPI: def app(
bitable_service: BitableService, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> FastAPI:
"""Test app with upload dir redirected to tmp_path.""" """Test app with upload dir redirected to tmp_path."""
upload_dir = tmp_path / "bitable_uploads" upload_dir = tmp_path / "bitable_uploads"
# Patch both the routes module variable AND the env var (service reads env var) # Patch both the routes module variable AND the env var (service reads env var)
@ -62,9 +64,9 @@ async def _create_table_with_field(
field_name: str = "files", field_name: str = "files",
) -> tuple[str, str]: ) -> tuple[str, str]:
"""Create a table + a field, return (table_id, field_id).""" """Create a table + a field, return (table_id, field_id)."""
table_id = ( table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
await client.post("/api/v1/bitable/tables", json={"name": "T"}) "id"
).json()["table"]["id"] ]
field_id = ( field_id = (
await client.post( await client.post(
f"/api/v1/bitable/tables/{table_id}/fields", f"/api/v1/bitable/tables/{table_id}/fields",
@ -149,9 +151,9 @@ async def test_upload_rejects_non_attachment_field(client: httpx.AsyncClient) ->
async def test_upload_404_unknown_field(client: httpx.AsyncClient) -> None: async def test_upload_404_unknown_field(client: httpx.AsyncClient) -> None:
table_id = ( table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
await client.post("/api/v1/bitable/tables", json={"name": "T"}) "id"
).json()["table"]["id"] ]
img_bytes, _ = _make_image_bytes() img_bytes, _ = _make_image_bytes()
resp = await client.post( resp = await client.post(
f"/api/v1/bitable/tables/{table_id}/upload", f"/api/v1/bitable/tables/{table_id}/upload",
@ -317,6 +319,6 @@ async def test_multiple_files_in_attachment_field(client: httpx.AsyncClient) ->
f"/api/v1/bitable/tables/{table_id}/records", f"/api/v1/bitable/tables/{table_id}/records",
json={"records": [{field_id: metas}]}, json={"records": [{field_id: metas}]},
) )
assert create_resp.status_code == 200 assert create_resp.status_code == 201
record = create_resp.json()["records"][0] record = create_resp.json()["records"][0]
assert len(record["values"][field_id]) == 2 assert len(record["values"][field_id]) == 2

View File

@ -1,15 +1,13 @@
"""Tests for bitable DB initialization, schema, and constraints (U1). """Tests for bitable DB initialization, schema, and constraints (U1).
Requires PostgreSQL marked ``postgres``. Skips automatically when PG-dependent tests skip via the ``bitable_db`` fixture when PostgreSQL is
``DATABASE_URL`` / ``AGENTKIT_DATABASE_URL`` is unset (see conftest.py). unavailable. The no-URL degradation test runs in all environments.
""" """
from __future__ import annotations from __future__ import annotations
import pytest import pytest
pytestmark = pytest.mark.postgres
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# init_bitable_db / BitableDB.init # init_bitable_db / BitableDB.init
@ -17,7 +15,7 @@ pytestmark = pytest.mark.postgres
async def test_init_creates_schema_and_all_tables(bitable_db) -> None: async def test_init_creates_schema_and_all_tables(bitable_db) -> None:
"""init creates the bitable schema and all 6 tables.""" """init creates the bitable schema and all 7 tables (V2 adds bitable_files)."""
from sqlalchemy import text from sqlalchemy import text
async with bitable_db.engine.begin() as conn: async with bitable_db.engine.begin() as conn:
@ -29,7 +27,7 @@ async def test_init_creates_schema_and_all_tables(bitable_db) -> None:
) )
assert result.fetchone() is not None assert result.fetchone() is not None
# All 6 tables present # All 7 tables present (V2 adds bitable_files)
result = await conn.execute( result = await conn.execute(
text( text(
"SELECT table_name FROM information_schema.tables " "SELECT table_name FROM information_schema.tables "
@ -39,6 +37,7 @@ async def test_init_creates_schema_and_all_tables(bitable_db) -> None:
tables = {row[0] for row in result.fetchall()} tables = {row[0] for row in result.fetchall()}
assert tables == { assert tables == {
"bitable_fields", "bitable_fields",
"bitable_files",
"bitable_meta", "bitable_meta",
"bitable_records", "bitable_records",
"bitable_recalc_queue", "bitable_recalc_queue",

View File

@ -0,0 +1,194 @@
"""Tests for default field creation on new tables (U2, R2).
Covers:
- create_table auto-creates 5 default fields with correct names/types/owners
- 状态 field config.options has 3 preset options (未开始/进行中/已完成)
- create_record auto-fills 创建人 field (agent-owned, system placeholder)
- create_record auto-fills 创建时间 field (agent-owned, ISO timestamp)
- User-supplied values for agent-owned fields are overwritten (system-managed)
"""
from __future__ import annotations
import pytest
from agentkit.bitable.models import FieldOwner, FieldType
pytestmark = pytest.mark.postgres
# ---------------------------------------------------------------------------
# Default fields on table creation
# ---------------------------------------------------------------------------
async def test_create_table_creates_5_default_fields(bitable_service) -> None:
"""create_table auto-creates 5 default fields with correct names/types/owners."""
table = await bitable_service.create_table(name="客户")
fields = await bitable_service.list_fields(table.id)
assert len(fields) == 5
field_map = {f.name: f for f in fields}
# 标题 (text, user)
assert "标题" in field_map
assert field_map["标题"].field_type == FieldType.text
assert field_map["标题"].owner == FieldOwner.user
# 状态 (select, user) — options checked in separate test
assert "状态" in field_map
assert field_map["状态"].field_type == FieldType.select
assert field_map["状态"].owner == FieldOwner.user
# 日期 (date, user)
assert "日期" in field_map
assert field_map["日期"].field_type == FieldType.date
assert field_map["日期"].owner == FieldOwner.user
# 创建人 (text, agent)
assert "创建人" in field_map
assert field_map["创建人"].field_type == FieldType.text
assert field_map["创建人"].owner == FieldOwner.agent
# 创建时间 (date, agent)
assert "创建时间" in field_map
assert field_map["创建时间"].field_type == FieldType.date
assert field_map["创建时间"].owner == FieldOwner.agent
async def test_status_field_has_3_preset_options(bitable_service) -> None:
"""状态 field config.options has 3 preset options with labels and colors."""
table = await bitable_service.create_table(name="T1")
fields = await bitable_service.list_fields(table.id)
status_field = next(f for f in fields if f.name == "状态")
options = status_field.config.get("options", [])
assert len(options) == 3
labels = [o["label"] for o in options]
values = [o["value"] for o in options]
assert labels == ["未开始", "进行中", "已完成"]
assert values == ["not_started", "in_progress", "done"]
# Each option has a color
for opt in options:
assert "color" in opt
assert opt["color"] in ("default", "processing", "success")
async def test_default_fields_are_created_under_correct_table(bitable_service) -> None:
"""Default fields are created under the new table's ID, not some other table."""
table1 = await bitable_service.create_table(name="T1")
table2 = await bitable_service.create_table(name="T2")
fields1 = await bitable_service.list_fields(table1.id)
fields2 = await bitable_service.list_fields(table2.id)
# Each table has its own 5 default fields
assert len(fields1) == 5
assert len(fields2) == 5
# Field IDs are distinct across tables
ids1 = {f.id for f in fields1}
ids2 = {f.id for f in fields2}
assert ids1.isdisjoint(ids2)
# All fields point to the right table
assert all(f.table_id == table1.id for f in fields1)
assert all(f.table_id == table2.id for f in fields2)
# ---------------------------------------------------------------------------
# Agent-owned field auto-fill on record creation
# ---------------------------------------------------------------------------
async def test_create_record_auto_fills_creator_field(bitable_service) -> None:
"""create_record auto-fills 创建人 field (agent-owned)."""
table = await bitable_service.create_table(name="T1")
fields = await bitable_service.list_fields(table.id)
creator_field = next(f for f in fields if f.name == "创建人")
record = await bitable_service.create_record(table.id, values={})
# 创建人 is auto-filled with "system" placeholder
assert record.values.get(creator_field.id) == "system"
async def test_create_record_auto_fills_created_time_field(bitable_service) -> None:
"""create_record auto-fills 创建时间 field with an ISO timestamp."""
table = await bitable_service.create_table(name="T1")
fields = await bitable_service.list_fields(table.id)
time_field = next(f for f in fields if f.name == "创建时间")
record = await bitable_service.create_record(table.id, values={})
time_val = record.values.get(time_field.id)
assert time_val is not None
# Should be an ISO 8601 string (parseable by datetime.fromisoformat)
from datetime import datetime
parsed = datetime.fromisoformat(time_val)
assert parsed.tzinfo is not None # timezone-aware
async def test_create_record_overwrites_user_supplied_agent_field(bitable_service) -> None:
"""User-supplied values for agent-owned fields are overwritten by system values.
Per R2 edge case: agent-owned fields are system-managed user input is ignored.
"""
table = await bitable_service.create_table(name="T1")
fields = await bitable_service.list_fields(table.id)
creator_field = next(f for f in fields if f.name == "创建人")
# User tries to set 创建人 to "hacker"
record = await bitable_service.create_record(table.id, values={creator_field.id: "hacker"})
# System overwrites with "system" — agent ownership means system-managed
assert record.values.get(creator_field.id) == "system"
async def test_create_record_preserves_user_owned_field_values(bitable_service) -> None:
"""User-supplied values for user-owned fields are preserved as-is."""
table = await bitable_service.create_table(name="T1")
fields = await bitable_service.list_fields(table.id)
title_field = next(f for f in fields if f.name == "标题")
status_field = next(f for f in fields if f.name == "状态")
record = await bitable_service.create_record(
table.id,
values={
title_field.id: "Acme Corp",
status_field.id: "in_progress",
},
)
assert record.values.get(title_field.id) == "Acme Corp"
assert record.values.get(status_field.id) == "in_progress"
# ---------------------------------------------------------------------------
# DEFAULT_FIELD_TEMPLATES constant (unit test, no PG needed)
# ---------------------------------------------------------------------------
def test_default_field_templates_has_5_entries() -> None:
"""DEFAULT_FIELD_TEMPLATES has exactly 5 entries (no PG required)."""
from agentkit.bitable.models import DEFAULT_FIELD_TEMPLATES
assert len(DEFAULT_FIELD_TEMPLATES) == 5
def test_default_field_templates_names_match_feishu_defaults() -> None:
"""Default field names match Feishu Bitable defaults."""
from agentkit.bitable.models import DEFAULT_FIELD_TEMPLATES
names = [t["name"] for t in DEFAULT_FIELD_TEMPLATES]
assert names == ["标题", "状态", "日期", "创建人", "创建时间"]
def test_default_field_templates_owners_match_plan() -> None:
"""Default field owners: 标题/状态/日期 are user-owned, 创建人/创建时间 are agent-owned."""
from agentkit.bitable.models import DEFAULT_FIELD_TEMPLATES
owner_map = {t["name"]: t["owner"] for t in DEFAULT_FIELD_TEMPLATES}
assert owner_map["标题"] == FieldOwner.user
assert owner_map["状态"] == FieldOwner.user
assert owner_map["日期"] == FieldOwner.user
assert owner_map["创建人"] == FieldOwner.agent
assert owner_map["创建时间"] == FieldOwner.agent

View File

@ -0,0 +1,194 @@
"""Tests for BitableFile entity + file layer CRUD (U1, R1).
Covers:
- Happy path: create get list update delete
- Cascade: deleting a file removes all its tables (and their fields/records/views)
- IDOR: non-owner access returns 404 (not 403) existence is hidden
- Internal token bypasses ownership check
- Integration: create file create table under file table.file_id correct
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.postgres
# ---------------------------------------------------------------------------
# Happy path CRUD
# ---------------------------------------------------------------------------
async def test_create_file_returns_with_defaults(bitable_service) -> None:
"""create_file returns a BitableFile with default icon and empty description."""
file = await bitable_service.create_file(name="销售管线", owner_user_id="u1")
assert file.id
assert file.name == "销售管线"
assert file.icon == "📋"
assert file.description == ""
assert file.owner_user_id == "u1"
assert file.created_at is not None
assert file.updated_at is not None
async def test_get_file_returns_created_file(bitable_service) -> None:
"""get_file returns the file by ID."""
file = await bitable_service.create_file(name="F1", owner_user_id="u1")
fetched = await bitable_service.get_file(file.id)
assert fetched is not None
assert fetched.id == file.id
assert fetched.name == "F1"
async def test_get_file_returns_none_for_missing(bitable_service) -> None:
"""get_file returns None when the ID doesn't exist."""
fetched = await bitable_service.get_file("nonexistent-id")
assert fetched is None
async def test_list_files_filters_by_owner(bitable_service) -> None:
"""list_files filters by owner_user_id when provided."""
await bitable_service.create_file(name="F1", owner_user_id="u1")
await bitable_service.create_file(name="F2", owner_user_id="u2")
await bitable_service.create_file(name="F3", owner_user_id="u1")
u1_files = await bitable_service.list_files(owner_user_id="u1")
assert len(u1_files) == 2
assert all(f.owner_user_id == "u1" for f in u1_files)
u2_files = await bitable_service.list_files(owner_user_id="u2")
assert len(u2_files) == 1
assert u2_files[0].name == "F2"
async def test_list_files_returns_all_when_no_owner_filter(bitable_service) -> None:
"""list_files with no owner filter returns all files."""
await bitable_service.create_file(name="F1", owner_user_id="u1")
await bitable_service.create_file(name="F2", owner_user_id="u2")
all_files = await bitable_service.list_files()
assert len(all_files) >= 2
async def test_update_file_changes_name_and_icon(bitable_service) -> None:
"""update_file patches name/icon/description."""
file = await bitable_service.create_file(name="Old", owner_user_id="u1")
updated = await bitable_service.update_file(
file.id, name="New", icon="🚀", description="updated desc"
)
assert updated is not None
assert updated.name == "New"
assert updated.icon == "🚀"
assert updated.description == "updated desc"
async def test_delete_file_returns_true(bitable_service) -> None:
"""delete_file returns True when the file existed."""
file = await bitable_service.create_file(name="F1", owner_user_id="u1")
deleted = await bitable_service.delete_file(file.id)
assert deleted is True
assert await bitable_service.get_file(file.id) is None
async def test_delete_file_returns_false_for_missing(bitable_service) -> None:
"""delete_file returns False when the file didn't exist."""
deleted = await bitable_service.delete_file("nonexistent-id")
assert deleted is False
# ---------------------------------------------------------------------------
# Cascade delete
# ---------------------------------------------------------------------------
async def test_delete_file_cascades_to_tables(bitable_service) -> None:
"""Deleting a file cascades to all tables under it (and their fields/records)."""
file = await bitable_service.create_file(name="F1", owner_user_id="u1")
table = await bitable_service.create_table(name="T1", file_id=file.id)
# create_table auto-creates 5 default fields (R2); verify they exist
fields = await bitable_service.list_fields(table.id)
assert len(fields) == 5
deleted = await bitable_service.delete_file(file.id)
assert deleted is True
# Table should be gone
assert await bitable_service.get_table(table.id) is None
# Fields should be gone (cascade from table delete)
fields_after = await bitable_service.list_fields(table.id)
assert fields_after == []
# ---------------------------------------------------------------------------
# File → Table integration
# ---------------------------------------------------------------------------
async def test_create_table_under_file_sets_file_id(bitable_service) -> None:
"""create_table with file_id correctly associates the table with the file."""
file = await bitable_service.create_file(name="F1", owner_user_id="u1")
table = await bitable_service.create_table(name="T1", file_id=file.id)
assert table.file_id == file.id
# list_tables_by_file returns the table
tables_in_file = await bitable_service.list_tables_by_file(file.id)
assert len(tables_in_file) == 1
assert tables_in_file[0].id == table.id
async def test_create_table_without_file_id_has_null_file_id(bitable_service) -> None:
"""create_table without file_id leaves file_id NULL (backward compat)."""
table = await bitable_service.create_table(name="Orphan")
assert table.file_id is None
# ---------------------------------------------------------------------------
# Schema V2 migration
# ---------------------------------------------------------------------------
async def test_schema_v2_files_table_exists(bitable_db) -> None:
"""V2 migration creates the bitable_files table."""
from sqlalchemy import text
async with bitable_db.engine.begin() as conn:
result = await conn.execute(
text(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = 'bitable' AND table_name = 'bitable_files'"
)
)
assert result.fetchone() is not None
async def test_schema_v2_tables_has_file_id_column(bitable_db) -> None:
"""V2 migration adds file_id column to bitable_tables."""
from sqlalchemy import text
async with bitable_db.engine.begin() as conn:
result = await conn.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'bitable' "
" AND table_name = 'bitable_tables' "
" AND column_name = 'file_id'"
)
)
assert result.fetchone() is not None
async def test_schema_version_is_2(bitable_db) -> None:
"""bitable_meta records schema_version = 2 after V2 migration."""
from agentkit.bitable.db import _META_SCHEMA_VERSION_KEY, _SCHEMA_VERSION
from sqlalchemy import text
async with bitable_db.engine.begin() as conn:
result = await conn.execute(
text("SELECT value FROM bitable.bitable_meta WHERE key = :key"),
{"key": _META_SCHEMA_VERSION_KEY},
)
row = result.fetchone()
assert row is not None
assert int(row[0]) == _SCHEMA_VERSION
assert _SCHEMA_VERSION == 2