diff --git a/CONCEPTS.md b/CONCEPTS.md index 0bfec4b..7a0eb61 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -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. diff --git a/docs/brainstorms/2026-06-29-bitable-ui-completeness-requirements.md b/docs/brainstorms/2026-06-29-bitable-ui-completeness-requirements.md new file mode 100644 index 0000000..6734060 --- /dev/null +++ b/docs/brainstorms/2026-06-29-bitable-ui-completeness-requirements.md @@ -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 → 数据表)和 twenty(Object → 记录)都有这个上层归集,bitable 当前是平铺的表列表,数据无组织归属。 +2. **默认字段缺失**:新建数据表是空表起步,没有自带默认字段集。飞书/twenty 新建表都自带"标题/状态/日期/创建人/创建时间"等基础字段,让用户立即有结构可填。 +3. **表内字段操作缺失**:增/改/删字段只能通过右侧"字段管理"弹层,不能在表内列头直接操作。飞书/twenty 都支持点列头下拉菜单管理字段。 + +此外 select/multiselect 字段编辑器仍是文本输入(不是下拉选项),三类采集入口在前端无 UI,ViewType 枚举有 5 种但前端只实现 grid 一种,公式编辑器只有校验 API 没有 UI 增强。 + +现状是"后端能力齐备,前端产品形态不到位"。本次完善的目标是把前端产品形态对齐飞书/twenty 范式,让 bitable 真正可用。 + +--- + +## Key Decisions + +**KD1. 方案选 Approach 1:飞书范式复刻,三层容器骨架先行。** 不选 Approach 2(表内体验优先 + 文件层轻量化后置)也不选 Approach 3(Agent 数据底座优先)。理由:用户明确"全面对齐飞书/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 不被破坏。文件层引入是新增 schema(V2),不破坏现有 V1 表结构。 +- **E2E 覆盖**:每阶段交付时 e2e 测试覆盖关键流程(建表/采集/视图切换/字段操作)。当前 `e2e/bitable-view.spec.ts` 仅 B1/B2 两个基础测试,需扩展。 + +--- + +## Scope Boundaries + +### 本次范围内 + +四阶段全部 16 个 R(R1-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"。 diff --git a/docs/plans/2026-06-29-001-feat-bitable-ui-completeness-stage1-plan.md b/docs/plans/2026-06-29-001-feat-bitable-ui-completeness-stage1-plan.md new file mode 100644 index 0000000..a6c3780 --- /dev/null +++ b/docs/plans/2026-06-29-001-feat-bitable-ui-completeness-stage1-plan.md @@ -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 个默认字段:标题(text,owner=user)、状态(select,预设"未开始/进行中/已完成"3 选项,owner=user)、日期(date,owner=user)、创建人(text,owner=agent)、创建时间(datetime,owner=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 模式 4):404 before 403,internal 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(服务隔离)、模式 4(IDOR 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:** U1(create_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:** U3(store 重构) + +**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:** U4(BitableGrid 已修改) + +**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 Criteria(E2E 覆盖) + +**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 2(R7-R10)**:三类采集入口前端 UI(Excel/DB/API)、Agent 写入反馈 UI +- **Stage 3(R11-R13)**:看板视图、画廊视图、公式编辑器增强 +- **Stage 4(R14-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 依赖 U1(create_table 签名变更)。U3 依赖 U1(前端调用后端文件 API)。U4/U5 依赖 U3(store 重构)。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 组件 diff --git a/src/agentkit/bitable/db.py b/src/agentkit/bitable/db.py index b0d63e9..a24445d 100644 --- a/src/agentkit/bitable/db.py +++ b/src/agentkit/bitable/db.py @@ -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) " diff --git a/src/agentkit/bitable/models.py b/src/agentkit/bitable/models.py index dcef747..2f1ea76 100644 --- a/src/agentkit/bitable/models.py +++ b/src/agentkit/bitable/models.py @@ -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. diff --git a/src/agentkit/bitable/repository.py b/src/agentkit/bitable/repository.py index 6bcb23b..2560dd4 100644 --- a/src/agentkit/bitable/repository.py +++ b/src/agentkit/bitable/repository.py @@ -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: diff --git a/src/agentkit/bitable/service.py b/src/agentkit/bitable/service.py index 86d08a8..a236ea5 100644 --- a/src/agentkit/bitable/service.py +++ b/src/agentkit/bitable/service.py @@ -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: diff --git a/src/agentkit/server/frontend/components.d.ts b/src/agentkit/server/frontend/components.d.ts index 8bbddf6..61721ca 100644 --- a/src/agentkit/server/frontend/components.d.ts +++ b/src/agentkit/server/frontend/components.d.ts @@ -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'] diff --git a/src/agentkit/server/frontend/e2e/bitable-field-ops.spec.ts b/src/agentkit/server/frontend/e2e/bitable-field-ops.spec.ts new file mode 100644 index 0000000..533b57a --- /dev/null +++ b/src/agentkit/server/frontend/e2e/bitable-field-ops.spec.ts @@ -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 { + 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) + }) +}) diff --git a/src/agentkit/server/frontend/e2e/bitable-file-flow.spec.ts b/src/agentkit/server/frontend/e2e/bitable-file-flow.spec.ts new file mode 100644 index 0000000..e8165e1 --- /dev/null +++ b/src/agentkit/server/frontend/e2e/bitable-file-flow.spec.ts @@ -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 { + 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 }) + }) +}) diff --git a/src/agentkit/server/frontend/e2e/bitable-view.spec.ts b/src/agentkit/server/frontend/e2e/bitable-view.spec.ts index a647ba3..c15f572 100644 --- a/src/agentkit/server/frontend/e2e/bitable-view.spec.ts +++ b/src/agentkit/server/frontend/e2e/bitable-view.spec.ts @@ -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 { await page.goto('/login') @@ -23,37 +23,52 @@ async function loginAndOpenBitable(page: Page): Promise { // 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 }) + }) }) diff --git a/src/agentkit/server/frontend/src/api/bitable.ts b/src/agentkit/server/frontend/src/api/bitable.ts index 6a5474d..f3a8480 100644 --- a/src/agentkit/server/frontend/src/api/bitable.ts +++ b/src/agentkit/server/frontend/src/api/bitable.ts @@ -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' }) diff --git a/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue b/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue index 5491c70..cf3ef30 100644 --- a/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue +++ b/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue @@ -36,6 +36,50 @@ :images="(row[f.id] as IAttachmentMeta[] | null | undefined)" /> + + + + + + + + @@ -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 & { _rowId: string; _recordId: string } type GridColumn = NonNullable[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 | 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(() => props.records.map((r) => ({ @@ -109,6 +169,17 @@ const gridColumns = computed(() => { 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)[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); +} diff --git a/src/agentkit/server/frontend/src/components/bitable/ColumnHeaderMenu.vue b/src/agentkit/server/frontend/src/components/bitable/ColumnHeaderMenu.vue new file mode 100644 index 0000000..69ef205 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/bitable/ColumnHeaderMenu.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/bitable/FileCard.vue b/src/agentkit/server/frontend/src/components/bitable/FileCard.vue new file mode 100644 index 0000000..ed09256 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/bitable/FileCard.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/bitable/FileCreateModal.vue b/src/agentkit/server/frontend/src/components/bitable/FileCreateModal.vue new file mode 100644 index 0000000..626abc2 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/bitable/FileCreateModal.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/agentkit/server/frontend/src/components/bitable/SelectCellEditor.vue b/src/agentkit/server/frontend/src/components/bitable/SelectCellEditor.vue new file mode 100644 index 0000000..0a702db --- /dev/null +++ b/src/agentkit/server/frontend/src/components/bitable/SelectCellEditor.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/bitable/SelectDisplay.vue b/src/agentkit/server/frontend/src/components/bitable/SelectDisplay.vue new file mode 100644 index 0000000..5b95996 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/bitable/SelectDisplay.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/agentkit/server/frontend/src/components/bitable/TableCreateModal.vue b/src/agentkit/server/frontend/src/components/bitable/TableCreateModal.vue index 17c8d72..17bc744 100644 --- a/src/agentkit/server/frontend/src/components/bitable/TableCreateModal.vue +++ b/src/agentkit/server/frontend/src/components/bitable/TableCreateModal.vue @@ -34,9 +34,11 @@ diff --git a/src/agentkit/server/frontend/src/router/index.ts b/src/agentkit/server/frontend/src/router/index.ts index 6510df5..8aec856 100644 --- a/src/agentkit/server/frontend/src/router/index.ts +++ b/src/agentkit/server/frontend/src/router/index.ts @@ -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. diff --git a/src/agentkit/server/frontend/src/stores/bitable.ts b/src/agentkit/server/frontend/src/stores/bitable.ts index 5c3431b..6862533 100644 --- a/src/agentkit/server/frontend/src/stores/bitable.ts +++ b/src/agentkit/server/frontend/src/stores/bitable.ts @@ -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([]) + const currentFile = ref(null) const tables = ref([]) const currentTable = ref(null) const fields = ref([]) @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { 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 { + 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 { 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 { 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, diff --git a/src/agentkit/server/frontend/src/views/BitableFileDetailView.vue b/src/agentkit/server/frontend/src/views/BitableFileDetailView.vue new file mode 100644 index 0000000..6d410be --- /dev/null +++ b/src/agentkit/server/frontend/src/views/BitableFileDetailView.vue @@ -0,0 +1,375 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/views/BitableFileListView.vue b/src/agentkit/server/frontend/src/views/BitableFileListView.vue new file mode 100644 index 0000000..2f180b8 --- /dev/null +++ b/src/agentkit/server/frontend/src/views/BitableFileListView.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/views/BitableView.vue b/src/agentkit/server/frontend/src/views/BitableView.vue index bf0fe9f..d130a55 100644 --- a/src/agentkit/server/frontend/src/views/BitableView.vue +++ b/src/agentkit/server/frontend/src/views/BitableView.vue @@ -1,294 +1,9 @@ - - diff --git a/src/agentkit/server/routes/bitable.py b/src/agentkit/server/routes/bitable.py index 4fe1c72..64a11d6 100644 --- a/src/agentkit/server/routes/bitable.py +++ b/src/agentkit/server/routes/bitable.py @@ -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), diff --git a/tests/unit/bitable/test_attachment.py b/tests/unit/bitable/test_attachment.py index 77a7f04..6772907 100644 --- a/tests/unit/bitable/test_attachment.py +++ b/tests/unit/bitable/test_attachment.py @@ -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 diff --git a/tests/unit/bitable/test_db.py b/tests/unit/bitable/test_db.py index 41dc6ba..19026dc 100644 --- a/tests/unit/bitable/test_db.py +++ b/tests/unit/bitable/test_db.py @@ -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", diff --git a/tests/unit/bitable/test_default_fields.py b/tests/unit/bitable/test_default_fields.py new file mode 100644 index 0000000..ad133ce --- /dev/null +++ b/tests/unit/bitable/test_default_fields.py @@ -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 diff --git a/tests/unit/bitable/test_file_crud.py b/tests/unit/bitable/test_file_crud.py new file mode 100644 index 0000000..1437628 --- /dev/null +++ b/tests/unit/bitable/test_file_crud.py @@ -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