From 0979612a35a80db62975f752b8eb3557ad3e69d7 Mon Sep 17 00:00:00 2001 From: Chiguyong Date: Tue, 7 Jul 2026 22:39:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(portal/bitable):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E8=A1=8C=E5=86=85=E7=BC=96=E8=BE=91=E5=99=A8=E4=B8=8E=E9=A3=9E?= =?UTF-8?q?=E4=B9=A6=E9=A3=8E=E6=A0=BC=E4=BA=A4=E4=BA=92=EF=BC=8C=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=85=A8=E9=83=A8=E5=AD=97=E6=AE=B5=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BitableGrid: 统一 edit slot,按字段类型分发 Text/Select/Date 编辑器 - SelectCellEditor: 点击单元格直接展开下拉,下拉宽度不小于列宽 - DateCellEditor/TextCellEditor: 行内编辑,Enter/失焦自动保存 - 新增 editValueCache 解决 multiselect 编辑后值丢失 - 新增 confirmEdit provide,供子编辑器显式关闭编辑状态 - 调试脚本支持 label/value 分离的 select 选项断言 - 验证通过:text、select、number、date、multiselect、readonly 系统字段 --- docs/ux/component-library.md | 447 +++++++++ docs/ux/design-system-guidelines.md | 490 ++++++++++ src/agentkit/server/frontend/components.d.ts | 8 + .../server/frontend/debug_bitable.mjs | 433 +++++++++ src/agentkit/server/frontend/src/App.vue | 15 +- .../server/frontend/src/api/bitable.ts | 2 + .../src/components/bitable/BitableGrid.vue | 850 +++++++++++++----- .../components/bitable/ColumnHeaderMenu.vue | 2 +- .../src/components/bitable/DateCellEditor.vue | 108 +++ .../components/bitable/FieldManagePanel.vue | 2 + .../src/components/bitable/FieldTypeIcon.vue | 2 + .../components/bitable/SelectCellEditor.vue | 171 +++- .../src/components/bitable/SelectDisplay.vue | 68 +- .../components/bitable/SystemFieldDisplay.vue | 31 + .../src/components/bitable/TextCellEditor.vue | 106 +++ .../components/bitable/selectOptionStyle.ts | 45 + .../src/components/layout/AppLayout.vue | 2 +- .../src/components/layout/SideNav.vue | 70 +- .../frontend/src/components/ui/UiCard.vue | 114 +++ .../src/components/ui/UiMetricCard.vue | 98 ++ .../src/components/ui/UiPageHeader.vue | 96 ++ .../src/components/ui/UiPageShell.vue | 50 ++ .../src/components/ui/UiTrendBadge.vue | 77 ++ .../frontend/src/components/ui/index.ts | 5 + .../server/frontend/src/stores/bitable.ts | 9 +- .../frontend/src/styles/bitable-tokens.css | 7 + .../server/frontend/src/styles/theme.ts | 4 +- .../server/frontend/src/styles/tokens.css | 42 + .../src/views/BitableFileDetailView.vue | 11 +- src/agentkit/server/static/index.html | 4 +- 30 files changed, 3050 insertions(+), 319 deletions(-) create mode 100644 docs/ux/component-library.md create mode 100644 docs/ux/design-system-guidelines.md create mode 100644 src/agentkit/server/frontend/debug_bitable.mjs create mode 100644 src/agentkit/server/frontend/src/components/bitable/DateCellEditor.vue create mode 100644 src/agentkit/server/frontend/src/components/bitable/SystemFieldDisplay.vue create mode 100644 src/agentkit/server/frontend/src/components/bitable/TextCellEditor.vue create mode 100644 src/agentkit/server/frontend/src/components/bitable/selectOptionStyle.ts create mode 100644 src/agentkit/server/frontend/src/components/ui/UiCard.vue create mode 100644 src/agentkit/server/frontend/src/components/ui/UiMetricCard.vue create mode 100644 src/agentkit/server/frontend/src/components/ui/UiPageHeader.vue create mode 100644 src/agentkit/server/frontend/src/components/ui/UiPageShell.vue create mode 100644 src/agentkit/server/frontend/src/components/ui/UiTrendBadge.vue create mode 100644 src/agentkit/server/frontend/src/components/ui/index.ts diff --git a/docs/ux/component-library.md b/docs/ux/component-library.md new file mode 100644 index 0000000..ea98935 --- /dev/null +++ b/docs/ux/component-library.md @@ -0,0 +1,447 @@ +# AgentKit 组件库完整设计 + +> 基于 [全局 UI/UX 规范](design-system-guidelines.md) 的组件库规划。 +> 覆盖:通用基础、状态反馈、表单、数据展示、导航、反馈层、业务复合、多维表格、日历、Dashboard。 +> 目标:消除跨页面重复样式,新增功能直接组装组件,不再手写 UI。 + +## 设计原则 + +1. **薄封装 Ant Design Vue**:不重造轮子,只统一 token、尺寸、圆角、间距。 +2. **组件只管外观,布局归页面**:grid/flex 由调用方决定。 +3. **按域分组**:`components/ui/` 通用,`components/bitable/`、`components/calendar/` 等业务域保持独立。 +4. **迁移渐进**:旧页面不强制重写,新页面必须用组件库。 + +## 组件分层 + +``` +components/ +├── ui/ # 通用基础(跨所有页面) +│ ├── primitives/ # 原子:Button/Icon/Tag/Badge/Avatar/Tooltip +│ ├── feedback/ # 状态:Empty/Loading/Error/Spin +│ ├── form/ # 表单:FormField/Input/Select/DatePicker/Switch/Checkbox/Radio/TextArea/SearchInput/FormSection +│ ├── data/ # 数据:DataTable/List/ListItem/Description/Timeline/Stat/CodeBlock +│ ├── navigation/ # 导航:Tabs/Breadcrumb/Pagination +│ └── overlay/ # 浮层:Drawer/Modal +├── layout/ # 应用级布局(已有:SideNav/AppLayout 等) +├── bitable/ # 多维表格域(已有) +├── calendar/ # 日历域(已有) +├── chat/ # 聊天域(已有) +├── evolution/ # 自进化域(已有) +├── kb/ # 知识库域(已有) +├── skills/ # 技能域(已有) +└── workflow/ # 工作流域(已有) +``` + +## 1. 通用基础(components/ui) + +### 1.1 已实现 + +| 组件 | 文件 | 状态 | +|------|------|------| +| `UiCard` | `UiCard.vue` | ✅ | +| `UiMetricCard` | `UiMetricCard.vue` | ✅ | +| `UiTrendBadge` | `UiTrendBadge.vue` | ✅ | +| `UiPageHeader` | `UiPageHeader.vue` | ✅ | +| `UiPageShell` | `UiPageShell.vue` | ✅ | + +### 1.2 原子组件(primitives) + +#### UiButton +统一按钮高度(32/40px)、圆角(`--radius-lg`)、4 种类型。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `type` | `'primary' \| 'secondary' \| 'ghost' \| 'icon'` | 按钮类型 | +| `size` | `'sm' \| 'md' \| 'lg'` | 高度 28/32/40px | +| `loading` | `boolean` | 加载态 | +| `disabled` | `boolean` | 禁用 | +| `icon` | `Component` | 前置图标 | +| `block` | `boolean` | 撑满宽度 | + +#### UiTag +pill 标签,语义色。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `type` | `'default' \| 'success' \| 'warning' \| 'error' \| 'info'` | 语义色 | +| `size` | `'sm' \| 'md'` | 高度 20/24px | +| `closable` | `boolean` | 可关闭 | + +#### UiBadge +状态点(success/error/warning/processing/default)。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `status` | `'success' \| 'error' \| 'warning' \| 'processing' \| 'default'` | 状态 | +| `text` | `string` | 文本 | +| `dot` | `boolean` | 仅圆点 | + +#### UiAvatar +头像,支持文字/图片/图标回退。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `src` | `string` | 图片地址 | +| `text` | `string` | 文字(取首字) | +| `icon` | `Component` | 图标 | +| `size` | `'sm' \| 'md' \| 'lg'` | 24/32/40px | +| `color` | `string` | 背景色(可选) | + +#### UiIcon +统一图标尺寸与颜色继承。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `icon` | `Component` | 图标组件 | +| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | 12/14/16/20px | + +#### UiTooltip +封装 a-tooltip,统一主题色与延迟。 + +### 1.3 状态反馈(feedback) + +#### UiEmpty +统一空状态(替代散落的 `a-empty`)。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `description` | `string` | 说明文字 | +| `image` | `'default' \| 'search' \| 'network'` | 插画类型 | +| 插槽 `action` | — | 操作按钮 | + +#### UiLoading +骨架屏,从 `bitable/LoadingState` 提炼。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `variant` | `'skeleton' \| 'spin' \| 'dots'` | 加载样式 | +| `rows` | `number` | 骨架行数 | +| `title` | `boolean` | 是否显示标题占位 | +| `fullPage` | `boolean` | 全屏遮罩 | + +#### UiError +错误提示,从 `bitable/ErrorState` 提炼。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `type` | `'error' \| 'warning' \| 'info'` | 类型 | +| `message` | `string` | 标题 | +| `description` | `string` | 详情 | +| `retryable` | `boolean` | 显示重试按钮 | +| 事件 `retry` | — | 重试回调 | + +#### UiSpin +局部加载旋转(卡片内、按钮内)。 + +### 1.4 表单(form) + +#### UiFormField +表单项容器:label + control + hint + error。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `label` | `string` | 标签 | +| `required` | `boolean` | 必填星号 | +| `error` | `string` | 错误信息 | +| `hint` | `string` | 辅助说明 | +| 插槽 `default` | — | 控件 | + +#### UiInput +统一输入框(40px 高、`--radius-md`、focus 边框)。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `modelValue` | `string` | 值 | +| `placeholder` | `string` | — | +| `size` | `'sm' \| 'md'` | 32/40px | +| `disabled` | `boolean` | — | +| `prefix` / `suffix` | 插槽 | 前/后置 | + +#### UiTextArea +多行输入,自适应高度。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `modelValue` | `string` | — | +| `rows` | `number` | 默认 3 | +| `autoSize` | `boolean` | 自适应 | +| `maxLength` | `number` | — | + +#### UiSelect +封装 a-select,统一样式与清空/搜索行为。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `modelValue` | `any` | — | +| `options` | `{ label, value }[]` | — | +| `placeholder` | `string` | — | +| `multiple` | `boolean` | 多选 | +| `searchable` | `boolean` | 可搜索 | +| `clearable` | `boolean` | 可清空 | + +#### UiDatePicker / UiDateRangePicker +日期/日期范围选择,统一格式与主题。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `modelValue` | `Dayjs \| string` | — | +| `showTime` | `boolean` | 含时间 | +| `format` | `string` | 显示格式 | + +#### UiSwitch +开关。 + +#### UiCheckbox / UiCheckboxGroup +复选框与组。 + +#### UiRadio / UiRadioGroup +单选框与组。 + +#### UiSearchInput +搜索输入框(带搜索图标、清空、防抖)。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `modelValue` | `string` | — | +| `placeholder` | `string` | — | +| `debounce` | `number` | 防抖 ms(默认 300) | +| 事件 `search` | — | 搜索回调 | + +#### UiFormSection +表单分组卡片(标题 + 内容区)。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `title` | `string` | 分组标题 | +| `description` | `string` | 说明 | + +### 1.5 数据展示(data) + +#### UiDataTable +封装 a-table,统一行高、表头、边框、空状态、分页。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `columns` | `Column[]` | — | +| `dataSource` | `T[]` | — | +| `loading` | `boolean` | — | +| `pagination` | `false \| PaginationConfig` | — | +| `rowKey` | `string \| Function` | — | +| 插槽 `empty` | — | 自定义空状态 | + +> 多维表格继续使用 `BitableGrid`(更高定制),普通 CRUD 表用 `UiDataTable`。 + +#### UiList / UiListItem +垂直列表容器与项。 + +| Prop (UiList) | 类型 | 说明 | +|----------------|------|------| +| `bordered` | `boolean` | 分隔线 | +| `hoverable` | `boolean` | hover 高亮 | + +| Prop (UiListItem) | 类型 | 说明 | +|-------------------|------|------| +| `avatar` | 插槽 | 头像位 | +| `title` | 插槽 | 标题 | +| `description` | 插槽 | 描述 | +| `extra` | 插槽 | 右侧操作 | + +#### UiDescription +描述列表(key-value 对齐布局)。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `columns` | `number` | 列数(默认 2) | +| `items` | `{ label, value }[]` | — | +| `bordered` | `boolean` | 边框样式 | + +#### UiTimeline +时间线(从 evolution/ExperienceTimeline 提炼)。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `items` | `TimelineItem[]` | — | + +```ts +interface TimelineItem { + color?: 'success' | 'error' | 'warning' | 'default' + title: string + description?: string + timestamp?: string +} +``` + +#### UiStat +统计数值(比 MetricCard 轻,无卡片容器)。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `label` | `string` | — | +| `value` | `string \| number` | — | +| `prefix` / `suffix` | `string` | — | + +#### UiCodeBlock +代码块(带复制按钮、语言标签)。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `code` | `string` | — | +| `language` | `string` | — | +| `showCopy` | `boolean` | — | + +### 1.6 导航(navigation) + +#### UiTabs +封装 a-tabs,统一下划线样式与间距。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `items` | `{ key, label, icon? }[]` | — | +| `v-model` | `string` | 当前 key | +| `position` | `'top' \| 'left'` | — | + +#### UiBreadcrumb +面包屑。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `items` | `{ title, to? }[]` | — | + +#### UiPagination +分页(封装 a-pagination)。 + +### 1.7 浮层(overlay) + +#### UiDrawer +封装 a-drawer,统一宽度(480/720)、圆角、header 样式。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `open` | `boolean` | — | +| `title` | `string` | — | +| `width` | `number` | 默认 480 | +| `placement` | `'right' \| 'left'` | — | +| 事件 `close` | — | — | + +#### UiModal +封装 a-modal,统一样式。 + +| Prop | 类型 | 说明 | +|------|------|------| +| `open` | `boolean` | — | +| `title` | `string` | — | +| `width` | `number` | 默认 520 | +| `loading` | `boolean` | 确认按钮加载 | +| 事件 `ok` / `cancel` | — | — | + +## 2. 业务复合组件 + +### 2.1 UiToolbar +列表/表格顶部工具栏:搜索 + 筛选 + 操作按钮。 + +| Prop/插槽 | 说明 | +|------------|------| +| 插槽 `search` | 搜索框位 | +| 插槽 `filters` | 筛选器位 | +| 插槽 `actions` | 右侧操作按钮 | + +### 2.2 UiFilterBar +水平筛选条(多个 UiSelect 组合)。 + +### 2.3 UiDetailDrawer +详情侧抽屉模板:title + description + tabs/content。 + +### 2.4 UiCrudPage +CRUD 页面骨架:Toolbar + DataTable + CreateModal + EditDrawer。 + +> ponytail: 这个组件较重,仅当 3+ 页面模式一致时才抽取,避免过度封装。 + +## 3. 业务域组件(已有,需对齐规范) + +### 3.1 多维表格(bitable/) + +已有:BitableGrid、各 Cell Editor/Display、LoadingState、ErrorState、RecordDetailDrawer、FieldManagePanel 等。 + +**对齐动作**: +- `LoadingState` → 提升为 `UiLoading`(bitable 内部改用 UiLoading) +- `ErrorState` → 提升为 `UiError` +- 其余保留在 bitable/ 目录(业务专用) + +### 3.2 日历(calendar/) + +已有:CalendarGrid、ListView、CardView、EventEditor、CalendarDrawer。 + +**对齐动作**: +- ListView 中的 `list-view__toolbar` / `__empty` 改用 UiToolbar / UiEmpty +- EventEditor 中的表单项改用 UiFormField + +### 3.3 聊天(chat/) + +已有:ChatInput、ChatMessage、各 Message 子组件。 + +**对齐动作**: +- ChatInput 内部样式对齐 token +- MessageShell 边距对齐 `--space-3` + +### 3.4 自进化(evolution/) + +已有:DashboardOverview、MetricsChart、各 Panel。 + +**对齐动作**: +- DashboardOverview 的 `overview-card` 改用 UiMetricCard +- 各 Panel 的 `__header` / `__empty` 对齐 UiCard / UiEmpty + +### 3.5 知识库(kb/) + +已有:SearchTest、KBSettings、DocumentUpload。 + +**对齐动作**:表单改用 UiFormField,列表改用 UiList。 + +### 3.6 技能(skills/) + +已有:SkillCard、SkillDetail。 + +**对齐动作**:SkillCard 内部颜色硬编码改 token。 + +### 3.7 终端(terminal/) + +已有:TerminalEmulator、TerminalPanel、WhitelistManager。 + +**对齐动作**:WhitelistManager 的表格改用 UiDataTable。 + +## 4. 实施路线 + +### 第一批(通用高频,立即实现) +- UiEmpty / UiLoading / UiError / UiSpin +- UiButton / UiTag / UiBadge +- UiFormField / UiInput / UiTextArea / UiSelect / UiSearchInput +- UiDrawer / UiModal + +### 第二批(数据展示) +- UiDataTable / UiList / UiListItem / UiDescription +- UiTimeline / UiStat / UiCodeBlock +- UiTabs / UiBreadcrumb / UiPagination + +### 第三批(原子补充) +- UiIcon / UiAvatar / UiTooltip +- UiDatePicker / UiSwitch / UiCheckbox / UiRadio +- UiFormSection + +### 第四批(业务复合) +- UiToolbar / UiFilterBar +- UiDetailDrawer +- UiCrudPage(视实际复用度决定是否抽取) + +### 第五批(域对齐) +- bitable LoadingState/ErrorState → UiLoading/UiError +- evolution DashboardOverview → UiMetricCard +- calendar ListView 工具栏 → UiToolbar +- skills SkillCard → token 化 +- terminal WhitelistManager → UiDataTable + +## 5. 使用约定 + +1. **新页面必须用组件库**:Code Review 检查清单第 2 条。 +2. **旧页面渐进迁移**:修改时顺手替换,不专门重构。 +3. **组件不满足需求时**:先在组件库扩展 prop/插槽,不要在页面内另写。 +4. **业务专用组件留在业务目录**:只有跨 2+ 域复用的才提升到 `components/ui/`。 diff --git a/docs/ux/design-system-guidelines.md b/docs/ux/design-system-guidelines.md new file mode 100644 index 0000000..84c91c9 --- /dev/null +++ b/docs/ux/design-system-guidelines.md @@ -0,0 +1,490 @@ +# AgentKit 全局 UI/UX 规范 + +> 基于 Skymetrics / Twenty 风格参考图提炼,适用于 AgentKit Web GUI 与 Tauri 桌面端。 +> 目标:建立跨页面一致的设计语言,减少「风格不统一」「组件各行其是」的返工。 +> +> 与 [Bitable 多维表格 UI/UX 规范](bitable-grid-guidelines.md) 互补:本规范覆盖全局框架与通用组件,Bitable 规范覆盖表格细节。 + +## 1. 设计原则 + +1. **克制用色**:近黑/纯白做主色,彩色仅用于语义信号(成功、警告、错误、accent)。 +2. **大圆角、柔阴影**:卡片与容器使用 12–16px 圆角;阴影极轻,主要靠背景层级区分。 +3. **密度适中**:数据行 40–44px、表单控件 32–40px,避免信息拥挤。 +4. **对齐优先**:所有列表、卡片、表格坚持统一的 12/16/24px 栅格与左对齐。 +5. **暗色原生**:所有颜色使用 token,禁止硬编码;深色模式不是反色,而是独立灰阶。 + +## 2. 颜色系统 + +### 2.1 品牌色 + +| Token | 浅色值 | 深色值 | 用途 | +|-------|--------|--------|------| +| `--color-primary` | `#1a1a1a` | `#fbfbfa` | 主按钮、关键文字、强调图标 | +| `--color-primary-hover` | `#2f2f2f` | `#ededec` | 主按钮 hover | +| `--color-primary-active` | `#000000` | `#ffffff` | 主按钮 active | +| `--color-primary-bg` | `#fbfbfa` | `#1a1a1a` | 反转背景 | + +### 2.2 语义色 + +| Token | 浅色值 | 深色值 | 用途 | +|-------|--------|--------|------| +| `--color-success` | `#22c55e` | `#4ade80` | 正向、上升、成功 | +| `--color-success-light` | `#dcfce7` | `#14532d` | 正向标签背景 | +| `--color-warning` | `#f59e0b` | `#fbbf24` | 警告、需注意 | +| `--color-warning-light` | `#fef9c3` | `#422006` | 警告标签背景 | +| `--color-error` | `#ef4444` | `#f87171` | 错误、下降、失败 | +| `--color-error-light` | `#fee2e2` | `#450a0a` | 错误标签背景 | +| `--color-info` | `#3b82f6` | `#60a5fa` | 信息、链接 | +| `--color-info-light` | `#dbeafe` | `#172554` | 信息标签背景 | + +> Dashboard 中的上升/下降指标统一使用绿色/红色,不得自定义其他正负色。 + +### 2.3 中性灰阶 + +| Token | 浅色值 | 深色值 | 用途 | +|-------|--------|--------|------| +| `--color-gray-50` | `#fbfbfa` | `#1a1a1a` | 最浅背景 | +| `--color-gray-100` | `#f7f7f5` | `#2f2f2f` | 次级背景 | +| `--color-gray-200` | `#ededec` | `#3a3a3a` | 边框、分隔 | +| `--color-gray-300` | `#dfdfde` | `#4a4a4a` | hover 边框 | +| `--color-gray-400` | `#cececd` | `#6b6b6a` | 禁用、次要图标 | +| `--color-gray-500` | `#9b9b9a` | `#9b9b9a` | placeholder | +| `--color-gray-600` | `#6b6b6a` | `#b0b0af` | 说明文字 | +| `--color-gray-700` | `#4a4a4a` | `#cececd` | 次级文字 | +| `--color-gray-800` | `#2f2f2f` | `#ededec` | 标题文字 | +| `--color-gray-900` | `#1a1a1a` | `#fbfbfa` | 主文字 | + +### 2.4 背景层级 + +| Token | 浅色值 | 深色值 | 用途 | +|-------|--------|--------|------| +| `--bg-canvas` | `#f3f4f6` | `#111111` | 页面画布(参考图浅灰/深黑) | +| `--bg-primary` | `#ffffff` | `#1a1a1a` | 卡片、弹窗、输入框 | +| `--bg-secondary` | `#fbfbfa` | `#1f1f1f` | 次级卡片、sidebar | +| `--bg-tertiary` | `#f7f7f5` | `#2a2a2a` | hover、选中、第三层背景 | +| `--bg-elevated` | `#ffffff` | `#252525` | 浮层、下拉菜单 | + +> ponytail: `--bg-canvas` 是新增 token,用于替代当前页面背景 `#f7f7f5` 以贴近参考图冷灰。如一次性迁移成本高,可先在新建页面使用,旧页面逐步替换。 + +### 2.5 文字色 + +| Token | 浅色值 | 深色值 | 用途 | +|-------|--------|--------|------| +| `--text-primary` | `#1a1a1a` | `#fbfbfa` | 标题、正文 | +| `--text-secondary` | `#4a4a4a` | `#cececd` | 次级说明 | +| `--text-tertiary` | `#6b6b6a` | `#9b9b9a` | 图标、辅助文字 | +| `--text-placeholder` | `#9b9b9a` | `#6b6b6a` | placeholder、禁用 | +| `--text-inverse` | `#ffffff` | `#1a1a1a` | 深色背景上的文字 | + +### 2.6 边框 + +| Token | 浅色值 | 深色值 | 用途 | +|-------|--------|--------|------| +| `--border-color` | `#ededec` | `#3a3a3a` | 默认分隔线 | +| `--border-color-hover` | `#dfdfde` | `#4a4a4a` | hover 边框 | +| `--border-color-strong` | `#e0e0e0` | `#555555` | 表格列分隔等需要更明显的地方 | +| `--border-color-active` | `var(--color-primary)` | `var(--color-primary)` | 聚焦/激活边框 | + +## 3. 字体与排版 + +### 3.1 字体栈 + +```css +--font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +--font-mono: 'SF Mono', 'Fira Code', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; +``` + +> 全局默认使用 `--font-sans`,代码块使用 `--font-mono`。 + +### 3.2 字号规范 + +| Token | 大小 | 行高 | 用途 | +|-------|------|------|------| +| `--font-xs` | 12px | 1.5 | 辅助说明、时间戳、徽章 | +| `--font-sm` | 13px | 1.5 | 表单标签、表格列头、小按钮 | +| `--font-base` | 14px | 1.5 | 正文、列表、输入框 | +| `--font-md` | 16px | 1.5 | 小标题、 emphasized 正文 | +| `--font-lg` | 20px | 1.3 | 页面/卡片标题 | +| `--font-xl` | 24px | 1.25 | 大标题、Dashboard 主指标 | +| `--font-2xl` | 32px | 1.2 | 超大指标(如 €123,927.85) | + +### 3.3 字重规范 + +| Token | 值 | 用途 | +|-------|-----|------| +| `--font-weight-normal` | 400 | 正文 | +| `--font-weight-medium` | 500 | 列头、按钮、强调 | +| `--font-weight-semibold` | 600 | 页面标题、数字指标 | +| `--font-weight-bold` | 700 | 极少使用,仅用于营销/关键数据 | + +## 4. 间距系统 + +| Token | 值 | 用途 | +|-------|-----|------| +| `--space-1` | 4px | 图标与文字间距、紧凑内边距 | +| `--space-2` | 8px | 按钮内部、小间距 | +| `--space-3` | 12px | 卡片内边距、列表项间距 | +| `--space-4` | 16px | 默认卡片内边距、表单间距 | +| `--space-5` | 20px | 大卡片内边距 | +| `--space-6` | 24px | 区块间距、页面边距 | +| `--space-8` | 32px | 大区块间距 | +| `--space-10` | 40px | 页面级间距 | +| `--space-12` | 48px | 超大间距 | + +> 页面边距统一使用 `--space-6`(24px),卡片内部默认 `--space-4`(16px)。 + +## 5. 圆角系统 + +| Token | 值 | 用途 | +|-------|-----|------| +| `--radius-sm` | 4px | 小标签、小按钮 | +| `--radius-md` | 6px | 输入框、小卡片 | +| `--radius-lg` | 10px | 按钮、弹窗 | +| `--radius-xl` | 14px | 大输入框、聊天框 | +| `--radius-2xl` | 16px | 卡片、大容器 | +| `--radius-card` | 12px | 通用卡片(保留旧 token 兼容) | +| `--radius-full` | 9999px | pill、头像、状态点 | + +> 参考图中大卡片圆角约 14–16px,聊天输入框圆角约 16px,发送按钮为圆形。 + +## 6. 阴影系统 + +参考图阴影极轻,主要靠背景层级和 1px 边框区分。 + +| Token | 值 | 用途 | +|-------|-----|------| +| `--shadow-none` | `none` | 扁平卡片 | +| `--shadow-sm` | `0 1px 2px rgba(0, 0, 0, 0.04)` | 轻微抬升 | +| `--shadow-md` | `0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)` | 下拉、浮层 | +| `--shadow-lg` | `0 4px 16px rgba(0, 0, 0, 0.06), 0 2px 4px rgba(0, 0, 0, 0.04)` | 弹窗、抽屉 | +| `--shadow-card` | `0 1px 3px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.04)` | 卡片(带 1px 描边感) | + +> Dashboard 卡片优先使用 `--shadow-none` 或 `--shadow-sm`,配合 `--bg-primary` 与 `--bg-canvas` 的层级差。 + +## 7. 布局框架 + +### 7.1 应用级布局 + +``` +┌─────────────────────────────────────────────┐ +│ Sidebar (240–260px) │ Header (56–64px) │ +│ ├───────────────────────┤ +│ │ Content (bg-canvas) │ +│ │ │ +└─────────────────────────────────────────────┘ +``` + +| Token | 值 | 用途 | +|-------|-----|------| +| `--sidebar-width` | 240px | 左侧导航宽度 | +| `--sidebar-collapsed-width` | 64px | 折叠后宽度 | +| `--header-height` | 56px | 顶部栏高度 | +| `--topnav-height` | 48px | 顶部小导航高度 | + +### 7.2 Sidebar 规范 + +- 宽度 240px,背景 `--bg-secondary`(浅色)或 `--bg-canvas`(深色)。 +- Logo 区高度 56–64px,与 header 对齐。 +- 导航项高度 40px,圆角 8px,内边距 8px 12px。 +- 默认状态:`color: var(--text-secondary)`。 +- Hover:`background: var(--bg-tertiary)`。 +- Active:`background: var(--bg-tertiary); color: var(--text-primary); font-weight: 500`。 +- 当前项左侧可加 3px 品牌色条作为视觉锚点(可选)。 + +### 7.3 Header 规范 + +- 高度 56px,背景 `--bg-primary`。 +- 左侧为页面标题,字号 `--font-lg`(20px),字重 500。 +- 右侧放置筛选器、工作区切换、用户头像等操作。 +- 与 content 之间用 1px `--border-color` 分隔。 + +### 7.4 Content 规范 + +- 背景 `--bg-canvas`。 +- 页面内边距 `--space-6`(24px)。 +- 卡片网格:默认 gap `--space-4`(16px),Dashboard 大卡片可用 `--space-5`(20px)。 + +## 8. 组件规范 + +### 8.1 Card 卡片 + +- 背景 `--bg-primary`。 +- 圆角 `--radius-2xl`(16px),必要时回退 `--radius-card`(12px)。 +- 内边距:`--space-5`(20px)用于 Dashboard 大卡片;`--space-4`(16px)用于普通卡片。 +- 阴影:`--shadow-none` 或 `--shadow-sm`。 +- 标题:字号 `--font-md`(16px),字重 500,颜色 `--text-primary`。 +- 副标题/说明:字号 `--font-xs`(12px),颜色 `--text-tertiary`。 + +#### Metric Card 指标卡 + +- 指标数字:字号 `--font-2xl`(32px),字重 600。 +- 指标标签:字号 `--font-sm`(13px),颜色 `--text-tertiary`。 +- 环比徽章:pill 形状,绿色/红色背景,文字与背景同色系深色/浅色。 +- 单位与货币使用 `--font-sm` 与主数字保持同一行基线。 + +### 8.2 Chat 聊天 + +#### 消息气泡 + +- 用户消息:右对齐,背景 `--bg-primary`(白色),文字 `--text-primary`。 +- AI 消息:左对齐,背景 `--bg-secondary`(浅灰/灰底),文字 `--text-primary`。 +- 圆角 12px,内边距 12px 16px。 +- 最大宽度 80%,单条消息宽度按内容自适应(而非固定宽度)。 + +#### 输入框 + +- 底部固定,背景 `--bg-primary`。 +- 输入区域:圆角 `--radius-2xl`(16px),边框 1px `--border-color`。 +- 左侧显示「AI Assistant」标识或当前技能 pill。 +- 发送按钮:圆形,直径 32–36px,背景 `--color-primary`,图标白色。 + +#### 快捷操作 + +- 以卡片形式展示在输入框上方,3 列网格。 +- 卡片圆角 `--radius-xl`(14px),hover 边框变为 `--border-color-hover`。 + +### 8.3 Button 按钮 + +| 类型 | 背景 | 文字 | 边框 | 圆角 | 高度 | +|------|------|------|------|------|------| +| Primary | `--color-primary` | `--text-inverse` | none | `--radius-lg` | 32px | +| Secondary | `--bg-primary` | `--text-primary` | 1px `--border-color` | `--radius-lg` | 32px | +| Ghost | transparent | `--text-secondary` | none | `--radius-md` | 24px | +| Icon | `--bg-secondary` | `--text-secondary` | 1px `--border-color` | `--radius-md` | 28px | + +> 当前参考图中发送按钮为圆形 icon button,直径 32–36px。 + +### 8.4 Input 输入框 + +- 高度 40px(标准)/ 32px(紧凑)。 +- 背景 `--bg-primary`。 +- 边框 1px `--border-color`,圆角 `--radius-md`(6px)。 +- Focus:`border-color: var(--border-color-active)`,无发光阴影。 +- Placeholder:`var(--text-placeholder)`。 + +### 8.5 Table 表格 + +详见 [Bitable 多维表格 UI/UX 规范](bitable-grid-guidelines.md)。核心摘要: + +- 行高 44px,列头高 40px。 +- 列头背景 `--bg-secondary`,下边框 1px `--border-color-strong`。 +- 数据单元格右边框 1px `--border-color`。 +- Select/Multiselect 渲染为 pill chip。 +- 主字段列 hover 下划线。 + +### 8.6 Tag / Badge 标签 + +- 默认 pill 圆角(`--radius-full`)。 +- 内边距 2px 10px(小)/ 4px 12px(标准)。 +- 高度 20px(小)/ 24px(标准)。 +- 状态色:success / warning / error / info,使用对应 `--color-*-light` 背景与 `--color-*` 文字/边框。 + +### 8.7 Dropdown / Select + +- 背景 `--bg-elevated`。 +- 圆角 `--radius-lg`(10px)。 +- 阴影 `--shadow-lg`。 +- 选项 hover:`--bg-tertiary`。 +- 选中项:`--bg-secondary`,文字 `--text-primary`。 + +## 9. 数据可视化 + +### 9.1 图表配色 + +| Token | 浅色值 | 深色值 | 用途 | +|-------|--------|--------|------| +| `--data-viz-primary` | `#22c55e` | `#4ade80` | 主折线/主柱状 | +| `--data-viz-secondary` | `#6b6b6a` | `#9b9b9a` | 对比线、辅助线 | +| `--data-viz-positive` | `#22c55e` | `#4ade80` | 上升、完成 | +| `--data-viz-negative` | `#ef4444` | `#f87171` | 下降、异常 | +| `--data-viz-neutral` | `#9b9b9a` | `#6b6b6a` | 中性数据 | +| `--data-viz-palette-1` | `#22c55e` | `#4ade80` | 分类色 1 | +| `--data-viz-palette-2` | `#3b82f6` | `#60a5fa` | 分类色 2 | +| `--data-viz-palette-3` | `#f59e0b` | `#fbbf24` | 分类色 3 | +| `--data-viz-palette-4` | `#a855f7` | `#c084fc` | 分类色 4 | +| `--data-viz-palette-5` | `#ec4899` | `#f472b6` | 分类色 5 | + +### 9.2 图表样式 + +- 坐标轴与网格线使用 `--border-color`。 +- 标签文字使用 `--text-tertiary`,字号 12px。 +- Tooltip 背景 `--bg-elevated`,圆角 `--radius-md`,阴影 `--shadow-lg`。 +- 折线图点 hover 放大 1.5 倍,显示当前数值。 + +## 10. 暗色 / 浅色主题 + +- 所有组件必须通过 CSS 变量切换,禁止在 JS 中写死颜色。 +- 深色模式不是简单反色:背景层级从 `#111111` 到 `#252525`,文字从 `#fbfbfa` 到 `#9b9b9a`。 +- 阴影在深色模式下应更暗、更柔和。 +- 切换主题时,同步设置 `data-theme="dark"` 到 `html` 或 `body`。 + +## 11. 动效与交互 + +- 过渡时长:`--transition-fast` 150ms、`--transition-normal` 200ms、`--transition-slow` 300ms。 +- hover 背景/边框变化使用 `ease` 缓动。 +- 按钮 active 轻微缩放 0.98(可选)。 +- 禁止无意义的弹跳、闪烁动画。 + +## 12. 响应式断点 + +| Token | 断点 | 行为 | +|-------|------|------| +| `--breakpoint-sm` | 640px | 手机,sidebar 变为抽屉 | +| `--breakpoint-md` | 768px | 平板,卡片两列 | +| `--breakpoint-lg` | 1024px | 小桌面,三列卡片 | +| `--breakpoint-xl` | 1280px | 大桌面,完整布局 | + +## 14. 组件库(components/ui) + +为避免每个页面重复写样式,项目提供了一组基于本规范的通用 UI 组件。新增页面应优先使用这些组件,而不是在页面内手写卡片/标题/指标样式。 + +### 14.1 可用组件 + +| 组件 | 路径 | 用途 | +|------|------|------| +| `UiCard` | `components/ui/UiCard.vue` | 通用卡片容器 | +| `UiMetricCard` | `components/ui/UiMetricCard.vue` | Dashboard 指标卡 | +| `UiTrendBadge` | `components/ui/UiTrendBadge.vue` | 趋势徽章(↑/↓/−) | +| `UiPageHeader` | `components/ui/UiPageHeader.vue` | 页面顶部标题栏 | +| `UiPageShell` | `components/ui/UiPageShell.vue` | 页面外壳(Header + Content) | + +统一从 `components/ui/index.ts` 导出: + +```ts +import { UiCard, UiMetricCard, UiPageShell, UiTrendBadge } from '@/components/ui' +``` + +### 14.2 UiCard + +```vue + + 内容区域 + + +``` + +Props: + +| Prop | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `title` | `string` | — | 卡片标题 | +| `subtitle` | `string` | — | 标题下方小字 | +| `padding` | `'sm' \| 'md' \| 'lg'` | `'md'` | 内边距 | +| `shadow` | `'none' \| 'sm'` | `'none'` | 阴影 | +| `hoverable` | `boolean` | `false` | hover 抬升效果 | + +### 14.3 UiMetricCard + +```vue + +``` + +Props: + +| Prop | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `label` | `string` | 必填 | 指标名称 | +| `value` | `string \| number` | 必填 | 指标值 | +| `prefix` | `string` | — | 前缀(如 $、€) | +| `suffix` | `string` | — | 后缀(如 %) | +| `caption` | `string` | — | 底部说明 | +| `trend` | `number` | — | 趋势百分比(正绿负红) | +| `trendLabel` | `string` | — | 趋势标签 | +| `hoverable` | `boolean` | `false` | 是否可点击 | + +### 14.4 UiPageShell + +```vue + + + +
+ + + +
+
+``` + +Props:同 `UiPageHeader`(`title`、`subtitle`、`back`)。 + +### 14.5 页面迁移示例 + +**Before(每个页面各自写样式)**: + +```vue + + + +``` + +**After(使用 UI 组件)**: + +```vue + + + +``` + +> ponytail: 组件只负责「外观规范」,布局(grid/flex)仍由页面决定,避免组件过度封装。 + +## 15. 检查清单(Code Review 用) + +新增/修改页面或组件时逐项确认: + +- [ ] 是否全部使用 `--*` token,无硬编码 hex +- [ ] 新增页面是否优先使用 `components/ui` 组件 +- [ ] 页面背景是否使用 `--bg-canvas` +- [ ] 卡片背景是否使用 `--bg-primary`,圆角是否 ≥ 12px +- [ ] 阴影是否克制(优先 `--shadow-none` / `--shadow-sm`) +- [ ] 文字层级是否使用 `--text-primary/secondary/tertiary` +- [ ] 按钮高度是否为 32px/40px,圆角是否一致 +- [ ] 表单输入框高度是否为 40px(标准) +- [ ] 表格是否遵循 Bitable 规范 +- [ ] 聊天消息气泡是否有明确用户/AI 区分 +- [ ] Dashboard 指标卡数字是否使用 `--font-2xl` +- [ ] 正向/负向数据是否使用绿色/红色语义色 +- [ ] 深色模式下颜色是否自然(非简单反色) +- [ ] 动效是否克制,过渡时长是否使用 token diff --git a/src/agentkit/server/frontend/components.d.ts b/src/agentkit/server/frontend/components.d.ts index 998f823..bf7d2d6 100644 --- a/src/agentkit/server/frontend/components.d.ts +++ b/src/agentkit/server/frontend/components.d.ts @@ -95,6 +95,7 @@ declare module 'vue' { ConditionNode: typeof import('./src/components/workflow/ConditionNode.vue')['default'] ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default'] DashboardOverview: typeof import('./src/components/evolution/DashboardOverview.vue')['default'] + DateCellEditor: typeof import('./src/components/bitable/DateCellEditor.vue')['default'] DateDisplay: typeof import('./src/components/bitable/DateDisplay.vue')['default'] DebateArgumentCard: typeof import('./src/components/chat/messages/DebateArgumentCard.vue')['default'] DebateBannerCard: typeof import('./src/components/chat/messages/DebateBannerCard.vue')['default'] @@ -172,6 +173,7 @@ declare module 'vue' { SplitPane: typeof import('./src/components/layout/SplitPane.vue')['default'] StickyModeHeader: typeof import('./src/components/chat/StickyModeHeader.vue')['default'] SyncSettings: typeof import('./src/components/calendar/SyncSettings.vue')['default'] + SystemFieldDisplay: typeof import('./src/components/bitable/SystemFieldDisplay.vue')['default'] SystemMonitorPanel: typeof import('./src/components/layout/SystemMonitorPanel.vue')['default'] SystemTab: typeof import('./src/components/layout/tabs/SystemTab.vue')['default'] TableCreateModal: typeof import('./src/components/bitable/TableCreateModal.vue')['default'] @@ -180,11 +182,17 @@ declare module 'vue' { TeamPlanCard: typeof import('./src/components/chat/messages/TeamPlanCard.vue')['default'] TerminalEmulator: typeof import('./src/components/terminal/TerminalEmulator.vue')['default'] TerminalPanel: typeof import('./src/components/terminal/TerminalPanel.vue')['default'] + TextCellEditor: typeof import('./src/components/bitable/TextCellEditor.vue')['default'] ThinkingBlock: typeof import('./src/components/chat/ThinkingBlock.vue')['default'] TitleBar: typeof import('./src/components/layout/TitleBar.vue')['default'] ToolCallCard: typeof import('./src/components/chat/ToolCallCard.vue')['default'] ToolCallIndicator: typeof import('./src/components/chat/ToolCallIndicator.vue')['default'] TopNav: typeof import('./src/components/layout/TopNav.vue')['default'] + UiCard: typeof import('./src/components/ui/UiCard.vue')['default'] + UiMetricCard: typeof import('./src/components/ui/UiMetricCard.vue')['default'] + UiPageHeader: typeof import('./src/components/ui/UiPageHeader.vue')['default'] + UiPageShell: typeof import('./src/components/ui/UiPageShell.vue')['default'] + UiTrendBadge: typeof import('./src/components/ui/UiTrendBadge.vue')['default'] UsagePanel: typeof import('./src/components/evolution/UsagePanel.vue')['default'] UserBubble: typeof import('./src/components/chat/messages/UserBubble.vue')['default'] UserSessionsPanel: typeof import('./src/components/admin/UserSessionsPanel.vue')['default'] diff --git a/src/agentkit/server/frontend/debug_bitable.mjs b/src/agentkit/server/frontend/debug_bitable.mjs new file mode 100644 index 0000000..df63283 --- /dev/null +++ b/src/agentkit/server/frontend/debug_bitable.mjs @@ -0,0 +1,433 @@ +import { chromium } from '@playwright/test' + +const BASE_URL = 'http://localhost:15173/' +const BITABLE_URL = 'http://localhost:15173/bitable' +const API_BASE_URL = 'http://localhost:18001/' +const USERNAME = 'admin@fischerai.cn' +const PASSWORD = 'Admin@123' + +async function apiLogin() { + const resp = await fetch(`${API_BASE_URL}api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: USERNAME, password: PASSWORD }), + }) + if (!resp.ok) { + throw new Error(`Login failed: ${resp.status} ${await resp.text()}`) + } + const data = await resp.json() + return data.access_token +} + +async function apiRequest(token, path, opts = {}) { + const resp = await fetch(`${API_BASE_URL}api/v1/bitable${path}`, { + ...opts, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...opts.headers, + }, + }) + const text = await resp.text() + if (!resp.ok) { + throw new Error(`API ${opts.method || 'GET'} ${path} failed: ${resp.status} ${text}`) + } + return text ? JSON.parse(text) : {} +} + +async function ensureTestFields(token, tableId) { + const fieldsResp = await apiRequest(token, `/tables/${tableId}/fields`) + const fields = fieldsResp.fields || [] + const result = { numberFieldId: null, multiselectFieldId: null } + let numberField = fields.find(f => f.name === '数字列') + let multiselectField = fields.find(f => f.name === '多选列') + + if (!numberField) { + const createResp = await apiRequest(token, `/tables/${tableId}/fields`, { + method: 'POST', + body: JSON.stringify({ name: '数字列', field_type: 'number', config: {} }), + }) + numberField = createResp.field + console.log('Created number field:', numberField.id) + } else { + console.log('Number field already exists:', numberField.id) + } + result.numberFieldId = numberField.id + + if (!multiselectField) { + const createResp = await apiRequest(token, `/tables/${tableId}/fields`, { + method: 'POST', + body: JSON.stringify({ + name: '多选列', + field_type: 'multiselect', + config: { options: ['选项A', '选项B', '选项C'] }, + }), + }) + multiselectField = createResp.field + console.log('Created multiselect field:', multiselectField.id) + } else { + console.log('Multiselect field already exists:', multiselectField.id) + } + + return result +} + +async function deleteTestFields(token, fieldIds) { + for (const id of fieldIds) { + if (!id) continue + try { + await apiRequest(token, `/fields/${id}?force=true`, { method: 'DELETE' }) + console.log('Deleted test field:', id) + } catch (err) { + console.log('Could not delete test field:', err.message) + } + } +} + +async function getRecordValue(token, tableId, recordId, fieldId) { + const resp = await apiRequest(token, `/tables/${tableId}/records`) + const record = (resp.records || []).find(r => r.id === recordId) + return record?.values?.[fieldId] +} + +async function updateRecordValue(token, recordId, fieldId, value) { + const values = typeof fieldId === 'object' && fieldId !== null ? fieldId : { [fieldId]: value } + return apiRequest(token, `/records/${recordId}`, { + method: 'PATCH', + body: JSON.stringify({ values }), + }) +} + +async function testCellEditor(page, name, colIdx, opts = {}) { + const { + expectReadOnly = false, + screenshot, + openDropdown = false, + fieldId, + recordId, + tableId, + token, + type, + newValue, + } = opts + console.log(`Testing ${name} (col ${colIdx})...`) + + // Read current backend value + let beforeValue + if (fieldId && recordId && tableId && token) { + beforeValue = await getRecordValue(token, tableId, recordId, fieldId) + } + + await page.keyboard.press('Escape') + await page.waitForTimeout(200) + + const cell = page.locator('.vxe-body--row:not(.bitable-grid-scope__placeholder-row)').first().locator('.vxe-body--column').nth(colIdx) + try { + await cell.click({ timeout: 5000 }) + await page.waitForTimeout(openDropdown ? 1200 : 800) + } catch (err) { + console.log(` Could not click ${name}:`, err.message) + return null + } + + // Debug: log the cell DOM structure for date/select editors. + if (type === 'date' || type === 'select') { + const domInfo = await page.evaluate((idx) => { + const firstRow = document.querySelector('.vxe-body--row:not(.bitable-grid-scope__placeholder-row)') + const col = firstRow?.querySelectorAll('.vxe-body--column')[idx] + return { + className: col?.className, + innerHTML: col?.innerHTML?.slice(0, 800), + activeElement: document.activeElement?.tagName, + activeElementClass: document.activeElement?.className, + } + }, colIdx) + console.log(` ${name} cell DOM:`, JSON.stringify(domInfo, null, 2)) + } + + // Read-only fields should not enter edit mode + if (expectReadOnly) { + const info = await page.evaluate((colIdx) => { + const firstRow = document.querySelector('.vxe-body--row:not(.bitable-grid-scope__placeholder-row)') + const col = firstRow?.querySelectorAll('.vxe-body--column')[colIdx] + return { + isEdit: col?.classList.contains('col--edit') ?? false, + text: col?.textContent?.slice(0, 80) ?? null, + } + }, colIdx) + const passed = !info.isEdit + console.log(` ${name} readonly check:`, JSON.stringify({ ...info, passed }, null, 2)) + if (screenshot) { + await page.screenshot({ path: screenshot, fullPage: false }) + console.log(` Screenshot saved to ${screenshot}`) + } + return { ...info, passed } + } + + // Interact based on field type + let interaction = 'none' + try { + if (type === 'text') { + const input = cell.locator('input').first() + await input.fill(String(newValue)) + await input.press('Enter') + interaction = `filled ${newValue} + Enter` + } else if (type === 'number') { + const input = cell.locator('input[type="number"]').first() + await input.fill(String(newValue)) + await input.press('Enter') + interaction = `filled ${newValue} + Enter` + } else if (type === 'date') { + // Type a new date directly into the picker input and confirm. + const input = cell.locator('.ant-picker-input > input').first() + await input.fill('2026-07-15') + await input.press('Enter') + interaction = 'typed 2026-07-15 + Enter' + } else if (type === 'select') { + // Click the matching dropdown option directly — keyboard search can miss + // the filtered highlight if the dropdown hasn't rendered yet. + // ponytail: some select fields store a value that differs from the label + // (e.g. label "已完成" -> value "done"); click by label, expect value. + const clickText = opts.clickText ?? String(newValue) + await page.waitForTimeout(300) + const option = page.locator('.bitable-select-dropdown .ant-select-item-option', { hasText: clickText }).first() + await option.click() + interaction = `clicked option ${clickText}` + } else if (type === 'multiselect') { + for (const v of newValue) { + const option = page.locator('.bitable-select-dropdown .ant-select-item-option', { hasText: v }).first() + await option.click() + } + // Press Escape to close the dropdown and trigger save. + await page.keyboard.press('Escape') + interaction = `selected ${newValue.join(', ')}` + } + } catch (err) { + console.log(` Interaction failed for ${name}:`, err.message) + } + + // Wait for save to propagate (vxe-grid edit-closed + API round-trip). + // ponytail: generous wait because vxe-grid edit-closed can be async and + // the backend PATCH is fire-and-forget from the UI perspective. + await page.waitForTimeout(3500) + + // Read backend value after interaction + let afterValue + let savePassed = null + if (fieldId && recordId && tableId && token) { + afterValue = await getRecordValue(token, tableId, recordId, fieldId) + if (type === 'text' || type === 'number') { + savePassed = afterValue === newValue || String(afterValue) === String(newValue) + } else if (type === 'date') { + savePassed = afterValue !== beforeValue && typeof afterValue === 'string' + } else if (type === 'select') { + savePassed = afterValue === newValue + } else if (type === 'multiselect') { + savePassed = Array.isArray(afterValue) && newValue.every(v => afterValue.includes(v)) + } + } + + const info = await page.evaluate((colIdx) => { + const firstRow = document.querySelector('.vxe-body--row:not(.bitable-grid-scope__placeholder-row)') + const col = firstRow?.querySelectorAll('.vxe-body--column')[colIdx] + const activeCol = firstRow?.querySelector('.vxe-body--column.col--active, .vxe-body--column.col--edit') + const editor = col?.querySelector('input, .ant-select, .ant-picker, .ant-input, .bitable-text-editor, .bitable-date-editor') + return { + clickedClass: col?.className, + isActive: col?.classList.contains('col--active') ?? false, + isEdit: col?.classList.contains('col--edit') ?? false, + activeClass: activeCol?.className, + editorTagName: editor?.tagName ?? null, + editorClassName: editor?.className ?? null, + } + }, colIdx) + + // With autoClear=true the cell may exit edit mode before we inspect it, so + // base success on the backend save when available, falling back to edit state. + const passed = expectReadOnly + ? !info.isEdit + : (savePassed != null ? savePassed : info.isEdit) + console.log(` ${name} result:`, JSON.stringify({ ...info, beforeValue, afterValue, interaction, savePassed, passed }, null, 2)) + if (screenshot) { + await page.screenshot({ path: screenshot, fullPage: false }) + console.log(` Screenshot saved to ${screenshot}`) + } + return { ...info, passed, beforeValue, afterValue, savePassed } +} + +async function main() { + console.log('API login...') + const token = await apiLogin() + console.log('Got API token') + + console.log('Launching browser...') + const browser = await chromium.launch({ + headless: true, + executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + }) + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + + page.on('console', msg => { + console.log(`PAGE CONSOLE [${msg.type()}]: ${msg.text()}`) + }) + page.on('pageerror', err => { + console.log(`PAGE ERROR: ${err.message}`) + console.log(err.stack) + }) + page.on('request', req => { + if (req.url().includes('/records/') && req.method() === 'PATCH') { + req.allHeaders().then(headers => req.postData()).then(body => { + console.log(`NETWORK PATCH ${req.url()} body:`, body) + }).catch(() => {}) + } + }) + + console.log('Navigating to login page...') + await page.goto(BASE_URL) + await page.waitForTimeout(1000) + + console.log('Logging in via UI...') + await page.fill('input[placeholder="请输入用户名"]', USERNAME) + await page.fill('input[placeholder="请输入密码"]', PASSWORD) + await page.click('button:has-text("登 录")') + await page.waitForURL(/^(?!.*\/login).*$/, { timeout: 10000 }) + console.log('Logged in, current URL:', page.url()) + + console.log('Goto bitable list...') + await page.goto(BITABLE_URL) + await page.waitForTimeout(3000) + + const listInfo = await page.evaluate(() => { + const cards = Array.from(document.querySelectorAll('.bitable-file-card, [class*="file-card"], .ant-card')) + return { cardCount: cards.length } + }) + console.log('Bitable list info:', listInfo) + + if (listInfo.cardCount > 0) { + console.log('Clicking first file card...') + await page.click('.bitable-file-card, [class*="file-card"], .ant-card') + await page.waitForTimeout(2000) + } + + const tableItems = await page.locator('.table-view-list__item').all() + if (tableItems.length > 0) { + console.log('Clicking first table item...') + await tableItems[0].click() + await page.waitForTimeout(4000) + } else { + console.log('No table items found') + await browser.close() + return + } + + const url = page.url() + console.log('Table URL:', url) + const tableId = url.split('/').pop() + console.log('Table ID:', tableId) + + console.log('Ensuring test fields exist...') + const { numberFieldId, multiselectFieldId } = await ensureTestFields(token, tableId) + + console.log('Reloading to reflect new fields...') + // ponytail: force a hard reload so Vite dev changes are picked up instead of + // a cached version from a previous Chromium session. + await page.evaluate(() => location.reload(true)) + await page.waitForTimeout(4000) + + // Build a map of column title -> { index, fieldId, fieldType } + const columnMap = await page.evaluate(() => { + const headers = Array.from(document.querySelectorAll('.vxe-header--row .vxe-header--column')) + const map = {} + headers.forEach((th, idx) => { + const title = th.querySelector('.column-header-menu__title, .vxe-cell--title')?.textContent?.trim() ?? '' + if (title) { + const attr = th.getAttribute('data-colid') || th.getAttribute('colid') + map[title] = { idx, colId: attr } + } + }) + return map + }) + + // Resolve column titles to field IDs via the API field list (more reliable than DOM attributes) + const fieldsResp = await apiRequest(token, `/tables/${tableId}/fields`) + const fields = fieldsResp.fields || [] + const fieldByName = Object.fromEntries(fields.map(f => [f.name, f])) + + const resolvedMap = {} + for (const [title, { idx }] of Object.entries(columnMap)) { + const f = fieldByName[title] + if (f) { + resolvedMap[title] = { idx, fieldId: f.id, fieldType: f.field_type, owner: f.owner } + } + } + console.log('Resolved column map:', resolvedMap) + + // Get first record id + const recordsResp = await apiRequest(token, `/tables/${tableId}/records`) + const records = recordsResp.records || [] + if (records.length === 0) { + console.log('No records to test') + await browser.close() + return + } + const recordId = records[0].id + console.log('Testing on record:', recordId) + + // Remember original values so we can restore them after tests + const originalValues = records[0].values + + const testSpecs = [ + { name: 'text-main', title: '标题', type: 'text', newValue: `测试标题-${Date.now()}`, opts: { screenshot: '/tmp/bitable_text.png' } }, + // Status options use Chinese labels but English values; click by label, expect value. + { name: 'select-status', title: '状态', type: 'select', newValue: 'done', clickText: '已完成', opts: { screenshot: '/tmp/bitable_select.png', openDropdown: true } }, + { name: 'number', title: '数字列', type: 'number', newValue: 42, opts: { screenshot: '/tmp/bitable_number.png' } }, + { name: 'date', title: '日期', type: 'date', newValue: null, opts: { screenshot: '/tmp/bitable_date.png', openDropdown: true } }, + // Pick an option not currently selected to avoid toggling existing values off. + { name: 'multiselect', title: '多选列', type: 'multiselect', newValue: ['选项C'], opts: { screenshot: '/tmp/bitable_multiselect.png', openDropdown: true } }, + { name: 'creator-readonly', title: '创建人', type: 'text', newValue: null, opts: { expectReadOnly: true, screenshot: '/tmp/bitable_creator.png' } }, + { name: 'created_at-readonly', title: '创建时间', type: 'date', newValue: null, opts: { expectReadOnly: true, screenshot: '/tmp/bitable_created_at.png' } }, + ] + .map(s => ({ ...s, col: resolvedMap[s.title]?.idx, fieldId: resolvedMap[s.title]?.fieldId, fieldType: resolvedMap[s.title]?.fieldType })) + .filter(s => s.col != null) + + const results = [] + for (const spec of testSpecs) { + const info = await testCellEditor(page, spec.name, spec.col, { + ...spec.opts, + fieldId: spec.fieldId, + recordId, + tableId, + token, + type: spec.type, + newValue: spec.newValue, + clickText: spec.clickText, + }) + results.push({ name: spec.name, ...info }) + } + + // Restore original values for the tested fields + console.log('Restoring original values...') + const restorePayload = {} + for (const spec of testSpecs) { + if (spec.opts?.expectReadOnly) continue + if (spec.fieldId && originalValues[spec.fieldId] !== undefined) { + restorePayload[spec.fieldId] = originalValues[spec.fieldId] + } + } + if (Object.keys(restorePayload).length > 0) { + await updateRecordValue(token, recordId, restorePayload) + console.log('Restored values:', restorePayload) + } + + console.log('Test summary:', JSON.stringify(results.map(r => ({ name: r.name, passed: r.passed, isEdit: r.isEdit, savePassed: r.savePassed, beforeValue: r.beforeValue, afterValue: r.afterValue })), null, 2)) + + console.log('Cleaning up test fields...') + await deleteTestFields(token, [numberFieldId, multiselectFieldId]) + + await browser.close() +} + +main().catch(err => { + console.error('ERROR:', err) + process.exit(1) +}) diff --git a/src/agentkit/server/frontend/src/App.vue b/src/agentkit/server/frontend/src/App.vue index 1752fd2..b18d46e 100644 --- a/src/agentkit/server/frontend/src/App.vue +++ b/src/agentkit/server/frontend/src/App.vue @@ -105,12 +105,21 @@ html, body, #app { } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, - 'Noto Sans', sans-serif; + font-family: var( + --font-sans, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + Arial, + 'Noto Sans', + sans-serif + ); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; color: var(--text-primary, #1a1a1a); - background: var(--bg-secondary, #fbfbfa); + background: var(--bg-canvas, #f3f4f6); } /* Notion-style scrollbar */ diff --git a/src/agentkit/server/frontend/src/api/bitable.ts b/src/agentkit/server/frontend/src/api/bitable.ts index 0c12daf..8f3208f 100644 --- a/src/agentkit/server/frontend/src/api/bitable.ts +++ b/src/agentkit/server/frontend/src/api/bitable.ts @@ -14,6 +14,8 @@ export type FieldType = | 'image' | 'formula' | 'lookup' + | 'relation' + | 'rollup' export type FieldOwner = 'agent' | 'user' diff --git a/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue b/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue index e58a2a4..dae561b 100644 --- a/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue +++ b/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue @@ -4,7 +4,7 @@ data section (node=null); grouping enabled produces interleaved header + data sections. The vxe-grid declaration + all slots are written ONCE here (no duplication). --> -