feat(bitable): 多维表格文件层 + 默认字段 + 表内字段操作 + ce-code-review 修复 (Stage 1)
Test / backend-test (pull_request) Has been cancelled Details
Test / frontend-unit (pull_request) Has been cancelled Details
Test / api-e2e (pull_request) Has been cancelled Details
Test / frontend-e2e (pull_request) Has been cancelled Details

实现多维表格 UI 完整性 Stage 1(U1-U6),补齐飞书/twenty 对齐缺失的文件层、
默认字段与表内字段操作能力,并修复 ce-code-review 走查发现的 P0/P1 级问题。

后端(U1-U2):
- 新增 BitableFile 实体(models/db/repository/service/routes),三级层级:文件→数据表→字段/记录
- Schema V2 迁移:bitable_files 表 + tables.file_id 列,幂等(IF NOT EXISTS),保留 V1 孤儿表
- 新建数据表自动创建 5 个默认字段(标题/状态/日期/创建人/创建时间)
- agent-owned 字段在 create_record 时自动填充(按 type+owner 匹配,传 actor_user_id)
- 7 个文件 REST 端点 + IDOR ownership 检查(404-before-403,internal token 旁路)

前端(U3-U5):
- 文件列表页(FileCard 网格 + 新建/重命名/删除)+ 文件详情页(侧栏表格列表 + vxe-table 网格)
- Vue Router 嵌套路由 /bitable → /bitable/:fileId → /bitable/:fileId/:tableId
- 列头菜单(编辑/隐藏/删除字段)+ 末尾 + 列新增字段
- select/multiselect 字段自定义单元格编辑器 + Tag 展示
- Pinia store 扩展 file 状态与动作,深链直访回退 getFile,fileId 切换 watch

测试(U6):
- 文件 CRUD(12 例)+ 默认字段(10 例)单元测试
- 3 个 E2E spec(视图加载、文件流、字段操作),后端不可用时优雅跳过

ce-code-review 修复(P0/P1):
- P0 路由冲突:GET /files/{file_id} 遮蔽下载端点 → 下载改 /uploads/{filename}
- P0 IDOR:update/delete field/record/view 五端点补 ownership 检查
- P1 is_initialized property 缺失致二次初始化崩溃
- P1 直接 URL 导航失效(files 数组为空)→ selectFile 回退 getFile
- P1 fileId 切换不重载 → 增加 watch
- P1 轮询丢弃最终公式值(wasCalculating 守卫)+ 复用视图 filters
- P1 测试断言 200→201;test_db 无 URL 用例解除 postgres 标记得以执行
- P2 _check_table_ownership 403→404;输入长度校验;upload field-table 一致性校验
- P2 multiselect 浅比较 → 深比较;E2E bitable-view 补 waitForServer 守卫

验证:ruff check 通过;pytest 91 passed/116 skipped;vue-tsc --noEmit 通过。
This commit is contained in:
chiguyong 2026-06-29 04:07:45 +08:00
parent f476d3339c
commit a6e1bf5884
30 changed files with 3295 additions and 351 deletions

View File

@ -7,6 +7,9 @@ Shared domain vocabulary for this project — entities, named processes, and sta
### 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.
### 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
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__)
# 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"
@ -52,13 +54,35 @@ class BitableBase(DeclarativeBase):
"""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):
"""ORM model for ``bitable.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)
file_id = Column(String, nullable=True)
name = Column(String, nullable=False)
description = Column(Text, default="")
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:
"""Resolve PostgreSQL connection URL.
@ -207,6 +282,10 @@ class BitableDB:
def session_factory(self) -> Any:
return self._session_factory
@property
def is_initialized(self) -> bool:
return self._initialized
async def _ensure_initialized(self) -> None:
"""Lazy-init async engine and session factory (with lock)."""
if self._initialized:
@ -260,8 +339,10 @@ class BitableDB:
# 4. Apply migrations if needed (future versions add elif blocks here)
if current_version < _SCHEMA_VERSION:
# V1: initial schema (already created above)
# Future: if current_version < 2: await _apply_v2_migration(conn)
# V1: initial schema (already created above via create_all)
# V2: file layer (bitable_files table + file_id column on tables)
if current_version < 2:
await _apply_v2_migration(conn)
await conn.execute(
text(
"INSERT INTO bitable.bitable_meta (key, value, updated_at) "

View File

@ -58,12 +58,32 @@ class RecalcStatus(str, Enum):
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):
"""A bitable table — collection of fields and records."""
model_config = ConfigDict(from_attributes=True)
id: str
file_id: str | None = None
name: str
description: str = ""
primary_key_field_id: str | None = None
@ -72,6 +92,54 @@ class Table(BaseModel):
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):
"""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 (
BitableDB,
FieldModel,
FileModel,
RecordModel,
RecalcQueueModel,
TableModel,
@ -25,6 +26,7 @@ from agentkit.bitable.db import (
_uuid_str,
)
from agentkit.bitable.models import (
BitableFile,
Field,
FieldOwner,
FieldType,
@ -55,6 +57,83 @@ class BitableRepository:
def _session_factory(self):
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 ──────────────────────────────────────────────
async def create_table(
@ -63,6 +142,7 @@ class BitableRepository:
description: str = "",
primary_key_field_id: str | None = None,
owner_user_id: str | None = None,
file_id: str | None = None,
) -> Table:
"""Create a new bitable table."""
async with self._session_factory() as session:
@ -70,6 +150,7 @@ class BitableRepository:
insert(TableModel)
.values(
id=_uuid_str(),
file_id=file_id,
name=name,
description=description,
primary_key_field_id=primary_key_field_id,
@ -352,6 +433,13 @@ class BitableRepository:
await session.commit()
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]:
"""List all views in a table."""
async with self._session_factory() as session:

View File

@ -10,11 +10,14 @@ from __future__ import annotations
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from agentkit.bitable.db import BitableDB
from agentkit.bitable.models import (
DEFAULT_FIELD_TEMPLATES,
BitableFile,
Field,
FieldOwner,
FieldType,
@ -69,6 +72,43 @@ class BitableService:
if self._recalc_worker is not None:
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 ──────────────────────────────────────────────
async def create_table(
@ -77,18 +117,45 @@ class BitableService:
description: str = "",
primary_key_field_id: str | None = None,
owner_user_id: str | None = None,
file_id: str | None = None,
) -> 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(
name=name,
description=description,
primary_key_field_id=primary_key_field_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:
await self._repo.create_pk_unique_index(table.id, primary_key_field_id)
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:
return await self._repo.get_table(table_id)
@ -194,8 +261,31 @@ class BitableService:
# ── Records ─────────────────────────────────────────────
async def create_record(self, table_id: str, values: dict[str, Any] | None = None) -> Record:
"""Create a new record. Triggers recalc for affected formula fields."""
async def create_record(
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)
await self._trigger_recalc_for_affected_fields(table_id, record.id)
return record
@ -429,6 +519,9 @@ class BitableService:
async def update_view(self, view_id: str, **kwargs: Any) -> View | None:
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) ────────────────
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']
CodeDiffViewer: typeof import('./src/components/code/CodeDiffViewer.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']
ConditionNode: typeof import('./src/components/workflow/ConditionNode.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']
FieldManagePanel: typeof import('./src/components/bitable/FieldManagePanel.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']
FileTree: typeof import('./src/components/code/FileTree.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']
SearchTest: typeof import('./src/components/kb/SearchTest.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']
SkillCard: typeof import('./src/components/skills/SkillCard.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
* to /bitable). This avoids the whoami cold-start bug that page.goto would
* trigger on a full reload.
*
* 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_USER, clearAuth } from './helpers'
import { TEST_USER, clearAuth, waitForServer } from './helpers'
async function loginAndOpenBitable(page: Page): Promise<void> {
await page.goto('/login')
@ -23,37 +23,52 @@ async function loginAndOpenBitable(page: Page): Promise<void> {
// SPA-navigate to /bitable via the TopNav "多维表格" button.
await page.getByRole('button', { name: '多维表格' }).click()
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('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)
// URL should be /bitable, not redirected to /login.
await expect(page).toHaveURL(/\/bitable/, { timeout: 10_000 })
// BitableView root element visible — no white screen.
await expect(page.locator('.bitable-view')).toBeVisible()
// File list view root element visible — no white screen.
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)
// 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,
})
// Either the sidebar (table list) or the placeholder is rendered.
// Either the file grid (cards) or the empty state is rendered.
await expect
.poll(
async () => {
const sidebar = await page.locator('.bitable-view__sidebar').count()
const placeholder = await page.locator('.bitable-view__placeholder').count()
return sidebar + placeholder
const grid = await page.locator('.bitable-file-list-view__grid').count()
const empty = await page.locator('.bitable-file-list-view__empty').count()
return grid + empty
},
{ timeout: 15_000, intervals: [1_000] },
)
.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'
/** 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 {
id: string
file_id: string | null
name: string
description: string
primary_key_field_id: string | null
@ -69,6 +81,18 @@ export interface IAttachmentMeta {
// ── Request types ──────────────────────────────────────────────────────
export interface ICreateFileRequest {
name: string
icon?: string
description?: string
}
export interface IUpdateFileRequest {
name?: string
icon?: string
description?: string
}
export interface ICreateTableRequest {
name: string
description?: string
@ -126,7 +150,58 @@ class BitableApiClient extends BaseApiClient {
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[] }> {
return this.request('/tables', { method: 'GET' })

View File

@ -36,6 +36,50 @@
:images="(row[f.id] as IAttachmentMeta[] | null | undefined)"
/>
</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>
</div>
</template>
@ -44,6 +88,7 @@
import { computed, ref } from 'vue'
import { VxeGrid } from 'vxe-table'
import { Empty as AEmpty } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import type { VxeGridProps, VxeGridEvents } from 'vxe-table'
import type {
IBitableField,
@ -53,6 +98,10 @@ import type {
} from '@/api/bitable'
import AttachmentCell from './AttachmentCell.vue'
import ImageCell from './ImageCell.vue'
import ColumnHeaderMenu from './ColumnHeaderMenu.vue'
import SelectCellEditor from './SelectCellEditor.vue'
import SelectDisplay from './SelectDisplay.vue'
import type { ISelectOption } from './SelectCellEditor.vue'
type GridRow = Record<string, unknown> & { _rowId: string; _recordId: string }
type GridColumn = NonNullable<VxeGridProps['columns']>[number]
@ -74,6 +123,10 @@ const props = withDefaults(
const emit = defineEmits<{
(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)
@ -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)
const rows = computed<GridRow[]>(() =>
props.records.map((r) => ({
@ -109,6 +169,17 @@ const gridColumns = computed<GridColumn[]>(() => {
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
})
@ -122,6 +193,8 @@ function buildColumn(f: IBitableField): GridColumn {
width: 160,
resizable: true,
showOverflow: 'tooltip',
// Use header slot for column dropdown menu (U4)
slots: { header: `header_${f.id}` },
}
// Formula fields are read-only
@ -134,7 +207,7 @@ function buildColumn(f: IBitableField): GridColumn {
return {
...base,
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 'multiselect':
// ponytail: select editor uses text input for v1; options wiring is U5c
// U5: custom dropdown editor via slots
return {
...base,
editRender: { enabled: true, name: 'VxeInput' },
editRender: { enabled: true },
slots: {
...base.slots,
edit: `edit_${f.id}`,
default: `cell_sel_${f.id}`,
},
}
default:
return {
@ -179,12 +257,17 @@ const onEditClosed: VxeGridEvents.EditClosed = (params) => {
const recordId = (row as GridRow)._recordId
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 original = props.records.find((r) => r.id === recordId)
if (!original) return
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', {
recordId,
@ -224,4 +307,20 @@ defineExpose({
.bitable-grid-scope :deep(.vxe-cell--dirty) {
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>

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

View File

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

View File

@ -89,12 +89,31 @@ const routes: RouteRecordRaw[] = [
meta: { title: 'Computer Use' },
},
// Bitable 多维表格 (独立全屏视图)
// Bitable 多维表格 (独立全屏视图,三层路由:文件列表 → 文件详情 → 表详情)
{
path: '/bitable',
name: 'bitable',
component: () => import('@/views/BitableView.vue'),
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.

View File

@ -13,6 +13,7 @@ import { ref, computed } from 'vue'
import { notification } from 'ant-design-vue'
import { bitableApi } from '@/api/bitable'
import type {
IBitableFile,
IBitableTable,
IBitableField,
IBitableRecord,
@ -22,6 +23,8 @@ import type {
export const useBitableStore = defineStore('bitable', () => {
// --- State ---
const files = ref<IBitableFile[]>([])
const currentFile = ref<IBitableFile | null>(null)
const tables = ref<IBitableTable[]>([])
const currentTable = ref<IBitableTable | null>(null)
const fields = ref<IBitableField[]>([])
@ -44,9 +47,138 @@ export const useBitableStore = defineStore('bitable', () => {
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> {
isLoading.value = true
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) */
async function refreshRecords(): Promise<void> {
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 */
async function pollRecalcStatus(tableId: string): Promise<void> {
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 || []
// 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),
)
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 newIds = newRecords.map((r) => r.id).join(',')
if (oldIds !== newIds || stillCalculating) {
if (oldIds !== newIds || stillCalculating || wasCalculating) {
records.value = newRecords
nextCursor.value = resp.next_cursor
}
@ -350,6 +501,8 @@ export const useBitableStore = defineStore('bitable', () => {
return {
// State
files,
currentFile,
tables,
currentTable,
fields,
@ -363,7 +516,15 @@ export const useBitableStore = defineStore('bitable', () => {
// Getters
formulaFields,
hasFormulaFields,
// Actions
// File actions (R1)
loadFiles,
createFile,
updateFile,
deleteFile,
selectFile,
loadTablesByFile,
createTableInFile,
// Table actions
loadTables,
selectTable,
loadMoreRecords,
@ -372,6 +533,7 @@ export const useBitableStore = defineStore('bitable', () => {
createTable,
updateField,
deleteField,
hideField,
refreshRecords,
createView,
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>
<div class="bitable-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>
<router-view />
</template>
<script setup lang="ts">
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Button as AButton, Tag as ATag, Modal as AModal } from 'ant-design-vue'
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')
},
})
}
// Legacy BitableView replaced by nested routes under /bitable.
// This component is kept as a passthrough for any stale imports;
// the router no longer references it directly.
</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:
"""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)
if table is None:
@ -93,7 +94,24 @@ async def _check_table_ownership(
if user.get("internal"):
return # Internal service token (KTD11) bypasses ownership
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):
name: str
description: str = ""
@ -164,6 +194,11 @@ async def create_table(
request: Request,
user: dict = Depends(require_bitable_auth),
) -> 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)
table = await service.create_table(
name=body.name,
@ -179,11 +214,126 @@ async def list_tables(
request: Request,
user: dict = Depends(require_bitable_auth),
) -> 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)
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]}
# ---------------------------------------------------------------------------
# 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}")
async def get_table(
table_id: str,
@ -270,6 +420,10 @@ async def update_field(
user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
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)
field = await service.update_field(field_id, **kwargs)
if field is None:
@ -285,6 +439,10 @@ async def delete_field(
user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
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:
deleted = await service.delete_field(field_id, force=force)
except FieldDependencyError as e:
@ -346,7 +504,9 @@ async def create_records(
await _check_table_ownership(service, table_id, user)
created = []
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"))
return {"success": True, "count": len(created), "records": created}
@ -398,6 +558,10 @@ async def update_record(
user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
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)
if record is None:
raise HTTPException(status_code=404, detail="Record not found")
@ -423,6 +587,10 @@ async def delete_single_record(
user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
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)
if not deleted:
raise HTTPException(status_code=404, detail="Record not found")
@ -492,6 +660,10 @@ async def update_view(
user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
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)
view = await service.update_view(view_id, **kwargs)
if view is None:
@ -535,6 +707,8 @@ async def upload_file(
field = await service.get_field(field_id)
if field is None:
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):
raise HTTPException(
status_code=400,
@ -588,11 +762,11 @@ async def upload_file(
"stored_name": stored_name,
"mime_type": mime,
"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(
filename: str,
user: dict = Depends(require_bitable_auth),

View File

@ -29,7 +29,9 @@ def _make_test_user() -> dict[str, Any]:
@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."""
upload_dir = tmp_path / "bitable_uploads"
# 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",
) -> tuple[str, str]:
"""Create a table + a field, return (table_id, field_id)."""
table_id = (
await client.post("/api/v1/bitable/tables", json={"name": "T"})
).json()["table"]["id"]
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
field_id = (
await client.post(
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:
table_id = (
await client.post("/api/v1/bitable/tables", json={"name": "T"})
).json()["table"]["id"]
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
img_bytes, _ = _make_image_bytes()
resp = await client.post(
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",
json={"records": [{field_id: metas}]},
)
assert create_resp.status_code == 200
assert create_resp.status_code == 201
record = create_resp.json()["records"][0]
assert len(record["values"][field_id]) == 2

View File

@ -1,15 +1,13 @@
"""Tests for bitable DB initialization, schema, and constraints (U1).
Requires PostgreSQL marked ``postgres``. Skips automatically when
``DATABASE_URL`` / ``AGENTKIT_DATABASE_URL`` is unset (see conftest.py).
PG-dependent tests skip via the ``bitable_db`` fixture when PostgreSQL is
unavailable. The no-URL degradation test runs in all environments.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.postgres
# ---------------------------------------------------------------------------
# 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:
"""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
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
# All 6 tables present
# All 7 tables present (V2 adds bitable_files)
result = await conn.execute(
text(
"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()}
assert tables == {
"bitable_fields",
"bitable_files",
"bitable_meta",
"bitable_records",
"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