Merge PR #3: feat(bitable): 多维表格文件层 + 默认字段 + 表内字段操作 (Stage 1)
合并 feat/bitable-ui-stage1 到 main — 多维表格 UI 完整性 Stage 1(U1-U6)+ ce-code-review P0/P1 修复
This commit is contained in:
commit
6e65352df8
|
|
@ -7,6 +7,9 @@ Shared domain vocabulary for this project — entities, named processes, and sta
|
||||||
### Bitable
|
### Bitable
|
||||||
A companion service providing multi-dimensional table storage (structured records with typed fields, views, and formula columns) that runs alongside the main AgentKit server. Owns its own database schema, isolated from core tables, and exposes a REST API boundary that all callers — agents and end-users alike — go through. Agents authenticate with an internal service token; end-users authenticate via JWT.
|
A companion service providing multi-dimensional table storage (structured records with typed fields, views, and formula columns) that runs alongside the main AgentKit server. Owns its own database schema, isolated from core tables, and exposes a REST API boundary that all callers — agents and end-users alike — go through. Agents authenticate with an internal service token; end-users authenticate via JWT.
|
||||||
|
|
||||||
|
### BitableFile
|
||||||
|
The top-level container that groups related data tables. A multi-dimensional table file (多维表格文件) holds zero or more tables and owns its own metadata (name, icon, description, owner). Tables reference their parent file via `file_id`. The file is the unit of ownership sharing and the top of the `文件 → 数据表 → 字段/记录` navigation hierarchy, mirroring the Feishu Bitable App and twenty Object container pattern.
|
||||||
|
|
||||||
### Field Ownership
|
### Field Ownership
|
||||||
The model controlling which actor may modify a field's definition or values. Every field has an owner of either the agent or the user. Agent-owned fields are written by the ingestion tooling during data import; user-owned fields are edited by humans in the UI. Upsert operations update only agent-owned fields — user-owned fields are never overwritten by agent writes, so human edits survive re-ingestion.
|
The model controlling which actor may modify a field's definition or values. Every field has an owner of either the agent or the user. Agent-owned fields are written by the ingestion tooling during data import; user-owned fields are edited by humans in the UI. Upsert operations update only agent-owned fields — user-owned fields are never overwritten by agent writes, so human edits survive re-ingestion.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Current schema version — bump when adding migrations.
|
# Current schema version — bump when adding migrations.
|
||||||
_SCHEMA_VERSION = 1
|
# V1: initial schema (tables/fields/records/views/recalc/meta).
|
||||||
|
# V2: add bitable_files table + file_id column on bitable_tables (R1).
|
||||||
|
_SCHEMA_VERSION = 2
|
||||||
|
|
||||||
_META_SCHEMA_VERSION_KEY = "schema_version"
|
_META_SCHEMA_VERSION_KEY = "schema_version"
|
||||||
|
|
||||||
|
|
@ -52,13 +54,35 @@ class BitableBase(DeclarativeBase):
|
||||||
"""Declarative base for bitable ORM models (independent schema)."""
|
"""Declarative base for bitable ORM models (independent schema)."""
|
||||||
|
|
||||||
|
|
||||||
|
class FileModel(BitableBase):
|
||||||
|
"""ORM model for ``bitable.bitable_files`` — top-level container (R1)."""
|
||||||
|
|
||||||
|
__tablename__ = "bitable_files"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_bitable_files_owner", "owner_user_id"),
|
||||||
|
{"schema": "bitable"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=_uuid_str)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
icon = Column(String, default="📋")
|
||||||
|
description = Column(Text, default="")
|
||||||
|
owner_user_id = Column(String, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
class TableModel(BitableBase):
|
class TableModel(BitableBase):
|
||||||
"""ORM model for ``bitable.bitable_tables``."""
|
"""ORM model for ``bitable.bitable_tables``."""
|
||||||
|
|
||||||
__tablename__ = "bitable_tables"
|
__tablename__ = "bitable_tables"
|
||||||
__table_args__ = {"schema": "bitable"}
|
__table_args__ = (
|
||||||
|
Index("ix_bitable_tables_file_id", "file_id"),
|
||||||
|
{"schema": "bitable"},
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(String, primary_key=True, default=_uuid_str)
|
id = Column(String, primary_key=True, default=_uuid_str)
|
||||||
|
file_id = Column(String, nullable=True)
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
description = Column(Text, default="")
|
description = Column(Text, default="")
|
||||||
primary_key_field_id = Column(String, nullable=True)
|
primary_key_field_id = Column(String, nullable=True)
|
||||||
|
|
@ -167,6 +191,57 @@ class MetaModel(BitableBase):
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_v2_migration(conn: Any) -> None:
|
||||||
|
"""V2 migration: create ``bitable_files`` table + add ``file_id`` to tables.
|
||||||
|
|
||||||
|
Idempotent — safe to call on fresh installs (``create_all`` already made
|
||||||
|
the files table and file_id column) and on V1 upgrades (ALTERs the
|
||||||
|
existing tables). The CREATE TABLE IF NOT EXISTS + ADD COLUMN IF NOT
|
||||||
|
EXISTS guards make both paths work.
|
||||||
|
|
||||||
|
Defensive: V1 tables without a ``file_id`` are left with ``file_id = NULL``
|
||||||
|
(orphaned). The service layer treats NULL file_id as "no parent file"
|
||||||
|
and continues to work — they're reachable via the deprecated
|
||||||
|
``/tables`` endpoint. The plan notes there is no existing production
|
||||||
|
data to migrate (verifier-confirmed), so this is purely defensive.
|
||||||
|
"""
|
||||||
|
# bitable_files table — created by create_all on fresh installs, but
|
||||||
|
# we recreate via raw SQL so V1→V2 upgrades (no create_all re-run for
|
||||||
|
# existing tables) also get it.
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE TABLE IF NOT EXISTS bitable.bitable_files ("
|
||||||
|
" id VARCHAR PRIMARY KEY,"
|
||||||
|
" name VARCHAR NOT NULL,"
|
||||||
|
" icon VARCHAR DEFAULT '📋',"
|
||||||
|
" description TEXT DEFAULT '',"
|
||||||
|
" owner_user_id VARCHAR,"
|
||||||
|
" created_at TIMESTAMPTZ DEFAULT NOW(),"
|
||||||
|
" updated_at TIMESTAMPTZ DEFAULT NOW()"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_bitable_files_owner "
|
||||||
|
"ON bitable.bitable_files (owner_user_id)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add file_id column to bitable_tables (idempotent).
|
||||||
|
# ponytail: ADD COLUMN IF NOT EXISTS is PostgreSQL 9.6+ — safe here.
|
||||||
|
await conn.execute(
|
||||||
|
text("ALTER TABLE bitable.bitable_tables ADD COLUMN IF NOT EXISTS file_id VARCHAR")
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_bitable_tables_file_id "
|
||||||
|
"ON bitable.bitable_tables (file_id)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("applied V2 migration: bitable_files table + file_id column")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_database_url(database_url: str | None = None) -> str | None:
|
def _resolve_database_url(database_url: str | None = None) -> str | None:
|
||||||
"""Resolve PostgreSQL connection URL.
|
"""Resolve PostgreSQL connection URL.
|
||||||
|
|
||||||
|
|
@ -207,6 +282,10 @@ class BitableDB:
|
||||||
def session_factory(self) -> Any:
|
def session_factory(self) -> Any:
|
||||||
return self._session_factory
|
return self._session_factory
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_initialized(self) -> bool:
|
||||||
|
return self._initialized
|
||||||
|
|
||||||
async def _ensure_initialized(self) -> None:
|
async def _ensure_initialized(self) -> None:
|
||||||
"""Lazy-init async engine and session factory (with lock)."""
|
"""Lazy-init async engine and session factory (with lock)."""
|
||||||
if self._initialized:
|
if self._initialized:
|
||||||
|
|
@ -260,8 +339,10 @@ class BitableDB:
|
||||||
|
|
||||||
# 4. Apply migrations if needed (future versions add elif blocks here)
|
# 4. Apply migrations if needed (future versions add elif blocks here)
|
||||||
if current_version < _SCHEMA_VERSION:
|
if current_version < _SCHEMA_VERSION:
|
||||||
# V1: initial schema (already created above)
|
# V1: initial schema (already created above via create_all)
|
||||||
# Future: if current_version < 2: await _apply_v2_migration(conn)
|
# V2: file layer (bitable_files table + file_id column on tables)
|
||||||
|
if current_version < 2:
|
||||||
|
await _apply_v2_migration(conn)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO bitable.bitable_meta (key, value, updated_at) "
|
"INSERT INTO bitable.bitable_meta (key, value, updated_at) "
|
||||||
|
|
|
||||||
|
|
@ -58,12 +58,32 @@ class RecalcStatus(str, Enum):
|
||||||
error = "error"
|
error = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class BitableFile(BaseModel):
|
||||||
|
"""Top-level container grouping related tables (多维表格文件).
|
||||||
|
|
||||||
|
Owns metadata (name/icon/description/owner). Tables reference their
|
||||||
|
parent file via ``file_id``. The file is the unit of ownership sharing
|
||||||
|
and the top of the ``文件 → 数据表 → 字段/记录`` navigation hierarchy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
icon: str = "📋"
|
||||||
|
description: str = ""
|
||||||
|
owner_user_id: str | None = None
|
||||||
|
created_at: datetime = PydanticField(default_factory=_utcnow)
|
||||||
|
updated_at: datetime = PydanticField(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
class Table(BaseModel):
|
class Table(BaseModel):
|
||||||
"""A bitable table — collection of fields and records."""
|
"""A bitable table — collection of fields and records."""
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
|
file_id: str | None = None
|
||||||
name: str
|
name: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
primary_key_field_id: str | None = None
|
primary_key_field_id: str | None = None
|
||||||
|
|
@ -72,6 +92,54 @@ class Table(BaseModel):
|
||||||
updated_at: datetime = PydanticField(default_factory=_utcnow)
|
updated_at: datetime = PydanticField(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default field templates (R2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Status select field options — labels and colors match Feishu Bitable defaults.
|
||||||
|
_STATUS_OPTIONS: list[dict[str, Any]] = [
|
||||||
|
{"label": "未开始", "value": "not_started", "color": "default"},
|
||||||
|
{"label": "进行中", "value": "in_progress", "color": "processing"},
|
||||||
|
{"label": "已完成", "value": "done", "color": "success"},
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Templates for the 5 default fields created on every new table (R2).
|
||||||
|
#: agent-owned fields (创建人/创建时间) are auto-filled by the service layer
|
||||||
|
#: on record creation; user-owned fields are user-editable.
|
||||||
|
DEFAULT_FIELD_TEMPLATES: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"name": "标题",
|
||||||
|
"field_type": FieldType.text,
|
||||||
|
"owner": FieldOwner.user,
|
||||||
|
"config": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "状态",
|
||||||
|
"field_type": FieldType.select,
|
||||||
|
"owner": FieldOwner.user,
|
||||||
|
"config": {"options": _STATUS_OPTIONS},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "日期",
|
||||||
|
"field_type": FieldType.date,
|
||||||
|
"owner": FieldOwner.user,
|
||||||
|
"config": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "创建人",
|
||||||
|
"field_type": FieldType.text,
|
||||||
|
"owner": FieldOwner.agent,
|
||||||
|
"config": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "创建时间",
|
||||||
|
"field_type": FieldType.date,
|
||||||
|
"owner": FieldOwner.agent,
|
||||||
|
"config": {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Field(BaseModel):
|
class Field(BaseModel):
|
||||||
"""A column definition in a bitable table.
|
"""A column definition in a bitable table.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from agentkit.bitable.db import (
|
from agentkit.bitable.db import (
|
||||||
BitableDB,
|
BitableDB,
|
||||||
FieldModel,
|
FieldModel,
|
||||||
|
FileModel,
|
||||||
RecordModel,
|
RecordModel,
|
||||||
RecalcQueueModel,
|
RecalcQueueModel,
|
||||||
TableModel,
|
TableModel,
|
||||||
|
|
@ -25,6 +26,7 @@ from agentkit.bitable.db import (
|
||||||
_uuid_str,
|
_uuid_str,
|
||||||
)
|
)
|
||||||
from agentkit.bitable.models import (
|
from agentkit.bitable.models import (
|
||||||
|
BitableFile,
|
||||||
Field,
|
Field,
|
||||||
FieldOwner,
|
FieldOwner,
|
||||||
FieldType,
|
FieldType,
|
||||||
|
|
@ -55,6 +57,83 @@ class BitableRepository:
|
||||||
def _session_factory(self):
|
def _session_factory(self):
|
||||||
return self._db.session_factory
|
return self._db.session_factory
|
||||||
|
|
||||||
|
# ── Files (R1) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
async def create_file(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
icon: str = "📋",
|
||||||
|
description: str = "",
|
||||||
|
owner_user_id: str | None = None,
|
||||||
|
) -> BitableFile:
|
||||||
|
"""Create a new bitable file (top-level container)."""
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
stmt = (
|
||||||
|
insert(FileModel)
|
||||||
|
.values(
|
||||||
|
id=_uuid_str(),
|
||||||
|
name=name,
|
||||||
|
icon=icon,
|
||||||
|
description=description,
|
||||||
|
owner_user_id=owner_user_id,
|
||||||
|
)
|
||||||
|
.returning(FileModel)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
entity = result.scalar_one()
|
||||||
|
await session.commit()
|
||||||
|
return BitableFile.model_validate(entity)
|
||||||
|
|
||||||
|
async def get_file(self, file_id: str) -> BitableFile | None:
|
||||||
|
"""Get a file by ID."""
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
stmt = select(FileModel).where(FileModel.id == file_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
entity = result.scalars().first()
|
||||||
|
return BitableFile.model_validate(entity) if entity else None
|
||||||
|
|
||||||
|
async def list_files(self, owner_user_id: str | None = None) -> list[BitableFile]:
|
||||||
|
"""List all files (optionally filtered by owner)."""
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
stmt = select(FileModel)
|
||||||
|
if owner_user_id:
|
||||||
|
stmt = stmt.where(FileModel.owner_user_id == owner_user_id)
|
||||||
|
stmt = stmt.order_by(FileModel.updated_at.desc())
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return [BitableFile.model_validate(e) for e in result.scalars().all()]
|
||||||
|
|
||||||
|
async def update_file(self, file_id: str, **kwargs: Any) -> BitableFile | None:
|
||||||
|
"""Update a file's attributes."""
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
stmt = (
|
||||||
|
update(FileModel)
|
||||||
|
.where(FileModel.id == file_id)
|
||||||
|
.values(**kwargs)
|
||||||
|
.returning(FileModel)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
entity = result.scalars().first()
|
||||||
|
await session.commit()
|
||||||
|
return BitableFile.model_validate(entity) if entity else None
|
||||||
|
|
||||||
|
async def delete_file(self, file_id: str) -> bool:
|
||||||
|
"""Delete a file. Caller is responsible for cascading table deletes."""
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
result = await session.execute(delete(FileModel).where(FileModel.id == file_id))
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
async def list_tables_by_file(self, file_id: str) -> list[Table]:
|
||||||
|
"""List all tables belonging to a file."""
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
stmt = (
|
||||||
|
select(TableModel)
|
||||||
|
.where(TableModel.file_id == file_id)
|
||||||
|
.order_by(TableModel.created_at)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return [Table.model_validate(e) for e in result.scalars().all()]
|
||||||
|
|
||||||
# ── Tables ──────────────────────────────────────────────
|
# ── Tables ──────────────────────────────────────────────
|
||||||
|
|
||||||
async def create_table(
|
async def create_table(
|
||||||
|
|
@ -63,6 +142,7 @@ class BitableRepository:
|
||||||
description: str = "",
|
description: str = "",
|
||||||
primary_key_field_id: str | None = None,
|
primary_key_field_id: str | None = None,
|
||||||
owner_user_id: str | None = None,
|
owner_user_id: str | None = None,
|
||||||
|
file_id: str | None = None,
|
||||||
) -> Table:
|
) -> Table:
|
||||||
"""Create a new bitable table."""
|
"""Create a new bitable table."""
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
|
|
@ -70,6 +150,7 @@ class BitableRepository:
|
||||||
insert(TableModel)
|
insert(TableModel)
|
||||||
.values(
|
.values(
|
||||||
id=_uuid_str(),
|
id=_uuid_str(),
|
||||||
|
file_id=file_id,
|
||||||
name=name,
|
name=name,
|
||||||
description=description,
|
description=description,
|
||||||
primary_key_field_id=primary_key_field_id,
|
primary_key_field_id=primary_key_field_id,
|
||||||
|
|
@ -352,6 +433,13 @@ class BitableRepository:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return View.model_validate(entity)
|
return View.model_validate(entity)
|
||||||
|
|
||||||
|
async def get_view(self, view_id: str) -> View | None:
|
||||||
|
"""Get a single view by ID (used for ownership checks)."""
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
result = await session.execute(select(ViewModel).where(ViewModel.id == view_id))
|
||||||
|
entity = result.scalars().first()
|
||||||
|
return View.model_validate(entity) if entity else None
|
||||||
|
|
||||||
async def list_views(self, table_id: str) -> list[View]:
|
async def list_views(self, table_id: str) -> list[View]:
|
||||||
"""List all views in a table."""
|
"""List all views in a table."""
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,14 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from agentkit.bitable.db import BitableDB
|
from agentkit.bitable.db import BitableDB
|
||||||
from agentkit.bitable.models import (
|
from agentkit.bitable.models import (
|
||||||
|
DEFAULT_FIELD_TEMPLATES,
|
||||||
|
BitableFile,
|
||||||
Field,
|
Field,
|
||||||
FieldOwner,
|
FieldOwner,
|
||||||
FieldType,
|
FieldType,
|
||||||
|
|
@ -69,6 +72,43 @@ class BitableService:
|
||||||
if self._recalc_worker is not None:
|
if self._recalc_worker is not None:
|
||||||
self._recalc_worker.invalidate_engine(table_id)
|
self._recalc_worker.invalidate_engine(table_id)
|
||||||
|
|
||||||
|
# ── Files (R1) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
async def create_file(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
icon: str = "📋",
|
||||||
|
description: str = "",
|
||||||
|
owner_user_id: str | None = None,
|
||||||
|
) -> BitableFile:
|
||||||
|
"""Create a new bitable file (top-level container)."""
|
||||||
|
return await self._repo.create_file(
|
||||||
|
name=name,
|
||||||
|
icon=icon,
|
||||||
|
description=description,
|
||||||
|
owner_user_id=owner_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_file(self, file_id: str) -> BitableFile | None:
|
||||||
|
return await self._repo.get_file(file_id)
|
||||||
|
|
||||||
|
async def list_files(self, owner_user_id: str | None = None) -> list[BitableFile]:
|
||||||
|
return await self._repo.list_files(owner_user_id=owner_user_id)
|
||||||
|
|
||||||
|
async def update_file(self, file_id: str, **kwargs: Any) -> BitableFile | None:
|
||||||
|
return await self._repo.update_file(file_id, **kwargs)
|
||||||
|
|
||||||
|
async def delete_file(self, file_id: str) -> bool:
|
||||||
|
"""Delete a file and cascade-delete all its tables (and their fields/records/views)."""
|
||||||
|
tables = await self._repo.list_tables_by_file(file_id)
|
||||||
|
for t in tables:
|
||||||
|
await self.delete_table(t.id)
|
||||||
|
return await self._repo.delete_file(file_id)
|
||||||
|
|
||||||
|
async def list_tables_by_file(self, file_id: str) -> list[Table]:
|
||||||
|
"""List all tables in a file."""
|
||||||
|
return await self._repo.list_tables_by_file(file_id)
|
||||||
|
|
||||||
# ── Tables ──────────────────────────────────────────────
|
# ── Tables ──────────────────────────────────────────────
|
||||||
|
|
||||||
async def create_table(
|
async def create_table(
|
||||||
|
|
@ -77,18 +117,45 @@ class BitableService:
|
||||||
description: str = "",
|
description: str = "",
|
||||||
primary_key_field_id: str | None = None,
|
primary_key_field_id: str | None = None,
|
||||||
owner_user_id: str | None = None,
|
owner_user_id: str | None = None,
|
||||||
|
file_id: str | None = None,
|
||||||
) -> Table:
|
) -> Table:
|
||||||
"""Create a new bitable table. Creates PK unique index if PK is set."""
|
"""Create a new bitable table with default fields (R2).
|
||||||
|
|
||||||
|
Creates the table, then immediately creates 5 default fields
|
||||||
|
(标题/状态/日期/创建人/创建时间). Creates PK unique index if PK is set.
|
||||||
|
"""
|
||||||
table = await self._repo.create_table(
|
table = await self._repo.create_table(
|
||||||
name=name,
|
name=name,
|
||||||
description=description,
|
description=description,
|
||||||
primary_key_field_id=primary_key_field_id,
|
primary_key_field_id=primary_key_field_id,
|
||||||
owner_user_id=owner_user_id,
|
owner_user_id=owner_user_id,
|
||||||
|
file_id=file_id,
|
||||||
)
|
)
|
||||||
|
# R2: create the 5 default fields for every new table.
|
||||||
|
await self._create_default_fields(table.id)
|
||||||
if primary_key_field_id:
|
if primary_key_field_id:
|
||||||
await self._repo.create_pk_unique_index(table.id, primary_key_field_id)
|
await self._repo.create_pk_unique_index(table.id, primary_key_field_id)
|
||||||
return table
|
return table
|
||||||
|
|
||||||
|
async def _create_default_fields(self, table_id: str) -> list[Field]:
|
||||||
|
"""Create the 5 default fields on a freshly-created table (R2).
|
||||||
|
|
||||||
|
Uses ``DEFAULT_FIELD_TEMPLATES`` from models. Fields are created
|
||||||
|
via the repository directly (no engine cache invalidation needed —
|
||||||
|
no formula fields among defaults).
|
||||||
|
"""
|
||||||
|
created: list[Field] = []
|
||||||
|
for tpl in DEFAULT_FIELD_TEMPLATES:
|
||||||
|
field = await self._repo.create_field(
|
||||||
|
table_id=table_id,
|
||||||
|
name=tpl["name"],
|
||||||
|
field_type=tpl["field_type"],
|
||||||
|
config=tpl.get("config", {}) or {},
|
||||||
|
owner=tpl["owner"],
|
||||||
|
)
|
||||||
|
created.append(field)
|
||||||
|
return created
|
||||||
|
|
||||||
async def get_table(self, table_id: str) -> Table | None:
|
async def get_table(self, table_id: str) -> Table | None:
|
||||||
return await self._repo.get_table(table_id)
|
return await self._repo.get_table(table_id)
|
||||||
|
|
||||||
|
|
@ -194,8 +261,31 @@ class BitableService:
|
||||||
|
|
||||||
# ── Records ─────────────────────────────────────────────
|
# ── Records ─────────────────────────────────────────────
|
||||||
|
|
||||||
async def create_record(self, table_id: str, values: dict[str, Any] | None = None) -> Record:
|
async def create_record(
|
||||||
"""Create a new record. Triggers recalc for affected formula fields."""
|
self,
|
||||||
|
table_id: str,
|
||||||
|
values: dict[str, Any] | None = None,
|
||||||
|
actor_user_id: str | None = None,
|
||||||
|
) -> Record:
|
||||||
|
"""Create a new record. Triggers recalc for affected formula fields.
|
||||||
|
|
||||||
|
R2: auto-fills agent-owned default fields (创建人/创建时间) with
|
||||||
|
system values. User-supplied values for agent-owned fields are
|
||||||
|
overwritten — agent ownership means system-managed (per plan edge case).
|
||||||
|
Matching by field_type+owner (not name) so renames don't break auto-fill.
|
||||||
|
"""
|
||||||
|
values = dict(values or {})
|
||||||
|
# Auto-fill agent-owned fields based on the table's field definitions.
|
||||||
|
fields = await self._repo.list_fields(table_id)
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
creator_value = actor_user_id or "system"
|
||||||
|
for f in fields:
|
||||||
|
if f.owner != FieldOwner.agent:
|
||||||
|
continue
|
||||||
|
if f.field_type == FieldType.text:
|
||||||
|
values[f.id] = creator_value
|
||||||
|
elif f.field_type == FieldType.date:
|
||||||
|
values[f.id] = now_iso
|
||||||
record = await self._repo.create_record(table_id, values)
|
record = await self._repo.create_record(table_id, values)
|
||||||
await self._trigger_recalc_for_affected_fields(table_id, record.id)
|
await self._trigger_recalc_for_affected_fields(table_id, record.id)
|
||||||
return record
|
return record
|
||||||
|
|
@ -429,6 +519,9 @@ class BitableService:
|
||||||
async def update_view(self, view_id: str, **kwargs: Any) -> View | None:
|
async def update_view(self, view_id: str, **kwargs: Any) -> View | None:
|
||||||
return await self._repo.update_view(view_id, **kwargs)
|
return await self._repo.update_view(view_id, **kwargs)
|
||||||
|
|
||||||
|
async def get_view(self, view_id: str) -> View | None:
|
||||||
|
return await self._repo.get_view(view_id)
|
||||||
|
|
||||||
# ── Recalc (U3: formula recalc pipeline) ────────────────
|
# ── Recalc (U3: formula recalc pipeline) ────────────────
|
||||||
|
|
||||||
async def _trigger_recalc_for_affected_fields(self, table_id: str, record_id: str) -> None:
|
async def _trigger_recalc_for_affected_fields(self, table_id: str, record_id: str) -> None:
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ declare module 'vue' {
|
||||||
ChatSidebar: typeof import('./src/components/chat/ChatSidebar.vue')['default']
|
ChatSidebar: typeof import('./src/components/chat/ChatSidebar.vue')['default']
|
||||||
CodeDiffViewer: typeof import('./src/components/code/CodeDiffViewer.vue')['default']
|
CodeDiffViewer: typeof import('./src/components/code/CodeDiffViewer.vue')['default']
|
||||||
CollaborationGraphCard: typeof import('./src/components/chat/messages/CollaborationGraphCard.vue')['default']
|
CollaborationGraphCard: typeof import('./src/components/chat/messages/CollaborationGraphCard.vue')['default']
|
||||||
|
ColumnHeaderMenu: typeof import('./src/components/bitable/ColumnHeaderMenu.vue')['default']
|
||||||
CommandHistory: typeof import('./src/components/terminal/CommandHistory.vue')['default']
|
CommandHistory: typeof import('./src/components/terminal/CommandHistory.vue')['default']
|
||||||
ConditionNode: typeof import('./src/components/workflow/ConditionNode.vue')['default']
|
ConditionNode: typeof import('./src/components/workflow/ConditionNode.vue')['default']
|
||||||
ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default']
|
ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default']
|
||||||
|
|
@ -109,6 +110,8 @@ declare module 'vue' {
|
||||||
FieldConfigForm: typeof import('./src/components/bitable/FieldConfigForm.vue')['default']
|
FieldConfigForm: typeof import('./src/components/bitable/FieldConfigForm.vue')['default']
|
||||||
FieldManagePanel: typeof import('./src/components/bitable/FieldManagePanel.vue')['default']
|
FieldManagePanel: typeof import('./src/components/bitable/FieldManagePanel.vue')['default']
|
||||||
FileAttachment: typeof import('./src/components/chat/messages/FileAttachment.vue')['default']
|
FileAttachment: typeof import('./src/components/chat/messages/FileAttachment.vue')['default']
|
||||||
|
FileCard: typeof import('./src/components/bitable/FileCard.vue')['default']
|
||||||
|
FileCreateModal: typeof import('./src/components/bitable/FileCreateModal.vue')['default']
|
||||||
FilePreview: typeof import('./src/components/chat/FilePreview.vue')['default']
|
FilePreview: typeof import('./src/components/chat/FilePreview.vue')['default']
|
||||||
FileTree: typeof import('./src/components/code/FileTree.vue')['default']
|
FileTree: typeof import('./src/components/code/FileTree.vue')['default']
|
||||||
FilterBuilder: typeof import('./src/components/bitable/FilterBuilder.vue')['default']
|
FilterBuilder: typeof import('./src/components/bitable/FilterBuilder.vue')['default']
|
||||||
|
|
@ -146,6 +149,8 @@ declare module 'vue' {
|
||||||
Scene6Error: typeof import('./src/components/preview/scenes/Scene6Error.vue')['default']
|
Scene6Error: typeof import('./src/components/preview/scenes/Scene6Error.vue')['default']
|
||||||
SearchTest: typeof import('./src/components/kb/SearchTest.vue')['default']
|
SearchTest: typeof import('./src/components/kb/SearchTest.vue')['default']
|
||||||
SegmentPreview: typeof import('./src/components/kb/SegmentPreview.vue')['default']
|
SegmentPreview: typeof import('./src/components/kb/SegmentPreview.vue')['default']
|
||||||
|
SelectCellEditor: typeof import('./src/components/bitable/SelectCellEditor.vue')['default']
|
||||||
|
SelectDisplay: typeof import('./src/components/bitable/SelectDisplay.vue')['default']
|
||||||
SideNav: typeof import('./src/components/layout/SideNav.vue')['default']
|
SideNav: typeof import('./src/components/layout/SideNav.vue')['default']
|
||||||
SkillCard: typeof import('./src/components/skills/SkillCard.vue')['default']
|
SkillCard: typeof import('./src/components/skills/SkillCard.vue')['default']
|
||||||
SkillDetail: typeof import('./src/components/skills/SkillDetail.vue')['default']
|
SkillDetail: typeof import('./src/components/skills/SkillDetail.vue')['default']
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Navigation: login via UI form → click TopNav "多维表格" icon (SPA navigate
|
||||||
* to /bitable). This avoids the whoami cold-start bug that page.goto would
|
* to /bitable). This avoids the whoami cold-start bug that page.goto would
|
||||||
* trigger on a full reload.
|
* trigger on a full reload.
|
||||||
*
|
*
|
||||||
* ponytail: bitable backend may not be fully configured (no DATABASE_URL);
|
* ponytail: bitable backend may not be fully configured (no DATABASE_URL);
|
||||||
* the view should still render its topbar and sidebar/placeholder gracefully.
|
* the view should still render its topbar and file list gracefully.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect, type Page } from '@playwright/test'
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
import { TEST_USER, clearAuth } from './helpers'
|
import { TEST_USER, clearAuth, waitForServer } from './helpers'
|
||||||
|
|
||||||
async function loginAndOpenBitable(page: Page): Promise<void> {
|
async function loginAndOpenBitable(page: Page): Promise<void> {
|
||||||
await page.goto('/login')
|
await page.goto('/login')
|
||||||
|
|
@ -23,37 +23,52 @@ async function loginAndOpenBitable(page: Page): Promise<void> {
|
||||||
// SPA-navigate to /bitable via the TopNav "多维表格" button.
|
// SPA-navigate to /bitable via the TopNav "多维表格" button.
|
||||||
await page.getByRole('button', { name: '多维表格' }).click()
|
await page.getByRole('button', { name: '多维表格' }).click()
|
||||||
await expect(page).toHaveURL(/\/bitable/, { timeout: 15_000 })
|
await expect(page).toHaveURL(/\/bitable/, { timeout: 15_000 })
|
||||||
await expect(page.locator('.bitable-view')).toBeVisible({ timeout: 15_000 })
|
await expect(page.locator('.bitable-file-list-view')).toBeVisible({ timeout: 15_000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Bitable View E2E', () => {
|
test.describe('Bitable View E2E', () => {
|
||||||
test('B1: bitable view loads without white screen or 401 redirect', async ({ page }) => {
|
test.beforeAll(async () => {
|
||||||
|
try {
|
||||||
|
await waitForServer(undefined, 5_000)
|
||||||
|
} catch {
|
||||||
|
test.skip(true, 'Backend not running — skipping bitable view E2E')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('B1: bitable file list loads without white screen or 401 redirect', async ({ page }) => {
|
||||||
await loginAndOpenBitable(page)
|
await loginAndOpenBitable(page)
|
||||||
|
|
||||||
// URL should be /bitable, not redirected to /login.
|
// URL should be /bitable, not redirected to /login.
|
||||||
await expect(page).toHaveURL(/\/bitable/, { timeout: 10_000 })
|
await expect(page).toHaveURL(/\/bitable/, { timeout: 10_000 })
|
||||||
// BitableView root element visible — no white screen.
|
// File list view root element visible — no white screen.
|
||||||
await expect(page.locator('.bitable-view')).toBeVisible()
|
await expect(page.locator('.bitable-file-list-view')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('B2: bitable core elements are visible', async ({ page }) => {
|
test('B2: bitable file list core elements are visible', async ({ page }) => {
|
||||||
await loginAndOpenBitable(page)
|
await loginAndOpenBitable(page)
|
||||||
|
|
||||||
// The topbar title "多维表格" should be visible.
|
// The topbar title "多维表格" should be visible.
|
||||||
await expect(page.locator('.bitable-view__title')).toContainText('多维表格', {
|
await expect(page.locator('.bitable-file-list-view__title')).toContainText('多维表格', {
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Either the sidebar (table list) or the placeholder is rendered.
|
// Either the file grid (cards) or the empty state is rendered.
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const sidebar = await page.locator('.bitable-view__sidebar').count()
|
const grid = await page.locator('.bitable-file-list-view__grid').count()
|
||||||
const placeholder = await page.locator('.bitable-view__placeholder').count()
|
const empty = await page.locator('.bitable-file-list-view__empty').count()
|
||||||
return sidebar + placeholder
|
return grid + empty
|
||||||
},
|
},
|
||||||
{ timeout: 15_000, intervals: [1_000] },
|
{ timeout: 15_000, intervals: [1_000] },
|
||||||
)
|
)
|
||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('B3: new file button is visible and clickable', async ({ page }) => {
|
||||||
|
await loginAndOpenBitable(page)
|
||||||
|
|
||||||
|
const newButton = page.getByRole('button', { name: /新建文件/ })
|
||||||
|
await expect(newButton).toBeVisible({ timeout: 10_000 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,20 @@ export type ViewType = 'grid' | 'kanban' | 'gantt' | 'gallery' | 'form'
|
||||||
|
|
||||||
export type RecalcStatus = 'pending' | 'calculating' | 'done' | 'error'
|
export type RecalcStatus = 'pending' | 'calculating' | 'done' | 'error'
|
||||||
|
|
||||||
|
/** Top-level container grouping related tables (R1, 多维表格文件). */
|
||||||
|
export interface IBitableFile {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
description: string
|
||||||
|
owner_user_id: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface IBitableTable {
|
export interface IBitableTable {
|
||||||
id: string
|
id: string
|
||||||
|
file_id: string | null
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
primary_key_field_id: string | null
|
primary_key_field_id: string | null
|
||||||
|
|
@ -69,6 +81,18 @@ export interface IAttachmentMeta {
|
||||||
|
|
||||||
// ── Request types ──────────────────────────────────────────────────────
|
// ── Request types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ICreateFileRequest {
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUpdateFileRequest {
|
||||||
|
name?: string
|
||||||
|
icon?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ICreateTableRequest {
|
export interface ICreateTableRequest {
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
|
|
@ -126,7 +150,58 @@ class BitableApiClient extends BaseApiClient {
|
||||||
super(baseUrl)
|
super(baseUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tables ───────────────────────────────────────────
|
// ── Files (R1) ───────────────────────────────────────
|
||||||
|
|
||||||
|
async listFiles(): Promise<{ success: boolean; files: IBitableFile[] }> {
|
||||||
|
return this.request('/files', { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFile(
|
||||||
|
data: ICreateFileRequest,
|
||||||
|
): Promise<{ success: boolean; file: IBitableFile }> {
|
||||||
|
return this.request('/files', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFile(
|
||||||
|
fileId: string,
|
||||||
|
): Promise<{ success: boolean; file: IBitableFile }> {
|
||||||
|
return this.request(`/files/${fileId}`, { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFile(
|
||||||
|
fileId: string,
|
||||||
|
data: IUpdateFileRequest,
|
||||||
|
): Promise<{ success: boolean; file: IBitableFile }> {
|
||||||
|
return this.request(`/files/${fileId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(fileId: string): Promise<{ success: boolean }> {
|
||||||
|
return this.request(`/files/${fileId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTablesInFile(
|
||||||
|
fileId: string,
|
||||||
|
): Promise<{ success: boolean; tables: IBitableTable[] }> {
|
||||||
|
return this.request(`/files/${fileId}/tables`, { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTableInFile(
|
||||||
|
fileId: string,
|
||||||
|
data: ICreateTableRequest,
|
||||||
|
): Promise<{ success: boolean; table: IBitableTable }> {
|
||||||
|
return this.request(`/files/${fileId}/tables`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tables (deprecated — prefer file-scoped endpoints above) ───────
|
||||||
|
|
||||||
async listTables(): Promise<{ success: boolean; tables: IBitableTable[] }> {
|
async listTables(): Promise<{ success: boolean; tables: IBitableTable[] }> {
|
||||||
return this.request('/tables', { method: 'GET' })
|
return this.request('/tables', { method: 'GET' })
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,50 @@
|
||||||
:images="(row[f.id] as IAttachmentMeta[] | null | undefined)"
|
:images="(row[f.id] as IAttachmentMeta[] | null | undefined)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- Column header dropdown menus (U4) -->
|
||||||
|
<template
|
||||||
|
v-for="f in fields"
|
||||||
|
:key="`hdr_${f.id}`"
|
||||||
|
#[`header_${f.id}`]"
|
||||||
|
>
|
||||||
|
<ColumnHeaderMenu
|
||||||
|
:field="f"
|
||||||
|
@edit="emit('config-field', $event)"
|
||||||
|
@hide="emit('hide-field', $event)"
|
||||||
|
@delete="emit('delete-field', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<!-- Select / Multiselect edit slots (U5) -->
|
||||||
|
<template
|
||||||
|
v-for="f in selectFields"
|
||||||
|
:key="`edit_${f.id}`"
|
||||||
|
#[`edit_${f.id}`]="{ row }"
|
||||||
|
>
|
||||||
|
<SelectCellEditor
|
||||||
|
:model-value="(row[f.id] as string | string[] | null | undefined)"
|
||||||
|
:options="(f.config.options as ISelectOption[] | string[] | undefined)"
|
||||||
|
:multiple="f.field_type === 'multiselect'"
|
||||||
|
@update:model-value="row[f.id] = $event"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<!-- Select / Multiselect display slots (U5) -->
|
||||||
|
<template
|
||||||
|
v-for="f in selectFields"
|
||||||
|
:key="`cell_sel_${f.id}`"
|
||||||
|
#[`cell_sel_${f.id}`]="{ row }"
|
||||||
|
>
|
||||||
|
<SelectDisplay
|
||||||
|
:value="(row[f.id] as string | string[] | null | undefined)"
|
||||||
|
:options="(f.config.options as ISelectOption[] | string[] | undefined)"
|
||||||
|
:multiple="f.field_type === 'multiselect'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<!-- Add-field column header -->
|
||||||
|
<template #header__add_field>
|
||||||
|
<div class="bitable-grid-scope__add-col" @click="emit('add-field')">
|
||||||
|
<PlusOutlined /> 新增字段
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</vxe-grid>
|
</vxe-grid>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -44,6 +88,7 @@
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { VxeGrid } from 'vxe-table'
|
import { VxeGrid } from 'vxe-table'
|
||||||
import { Empty as AEmpty } from 'ant-design-vue'
|
import { Empty as AEmpty } from 'ant-design-vue'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||||
import type { VxeGridProps, VxeGridEvents } from 'vxe-table'
|
import type { VxeGridProps, VxeGridEvents } from 'vxe-table'
|
||||||
import type {
|
import type {
|
||||||
IBitableField,
|
IBitableField,
|
||||||
|
|
@ -53,6 +98,10 @@ import type {
|
||||||
} from '@/api/bitable'
|
} from '@/api/bitable'
|
||||||
import AttachmentCell from './AttachmentCell.vue'
|
import AttachmentCell from './AttachmentCell.vue'
|
||||||
import ImageCell from './ImageCell.vue'
|
import ImageCell from './ImageCell.vue'
|
||||||
|
import ColumnHeaderMenu from './ColumnHeaderMenu.vue'
|
||||||
|
import SelectCellEditor from './SelectCellEditor.vue'
|
||||||
|
import SelectDisplay from './SelectDisplay.vue'
|
||||||
|
import type { ISelectOption } from './SelectCellEditor.vue'
|
||||||
|
|
||||||
type GridRow = Record<string, unknown> & { _rowId: string; _recordId: string }
|
type GridRow = Record<string, unknown> & { _rowId: string; _recordId: string }
|
||||||
type GridColumn = NonNullable<VxeGridProps['columns']>[number]
|
type GridColumn = NonNullable<VxeGridProps['columns']>[number]
|
||||||
|
|
@ -74,6 +123,10 @@ const props = withDefaults(
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'edit-cell', payload: { recordId: string; fieldId: string; value: unknown }): void
|
(e: 'edit-cell', payload: { recordId: string; fieldId: string; value: unknown }): void
|
||||||
|
(e: 'add-field'): void
|
||||||
|
(e: 'config-field', field: IBitableField): void
|
||||||
|
(e: 'hide-field', fieldId: string): void
|
||||||
|
(e: 'delete-field', field: IBitableField): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const gridRef = ref<InstanceType<typeof VxeGrid> | null>(null)
|
const gridRef = ref<InstanceType<typeof VxeGrid> | null>(null)
|
||||||
|
|
@ -85,6 +138,13 @@ const attachmentFields = computed(() =>
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Fields that use select/multiselect editors (U5)
|
||||||
|
const selectFields = computed(() =>
|
||||||
|
props.fields.filter(
|
||||||
|
(f) => f.field_type === 'select' || f.field_type === 'multiselect',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// Map records to grid rows with a stable _rowId (record.id)
|
// Map records to grid rows with a stable _rowId (record.id)
|
||||||
const rows = computed<GridRow[]>(() =>
|
const rows = computed<GridRow[]>(() =>
|
||||||
props.records.map((r) => ({
|
props.records.map((r) => ({
|
||||||
|
|
@ -109,6 +169,17 @@ const gridColumns = computed<GridColumn[]>(() => {
|
||||||
cols.push(buildColumn(f))
|
cols.push(buildColumn(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add-field column (U4)
|
||||||
|
cols.push({
|
||||||
|
field: '__add_field',
|
||||||
|
title: '',
|
||||||
|
width: 120,
|
||||||
|
fixed: 'right',
|
||||||
|
resizable: false,
|
||||||
|
editRender: { enabled: false },
|
||||||
|
slots: { header: 'header__add_field' },
|
||||||
|
})
|
||||||
|
|
||||||
return cols
|
return cols
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -122,6 +193,8 @@ function buildColumn(f: IBitableField): GridColumn {
|
||||||
width: 160,
|
width: 160,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
showOverflow: 'tooltip',
|
showOverflow: 'tooltip',
|
||||||
|
// Use header slot for column dropdown menu (U4)
|
||||||
|
slots: { header: `header_${f.id}` },
|
||||||
}
|
}
|
||||||
|
|
||||||
// Formula fields are read-only
|
// Formula fields are read-only
|
||||||
|
|
@ -134,7 +207,7 @@ function buildColumn(f: IBitableField): GridColumn {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
editRender: { enabled: false },
|
editRender: { enabled: false },
|
||||||
slots: { default: `cell_${f.id}` },
|
slots: { ...base.slots, default: `cell_${f.id}` },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,10 +231,15 @@ function buildColumn(f: IBitableField): GridColumn {
|
||||||
}
|
}
|
||||||
case 'select':
|
case 'select':
|
||||||
case 'multiselect':
|
case 'multiselect':
|
||||||
// ponytail: select editor uses text input for v1; options wiring is U5c
|
// U5: custom dropdown editor via slots
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
editRender: { enabled: true, name: 'VxeInput' },
|
editRender: { enabled: true },
|
||||||
|
slots: {
|
||||||
|
...base.slots,
|
||||||
|
edit: `edit_${f.id}`,
|
||||||
|
default: `cell_sel_${f.id}`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
|
|
@ -179,12 +257,17 @@ const onEditClosed: VxeGridEvents.EditClosed = (params) => {
|
||||||
const recordId = (row as GridRow)._recordId
|
const recordId = (row as GridRow)._recordId
|
||||||
if (!recordId) return
|
if (!recordId) return
|
||||||
|
|
||||||
// Only emit if value actually changed
|
// Only emit if value actually changed (deep-equal for arrays / multiselect)
|
||||||
const newValue = (row as Record<string, unknown>)[field]
|
const newValue = (row as Record<string, unknown>)[field]
|
||||||
const original = props.records.find((r) => r.id === recordId)
|
const original = props.records.find((r) => r.id === recordId)
|
||||||
if (!original) return
|
if (!original) return
|
||||||
const oldValue = original.values[field]
|
const oldValue = original.values[field]
|
||||||
if (newValue === oldValue) return
|
const sameValue =
|
||||||
|
Array.isArray(newValue) && Array.isArray(oldValue)
|
||||||
|
? newValue.length === oldValue.length &&
|
||||||
|
newValue.every((v, i) => v === oldValue[i])
|
||||||
|
: newValue === oldValue
|
||||||
|
if (sameValue) return
|
||||||
|
|
||||||
emit('edit-cell', {
|
emit('edit-cell', {
|
||||||
recordId,
|
recordId,
|
||||||
|
|
@ -224,4 +307,20 @@ defineExpose({
|
||||||
.bitable-grid-scope :deep(.vxe-cell--dirty) {
|
.bitable-grid-scope :deep(.vxe-cell--dirty) {
|
||||||
color: var(--color-primary, #1677ff);
|
color: var(--color-primary, #1677ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bitable-grid-scope__add-col {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary, #8c8c8c);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 100%;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bitable-grid-scope__add-col:hover {
|
||||||
|
color: var(--color-primary, #1677ff);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, watch } from 'vue'
|
import { ref, reactive, watch } from 'vue'
|
||||||
import type { FormInstance } from 'ant-design-vue'
|
import type { FormInstance } from 'ant-design-vue'
|
||||||
|
import { useBitableStore } from '@/stores/bitable'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
|
fileId?: string | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -44,6 +46,7 @@ const emit = defineEmits<{
|
||||||
(e: 'cancel'): void
|
(e: 'cancel'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const store = useBitableStore()
|
||||||
const formRef = ref<FormInstance | null>(null)
|
const formRef = ref<FormInstance | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
|
@ -79,13 +82,23 @@ async function handleOk(): Promise<void> {
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
// Lazy import to avoid circular dependency
|
// Use file-scoped creation when fileId is provided (R1).
|
||||||
const { bitableApi } = await import('@/api/bitable')
|
// Fall back to legacy createTable for backward compat.
|
||||||
const resp = await bitableApi.createTable({
|
let tableId: string | null = null
|
||||||
name: formState.name.trim(),
|
if (props.fileId) {
|
||||||
description: formState.description.trim() || undefined,
|
const table = await store.createTableInFile(
|
||||||
})
|
formState.name.trim(),
|
||||||
emit('success', resp.table.id)
|
formState.description.trim() || undefined,
|
||||||
|
)
|
||||||
|
tableId = table?.id ?? null
|
||||||
|
} else {
|
||||||
|
const table = await store.createTable(
|
||||||
|
formState.name.trim(),
|
||||||
|
formState.description.trim() || undefined,
|
||||||
|
)
|
||||||
|
tableId = table?.id ?? null
|
||||||
|
}
|
||||||
|
if (tableId) emit('success', tableId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const { notification } = await import('ant-design-vue')
|
const { notification } = await import('ant-design-vue')
|
||||||
notification.error({
|
notification.error({
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
size="small"
|
size="small"
|
||||||
type="text"
|
type="text"
|
||||||
:icon="h(PlusOutlined)"
|
:icon="h(PlusOutlined)"
|
||||||
@click="emit('create')"
|
@click="emit('create', fileId ?? null)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -43,12 +43,13 @@ import type { IBitableTable } from '@/api/bitable'
|
||||||
defineProps<{
|
defineProps<{
|
||||||
tables: IBitableTable[]
|
tables: IBitableTable[]
|
||||||
activeId: string | null
|
activeId: string | null
|
||||||
|
fileId?: string | null
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'select', tableId: string): void
|
(e: 'select', tableId: string): void
|
||||||
(e: 'create'): void
|
(e: 'create', fileId: string | null): void
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,12 +89,31 @@ const routes: RouteRecordRaw[] = [
|
||||||
meta: { title: 'Computer Use' },
|
meta: { title: 'Computer Use' },
|
||||||
},
|
},
|
||||||
|
|
||||||
// Bitable 多维表格 (独立全屏视图)
|
// Bitable 多维表格 (独立全屏视图,三层路由:文件列表 → 文件详情 → 表详情)
|
||||||
{
|
{
|
||||||
path: '/bitable',
|
path: '/bitable',
|
||||||
name: 'bitable',
|
name: 'bitable',
|
||||||
component: () => import('@/views/BitableView.vue'),
|
|
||||||
meta: { title: '多维表格' },
|
meta: { title: '多维表格' },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'bitable-file-list',
|
||||||
|
component: () => import('@/views/BitableFileListView.vue'),
|
||||||
|
meta: { title: '多维表格' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':fileId',
|
||||||
|
name: 'bitable-file-detail',
|
||||||
|
component: () => import('@/views/BitableFileDetailView.vue'),
|
||||||
|
meta: { title: '多维表格' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':fileId/:tableId',
|
||||||
|
name: 'bitable-table-detail',
|
||||||
|
component: () => import('@/views/BitableFileDetailView.vue'),
|
||||||
|
meta: { title: '多维表格' },
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// Admin console (U9) — AdminLayout wraps all /admin/* child routes.
|
// Admin console (U9) — AdminLayout wraps all /admin/* child routes.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { ref, computed } from 'vue'
|
||||||
import { notification } from 'ant-design-vue'
|
import { notification } from 'ant-design-vue'
|
||||||
import { bitableApi } from '@/api/bitable'
|
import { bitableApi } from '@/api/bitable'
|
||||||
import type {
|
import type {
|
||||||
|
IBitableFile,
|
||||||
IBitableTable,
|
IBitableTable,
|
||||||
IBitableField,
|
IBitableField,
|
||||||
IBitableRecord,
|
IBitableRecord,
|
||||||
|
|
@ -22,6 +23,8 @@ import type {
|
||||||
|
|
||||||
export const useBitableStore = defineStore('bitable', () => {
|
export const useBitableStore = defineStore('bitable', () => {
|
||||||
// --- State ---
|
// --- State ---
|
||||||
|
const files = ref<IBitableFile[]>([])
|
||||||
|
const currentFile = ref<IBitableFile | null>(null)
|
||||||
const tables = ref<IBitableTable[]>([])
|
const tables = ref<IBitableTable[]>([])
|
||||||
const currentTable = ref<IBitableTable | null>(null)
|
const currentTable = ref<IBitableTable | null>(null)
|
||||||
const fields = ref<IBitableField[]>([])
|
const fields = ref<IBitableField[]>([])
|
||||||
|
|
@ -44,9 +47,138 @@ export const useBitableStore = defineStore('bitable', () => {
|
||||||
|
|
||||||
const hasFormulaFields = computed(() => formulaFields.value.length > 0)
|
const hasFormulaFields = computed(() => formulaFields.value.length > 0)
|
||||||
|
|
||||||
// --- Actions ---
|
// --- File actions (R1) ---
|
||||||
|
|
||||||
/** Load all bitable tables */
|
/** Load all bitable files owned by the current user */
|
||||||
|
async function loadFiles(): Promise<void> {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const resp = await bitableApi.listFiles()
|
||||||
|
files.value = resp.files || []
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '加载文件列表失败'
|
||||||
|
notification.error({ message: '加载失败', description: error.value })
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new bitable file */
|
||||||
|
async function createFile(
|
||||||
|
name: string,
|
||||||
|
icon = '📋',
|
||||||
|
description = '',
|
||||||
|
): Promise<IBitableFile | null> {
|
||||||
|
try {
|
||||||
|
const resp = await bitableApi.createFile({ name, icon, description })
|
||||||
|
files.value.unshift(resp.file)
|
||||||
|
return resp.file
|
||||||
|
} catch (err) {
|
||||||
|
notification.error({
|
||||||
|
message: '创建文件失败',
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a file's metadata */
|
||||||
|
async function updateFile(
|
||||||
|
fileId: string,
|
||||||
|
data: { name?: string; icon?: string; description?: string },
|
||||||
|
): Promise<IBitableFile | null> {
|
||||||
|
try {
|
||||||
|
const resp = await bitableApi.updateFile(fileId, data)
|
||||||
|
const idx = files.value.findIndex((f) => f.id === fileId)
|
||||||
|
if (idx >= 0) files.value[idx] = resp.file
|
||||||
|
if (currentFile.value?.id === fileId) currentFile.value = resp.file
|
||||||
|
return resp.file
|
||||||
|
} catch (err) {
|
||||||
|
notification.error({
|
||||||
|
message: '更新文件失败',
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a file (cascades to all its tables) */
|
||||||
|
async function deleteFile(fileId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await bitableApi.deleteFile(fileId)
|
||||||
|
files.value = files.value.filter((f) => f.id !== fileId)
|
||||||
|
if (currentFile.value?.id === fileId) {
|
||||||
|
currentFile.value = null
|
||||||
|
tables.value = []
|
||||||
|
currentTable.value = null
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
notification.error({
|
||||||
|
message: '删除文件失败',
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select a file and load its tables. Falls back to direct fetch on deep-link nav. */
|
||||||
|
async function selectFile(fileId: string): Promise<void> {
|
||||||
|
let file = files.value.find((f) => f.id === fileId) ?? null
|
||||||
|
if (!file) {
|
||||||
|
try {
|
||||||
|
const resp = await bitableApi.getFile(fileId)
|
||||||
|
file = resp.file
|
||||||
|
} catch {
|
||||||
|
file = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentFile.value = file
|
||||||
|
if (!file) return
|
||||||
|
await loadTablesByFile(fileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Table actions ---
|
||||||
|
|
||||||
|
/** Load tables belonging to a specific file (R3) */
|
||||||
|
async function loadTablesByFile(fileId: string): Promise<void> {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const resp = await bitableApi.listTablesInFile(fileId)
|
||||||
|
tables.value = resp.tables || []
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '加载表格列表失败'
|
||||||
|
notification.error({ message: '加载失败', description: error.value })
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new table under the current file (auto-creates 5 default fields) */
|
||||||
|
async function createTableInFile(
|
||||||
|
name: string,
|
||||||
|
description?: string,
|
||||||
|
): Promise<IBitableTable | null> {
|
||||||
|
if (!currentFile.value) return null
|
||||||
|
try {
|
||||||
|
const resp = await bitableApi.createTableInFile(currentFile.value.id, {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
tables.value.push(resp.table)
|
||||||
|
return resp.table
|
||||||
|
} catch (err) {
|
||||||
|
notification.error({
|
||||||
|
message: '创建表格失败',
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load all bitable tables (deprecated — prefer loadTablesByFile) */
|
||||||
async function loadTables(): Promise<void> {
|
async function loadTables(): Promise<void> {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
@ -222,6 +354,18 @@ export const useBitableStore = defineStore('bitable', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hide a field in the current view (R5 column header menu) */
|
||||||
|
async function hideField(fieldId: string): Promise<void> {
|
||||||
|
if (!currentView.value) return
|
||||||
|
const config = { ...currentView.value.config }
|
||||||
|
const hiddenFields = (config.hidden_fields as string[]) ?? []
|
||||||
|
if (!hiddenFields.includes(fieldId)) {
|
||||||
|
hiddenFields.push(fieldId)
|
||||||
|
}
|
||||||
|
config.hidden_fields = hiddenFields
|
||||||
|
await updateView(currentView.value.id, { config })
|
||||||
|
}
|
||||||
|
|
||||||
/** Refresh records (e.g. after Agent writes data via BitableTool) */
|
/** Refresh records (e.g. after Agent writes data via BitableTool) */
|
||||||
async function refreshRecords(): Promise<void> {
|
async function refreshRecords(): Promise<void> {
|
||||||
if (!currentTable.value) return
|
if (!currentTable.value) return
|
||||||
|
|
@ -319,7 +463,12 @@ export const useBitableStore = defineStore('bitable', () => {
|
||||||
/** Poll recalc status: reload records if any formula fields are still calculating */
|
/** Poll recalc status: reload records if any formula fields are still calculating */
|
||||||
async function pollRecalcStatus(tableId: string): Promise<void> {
|
async function pollRecalcStatus(tableId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await bitableApi.listRecords(tableId, { limit: 100 })
|
// Reuse current view filters so polling doesn't overwrite a filtered set
|
||||||
|
const filters = currentView.value?.config?.filters
|
||||||
|
const resp = await bitableApi.listRecords(tableId, {
|
||||||
|
limit: 100,
|
||||||
|
filters: filters ? JSON.stringify(filters) : undefined,
|
||||||
|
})
|
||||||
const newRecords = resp.records || []
|
const newRecords = resp.records || []
|
||||||
|
|
||||||
// Single traversal: collect pending records (formula field values still null)
|
// Single traversal: collect pending records (formula field values still null)
|
||||||
|
|
@ -327,11 +476,13 @@ export const useBitableStore = defineStore('bitable', () => {
|
||||||
formulaFields.value.some((f) => rec.values[f.id] == null),
|
formulaFields.value.some((f) => rec.values[f.id] == null),
|
||||||
)
|
)
|
||||||
const stillCalculating = pending.length > 0
|
const stillCalculating = pending.length > 0
|
||||||
|
const wasCalculating = recalcPendingCount.value > 0
|
||||||
|
|
||||||
// Only update state if records actually changed (avoid unnecessary re-renders)
|
// Update state when records changed, still calculating, or just finished
|
||||||
|
// (wasCalculating ensures the final computed values are applied).
|
||||||
const oldIds = records.value.map((r) => r.id).join(',')
|
const oldIds = records.value.map((r) => r.id).join(',')
|
||||||
const newIds = newRecords.map((r) => r.id).join(',')
|
const newIds = newRecords.map((r) => r.id).join(',')
|
||||||
if (oldIds !== newIds || stillCalculating) {
|
if (oldIds !== newIds || stillCalculating || wasCalculating) {
|
||||||
records.value = newRecords
|
records.value = newRecords
|
||||||
nextCursor.value = resp.next_cursor
|
nextCursor.value = resp.next_cursor
|
||||||
}
|
}
|
||||||
|
|
@ -350,6 +501,8 @@ export const useBitableStore = defineStore('bitable', () => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
|
files,
|
||||||
|
currentFile,
|
||||||
tables,
|
tables,
|
||||||
currentTable,
|
currentTable,
|
||||||
fields,
|
fields,
|
||||||
|
|
@ -363,7 +516,15 @@ export const useBitableStore = defineStore('bitable', () => {
|
||||||
// Getters
|
// Getters
|
||||||
formulaFields,
|
formulaFields,
|
||||||
hasFormulaFields,
|
hasFormulaFields,
|
||||||
// Actions
|
// File actions (R1)
|
||||||
|
loadFiles,
|
||||||
|
createFile,
|
||||||
|
updateFile,
|
||||||
|
deleteFile,
|
||||||
|
selectFile,
|
||||||
|
loadTablesByFile,
|
||||||
|
createTableInFile,
|
||||||
|
// Table actions
|
||||||
loadTables,
|
loadTables,
|
||||||
selectTable,
|
selectTable,
|
||||||
loadMoreRecords,
|
loadMoreRecords,
|
||||||
|
|
@ -372,6 +533,7 @@ export const useBitableStore = defineStore('bitable', () => {
|
||||||
createTable,
|
createTable,
|
||||||
updateField,
|
updateField,
|
||||||
deleteField,
|
deleteField,
|
||||||
|
hideField,
|
||||||
refreshRecords,
|
refreshRecords,
|
||||||
createView,
|
createView,
|
||||||
updateView,
|
updateView,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<template>
|
||||||
<div class="bitable-view">
|
<router-view />
|
||||||
<!-- Top bar -->
|
|
||||||
<div class="bitable-view__topbar">
|
|
||||||
<div class="bitable-view__topbar-left">
|
|
||||||
<a-button type="text" :icon="h(ArrowLeftOutlined)" @click="goBack" />
|
|
||||||
<span class="bitable-view__title">多维表格</span>
|
|
||||||
</div>
|
|
||||||
<div class="bitable-view__topbar-right">
|
|
||||||
<a-tag v-if="store.recalcPendingCount > 0" color="processing">
|
|
||||||
<LoadingOutlined /> 计算中 ({{ store.recalcPendingCount }})
|
|
||||||
</a-tag>
|
|
||||||
<a-button
|
|
||||||
v-if="store.currentTable"
|
|
||||||
size="small"
|
|
||||||
:icon="h(SettingOutlined)"
|
|
||||||
@click="fieldPanelOpen = true"
|
|
||||||
>
|
|
||||||
字段管理
|
|
||||||
</a-button>
|
|
||||||
<a-button size="small" :icon="h(ReloadOutlined)" @click="handleRefresh">
|
|
||||||
刷新
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body: left sidebar + right grid -->
|
|
||||||
<div class="bitable-view__body">
|
|
||||||
<aside class="bitable-view__sidebar">
|
|
||||||
<TableViewList
|
|
||||||
:tables="store.tables"
|
|
||||||
:active-id="store.currentTable?.id ?? null"
|
|
||||||
:loading="store.isLoading"
|
|
||||||
@select="handleSelectTable"
|
|
||||||
@create="createModalOpen = true"
|
|
||||||
/>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="bitable-view__main">
|
|
||||||
<div v-if="!store.currentTable" class="bitable-view__placeholder">
|
|
||||||
<TableOutlined style="font-size: 48px; color: var(--text-placeholder)" />
|
|
||||||
<p>请选择左侧的数据表</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="bitable-view__grid-header">
|
|
||||||
<h3 class="bitable-view__table-name">{{ store.currentTable.name }}</h3>
|
|
||||||
<span class="bitable-view__field-count">
|
|
||||||
{{ store.fields.length }} 个字段 · {{ store.records.length }} 条记录
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- View switcher (U5c) -->
|
|
||||||
<ViewSwitcher
|
|
||||||
:views="store.views"
|
|
||||||
:active-view-id="store.currentView?.id ?? null"
|
|
||||||
@switch="handleSwitchView"
|
|
||||||
@create="handleCreateView"
|
|
||||||
@config="viewConfigOpen = true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="bitable-view__grid-container">
|
|
||||||
<BitableGrid
|
|
||||||
:fields="visibleFields"
|
|
||||||
:records="store.records"
|
|
||||||
:loading="store.isLoading"
|
|
||||||
height="100%"
|
|
||||||
@edit-cell="handleEditCell"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Load more (cursor pagination) -->
|
|
||||||
<div v-if="store.nextCursor" class="bitable-view__load-more">
|
|
||||||
<a-button @click="store.loadMoreRecords()">加载更多</a-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table create modal (U5b) -->
|
|
||||||
<TableCreateModal
|
|
||||||
:open="createModalOpen"
|
|
||||||
@success="handleTableCreated"
|
|
||||||
@cancel="createModalOpen = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Field management panel (U5b) -->
|
|
||||||
<FieldManagePanel
|
|
||||||
:open="fieldPanelOpen"
|
|
||||||
:fields="store.fields"
|
|
||||||
@close="fieldPanelOpen = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- View config panel (U5c) -->
|
|
||||||
<ViewConfigPanel
|
|
||||||
:open="viewConfigOpen"
|
|
||||||
:fields="store.fields"
|
|
||||||
:view="store.currentView"
|
|
||||||
@close="viewConfigOpen = false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
|
// Legacy BitableView — replaced by nested routes under /bitable.
|
||||||
import { useRouter } from 'vue-router'
|
// This component is kept as a passthrough for any stale imports;
|
||||||
import { Button as AButton, Tag as ATag, Modal as AModal } from 'ant-design-vue'
|
// the router no longer references it directly.
|
||||||
import {
|
|
||||||
ArrowLeftOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
LoadingOutlined,
|
|
||||||
TableOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
} from '@ant-design/icons-vue'
|
|
||||||
import { useBitableStore } from '@/stores/bitable'
|
|
||||||
import TableViewList from '@/components/bitable/TableViewList.vue'
|
|
||||||
import BitableGrid from '@/components/bitable/BitableGrid.vue'
|
|
||||||
import TableCreateModal from '@/components/bitable/TableCreateModal.vue'
|
|
||||||
import FieldManagePanel from '@/components/bitable/FieldManagePanel.vue'
|
|
||||||
import ViewSwitcher from '@/components/bitable/ViewSwitcher.vue'
|
|
||||||
import ViewConfigPanel from '@/components/bitable/ViewConfigPanel.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const store = useBitableStore()
|
|
||||||
|
|
||||||
const createModalOpen = ref(false)
|
|
||||||
const fieldPanelOpen = ref(false)
|
|
||||||
const viewConfigOpen = ref(false)
|
|
||||||
|
|
||||||
// Filter out hidden fields based on current view config
|
|
||||||
const visibleFields = computed(() => {
|
|
||||||
const hiddenIds = (store.currentView?.config?.hidden_fields as string[]) ?? []
|
|
||||||
if (hiddenIds.length === 0) return store.fields
|
|
||||||
return store.fields.filter((f) => !hiddenIds.includes(f.id))
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
store.loadTables()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
store.stopPolling()
|
|
||||||
})
|
|
||||||
|
|
||||||
function goBack(): void {
|
|
||||||
router.push('/agent/chat')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelectTable(tableId: string): void {
|
|
||||||
store.selectTable(tableId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRefresh(): void {
|
|
||||||
store.refreshRecords()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTableCreated(tableId: string): Promise<void> {
|
|
||||||
createModalOpen.value = false
|
|
||||||
await store.loadTables()
|
|
||||||
await store.selectTable(tableId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEditCell(payload: {
|
|
||||||
recordId: string
|
|
||||||
fieldId: string
|
|
||||||
value: unknown
|
|
||||||
}): Promise<void> {
|
|
||||||
await store.updateCell(payload.recordId, payload.fieldId, payload.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSwitchView(viewId: string): void {
|
|
||||||
store.switchView(viewId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateView(): Promise<void> {
|
|
||||||
// ponytail: simple prompt for view name; full create modal is overkill for v1
|
|
||||||
let name = ''
|
|
||||||
AModal.confirm({
|
|
||||||
title: '新建视图',
|
|
||||||
content: h('input', {
|
|
||||||
class: 'ant-input',
|
|
||||||
placeholder: '请输入视图名称',
|
|
||||||
onInput: (e: Event) => {
|
|
||||||
name = (e.target as HTMLInputElement).value
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
onOk: async () => {
|
|
||||||
if (!name.trim()) return
|
|
||||||
await store.createView(name.trim(), 'grid')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.bitable-view {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
width: 100vw;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-primary, #fff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitable-view__topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-bottom: 1px solid var(--border-color, #f0f0f0);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitable-view__topbar-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitable-view__topbar-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitable-view__title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitable-view__body {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitable-view__sidebar {
|
|
||||||
width: 240px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitable-view__main {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitable-view__placeholder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
gap: 16px;
|
|
||||||
color: var(--text-placeholder, #bfbfbf);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitable-view__grid-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid var(--border-color, #f0f0f0);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitable-view__table-name {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitable-view__field-count {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary, #8c8c8c);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitable-view__grid-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitable-view__load-more {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 8px;
|
|
||||||
border-top: 1px solid var(--border-color, #f0f0f0);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,8 @@ async def _check_table_ownership(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Verify the user owns the table. Internal service users bypass check.
|
"""Verify the user owns the table. Internal service users bypass check.
|
||||||
|
|
||||||
Raises 404 if table not found, 403 if not owned.
|
Pattern 4 (IDOR ownership): 404 before 403 — never leak existence of a
|
||||||
|
table the user doesn't own. Internal token bypasses (KTD11).
|
||||||
"""
|
"""
|
||||||
table = await service.get_table(table_id)
|
table = await service.get_table(table_id)
|
||||||
if table is None:
|
if table is None:
|
||||||
|
|
@ -93,7 +94,24 @@ async def _check_table_ownership(
|
||||||
if user.get("internal"):
|
if user.get("internal"):
|
||||||
return # Internal service token (KTD11) bypasses ownership
|
return # Internal service token (KTD11) bypasses ownership
|
||||||
if table.owner_user_id and table.owner_user_id != user.get("user_id"):
|
if table.owner_user_id and table.owner_user_id != user.get("user_id"):
|
||||||
raise HTTPException(status_code=403, detail="Not authorized to access this table")
|
raise HTTPException(status_code=404, detail="Table not found")
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_file_ownership(
|
||||||
|
service: BitableService, file_id: str, user: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Verify the user owns the file. Internal service users bypass check.
|
||||||
|
|
||||||
|
Pattern 4 (IDOR ownership): 404 before 403 — never leak existence of a
|
||||||
|
file the user doesn't own. Internal token bypasses (KTD11).
|
||||||
|
"""
|
||||||
|
file = await service.get_file(file_id)
|
||||||
|
if file is None:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
if user.get("internal"):
|
||||||
|
return # Internal service token (KTD11) bypasses ownership
|
||||||
|
if file.owner_user_id and file.owner_user_id != user.get("user_id"):
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -101,6 +119,18 @@ async def _check_table_ownership(
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class CreateFileRequest(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
icon: str = Field("📋", max_length=20)
|
||||||
|
description: str = Field("", max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateFileRequest(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
icon: str | None = Field(None, max_length=20)
|
||||||
|
description: str | None = Field(None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
class CreateTableRequest(BaseModel):
|
class CreateTableRequest(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
@ -164,6 +194,11 @@ async def create_table(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: dict = Depends(require_bitable_auth),
|
user: dict = Depends(require_bitable_auth),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Deprecated: use POST /files/{file_id}/tables instead.
|
||||||
|
|
||||||
|
Creates a table without a parent file (file_id = NULL). Kept for
|
||||||
|
backward compatibility — new clients should create tables under a file.
|
||||||
|
"""
|
||||||
service = _get_service(request)
|
service = _get_service(request)
|
||||||
table = await service.create_table(
|
table = await service.create_table(
|
||||||
name=body.name,
|
name=body.name,
|
||||||
|
|
@ -179,11 +214,126 @@ async def list_tables(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: dict = Depends(require_bitable_auth),
|
user: dict = Depends(require_bitable_auth),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Deprecated: use GET /files/{file_id}/tables instead.
|
||||||
|
|
||||||
|
Lists all tables owned by the user regardless of parent file.
|
||||||
|
"""
|
||||||
service = _get_service(request)
|
service = _get_service(request)
|
||||||
tables = await service.list_tables(owner_user_id=user.get("user_id"))
|
tables = await service.list_tables(owner_user_id=user.get("user_id"))
|
||||||
return {"success": True, "tables": [t.model_dump(mode="json") for t in tables]}
|
return {"success": True, "tables": [t.model_dump(mode="json") for t in tables]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File endpoints (R1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/files", status_code=201)
|
||||||
|
async def create_file(
|
||||||
|
body: CreateFileRequest,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_bitable_auth),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new bitable file (top-level container)."""
|
||||||
|
service = _get_service(request)
|
||||||
|
file = await service.create_file(
|
||||||
|
name=body.name,
|
||||||
|
icon=body.icon,
|
||||||
|
description=body.description,
|
||||||
|
owner_user_id=user.get("user_id"),
|
||||||
|
)
|
||||||
|
return {"success": True, "file": file.model_dump(mode="json")}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files")
|
||||||
|
async def list_files(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_bitable_auth),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List all bitable files owned by the current user."""
|
||||||
|
service = _get_service(request)
|
||||||
|
files = await service.list_files(owner_user_id=user.get("user_id"))
|
||||||
|
return {"success": True, "files": [f.model_dump(mode="json") for f in files]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_id}")
|
||||||
|
async def get_file(
|
||||||
|
file_id: str,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_bitable_auth),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get a single bitable file by ID (with ownership check)."""
|
||||||
|
service = _get_service(request)
|
||||||
|
await _check_file_ownership(service, file_id, user)
|
||||||
|
file = await service.get_file(file_id)
|
||||||
|
return {"success": True, "file": file.model_dump(mode="json")}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/files/{file_id}")
|
||||||
|
async def update_file(
|
||||||
|
file_id: str,
|
||||||
|
body: UpdateFileRequest,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_bitable_auth),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update a bitable file's metadata (name/icon/description)."""
|
||||||
|
service = _get_service(request)
|
||||||
|
await _check_file_ownership(service, file_id, user)
|
||||||
|
kwargs = body.model_dump(exclude_none=True)
|
||||||
|
file = await service.update_file(file_id, **kwargs)
|
||||||
|
if file is None:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
return {"success": True, "file": file.model_dump(mode="json")}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/files/{file_id}")
|
||||||
|
async def delete_file(
|
||||||
|
file_id: str,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_bitable_auth),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Delete a bitable file. Cascades to all tables under the file."""
|
||||||
|
service = _get_service(request)
|
||||||
|
await _check_file_ownership(service, file_id, user)
|
||||||
|
deleted = await service.delete_file(file_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_id}/tables")
|
||||||
|
async def list_tables_in_file(
|
||||||
|
file_id: str,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_bitable_auth),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List all tables in a file (with file ownership check)."""
|
||||||
|
service = _get_service(request)
|
||||||
|
await _check_file_ownership(service, file_id, user)
|
||||||
|
tables = await service.list_tables_by_file(file_id)
|
||||||
|
return {"success": True, "tables": [t.model_dump(mode="json") for t in tables]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/files/{file_id}/tables", status_code=201)
|
||||||
|
async def create_table_in_file(
|
||||||
|
file_id: str,
|
||||||
|
body: CreateTableRequest,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_bitable_auth),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new table under a file. Auto-creates 5 default fields (R2)."""
|
||||||
|
service = _get_service(request)
|
||||||
|
await _check_file_ownership(service, file_id, user)
|
||||||
|
table = await service.create_table(
|
||||||
|
name=body.name,
|
||||||
|
description=body.description,
|
||||||
|
primary_key_field_id=body.primary_key_field_id,
|
||||||
|
owner_user_id=user.get("user_id"),
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
return {"success": True, "table": table.model_dump(mode="json")}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tables/{table_id}")
|
@router.get("/tables/{table_id}")
|
||||||
async def get_table(
|
async def get_table(
|
||||||
table_id: str,
|
table_id: str,
|
||||||
|
|
@ -270,6 +420,10 @@ async def update_field(
|
||||||
user: dict = Depends(require_bitable_auth),
|
user: dict = Depends(require_bitable_auth),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
service = _get_service(request)
|
service = _get_service(request)
|
||||||
|
existing = await service.get_field(field_id)
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Field not found")
|
||||||
|
await _check_table_ownership(service, existing.table_id, user)
|
||||||
kwargs = body.model_dump(exclude_none=True)
|
kwargs = body.model_dump(exclude_none=True)
|
||||||
field = await service.update_field(field_id, **kwargs)
|
field = await service.update_field(field_id, **kwargs)
|
||||||
if field is None:
|
if field is None:
|
||||||
|
|
@ -285,6 +439,10 @@ async def delete_field(
|
||||||
user: dict = Depends(require_bitable_auth),
|
user: dict = Depends(require_bitable_auth),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
service = _get_service(request)
|
service = _get_service(request)
|
||||||
|
existing = await service.get_field(field_id)
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Field not found")
|
||||||
|
await _check_table_ownership(service, existing.table_id, user)
|
||||||
try:
|
try:
|
||||||
deleted = await service.delete_field(field_id, force=force)
|
deleted = await service.delete_field(field_id, force=force)
|
||||||
except FieldDependencyError as e:
|
except FieldDependencyError as e:
|
||||||
|
|
@ -346,7 +504,9 @@ async def create_records(
|
||||||
await _check_table_ownership(service, table_id, user)
|
await _check_table_ownership(service, table_id, user)
|
||||||
created = []
|
created = []
|
||||||
for rec_values in body.records:
|
for rec_values in body.records:
|
||||||
record = await service.create_record(table_id, values=rec_values)
|
record = await service.create_record(
|
||||||
|
table_id, values=rec_values, actor_user_id=user.get("user_id")
|
||||||
|
)
|
||||||
created.append(record.model_dump(mode="json"))
|
created.append(record.model_dump(mode="json"))
|
||||||
return {"success": True, "count": len(created), "records": created}
|
return {"success": True, "count": len(created), "records": created}
|
||||||
|
|
||||||
|
|
@ -398,6 +558,10 @@ async def update_record(
|
||||||
user: dict = Depends(require_bitable_auth),
|
user: dict = Depends(require_bitable_auth),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
service = _get_service(request)
|
service = _get_service(request)
|
||||||
|
existing = await service.get_record(record_id)
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
|
await _check_table_ownership(service, existing.table_id, user)
|
||||||
record = await service.update_record_values(record_id, body.values)
|
record = await service.update_record_values(record_id, body.values)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
|
|
@ -423,6 +587,10 @@ async def delete_single_record(
|
||||||
user: dict = Depends(require_bitable_auth),
|
user: dict = Depends(require_bitable_auth),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
service = _get_service(request)
|
service = _get_service(request)
|
||||||
|
existing = await service.get_record(record_id)
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
|
await _check_table_ownership(service, existing.table_id, user)
|
||||||
deleted = await service.delete_record(record_id)
|
deleted = await service.delete_record(record_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
|
|
@ -492,6 +660,10 @@ async def update_view(
|
||||||
user: dict = Depends(require_bitable_auth),
|
user: dict = Depends(require_bitable_auth),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
service = _get_service(request)
|
service = _get_service(request)
|
||||||
|
existing = await service.get_view(view_id)
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(status_code=404, detail="View not found")
|
||||||
|
await _check_table_ownership(service, existing.table_id, user)
|
||||||
kwargs = body.model_dump(exclude_none=True)
|
kwargs = body.model_dump(exclude_none=True)
|
||||||
view = await service.update_view(view_id, **kwargs)
|
view = await service.update_view(view_id, **kwargs)
|
||||||
if view is None:
|
if view is None:
|
||||||
|
|
@ -535,6 +707,8 @@ async def upload_file(
|
||||||
field = await service.get_field(field_id)
|
field = await service.get_field(field_id)
|
||||||
if field is None:
|
if field is None:
|
||||||
raise HTTPException(status_code=404, detail="Field not found")
|
raise HTTPException(status_code=404, detail="Field not found")
|
||||||
|
if field.table_id != table_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Field does not belong to this table")
|
||||||
if field.field_type not in (FieldType.attachment, FieldType.image):
|
if field.field_type not in (FieldType.attachment, FieldType.image):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
@ -588,11 +762,11 @@ async def upload_file(
|
||||||
"stored_name": stored_name,
|
"stored_name": stored_name,
|
||||||
"mime_type": mime,
|
"mime_type": mime,
|
||||||
"size": total_size,
|
"size": total_size,
|
||||||
"url": f"/api/v1/bitable/files/{stored_name}",
|
"url": f"/api/v1/bitable/uploads/{stored_name}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/files/{filename}")
|
@router.get("/uploads/{filename}")
|
||||||
async def download_file(
|
async def download_file(
|
||||||
filename: str,
|
filename: str,
|
||||||
user: dict = Depends(require_bitable_auth),
|
user: dict = Depends(require_bitable_auth),
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ def _make_test_user() -> dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app(bitable_service: BitableService, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> FastAPI:
|
def app(
|
||||||
|
bitable_service: BitableService, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> FastAPI:
|
||||||
"""Test app with upload dir redirected to tmp_path."""
|
"""Test app with upload dir redirected to tmp_path."""
|
||||||
upload_dir = tmp_path / "bitable_uploads"
|
upload_dir = tmp_path / "bitable_uploads"
|
||||||
# Patch both the routes module variable AND the env var (service reads env var)
|
# Patch both the routes module variable AND the env var (service reads env var)
|
||||||
|
|
@ -62,9 +64,9 @@ async def _create_table_with_field(
|
||||||
field_name: str = "files",
|
field_name: str = "files",
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Create a table + a field, return (table_id, field_id)."""
|
"""Create a table + a field, return (table_id, field_id)."""
|
||||||
table_id = (
|
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
|
||||||
await client.post("/api/v1/bitable/tables", json={"name": "T"})
|
"id"
|
||||||
).json()["table"]["id"]
|
]
|
||||||
field_id = (
|
field_id = (
|
||||||
await client.post(
|
await client.post(
|
||||||
f"/api/v1/bitable/tables/{table_id}/fields",
|
f"/api/v1/bitable/tables/{table_id}/fields",
|
||||||
|
|
@ -149,9 +151,9 @@ async def test_upload_rejects_non_attachment_field(client: httpx.AsyncClient) ->
|
||||||
|
|
||||||
|
|
||||||
async def test_upload_404_unknown_field(client: httpx.AsyncClient) -> None:
|
async def test_upload_404_unknown_field(client: httpx.AsyncClient) -> None:
|
||||||
table_id = (
|
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
|
||||||
await client.post("/api/v1/bitable/tables", json={"name": "T"})
|
"id"
|
||||||
).json()["table"]["id"]
|
]
|
||||||
img_bytes, _ = _make_image_bytes()
|
img_bytes, _ = _make_image_bytes()
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"/api/v1/bitable/tables/{table_id}/upload",
|
f"/api/v1/bitable/tables/{table_id}/upload",
|
||||||
|
|
@ -317,6 +319,6 @@ async def test_multiple_files_in_attachment_field(client: httpx.AsyncClient) ->
|
||||||
f"/api/v1/bitable/tables/{table_id}/records",
|
f"/api/v1/bitable/tables/{table_id}/records",
|
||||||
json={"records": [{field_id: metas}]},
|
json={"records": [{field_id: metas}]},
|
||||||
)
|
)
|
||||||
assert create_resp.status_code == 200
|
assert create_resp.status_code == 201
|
||||||
record = create_resp.json()["records"][0]
|
record = create_resp.json()["records"][0]
|
||||||
assert len(record["values"][field_id]) == 2
|
assert len(record["values"][field_id]) == 2
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
"""Tests for bitable DB initialization, schema, and constraints (U1).
|
"""Tests for bitable DB initialization, schema, and constraints (U1).
|
||||||
|
|
||||||
Requires PostgreSQL — marked ``postgres``. Skips automatically when
|
PG-dependent tests skip via the ``bitable_db`` fixture when PostgreSQL is
|
||||||
``DATABASE_URL`` / ``AGENTKIT_DATABASE_URL`` is unset (see conftest.py).
|
unavailable. The no-URL degradation test runs in all environments.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
pytestmark = pytest.mark.postgres
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# init_bitable_db / BitableDB.init
|
# init_bitable_db / BitableDB.init
|
||||||
|
|
@ -17,7 +15,7 @@ pytestmark = pytest.mark.postgres
|
||||||
|
|
||||||
|
|
||||||
async def test_init_creates_schema_and_all_tables(bitable_db) -> None:
|
async def test_init_creates_schema_and_all_tables(bitable_db) -> None:
|
||||||
"""init creates the bitable schema and all 6 tables."""
|
"""init creates the bitable schema and all 7 tables (V2 adds bitable_files)."""
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
async with bitable_db.engine.begin() as conn:
|
async with bitable_db.engine.begin() as conn:
|
||||||
|
|
@ -29,7 +27,7 @@ async def test_init_creates_schema_and_all_tables(bitable_db) -> None:
|
||||||
)
|
)
|
||||||
assert result.fetchone() is not None
|
assert result.fetchone() is not None
|
||||||
|
|
||||||
# All 6 tables present
|
# All 7 tables present (V2 adds bitable_files)
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT table_name FROM information_schema.tables "
|
"SELECT table_name FROM information_schema.tables "
|
||||||
|
|
@ -39,6 +37,7 @@ async def test_init_creates_schema_and_all_tables(bitable_db) -> None:
|
||||||
tables = {row[0] for row in result.fetchall()}
|
tables = {row[0] for row in result.fetchall()}
|
||||||
assert tables == {
|
assert tables == {
|
||||||
"bitable_fields",
|
"bitable_fields",
|
||||||
|
"bitable_files",
|
||||||
"bitable_meta",
|
"bitable_meta",
|
||||||
"bitable_records",
|
"bitable_records",
|
||||||
"bitable_recalc_queue",
|
"bitable_recalc_queue",
|
||||||
|
|
|
||||||
|
|
@ -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