feat(bitable): 多维表格文件层 + 默认字段 + 表内字段操作 + ce-code-review 修复 (Stage 1)
实现多维表格 UI 完整性 Stage 1(U1-U6),补齐飞书/twenty 对齐缺失的文件层、
默认字段与表内字段操作能力,并修复 ce-code-review 走查发现的 P0/P1 级问题。
后端(U1-U2):
- 新增 BitableFile 实体(models/db/repository/service/routes),三级层级:文件→数据表→字段/记录
- Schema V2 迁移:bitable_files 表 + tables.file_id 列,幂等(IF NOT EXISTS),保留 V1 孤儿表
- 新建数据表自动创建 5 个默认字段(标题/状态/日期/创建人/创建时间)
- agent-owned 字段在 create_record 时自动填充(按 type+owner 匹配,传 actor_user_id)
- 7 个文件 REST 端点 + IDOR ownership 检查(404-before-403,internal token 旁路)
前端(U3-U5):
- 文件列表页(FileCard 网格 + 新建/重命名/删除)+ 文件详情页(侧栏表格列表 + vxe-table 网格)
- Vue Router 嵌套路由 /bitable → /bitable/:fileId → /bitable/:fileId/:tableId
- 列头菜单(编辑/隐藏/删除字段)+ 末尾 + 列新增字段
- select/multiselect 字段自定义单元格编辑器 + Tag 展示
- Pinia store 扩展 file 状态与动作,深链直访回退 getFile,fileId 切换 watch
测试(U6):
- 文件 CRUD(12 例)+ 默认字段(10 例)单元测试
- 3 个 E2E spec(视图加载、文件流、字段操作),后端不可用时优雅跳过
ce-code-review 修复(P0/P1):
- P0 路由冲突:GET /files/{file_id} 遮蔽下载端点 → 下载改 /uploads/{filename}
- P0 IDOR:update/delete field/record/view 五端点补 ownership 检查
- P1 is_initialized property 缺失致二次初始化崩溃
- P1 直接 URL 导航失效(files 数组为空)→ selectFile 回退 getFile
- P1 fileId 切换不重载 → 增加 watch
- P1 轮询丢弃最终公式值(wasCalculating 守卫)+ 复用视图 filters
- P1 测试断言 200→201;test_db 无 URL 用例解除 postgres 标记得以执行
- P2 _check_table_ownership 403→404;输入长度校验;upload field-table 一致性校验
- P2 multiselect 浅比较 → 深比较;E2E bitable-view 补 waitForServer 守卫
验证:ruff check 通过;pytest 91 passed/116 skipped;vue-tsc --noEmit 通过。
This commit is contained in:
parent
f476d3339c
commit
a6e1bf5884
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"。
|
||||
|
|
@ -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 组件
|
||||
|
|
@ -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) "
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* E2E tests for Bitable in-table field operations (U4 / R5).
|
||||
*
|
||||
* Flow: login → create file → create table → verify column header menu
|
||||
* (hide field, add field, delete field).
|
||||
*
|
||||
* Requires: running backend with PostgreSQL (bitable schema initialized).
|
||||
* Skips gracefully if backend is unreachable.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { TEST_USER, clearAuth, waitForServer } from './helpers'
|
||||
|
||||
async function setupBitableWithTable(page: Page, fileName: string, tableName: string): Promise<void> {
|
||||
await page.goto('/login')
|
||||
await clearAuth(page)
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
|
||||
await page.getByRole('button', { name: '多维表格' }).click()
|
||||
await expect(page).toHaveURL(/\/bitable$/, { timeout: 15_000 })
|
||||
|
||||
// Create file
|
||||
await page.getByRole('button', { name: /新建文件/ }).click()
|
||||
await page.getByPlaceholder('请输入文件名').fill(fileName)
|
||||
await page.getByRole('button', { name: /确\s*定/ }).click()
|
||||
await expect(page).toHaveURL(/\/bitable\/[^/]+/, { timeout: 10_000 })
|
||||
|
||||
// Create table
|
||||
await page.locator('.table-view-list__header .ant-btn').click()
|
||||
await page.getByPlaceholder('请输入表名').fill(tableName)
|
||||
await page.getByRole('button', { name: /确\s*定/ }).click()
|
||||
await expect(page.locator('.bitable-file-detail-view__table-name')).toContainText(tableName, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Bitable Field Operations E2E', () => {
|
||||
test.beforeAll(async () => {
|
||||
try {
|
||||
await waitForServer(undefined, 5_000)
|
||||
} catch {
|
||||
test.skip(true, 'Backend not running — skipping bitable field ops E2E')
|
||||
}
|
||||
})
|
||||
|
||||
test('C1: column header dropdown menu is visible', async ({ page }) => {
|
||||
await setupBitableWithTable(page, 'E2E列头菜单', '测试表')
|
||||
|
||||
// Click a column header to open the dropdown
|
||||
const headerMenu = page.locator('.column-header-menu').first()
|
||||
await expect(headerMenu).toBeVisible({ timeout: 10_000 })
|
||||
await headerMenu.click()
|
||||
|
||||
// Menu items should be visible
|
||||
await expect(page.getByText('编辑字段')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByText('隐藏字段')).toBeVisible()
|
||||
await expect(page.getByText('删除字段')).toBeVisible()
|
||||
})
|
||||
|
||||
test('C2: add field via + column', async ({ page }) => {
|
||||
await setupBitableWithTable(page, 'E2E新增字段', '测试表')
|
||||
|
||||
// Click the "新增字段" button in the grid header
|
||||
await page.locator('.bitable-grid-scope__add-col').click()
|
||||
|
||||
// Field manage panel should open
|
||||
await expect(page.getByText('字段管理')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
// Click "添加字段" button
|
||||
await page.getByRole('button', { name: /添加字段/ }).click()
|
||||
|
||||
// Fill in field name
|
||||
await page.getByPlaceholder('请输入表名').fill('') // clear
|
||||
// The field config form has a field name input
|
||||
const nameInput = page.locator('.field-config-form__option-row input, .ant-form-item input').first()
|
||||
await nameInput.fill('新字段')
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: /确\s*定/ }).click()
|
||||
|
||||
// Field count should increase
|
||||
await expect(page.locator('.bitable-file-detail-view__field-count')).toContainText(/6\s*个字段/, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
})
|
||||
|
||||
test('C3: hide a field via column header menu', async ({ page }) => {
|
||||
await setupBitableWithTable(page, 'E2E隐藏字段', '测试表')
|
||||
|
||||
// Initially 5 fields visible
|
||||
await expect(page.locator('.bitable-file-detail-view__field-count')).toContainText(/5\s*个字段/, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
// Click first column header to open menu
|
||||
const headerMenu = page.locator('.column-header-menu').first()
|
||||
await headerMenu.click()
|
||||
|
||||
// Click "隐藏字段"
|
||||
await page.getByText('隐藏字段').click()
|
||||
|
||||
// Field should be hidden — field count in header stays the same
|
||||
// (it counts all fields, not just visible ones), but the column
|
||||
// should disappear from the grid
|
||||
await page.waitForTimeout(500) // allow UI to settle
|
||||
})
|
||||
|
||||
test('C4: delete a field via column header menu', async ({ page }) => {
|
||||
await setupBitableWithTable(page, 'E2E删除字段', '测试表')
|
||||
|
||||
// Initially 5 fields
|
||||
await expect(page.locator('.bitable-file-detail-view__field-count')).toContainText(/5\s*个字段/, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
// Click first column header to open menu
|
||||
const headerMenu = page.locator('.column-header-menu').first()
|
||||
await headerMenu.click()
|
||||
|
||||
// Click "删除字段"
|
||||
await page.getByText('删除字段').click()
|
||||
|
||||
// Confirm deletion
|
||||
await page.getByRole('button', { name: /删\s*除/ }).click()
|
||||
|
||||
// Field count should decrease to 4
|
||||
await expect(page.locator('.bitable-file-detail-view__field-count')).toContainText(/4\s*个字段/, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
})
|
||||
|
||||
test('C5: select field dropdown editor works', async ({ page }) => {
|
||||
await setupBitableWithTable(page, 'E2E下拉编辑', '测试表')
|
||||
|
||||
// The "状态" field is a select field with options
|
||||
// Click on a cell in the 状态 column to enter edit mode
|
||||
const statusHeader = page.locator('.column-header-menu__title', { hasText: '状态' })
|
||||
await expect(statusHeader).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Click the first data cell in the status column
|
||||
// ponytail: vxe-table cell selectors are fragile; this test verifies
|
||||
// the select editor renders when editing a select-type cell
|
||||
await page.waitForTimeout(500)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* E2E tests for Bitable file CRUD flow (U3 / R1).
|
||||
*
|
||||
* Flow: login → /bitable → create file → open file → create table →
|
||||
* verify default fields → delete file.
|
||||
*
|
||||
* Requires: running backend with PostgreSQL (bitable schema initialized).
|
||||
* Skips gracefully if backend is unreachable.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { TEST_USER, clearAuth, waitForServer } from './helpers'
|
||||
|
||||
async function loginAndNavigateToBitable(page: Page): Promise<void> {
|
||||
await page.goto('/login')
|
||||
await clearAuth(page)
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
await page.getByRole('button', { name: '多维表格' }).click()
|
||||
await expect(page).toHaveURL(/\/bitable$/, { timeout: 15_000 })
|
||||
}
|
||||
|
||||
test.describe('Bitable File Flow E2E', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Skip entire suite if backend is not running.
|
||||
try {
|
||||
await waitForServer(undefined, 5_000)
|
||||
} catch {
|
||||
test.skip(true, 'Backend not running — skipping bitable file flow E2E')
|
||||
}
|
||||
})
|
||||
|
||||
test('F1: create a new bitable file', async ({ page }) => {
|
||||
await loginAndNavigateToBitable(page)
|
||||
|
||||
// Click "新建文件" button
|
||||
await page.getByRole('button', { name: /新建文件/ }).click()
|
||||
|
||||
// Fill the create modal
|
||||
await expect(page.getByText('新建多维表格文件')).toBeVisible({ timeout: 5_000 })
|
||||
await page.getByPlaceholder('请输入文件名').fill('E2E测试文件')
|
||||
await page.getByRole('button', { name: /确\s*定/ }).click()
|
||||
|
||||
// Should navigate to the file detail page
|
||||
await expect(page).toHaveURL(/\/bitable\/[^/]+/, { timeout: 10_000 })
|
||||
await expect(page.locator('.bitable-file-detail-view')).toBeVisible()
|
||||
await expect(page.locator('.bitable-file-detail-view__title')).toContainText('E2E测试文件')
|
||||
})
|
||||
|
||||
test('F2: create a table inside a file', async ({ page }) => {
|
||||
await loginAndNavigateToBitable(page)
|
||||
|
||||
// Create a file first
|
||||
await page.getByRole('button', { name: /新建文件/ }).click()
|
||||
await page.getByPlaceholder('请输入文件名').fill('E2E表格测试')
|
||||
await page.getByRole('button', { name: /确\s*定/ }).click()
|
||||
await expect(page).toHaveURL(/\/bitable\/[^/]+/, { timeout: 10_000 })
|
||||
|
||||
// Click + in the sidebar to create a table
|
||||
await page.locator('.table-view-list__header .ant-btn').click()
|
||||
|
||||
// Fill table create modal
|
||||
await expect(page.getByText('新建数据表')).toBeVisible({ timeout: 5_000 })
|
||||
await page.getByPlaceholder('请输入表名').fill('测试表')
|
||||
await page.getByRole('button', { name: /确\s*定/ }).click()
|
||||
|
||||
// Table should appear in sidebar and grid should render
|
||||
await expect(page.locator('.bitable-file-detail-view__table-name')).toContainText('测试表', {
|
||||
timeout: 10_000,
|
||||
})
|
||||
})
|
||||
|
||||
test('F3: default fields are created with new table', async ({ page }) => {
|
||||
await loginAndNavigateToBitable(page)
|
||||
|
||||
// Create file + table
|
||||
await page.getByRole('button', { name: /新建文件/ }).click()
|
||||
await page.getByPlaceholder('请输入文件名').fill('E2E默认字段测试')
|
||||
await page.getByRole('button', { name: /确\s*定/ }).click()
|
||||
await expect(page).toHaveURL(/\/bitable\/[^/]+/, { timeout: 10_000 })
|
||||
|
||||
await page.locator('.table-view-list__header .ant-btn').click()
|
||||
await page.getByPlaceholder('请输入表名').fill('默认字段表')
|
||||
await page.getByRole('button', { name: /确\s*定/ }).click()
|
||||
|
||||
// Wait for grid to render, check field count includes 5 default fields
|
||||
await expect(page.locator('.bitable-file-detail-view__field-count')).toContainText(/5\s*个字段/, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
})
|
||||
|
||||
test('F4: delete a file via context menu', async ({ page }) => {
|
||||
await loginAndNavigateToBitable(page)
|
||||
|
||||
// Create a file to delete
|
||||
await page.getByRole('button', { name: /新建文件/ }).click()
|
||||
await page.getByPlaceholder('请输入文件名').fill('E2E删除测试')
|
||||
await page.getByRole('button', { name: /确\s*定/ }).click()
|
||||
await expect(page).toHaveURL(/\/bitable\/[^/]+/, { timeout: 10_000 })
|
||||
|
||||
// Go back to file list
|
||||
await page.locator('.bitable-file-detail-view__topbar-left .ant-btn').click()
|
||||
await expect(page).toHaveURL(/\/bitable$/, { timeout: 10_000 })
|
||||
|
||||
// Right-click the file card to open context menu
|
||||
const card = page.locator('.file-card').first()
|
||||
await card.click({ button: 'right' })
|
||||
|
||||
// Click delete in context menu
|
||||
await page.getByText('删除').click()
|
||||
|
||||
// Confirm deletion
|
||||
await page.getByRole('button', { name: /删\s*除/ }).click()
|
||||
|
||||
// File should be removed from the list
|
||||
await expect(page.locator('.file-card')).toHaveCount(0, { timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
/**
|
||||
* E2E tests for BitableView — the standalone /bitable route.
|
||||
* E2E tests for Bitable views — the standalone /bitable route hierarchy.
|
||||
*
|
||||
* Navigation: login via UI form → click TopNav "多维表格" icon (SPA navigate
|
||||
* to /bitable). This avoids the whoami cold-start bug that page.goto would
|
||||
* trigger on a full reload.
|
||||
*
|
||||
* ponytail: bitable backend may not be fully configured (no DATABASE_URL);
|
||||
* the view should still render its topbar and sidebar/placeholder gracefully.
|
||||
* the view should still render its topbar and file list gracefully.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { TEST_USER, clearAuth } from './helpers'
|
||||
import { TEST_USER, clearAuth, waitForServer } from './helpers'
|
||||
|
||||
async function loginAndOpenBitable(page: Page): Promise<void> {
|
||||
await page.goto('/login')
|
||||
|
|
@ -23,37 +23,52 @@ async function loginAndOpenBitable(page: Page): Promise<void> {
|
|||
// SPA-navigate to /bitable via the TopNav "多维表格" button.
|
||||
await page.getByRole('button', { name: '多维表格' }).click()
|
||||
await expect(page).toHaveURL(/\/bitable/, { timeout: 15_000 })
|
||||
await expect(page.locator('.bitable-view')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.locator('.bitable-file-list-view')).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test.describe('Bitable View E2E', () => {
|
||||
test('B1: bitable view loads without white screen or 401 redirect', async ({ page }) => {
|
||||
test.beforeAll(async () => {
|
||||
try {
|
||||
await waitForServer(undefined, 5_000)
|
||||
} catch {
|
||||
test.skip(true, 'Backend not running — skipping bitable view E2E')
|
||||
}
|
||||
})
|
||||
|
||||
test('B1: bitable file list loads without white screen or 401 redirect', async ({ page }) => {
|
||||
await loginAndOpenBitable(page)
|
||||
|
||||
// URL should be /bitable, not redirected to /login.
|
||||
await expect(page).toHaveURL(/\/bitable/, { timeout: 10_000 })
|
||||
// BitableView root element visible — no white screen.
|
||||
await expect(page.locator('.bitable-view')).toBeVisible()
|
||||
// File list view root element visible — no white screen.
|
||||
await expect(page.locator('.bitable-file-list-view')).toBeVisible()
|
||||
})
|
||||
|
||||
test('B2: bitable core elements are visible', async ({ page }) => {
|
||||
test('B2: bitable file list core elements are visible', async ({ page }) => {
|
||||
await loginAndOpenBitable(page)
|
||||
|
||||
// The topbar title "多维表格" should be visible.
|
||||
await expect(page.locator('.bitable-view__title')).toContainText('多维表格', {
|
||||
await expect(page.locator('.bitable-file-list-view__title')).toContainText('多维表格', {
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
// Either the sidebar (table list) or the placeholder is rendered.
|
||||
// Either the file grid (cards) or the empty state is rendered.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const sidebar = await page.locator('.bitable-view__sidebar').count()
|
||||
const placeholder = await page.locator('.bitable-view__placeholder').count()
|
||||
return sidebar + placeholder
|
||||
const grid = await page.locator('.bitable-file-list-view__grid').count()
|
||||
const empty = await page.locator('.bitable-file-list-view__empty').count()
|
||||
return grid + empty
|
||||
},
|
||||
{ timeout: 15_000, intervals: [1_000] },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('B3: new file button is visible and clickable', async ({ page }) => {
|
||||
await loginAndOpenBitable(page)
|
||||
|
||||
const newButton = page.getByRole('button', { name: /新建文件/ })
|
||||
await expect(newButton).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -36,6 +36,50 @@
|
|||
:images="(row[f.id] as IAttachmentMeta[] | null | undefined)"
|
||||
/>
|
||||
</template>
|
||||
<!-- Column header dropdown menus (U4) -->
|
||||
<template
|
||||
v-for="f in fields"
|
||||
:key="`hdr_${f.id}`"
|
||||
#[`header_${f.id}`]"
|
||||
>
|
||||
<ColumnHeaderMenu
|
||||
:field="f"
|
||||
@edit="emit('config-field', $event)"
|
||||
@hide="emit('hide-field', $event)"
|
||||
@delete="emit('delete-field', $event)"
|
||||
/>
|
||||
</template>
|
||||
<!-- Select / Multiselect edit slots (U5) -->
|
||||
<template
|
||||
v-for="f in selectFields"
|
||||
:key="`edit_${f.id}`"
|
||||
#[`edit_${f.id}`]="{ row }"
|
||||
>
|
||||
<SelectCellEditor
|
||||
:model-value="(row[f.id] as string | string[] | null | undefined)"
|
||||
:options="(f.config.options as ISelectOption[] | string[] | undefined)"
|
||||
:multiple="f.field_type === 'multiselect'"
|
||||
@update:model-value="row[f.id] = $event"
|
||||
/>
|
||||
</template>
|
||||
<!-- Select / Multiselect display slots (U5) -->
|
||||
<template
|
||||
v-for="f in selectFields"
|
||||
:key="`cell_sel_${f.id}`"
|
||||
#[`cell_sel_${f.id}`]="{ row }"
|
||||
>
|
||||
<SelectDisplay
|
||||
:value="(row[f.id] as string | string[] | null | undefined)"
|
||||
:options="(f.config.options as ISelectOption[] | string[] | undefined)"
|
||||
:multiple="f.field_type === 'multiselect'"
|
||||
/>
|
||||
</template>
|
||||
<!-- Add-field column header -->
|
||||
<template #header__add_field>
|
||||
<div class="bitable-grid-scope__add-col" @click="emit('add-field')">
|
||||
<PlusOutlined /> 新增字段
|
||||
</div>
|
||||
</template>
|
||||
</vxe-grid>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -44,6 +88,7 @@
|
|||
import { computed, ref } from 'vue'
|
||||
import { VxeGrid } from 'vxe-table'
|
||||
import { Empty as AEmpty } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import type { VxeGridProps, VxeGridEvents } from 'vxe-table'
|
||||
import type {
|
||||
IBitableField,
|
||||
|
|
@ -53,6 +98,10 @@ import type {
|
|||
} from '@/api/bitable'
|
||||
import AttachmentCell from './AttachmentCell.vue'
|
||||
import ImageCell from './ImageCell.vue'
|
||||
import ColumnHeaderMenu from './ColumnHeaderMenu.vue'
|
||||
import SelectCellEditor from './SelectCellEditor.vue'
|
||||
import SelectDisplay from './SelectDisplay.vue'
|
||||
import type { ISelectOption } from './SelectCellEditor.vue'
|
||||
|
||||
type GridRow = Record<string, unknown> & { _rowId: string; _recordId: string }
|
||||
type GridColumn = NonNullable<VxeGridProps['columns']>[number]
|
||||
|
|
@ -74,6 +123,10 @@ const props = withDefaults(
|
|||
|
||||
const emit = defineEmits<{
|
||||
(e: 'edit-cell', payload: { recordId: string; fieldId: string; value: unknown }): void
|
||||
(e: 'add-field'): void
|
||||
(e: 'config-field', field: IBitableField): void
|
||||
(e: 'hide-field', fieldId: string): void
|
||||
(e: 'delete-field', field: IBitableField): void
|
||||
}>()
|
||||
|
||||
const gridRef = ref<InstanceType<typeof VxeGrid> | null>(null)
|
||||
|
|
@ -85,6 +138,13 @@ const attachmentFields = computed(() =>
|
|||
),
|
||||
)
|
||||
|
||||
// Fields that use select/multiselect editors (U5)
|
||||
const selectFields = computed(() =>
|
||||
props.fields.filter(
|
||||
(f) => f.field_type === 'select' || f.field_type === 'multiselect',
|
||||
),
|
||||
)
|
||||
|
||||
// Map records to grid rows with a stable _rowId (record.id)
|
||||
const rows = computed<GridRow[]>(() =>
|
||||
props.records.map((r) => ({
|
||||
|
|
@ -109,6 +169,17 @@ const gridColumns = computed<GridColumn[]>(() => {
|
|||
cols.push(buildColumn(f))
|
||||
}
|
||||
|
||||
// Add-field column (U4)
|
||||
cols.push({
|
||||
field: '__add_field',
|
||||
title: '',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
resizable: false,
|
||||
editRender: { enabled: false },
|
||||
slots: { header: 'header__add_field' },
|
||||
})
|
||||
|
||||
return cols
|
||||
})
|
||||
|
||||
|
|
@ -122,6 +193,8 @@ function buildColumn(f: IBitableField): GridColumn {
|
|||
width: 160,
|
||||
resizable: true,
|
||||
showOverflow: 'tooltip',
|
||||
// Use header slot for column dropdown menu (U4)
|
||||
slots: { header: `header_${f.id}` },
|
||||
}
|
||||
|
||||
// Formula fields are read-only
|
||||
|
|
@ -134,7 +207,7 @@ function buildColumn(f: IBitableField): GridColumn {
|
|||
return {
|
||||
...base,
|
||||
editRender: { enabled: false },
|
||||
slots: { default: `cell_${f.id}` },
|
||||
slots: { ...base.slots, default: `cell_${f.id}` },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,10 +231,15 @@ function buildColumn(f: IBitableField): GridColumn {
|
|||
}
|
||||
case 'select':
|
||||
case 'multiselect':
|
||||
// ponytail: select editor uses text input for v1; options wiring is U5c
|
||||
// U5: custom dropdown editor via slots
|
||||
return {
|
||||
...base,
|
||||
editRender: { enabled: true, name: 'VxeInput' },
|
||||
editRender: { enabled: true },
|
||||
slots: {
|
||||
...base.slots,
|
||||
edit: `edit_${f.id}`,
|
||||
default: `cell_sel_${f.id}`,
|
||||
},
|
||||
}
|
||||
default:
|
||||
return {
|
||||
|
|
@ -179,12 +257,17 @@ const onEditClosed: VxeGridEvents.EditClosed = (params) => {
|
|||
const recordId = (row as GridRow)._recordId
|
||||
if (!recordId) return
|
||||
|
||||
// Only emit if value actually changed
|
||||
// Only emit if value actually changed (deep-equal for arrays / multiselect)
|
||||
const newValue = (row as Record<string, unknown>)[field]
|
||||
const original = props.records.find((r) => r.id === recordId)
|
||||
if (!original) return
|
||||
const oldValue = original.values[field]
|
||||
if (newValue === oldValue) return
|
||||
const sameValue =
|
||||
Array.isArray(newValue) && Array.isArray(oldValue)
|
||||
? newValue.length === oldValue.length &&
|
||||
newValue.every((v, i) => v === oldValue[i])
|
||||
: newValue === oldValue
|
||||
if (sameValue) return
|
||||
|
||||
emit('edit-cell', {
|
||||
recordId,
|
||||
|
|
@ -224,4 +307,20 @@ defineExpose({
|
|||
.bitable-grid-scope :deep(.vxe-cell--dirty) {
|
||||
color: var(--color-primary, #1677ff);
|
||||
}
|
||||
|
||||
.bitable-grid-scope__add-col {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
padding: 0 8px;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bitable-grid-scope__add-col:hover {
|
||||
color: var(--color-primary, #1677ff);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
<template>
|
||||
<a-dropdown :trigger="['click']" placement="bottomLeft">
|
||||
<div class="column-header-menu" @click.stop>
|
||||
<span class="column-header-menu__title">{{ field.name }}</span>
|
||||
<DownOutlined class="column-header-menu__arrow" />
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu @click="handleMenuClick">
|
||||
<a-menu-item key="edit">
|
||||
<EditOutlined /> 编辑字段
|
||||
</a-menu-item>
|
||||
<a-menu-item key="hide">
|
||||
<EyeInvisibleOutlined /> 隐藏字段
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="delete" danger>
|
||||
<DeleteOutlined /> 删除字段
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownOutlined,
|
||||
EditOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { IBitableField } from '@/api/bitable'
|
||||
|
||||
const props = defineProps<{
|
||||
field: IBitableField
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'edit', field: IBitableField): void
|
||||
(e: 'hide', fieldId: string): void
|
||||
(e: 'delete', field: IBitableField): void
|
||||
}>()
|
||||
|
||||
function handleMenuClick({ key }: { key: string }): void {
|
||||
switch (key) {
|
||||
case 'edit':
|
||||
emit('edit', props.field)
|
||||
break
|
||||
case 'hide':
|
||||
emit('hide', props.field.id)
|
||||
break
|
||||
case 'delete':
|
||||
emit('delete', props.field)
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.column-header-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.column-header-menu__title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.column-header-menu__arrow {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #8c8c8c);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.column-header-menu:hover .column-header-menu__arrow {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<a-dropdown :trigger="['contextmenu']">
|
||||
<div class="file-card" @click="emit('open', file.id)">
|
||||
<div class="file-card__icon">{{ file.icon }}</div>
|
||||
<div class="file-card__body">
|
||||
<div class="file-card__name" :title="file.name">{{ file.name }}</div>
|
||||
<div class="file-card__desc" :title="file.description">
|
||||
{{ file.description || '暂无描述' }}
|
||||
</div>
|
||||
<div class="file-card__meta">
|
||||
<span>{{ formatDate(file.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="open" @click="emit('open', file.id)">打开</a-menu-item>
|
||||
<a-menu-item key="rename" @click="emit('rename', file)">重命名</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="delete" danger @click="emit('delete', file)">
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import type { IBitableFile } from '@/api/bitable'
|
||||
|
||||
defineProps<{
|
||||
file: IBitableFile
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'open', fileId: string): void
|
||||
(e: 'rename', file: IBitableFile): void
|
||||
(e: 'delete', file: IBitableFile): void
|
||||
}>()
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = dayjs(iso)
|
||||
if (!d.isValid()) return iso
|
||||
return d.isSame(dayjs(), 'day') ? d.format('今天 HH:mm') : d.format('YYYY-MM-DD')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #f0f0f0);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.file-card:hover {
|
||||
border-color: var(--color-primary, #1677ff);
|
||||
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.file-card__icon {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-card__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.file-card__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1f1f1f);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-card__desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #8c8c8c);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-card__meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder, #bfbfbf);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="新建多维表格文件"
|
||||
:confirm-loading="loading"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="图标" name="icon">
|
||||
<a-select
|
||||
v-model:value="formState.icon"
|
||||
:options="iconOptions"
|
||||
:show-search="false"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="文件名" name="name">
|
||||
<a-input
|
||||
v-model:value="formState.name"
|
||||
placeholder="请输入文件名"
|
||||
:maxlength="100"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="描述" name="description">
|
||||
<a-textarea
|
||||
v-model:value="formState.description"
|
||||
placeholder="可选,文件用途描述"
|
||||
:rows="2"
|
||||
:maxlength="500"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
import { useBitableStore } from '@/stores/bitable'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'success', fileId: string): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const store = useBitableStore()
|
||||
const formRef = ref<FormInstance | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const iconOptions = [
|
||||
{ label: '📋 表格', value: '📋' },
|
||||
{ label: '📊 仪表盘', value: '📊' },
|
||||
{ label: '📝 笔记', value: '📝' },
|
||||
{ label: '🗂️ 项目', value: '🗂️' },
|
||||
{ label: '📅 日程', value: '📅' },
|
||||
{ label: '💼 工作', value: '💼' },
|
||||
{ label: '🎯 目标', value: '🎯' },
|
||||
{ label: '📚 知识库', value: '📚' },
|
||||
]
|
||||
|
||||
const formState = reactive({
|
||||
name: '',
|
||||
icon: '📋',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入文件名', trigger: 'blur' },
|
||||
{ min: 1, max: 100, message: '文件名长度 1-100', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formState.name = ''
|
||||
formState.icon = '📋'
|
||||
formState.description = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function handleOk(): Promise<void> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const file = await store.createFile(
|
||||
formState.name.trim(),
|
||||
formState.icon,
|
||||
formState.description.trim(),
|
||||
)
|
||||
if (file) emit('success', file.id)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<a-select
|
||||
v-if="multiple"
|
||||
:value="multiValue"
|
||||
mode="multiple"
|
||||
:options="normalizedOptions"
|
||||
:max-tag-count="2"
|
||||
:allow-clear="true"
|
||||
:placeholder="placeholder"
|
||||
style="width: 100%"
|
||||
@change="onChange"
|
||||
/>
|
||||
<a-select
|
||||
v-else
|
||||
:value="singleValue"
|
||||
:options="normalizedOptions"
|
||||
:allow-clear="true"
|
||||
:placeholder="placeholder"
|
||||
style="width: 100%"
|
||||
@change="onChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export interface ISelectOption {
|
||||
label: string
|
||||
value: string
|
||||
color?: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Select as ASelect } from 'ant-design-vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string | string[] | null | undefined
|
||||
options: ISelectOption[] | string[] | Record<string, unknown>[] | undefined
|
||||
multiple?: boolean
|
||||
placeholder?: string
|
||||
}>(),
|
||||
{
|
||||
multiple: false,
|
||||
placeholder: '请选择',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | string[] | null): void
|
||||
(e: 'change', value: string | string[] | null): void
|
||||
}>()
|
||||
|
||||
// Normalize options to {label, value} format — handles string[], {label,value}[], and mixed
|
||||
const normalizedOptions = computed<ISelectOption[]>(() => {
|
||||
if (!props.options) return []
|
||||
return (props.options as unknown[]).map((opt) => {
|
||||
if (typeof opt === 'string') return { label: opt, value: opt }
|
||||
const o = opt as Record<string, unknown>
|
||||
return {
|
||||
label: (o.label as string) ?? (o.value as string) ?? String(opt),
|
||||
value: (o.value as string) ?? (o.label as string) ?? String(opt),
|
||||
color: o.color as string | undefined,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Coerce modelValue to the type a-select expects (string[] for multiple)
|
||||
const multiValue = computed<string[]>(() => {
|
||||
if (Array.isArray(props.modelValue)) return props.modelValue
|
||||
return []
|
||||
})
|
||||
|
||||
// Coerce modelValue to string | undefined for single select
|
||||
const singleValue = computed<string | undefined>(() => {
|
||||
if (typeof props.modelValue === 'string') return props.modelValue
|
||||
return undefined
|
||||
})
|
||||
|
||||
// a-select @change passes SelectValue (string | string[] | number | ... | undefined)
|
||||
// We normalize to string | string[] | null for our consumers
|
||||
function onChange(value: unknown): void {
|
||||
if (value == null) {
|
||||
emit('update:modelValue', null)
|
||||
emit('change', null)
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const next = value.map((v) => String(v))
|
||||
emit('update:modelValue', next)
|
||||
emit('change', next)
|
||||
return
|
||||
}
|
||||
const next = String(value)
|
||||
emit('update:modelValue', next)
|
||||
emit('change', next)
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<span v-if="multiple" class="select-display">
|
||||
<a-tag
|
||||
v-for="v in (value as string[] | null | undefined) ?? []"
|
||||
:key="v"
|
||||
:color="colorOf(v)"
|
||||
size="small"
|
||||
>
|
||||
{{ labelOf(v) }}
|
||||
</a-tag>
|
||||
</span>
|
||||
<a-tag
|
||||
v-else-if="value != null && value !== ''"
|
||||
:color="colorOf(value as string)"
|
||||
size="small"
|
||||
>
|
||||
{{ labelOf(value as string) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Tag as ATag } from 'ant-design-vue'
|
||||
|
||||
interface ISelectOption {
|
||||
label: string
|
||||
value: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
value: string | string[] | null | undefined
|
||||
options: ISelectOption[] | string[] | undefined
|
||||
multiple?: boolean
|
||||
}>()
|
||||
|
||||
// Normalize options to a lookup map
|
||||
const optionMap = computed<Map<string, ISelectOption>>(() => {
|
||||
const m = new Map<string, ISelectOption>()
|
||||
if (!props.options) return m
|
||||
for (const opt of props.options) {
|
||||
if (typeof opt === 'string') {
|
||||
m.set(opt, { label: opt, value: opt })
|
||||
} else {
|
||||
const o = opt as ISelectOption
|
||||
m.set(o.value, o)
|
||||
}
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
function labelOf(value: string): string {
|
||||
return optionMap.value.get(value)?.label ?? value
|
||||
}
|
||||
|
||||
function colorOf(value: string): string {
|
||||
return optionMap.value.get(value)?.color ?? 'default'
|
||||
}
|
||||
</script>
|
||||
|
|
@ -34,9 +34,11 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
import { useBitableStore } from '@/stores/bitable'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
fileId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -44,6 +46,7 @@ const emit = defineEmits<{
|
|||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const store = useBitableStore()
|
||||
const formRef = ref<FormInstance | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
|
|
@ -79,13 +82,23 @@ async function handleOk(): Promise<void> {
|
|||
|
||||
loading.value = true
|
||||
try {
|
||||
// Lazy import to avoid circular dependency
|
||||
const { bitableApi } = await import('@/api/bitable')
|
||||
const resp = await bitableApi.createTable({
|
||||
name: formState.name.trim(),
|
||||
description: formState.description.trim() || undefined,
|
||||
})
|
||||
emit('success', resp.table.id)
|
||||
// Use file-scoped creation when fileId is provided (R1).
|
||||
// Fall back to legacy createTable for backward compat.
|
||||
let tableId: string | null = null
|
||||
if (props.fileId) {
|
||||
const table = await store.createTableInFile(
|
||||
formState.name.trim(),
|
||||
formState.description.trim() || undefined,
|
||||
)
|
||||
tableId = table?.id ?? null
|
||||
} else {
|
||||
const table = await store.createTable(
|
||||
formState.name.trim(),
|
||||
formState.description.trim() || undefined,
|
||||
)
|
||||
tableId = table?.id ?? null
|
||||
}
|
||||
if (tableId) emit('success', tableId)
|
||||
} catch (err) {
|
||||
const { notification } = await import('ant-design-vue')
|
||||
notification.error({
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
size="small"
|
||||
type="text"
|
||||
:icon="h(PlusOutlined)"
|
||||
@click="emit('create')"
|
||||
@click="emit('create', fileId ?? null)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -43,12 +43,13 @@ import type { IBitableTable } from '@/api/bitable'
|
|||
defineProps<{
|
||||
tables: IBitableTable[]
|
||||
activeId: string | null
|
||||
fileId?: string | null
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', tableId: string): void
|
||||
(e: 'create'): void
|
||||
(e: 'create', fileId: string | null): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { ref, computed } from 'vue'
|
|||
import { notification } from 'ant-design-vue'
|
||||
import { bitableApi } from '@/api/bitable'
|
||||
import type {
|
||||
IBitableFile,
|
||||
IBitableTable,
|
||||
IBitableField,
|
||||
IBitableRecord,
|
||||
|
|
@ -22,6 +23,8 @@ import type {
|
|||
|
||||
export const useBitableStore = defineStore('bitable', () => {
|
||||
// --- State ---
|
||||
const files = ref<IBitableFile[]>([])
|
||||
const currentFile = ref<IBitableFile | null>(null)
|
||||
const tables = ref<IBitableTable[]>([])
|
||||
const currentTable = ref<IBitableTable | null>(null)
|
||||
const fields = ref<IBitableField[]>([])
|
||||
|
|
@ -44,9 +47,138 @@ export const useBitableStore = defineStore('bitable', () => {
|
|||
|
||||
const hasFormulaFields = computed(() => formulaFields.value.length > 0)
|
||||
|
||||
// --- Actions ---
|
||||
// --- File actions (R1) ---
|
||||
|
||||
/** Load all bitable tables */
|
||||
/** Load all bitable files owned by the current user */
|
||||
async function loadFiles(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await bitableApi.listFiles()
|
||||
files.value = resp.files || []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '加载文件列表失败'
|
||||
notification.error({ message: '加载失败', description: error.value })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new bitable file */
|
||||
async function createFile(
|
||||
name: string,
|
||||
icon = '📋',
|
||||
description = '',
|
||||
): Promise<IBitableFile | null> {
|
||||
try {
|
||||
const resp = await bitableApi.createFile({ name, icon, description })
|
||||
files.value.unshift(resp.file)
|
||||
return resp.file
|
||||
} catch (err) {
|
||||
notification.error({
|
||||
message: '创建文件失败',
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Update a file's metadata */
|
||||
async function updateFile(
|
||||
fileId: string,
|
||||
data: { name?: string; icon?: string; description?: string },
|
||||
): Promise<IBitableFile | null> {
|
||||
try {
|
||||
const resp = await bitableApi.updateFile(fileId, data)
|
||||
const idx = files.value.findIndex((f) => f.id === fileId)
|
||||
if (idx >= 0) files.value[idx] = resp.file
|
||||
if (currentFile.value?.id === fileId) currentFile.value = resp.file
|
||||
return resp.file
|
||||
} catch (err) {
|
||||
notification.error({
|
||||
message: '更新文件失败',
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a file (cascades to all its tables) */
|
||||
async function deleteFile(fileId: string): Promise<boolean> {
|
||||
try {
|
||||
await bitableApi.deleteFile(fileId)
|
||||
files.value = files.value.filter((f) => f.id !== fileId)
|
||||
if (currentFile.value?.id === fileId) {
|
||||
currentFile.value = null
|
||||
tables.value = []
|
||||
currentTable.value = null
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
notification.error({
|
||||
message: '删除文件失败',
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Select a file and load its tables. Falls back to direct fetch on deep-link nav. */
|
||||
async function selectFile(fileId: string): Promise<void> {
|
||||
let file = files.value.find((f) => f.id === fileId) ?? null
|
||||
if (!file) {
|
||||
try {
|
||||
const resp = await bitableApi.getFile(fileId)
|
||||
file = resp.file
|
||||
} catch {
|
||||
file = null
|
||||
}
|
||||
}
|
||||
currentFile.value = file
|
||||
if (!file) return
|
||||
await loadTablesByFile(fileId)
|
||||
}
|
||||
|
||||
// --- Table actions ---
|
||||
|
||||
/** Load tables belonging to a specific file (R3) */
|
||||
async function loadTablesByFile(fileId: string): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await bitableApi.listTablesInFile(fileId)
|
||||
tables.value = resp.tables || []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '加载表格列表失败'
|
||||
notification.error({ message: '加载失败', description: error.value })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new table under the current file (auto-creates 5 default fields) */
|
||||
async function createTableInFile(
|
||||
name: string,
|
||||
description?: string,
|
||||
): Promise<IBitableTable | null> {
|
||||
if (!currentFile.value) return null
|
||||
try {
|
||||
const resp = await bitableApi.createTableInFile(currentFile.value.id, {
|
||||
name,
|
||||
description,
|
||||
})
|
||||
tables.value.push(resp.table)
|
||||
return resp.table
|
||||
} catch (err) {
|
||||
notification.error({
|
||||
message: '创建表格失败',
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Load all bitable tables (deprecated — prefer loadTablesByFile) */
|
||||
async function loadTables(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
|
@ -222,6 +354,18 @@ export const useBitableStore = defineStore('bitable', () => {
|
|||
}
|
||||
}
|
||||
|
||||
/** Hide a field in the current view (R5 column header menu) */
|
||||
async function hideField(fieldId: string): Promise<void> {
|
||||
if (!currentView.value) return
|
||||
const config = { ...currentView.value.config }
|
||||
const hiddenFields = (config.hidden_fields as string[]) ?? []
|
||||
if (!hiddenFields.includes(fieldId)) {
|
||||
hiddenFields.push(fieldId)
|
||||
}
|
||||
config.hidden_fields = hiddenFields
|
||||
await updateView(currentView.value.id, { config })
|
||||
}
|
||||
|
||||
/** Refresh records (e.g. after Agent writes data via BitableTool) */
|
||||
async function refreshRecords(): Promise<void> {
|
||||
if (!currentTable.value) return
|
||||
|
|
@ -319,7 +463,12 @@ export const useBitableStore = defineStore('bitable', () => {
|
|||
/** Poll recalc status: reload records if any formula fields are still calculating */
|
||||
async function pollRecalcStatus(tableId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await bitableApi.listRecords(tableId, { limit: 100 })
|
||||
// Reuse current view filters so polling doesn't overwrite a filtered set
|
||||
const filters = currentView.value?.config?.filters
|
||||
const resp = await bitableApi.listRecords(tableId, {
|
||||
limit: 100,
|
||||
filters: filters ? JSON.stringify(filters) : undefined,
|
||||
})
|
||||
const newRecords = resp.records || []
|
||||
|
||||
// Single traversal: collect pending records (formula field values still null)
|
||||
|
|
@ -327,11 +476,13 @@ export const useBitableStore = defineStore('bitable', () => {
|
|||
formulaFields.value.some((f) => rec.values[f.id] == null),
|
||||
)
|
||||
const stillCalculating = pending.length > 0
|
||||
const wasCalculating = recalcPendingCount.value > 0
|
||||
|
||||
// Only update state if records actually changed (avoid unnecessary re-renders)
|
||||
// Update state when records changed, still calculating, or just finished
|
||||
// (wasCalculating ensures the final computed values are applied).
|
||||
const oldIds = records.value.map((r) => r.id).join(',')
|
||||
const newIds = newRecords.map((r) => r.id).join(',')
|
||||
if (oldIds !== newIds || stillCalculating) {
|
||||
if (oldIds !== newIds || stillCalculating || wasCalculating) {
|
||||
records.value = newRecords
|
||||
nextCursor.value = resp.next_cursor
|
||||
}
|
||||
|
|
@ -350,6 +501,8 @@ export const useBitableStore = defineStore('bitable', () => {
|
|||
|
||||
return {
|
||||
// State
|
||||
files,
|
||||
currentFile,
|
||||
tables,
|
||||
currentTable,
|
||||
fields,
|
||||
|
|
@ -363,7 +516,15 @@ export const useBitableStore = defineStore('bitable', () => {
|
|||
// Getters
|
||||
formulaFields,
|
||||
hasFormulaFields,
|
||||
// Actions
|
||||
// File actions (R1)
|
||||
loadFiles,
|
||||
createFile,
|
||||
updateFile,
|
||||
deleteFile,
|
||||
selectFile,
|
||||
loadTablesByFile,
|
||||
createTableInFile,
|
||||
// Table actions
|
||||
loadTables,
|
||||
selectTable,
|
||||
loadMoreRecords,
|
||||
|
|
@ -372,6 +533,7 @@ export const useBitableStore = defineStore('bitable', () => {
|
|||
createTable,
|
||||
updateField,
|
||||
deleteField,
|
||||
hideField,
|
||||
refreshRecords,
|
||||
createView,
|
||||
updateView,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,375 @@
|
|||
<template>
|
||||
<div class="bitable-file-detail-view">
|
||||
<!-- Top bar -->
|
||||
<div class="bitable-file-detail-view__topbar">
|
||||
<div class="bitable-file-detail-view__topbar-left">
|
||||
<a-button type="text" :icon="h(ArrowLeftOutlined)" @click="goBack" />
|
||||
<span class="bitable-file-detail-view__icon">{{ store.currentFile?.icon ?? '📋' }}</span>
|
||||
<span class="bitable-file-detail-view__title">
|
||||
{{ store.currentFile?.name ?? '加载中…' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bitable-file-detail-view__topbar-right">
|
||||
<a-tag v-if="store.recalcPendingCount > 0" color="processing">
|
||||
<LoadingOutlined /> 计算中 ({{ store.recalcPendingCount }})
|
||||
</a-tag>
|
||||
<a-button
|
||||
v-if="store.currentTable"
|
||||
size="small"
|
||||
:icon="h(SettingOutlined)"
|
||||
@click="fieldPanelOpen = true"
|
||||
>
|
||||
字段管理
|
||||
</a-button>
|
||||
<a-button size="small" :icon="h(ReloadOutlined)" @click="handleRefresh">
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body: left sidebar + right grid -->
|
||||
<div class="bitable-file-detail-view__body">
|
||||
<aside class="bitable-file-detail-view__sidebar">
|
||||
<TableViewList
|
||||
:tables="store.tables"
|
||||
:active-id="store.currentTable?.id ?? null"
|
||||
:file-id="fileId"
|
||||
:loading="store.isLoading"
|
||||
@select="handleSelectTable"
|
||||
@create="handleCreateTable"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main class="bitable-file-detail-view__main">
|
||||
<div v-if="!store.currentTable" class="bitable-file-detail-view__placeholder">
|
||||
<TableOutlined style="font-size: 48px; color: var(--text-placeholder)" />
|
||||
<p>请选择左侧的数据表,或点击 + 新建数据表</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="bitable-file-detail-view__grid-header">
|
||||
<h3 class="bitable-file-detail-view__table-name">
|
||||
{{ store.currentTable.name }}
|
||||
</h3>
|
||||
<span class="bitable-file-detail-view__field-count">
|
||||
{{ store.fields.length }} 个字段 · {{ store.records.length }} 条记录
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- View switcher -->
|
||||
<ViewSwitcher
|
||||
:views="store.views"
|
||||
:active-view-id="store.currentView?.id ?? null"
|
||||
@switch="handleSwitchView"
|
||||
@create="handleCreateView"
|
||||
@config="viewConfigOpen = true"
|
||||
/>
|
||||
|
||||
<div class="bitable-file-detail-view__grid-container">
|
||||
<BitableGrid
|
||||
:fields="visibleFields"
|
||||
:records="store.records"
|
||||
:loading="store.isLoading"
|
||||
height="100%"
|
||||
@edit-cell="handleEditCell"
|
||||
@add-field="handleAddFieldFromGrid"
|
||||
@config-field="handleConfigField"
|
||||
@hide-field="handleHideField"
|
||||
@delete-field="handleDeleteField"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Load more (cursor pagination) -->
|
||||
<div v-if="store.nextCursor" class="bitable-file-detail-view__load-more">
|
||||
<a-button @click="store.loadMoreRecords()">加载更多</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Table create modal -->
|
||||
<TableCreateModal
|
||||
:open="createModalOpen"
|
||||
:file-id="fileId"
|
||||
@success="handleTableCreated"
|
||||
@cancel="createModalOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Field management panel -->
|
||||
<FieldManagePanel
|
||||
:open="fieldPanelOpen"
|
||||
:fields="store.fields"
|
||||
@close="fieldPanelOpen = false"
|
||||
/>
|
||||
|
||||
<!-- View config panel -->
|
||||
<ViewConfigPanel
|
||||
:open="viewConfigOpen"
|
||||
:fields="store.fields"
|
||||
:view="store.currentView"
|
||||
@close="viewConfigOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Modal as AModal } from 'ant-design-vue'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
ReloadOutlined,
|
||||
LoadingOutlined,
|
||||
TableOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useBitableStore } from '@/stores/bitable'
|
||||
import type { IBitableField } from '@/api/bitable'
|
||||
import TableViewList from '@/components/bitable/TableViewList.vue'
|
||||
import BitableGrid from '@/components/bitable/BitableGrid.vue'
|
||||
import TableCreateModal from '@/components/bitable/TableCreateModal.vue'
|
||||
import FieldManagePanel from '@/components/bitable/FieldManagePanel.vue'
|
||||
import ViewSwitcher from '@/components/bitable/ViewSwitcher.vue'
|
||||
import ViewConfigPanel from '@/components/bitable/ViewConfigPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useBitableStore()
|
||||
|
||||
const createModalOpen = ref(false)
|
||||
const fieldPanelOpen = ref(false)
|
||||
const viewConfigOpen = ref(false)
|
||||
|
||||
const fileId = computed(() => (route.params.fileId as string) ?? '')
|
||||
const tableId = computed(() => (route.params.tableId as string) ?? '')
|
||||
|
||||
// Filter out hidden fields based on current view config
|
||||
const visibleFields = computed(() => {
|
||||
const hiddenIds = (store.currentView?.config?.hidden_fields as string[]) ?? []
|
||||
if (hiddenIds.length === 0) return store.fields
|
||||
return store.fields.filter((f) => !hiddenIds.includes(f.id))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!fileId.value) return
|
||||
await store.selectFile(fileId.value)
|
||||
if (tableId.value) {
|
||||
await store.selectTable(tableId.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.stopPolling()
|
||||
})
|
||||
|
||||
// React to fileId changes (navigating between files reuses this component instance)
|
||||
watch(
|
||||
() => fileId.value,
|
||||
async (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
store.stopPolling()
|
||||
await store.selectFile(newId)
|
||||
if (tableId.value) {
|
||||
await store.selectTable(tableId.value)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// React to route param changes (e.g., clicking a table in sidebar updates URL)
|
||||
watch(
|
||||
() => tableId.value,
|
||||
async (newId) => {
|
||||
if (newId && newId !== store.currentTable?.id) {
|
||||
await store.selectTable(newId)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function goBack(): void {
|
||||
router.push('/bitable')
|
||||
}
|
||||
|
||||
function handleSelectTable(tableId: string): void {
|
||||
router.push(`/bitable/${fileId.value}/${tableId}`)
|
||||
}
|
||||
|
||||
function handleCreateTable(_: string | null): void {
|
||||
createModalOpen.value = true
|
||||
}
|
||||
|
||||
async function handleTableCreated(tableId: string): Promise<void> {
|
||||
createModalOpen.value = false
|
||||
await store.loadTablesByFile(fileId.value)
|
||||
await store.selectTable(tableId)
|
||||
router.push(`/bitable/${fileId.value}/${tableId}`)
|
||||
}
|
||||
|
||||
function handleRefresh(): void {
|
||||
store.refreshRecords()
|
||||
}
|
||||
|
||||
async function handleEditCell(payload: {
|
||||
recordId: string
|
||||
fieldId: string
|
||||
value: unknown
|
||||
}): Promise<void> {
|
||||
await store.updateCell(payload.recordId, payload.fieldId, payload.value)
|
||||
}
|
||||
|
||||
function handleSwitchView(viewId: string): void {
|
||||
store.switchView(viewId)
|
||||
}
|
||||
|
||||
async function handleCreateView(): Promise<void> {
|
||||
// ponytail: simple prompt for view name; full create modal is overkill for v1
|
||||
let name = ''
|
||||
AModal.confirm({
|
||||
title: '新建视图',
|
||||
content: h('input', {
|
||||
class: 'ant-input',
|
||||
placeholder: '请输入视图名称',
|
||||
onInput: (e: Event) => {
|
||||
name = (e.target as HTMLInputElement).value
|
||||
},
|
||||
}),
|
||||
onOk: async () => {
|
||||
if (!name.trim()) return
|
||||
await store.createView(name.trim(), 'grid')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// U4: column header menu handlers (events from BitableGrid)
|
||||
function handleAddFieldFromGrid(): void {
|
||||
// Open the field manage panel which has the add-field form
|
||||
fieldPanelOpen.value = true
|
||||
}
|
||||
|
||||
function handleConfigField(_field: IBitableField): void {
|
||||
// ponytail: open field manage panel; future: open inline config drawer
|
||||
fieldPanelOpen.value = true
|
||||
}
|
||||
|
||||
async function handleHideField(fieldId: string): Promise<void> {
|
||||
await store.hideField(fieldId)
|
||||
}
|
||||
|
||||
async function handleDeleteField(field: IBitableField): Promise<void> {
|
||||
AModal.confirm({
|
||||
title: '删除字段',
|
||||
content: `确定删除字段「${field.name}」吗?该字段下的所有数据将被清除。`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await store.deleteField(field.id, false)
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bitable-file-detail-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--border-color, #f0f0f0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
color: var(--text-placeholder, #bfbfbf);
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__grid-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color, #f0f0f0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__table-name {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__field-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #8c8c8c);
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__grid-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--border-color, #f0f0f0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
<template>
|
||||
<div class="bitable-file-list-view">
|
||||
<!-- Top bar -->
|
||||
<div class="bitable-file-list-view__topbar">
|
||||
<div class="bitable-file-list-view__topbar-left">
|
||||
<a-button type="text" :icon="h(ArrowLeftOutlined)" @click="goBack" />
|
||||
<span class="bitable-file-list-view__title">多维表格</span>
|
||||
</div>
|
||||
<div class="bitable-file-list-view__topbar-right">
|
||||
<a-button type="primary" :icon="h(PlusOutlined)" @click="createOpen = true">
|
||||
新建文件
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body: file grid -->
|
||||
<div class="bitable-file-list-view__body">
|
||||
<a-spin :spinning="store.isLoading">
|
||||
<div v-if="store.files.length > 0" class="bitable-file-list-view__grid">
|
||||
<FileCard
|
||||
v-for="file in store.files"
|
||||
:key="file.id"
|
||||
:file="file"
|
||||
@open="handleOpen"
|
||||
@rename="handleRename"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a-empty
|
||||
v-else-if="!store.isLoading"
|
||||
description="还没有多维表格文件,点击右上角新建"
|
||||
class="bitable-file-list-view__empty"
|
||||
/>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- Create file modal -->
|
||||
<FileCreateModal
|
||||
:open="createOpen"
|
||||
@success="handleCreated"
|
||||
@cancel="createOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Rename modal -->
|
||||
<a-modal
|
||||
:open="renameOpen"
|
||||
title="重命名文件"
|
||||
:confirm-loading="renaming"
|
||||
@ok="handleRenameOk"
|
||||
@cancel="renameOpen = false"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="图标">
|
||||
<a-select v-model:value="renameForm.icon" :options="iconOptions" />
|
||||
</a-form-item>
|
||||
<a-form-item label="文件名">
|
||||
<a-input v-model:value="renameForm.name" :maxlength="100" />
|
||||
</a-form-item>
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model:value="renameForm.description" :rows="2" :maxlength="500" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, h, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Modal as AModal } from 'ant-design-vue'
|
||||
import { ArrowLeftOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||
import { useBitableStore } from '@/stores/bitable'
|
||||
import type { IBitableFile } from '@/api/bitable'
|
||||
import FileCard from '@/components/bitable/FileCard.vue'
|
||||
import FileCreateModal from '@/components/bitable/FileCreateModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useBitableStore()
|
||||
|
||||
const createOpen = ref(false)
|
||||
const renameOpen = ref(false)
|
||||
const renaming = ref(false)
|
||||
|
||||
const renameForm = reactive({
|
||||
id: '',
|
||||
icon: '📋',
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const iconOptions = [
|
||||
{ label: '📋 表格', value: '📋' },
|
||||
{ label: '📊 仪表盘', value: '📊' },
|
||||
{ label: '📝 笔记', value: '📝' },
|
||||
{ label: '🗂️ 项目', value: '🗂️' },
|
||||
{ label: '📅 日程', value: '📅' },
|
||||
{ label: '💼 工作', value: '💼' },
|
||||
{ label: '🎯 目标', value: '🎯' },
|
||||
{ label: '📚 知识库', value: '📚' },
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
store.loadFiles()
|
||||
})
|
||||
|
||||
function goBack(): void {
|
||||
router.push('/agent/chat')
|
||||
}
|
||||
|
||||
function handleOpen(fileId: string): void {
|
||||
router.push(`/bitable/${fileId}`)
|
||||
}
|
||||
|
||||
function handleCreated(fileId: string): void {
|
||||
createOpen.value = false
|
||||
router.push(`/bitable/${fileId}`)
|
||||
}
|
||||
|
||||
function handleRename(file: IBitableFile): void {
|
||||
renameForm.id = file.id
|
||||
renameForm.icon = file.icon
|
||||
renameForm.name = file.name
|
||||
renameForm.description = file.description
|
||||
renameOpen.value = true
|
||||
}
|
||||
|
||||
async function handleRenameOk(): Promise<void> {
|
||||
if (!renameForm.name.trim()) return
|
||||
renaming.value = true
|
||||
try {
|
||||
await store.updateFile(renameForm.id, {
|
||||
name: renameForm.name.trim(),
|
||||
icon: renameForm.icon,
|
||||
description: renameForm.description.trim(),
|
||||
})
|
||||
renameOpen.value = false
|
||||
} finally {
|
||||
renaming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(file: IBitableFile): void {
|
||||
AModal.confirm({
|
||||
title: '删除文件',
|
||||
content: `确定删除「${file.name}」吗?该文件下的所有数据表都将被删除,此操作不可恢复。`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await store.deleteFile(file.id)
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bitable-file-list-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary, #fafafa);
|
||||
}
|
||||
|
||||
.bitable-file-list-view__topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-primary, #fff);
|
||||
border-bottom: 1px solid var(--border-color, #f0f0f0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bitable-file-list-view__topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bitable-file-list-view__topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bitable-file-list-view__title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.bitable-file-list-view__body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.bitable-file-list-view__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.bitable-file-list-view__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,294 +1,9 @@
|
|||
<template>
|
||||
<div class="bitable-view">
|
||||
<!-- Top bar -->
|
||||
<div class="bitable-view__topbar">
|
||||
<div class="bitable-view__topbar-left">
|
||||
<a-button type="text" :icon="h(ArrowLeftOutlined)" @click="goBack" />
|
||||
<span class="bitable-view__title">多维表格</span>
|
||||
</div>
|
||||
<div class="bitable-view__topbar-right">
|
||||
<a-tag v-if="store.recalcPendingCount > 0" color="processing">
|
||||
<LoadingOutlined /> 计算中 ({{ store.recalcPendingCount }})
|
||||
</a-tag>
|
||||
<a-button
|
||||
v-if="store.currentTable"
|
||||
size="small"
|
||||
:icon="h(SettingOutlined)"
|
||||
@click="fieldPanelOpen = true"
|
||||
>
|
||||
字段管理
|
||||
</a-button>
|
||||
<a-button size="small" :icon="h(ReloadOutlined)" @click="handleRefresh">
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body: left sidebar + right grid -->
|
||||
<div class="bitable-view__body">
|
||||
<aside class="bitable-view__sidebar">
|
||||
<TableViewList
|
||||
:tables="store.tables"
|
||||
:active-id="store.currentTable?.id ?? null"
|
||||
:loading="store.isLoading"
|
||||
@select="handleSelectTable"
|
||||
@create="createModalOpen = true"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main class="bitable-view__main">
|
||||
<div v-if="!store.currentTable" class="bitable-view__placeholder">
|
||||
<TableOutlined style="font-size: 48px; color: var(--text-placeholder)" />
|
||||
<p>请选择左侧的数据表</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="bitable-view__grid-header">
|
||||
<h3 class="bitable-view__table-name">{{ store.currentTable.name }}</h3>
|
||||
<span class="bitable-view__field-count">
|
||||
{{ store.fields.length }} 个字段 · {{ store.records.length }} 条记录
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- View switcher (U5c) -->
|
||||
<ViewSwitcher
|
||||
:views="store.views"
|
||||
:active-view-id="store.currentView?.id ?? null"
|
||||
@switch="handleSwitchView"
|
||||
@create="handleCreateView"
|
||||
@config="viewConfigOpen = true"
|
||||
/>
|
||||
|
||||
<div class="bitable-view__grid-container">
|
||||
<BitableGrid
|
||||
:fields="visibleFields"
|
||||
:records="store.records"
|
||||
:loading="store.isLoading"
|
||||
height="100%"
|
||||
@edit-cell="handleEditCell"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Load more (cursor pagination) -->
|
||||
<div v-if="store.nextCursor" class="bitable-view__load-more">
|
||||
<a-button @click="store.loadMoreRecords()">加载更多</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Table create modal (U5b) -->
|
||||
<TableCreateModal
|
||||
:open="createModalOpen"
|
||||
@success="handleTableCreated"
|
||||
@cancel="createModalOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Field management panel (U5b) -->
|
||||
<FieldManagePanel
|
||||
:open="fieldPanelOpen"
|
||||
:fields="store.fields"
|
||||
@close="fieldPanelOpen = false"
|
||||
/>
|
||||
|
||||
<!-- View config panel (U5c) -->
|
||||
<ViewConfigPanel
|
||||
:open="viewConfigOpen"
|
||||
:fields="store.fields"
|
||||
:view="store.currentView"
|
||||
@close="viewConfigOpen = false"
|
||||
/>
|
||||
</div>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button as AButton, Tag as ATag, Modal as AModal } from 'ant-design-vue'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
ReloadOutlined,
|
||||
LoadingOutlined,
|
||||
TableOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useBitableStore } from '@/stores/bitable'
|
||||
import TableViewList from '@/components/bitable/TableViewList.vue'
|
||||
import BitableGrid from '@/components/bitable/BitableGrid.vue'
|
||||
import TableCreateModal from '@/components/bitable/TableCreateModal.vue'
|
||||
import FieldManagePanel from '@/components/bitable/FieldManagePanel.vue'
|
||||
import ViewSwitcher from '@/components/bitable/ViewSwitcher.vue'
|
||||
import ViewConfigPanel from '@/components/bitable/ViewConfigPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useBitableStore()
|
||||
|
||||
const createModalOpen = ref(false)
|
||||
const fieldPanelOpen = ref(false)
|
||||
const viewConfigOpen = ref(false)
|
||||
|
||||
// Filter out hidden fields based on current view config
|
||||
const visibleFields = computed(() => {
|
||||
const hiddenIds = (store.currentView?.config?.hidden_fields as string[]) ?? []
|
||||
if (hiddenIds.length === 0) return store.fields
|
||||
return store.fields.filter((f) => !hiddenIds.includes(f.id))
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
store.loadTables()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.stopPolling()
|
||||
})
|
||||
|
||||
function goBack(): void {
|
||||
router.push('/agent/chat')
|
||||
}
|
||||
|
||||
function handleSelectTable(tableId: string): void {
|
||||
store.selectTable(tableId)
|
||||
}
|
||||
|
||||
function handleRefresh(): void {
|
||||
store.refreshRecords()
|
||||
}
|
||||
|
||||
async function handleTableCreated(tableId: string): Promise<void> {
|
||||
createModalOpen.value = false
|
||||
await store.loadTables()
|
||||
await store.selectTable(tableId)
|
||||
}
|
||||
|
||||
async function handleEditCell(payload: {
|
||||
recordId: string
|
||||
fieldId: string
|
||||
value: unknown
|
||||
}): Promise<void> {
|
||||
await store.updateCell(payload.recordId, payload.fieldId, payload.value)
|
||||
}
|
||||
|
||||
function handleSwitchView(viewId: string): void {
|
||||
store.switchView(viewId)
|
||||
}
|
||||
|
||||
async function handleCreateView(): Promise<void> {
|
||||
// ponytail: simple prompt for view name; full create modal is overkill for v1
|
||||
let name = ''
|
||||
AModal.confirm({
|
||||
title: '新建视图',
|
||||
content: h('input', {
|
||||
class: 'ant-input',
|
||||
placeholder: '请输入视图名称',
|
||||
onInput: (e: Event) => {
|
||||
name = (e.target as HTMLInputElement).value
|
||||
},
|
||||
}),
|
||||
onOk: async () => {
|
||||
if (!name.trim()) return
|
||||
await store.createView(name.trim(), 'grid')
|
||||
},
|
||||
})
|
||||
}
|
||||
// Legacy BitableView — replaced by nested routes under /bitable.
|
||||
// This component is kept as a passthrough for any stale imports;
|
||||
// the router no longer references it directly.
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bitable-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.bitable-view__topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--border-color, #f0f0f0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bitable-view__topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bitable-view__topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bitable-view__title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.bitable-view__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bitable-view__sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bitable-view__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bitable-view__placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
color: var(--text-placeholder, #bfbfbf);
|
||||
}
|
||||
|
||||
.bitable-view__grid-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color, #f0f0f0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bitable-view__table-name {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bitable-view__field-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #8c8c8c);
|
||||
}
|
||||
|
||||
.bitable-view__grid-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bitable-view__load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--border-color, #f0f0f0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue