fix(portal/bitable): 统一行内编辑器与飞书风格交互,验证全部字段类型

- BitableGrid: 统一 edit slot,按字段类型分发 Text/Select/Date 编辑器
- SelectCellEditor: 点击单元格直接展开下拉,下拉宽度不小于列宽
- DateCellEditor/TextCellEditor: 行内编辑,Enter/失焦自动保存
- 新增 editValueCache 解决 multiselect 编辑后值丢失
- 新增 confirmEdit provide,供子编辑器显式关闭编辑状态
- 调试脚本支持 label/value 分离的 select 选项断言
- 验证通过:text、select、number、date、multiselect、readonly 系统字段
This commit is contained in:
Chiguyong 2026-07-07 22:39:02 +08:00
parent 7e7fcda3fe
commit 0979612a35
30 changed files with 3050 additions and 319 deletions

View File

@ -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/`

View File

@ -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. **大圆角、柔阴影**:卡片与容器使用 1216px 圆角;阴影极轻,主要靠背景层级区分。
3. **密度适中**:数据行 4044px、表单控件 3240px避免信息拥挤。
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、头像、状态点 |
> 参考图中大卡片圆角约 1416px聊天输入框圆角约 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 (240260px) │ Header (5664px) │
│ ├───────────────────────┤
│ │ 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 区高度 5664px与 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`16pxDashboard 大卡片可用 `--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。
- 发送按钮:圆形,直径 3236px背景 `--color-primary`,图标白色。
#### 快捷操作
- 以卡片形式展示在输入框上方3 列网格。
- 卡片圆角 `--radius-xl`14pxhover 边框变为 `--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直径 3236px。
### 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
<UiCard title="卡片标题" subtitle="辅助说明">
内容区域
<template #extra>
<a-button type="link">查看更多</a-button>
</template>
</UiCard>
```
Props
| Prop | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `title` | `string` | — | 卡片标题 |
| `subtitle` | `string` | — | 标题下方小字 |
| `padding` | `'sm' \| 'md' \| 'lg'` | `'md'` | 内边距 |
| `shadow` | `'none' \| 'sm'` | `'none'` | 阴影 |
| `hoverable` | `boolean` | `false` | hover 抬升效果 |
### 14.3 UiMetricCard
```vue
<UiMetricCard
label="总销售额"
value="25430"
prefix="$"
:trend="7.5"
trend-label="较上月"
caption="过去 30 天"
hoverable
@click="openDetail"
/>
```
Props
| Prop | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `label` | `string` | 必填 | 指标名称 |
| `value` | `string \| number` | 必填 | 指标值 |
| `prefix` | `string` | — | 前缀(如 $、€) |
| `suffix` | `string` | — | 后缀(如 % |
| `caption` | `string` | — | 底部说明 |
| `trend` | `number` | — | 趋势百分比(正绿负红) |
| `trendLabel` | `string` | — | 趋势标签 |
| `hoverable` | `boolean` | `false` | 是否可点击 |
### 14.4 UiPageShell
```vue
<UiPageShell title="General metrics" subtitle="实时数据概览">
<template #header-extra>
<a-select :options="dateRanges" />
<a-button type="primary">导出</a-button>
</template>
<div class="metric-grid">
<UiMetricCard label="Profit Margin" value="123927.85" prefix="€" :trend="38" />
<UiMetricCard label="Orders" value="2792" :trend="11" />
<!-- ... -->
</div>
</UiPageShell>
```
Props`UiPageHeader``title`、`subtitle`、`back`)。
### 14.5 页面迁移示例
**Before每个页面各自写样式**
```vue
<template>
<div class="my-page">
<div class="my-page__header">
<h1>页面标题</h1>
</div>
<div class="my-page__cards">
<a-card class="my-card">...</a-card>
<a-card class="my-card">...</a-card>
</div>
</div>
</template>
<style scoped>
.my-page { padding: 24px; background: #f7f7f5; }
.my-page__header { margin-bottom: 16px; }
.my-page__header h1 { font-size: 20px; font-weight: 500; }
.my-card { border-radius: 16px; }
</style>
```
**After使用 UI 组件)**
```vue
<template>
<UiPageShell title="页面标题">
<div class="my-page__cards">
<UiCard title="卡片 A">...</UiCard>
<UiCard title="卡片 B">...</UiCard>
</div>
</UiPageShell>
</template>
<style scoped>
.my-page__cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4, 16px);
}
</style>
```
> 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

View File

@ -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']

View File

@ -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)
})

View File

@ -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 */

View File

@ -14,6 +14,8 @@ export type FieldType =
| 'image'
| 'formula'
| 'lookup'
| 'relation'
| 'rollup'
export type FieldOwner = 'agent' | 'user'

View File

@ -100,7 +100,7 @@ function handleMenuClick({ key }: { key: string }): void {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
font-weight: 500;
font-size: var(--bitable-font-sm);
}

View File

@ -0,0 +1,108 @@
<template>
<a-date-picker
ref="pickerRef"
:value="dateValue"
:open="open"
class="bitable-date-editor"
size="small"
popup-class-name="bitable-date-dropdown vxe-table--ignore-clear"
@update:value="onUpdate"
@openChange="onOpenChange"
/>
</template>
<script setup lang="ts">
import { computed, inject, ref, onMounted, nextTick } from 'vue'
import { DatePicker as ADatePicker } from 'ant-design-vue'
import dayjs from 'dayjs'
const props = defineProps<{
modelValue: string | null | undefined
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
}>()
const pickerRef = ref<InstanceType<typeof ADatePicker> | null>(null)
const confirmEdit = inject<() => void>('bitableConfirmEdit', () => {})
const open = ref(false)
const dateValue = computed<dayjs.Dayjs | undefined>(() => {
if (!props.modelValue) return undefined
const d = dayjs(props.modelValue)
return d.isValid() ? d : undefined
})
function onUpdate(value: unknown): void {
const d = value && (value as { isValid?: () => boolean }).isValid ? (value as dayjs.Dayjs) : null
emit('update:modelValue', d ? d.format('YYYY-MM-DD') : null)
// Feishu-style: selecting a date confirms the edit immediately.
open.value = false
nextTick(() => confirmEdit())
}
function onOpenChange(isOpen: boolean): void {
open.value = isOpen
// If the user dismisses the calendar without picking, confirm the edit so
// vxe-grid exits edit mode.
if (!isOpen) {
nextTick(() => confirmEdit())
}
}
onMounted(() => {
// ponytail: debug log to verify the editor mounts.
// eslint-disable-next-line no-console
console.log('[DateCellEditor mounted]', { modelValue: props.modelValue })
// Feishu-style: open the calendar panel as soon as the cell editor mounts.
open.value = true
nextTick(() => {
const el = pickerRef.value?.$el as HTMLElement | undefined
const input = el?.querySelector('input') as HTMLInputElement | undefined
input?.focus()
})
})
</script>
<style scoped>
.bitable-date-editor {
width: 100% !important;
height: 100% !important;
min-height: 100% !important;
display: flex !important;
align-items: center !important;
border: none !important;
border-radius: 0 !important;
background-color: transparent !important;
padding: 0 !important;
box-shadow: none !important;
outline: none !important;
}
.bitable-date-editor:deep(.ant-picker-input) {
width: 100% !important;
height: 100% !important;
display: flex !important;
align-items: center !important;
}
.bitable-date-editor:deep(.ant-picker-input > input) {
width: 100% !important;
height: 100% !important;
min-height: 100% !important;
border: none !important;
border-radius: 0 !important;
background-color: transparent !important;
padding: 0 !important;
font-size: var(--bitable-font-sm) !important;
color: var(--bitable-color-text) !important;
box-shadow: none !important;
outline: none !important;
}
.bitable-date-editor:deep(.ant-picker-suffix),
.bitable-date-editor:deep(.ant-picker-clear) {
display: none !important;
}
</style>

View File

@ -186,6 +186,8 @@ function typeLabel(t: FieldType): string {
image: '图片',
formula: '公式',
lookup: '引用',
relation: '关联',
rollup: '汇总',
}
return labels[t] ?? t
}

View File

@ -33,6 +33,8 @@ const ICON_MAP: Record<FieldType, Component> = {
image: PictureOutlined,
formula: FunctionOutlined,
lookup: LinkOutlined,
relation: LinkOutlined,
rollup: LinkOutlined,
}
const iconComponent = computed<Component>(

View File

@ -1,37 +1,69 @@
<template>
<a-select
v-if="multiple"
ref="selectRef"
:value="multiValue"
class="bitable-select-editor"
mode="multiple"
:options="normalizedOptions"
:max-tag-count="2"
:allow-clear="true"
:show-search="true"
:placeholder="placeholder"
popup-class-name="bitable-select-dropdown vxe-table--ignore-clear"
:dropdown-style="dropdownStyle"
:dropdown-match-select-width="false"
:open="isOpen"
style="width: 100%"
@change="onChange"
/>
@dropdown-visible-change="onDropdownVisibleChange"
>
<template #option="{ value: optValue, label: optLabel }">
<span
class="bitable-select-option-chip"
:style="chipStyle(colorKeyFor(String(optValue), normalizedOptions))"
>
{{ optLabel }}
</span>
</template>
</a-select>
<a-select
v-else
ref="selectRef"
:value="singleValue"
class="bitable-select-editor"
:options="normalizedOptions"
:allow-clear="true"
:show-search="true"
:placeholder="placeholder"
popup-class-name="bitable-select-dropdown vxe-table--ignore-clear"
:dropdown-style="dropdownStyle"
:dropdown-match-select-width="false"
:open="isOpen"
style="width: 100%"
@change="onChange"
/>
@dropdown-visible-change="onDropdownVisibleChange"
>
<template #option="{ value: optValue, label: optLabel }">
<span
class="bitable-select-option-chip"
:style="chipStyle(colorKeyFor(String(optValue), normalizedOptions))"
>
{{ optLabel }}
</span>
</template>
</a-select>
</template>
<script lang="ts">
export interface ISelectOption {
label: string
value: string
color?: string
}
// Re-export for backwards compatibility with existing consumers.
export type { ISelectOption } from './selectOptionStyle'
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, inject, ref, onMounted, nextTick } from 'vue'
import { Select as ASelect } from 'ant-design-vue'
import { colorKeyFor, chipStyle, type ISelectOption } from './selectOptionStyle'
const props = withDefaults(
defineProps<{
@ -51,6 +83,43 @@ const emit = defineEmits<{
(e: 'change', value: string | string[] | null): void
}>()
const selectRef = ref<InstanceType<typeof ASelect> | null>(null)
const confirmEdit = inject<() => void>('bitableConfirmEdit', () => {})
// Open the dropdown as soon as the cell editor mounts, matching Feishu's
// "click column -> dropdown appears" behavior.
const isOpen = ref(false)
// Track whether the value changed while the dropdown was open, so we can
// auto-save when the dropdown closes (single select immediately, multiselect
// on close).
const changedWhileOpen = ref(false)
// Ensure the dropdown is at least as wide as the table column. We measure the
// closest vxe-table cell on mount and apply it as the dropdown min-width.
const dropdownMinWidth = ref<number | undefined>(undefined)
const dropdownStyle = computed<Record<string, string>>(() => ({
minWidth: dropdownMinWidth.value ? `${dropdownMinWidth.value}px` : 'auto',
}))
onMounted(() => {
// ponytail: debug log to verify the editor mounts.
// eslint-disable-next-line no-console
console.log('[SelectCellEditor mounted]', { modelValue: props.modelValue, multiple: props.multiple })
changedWhileOpen.value = false
// Feishu-style: open the dropdown as soon as the cell editor mounts.
isOpen.value = true
nextTick(() => {
const selectEl = (selectRef.value?.$el as HTMLElement | undefined) ?? null
const cellEl = selectEl?.closest('.vxe-body--column') as HTMLElement | undefined
if (cellEl) {
dropdownMinWidth.value = cellEl.getBoundingClientRect().width
}
// Focus the select search input so keyboard navigation works immediately.
selectEl?.querySelector('input')?.focus()
})
})
// Normalize options to {label, value} format handles string[], {label,value}[], and mixed
const normalizedOptions = computed<ISelectOption[]>(() => {
if (!props.options) return []
@ -80,19 +149,91 @@ const singleValue = computed<string | undefined>(() => {
// a-select @change passes SelectValue (string | string[] | number | ... | undefined)
// We normalize to string | string[] | null for our consumers
function onChange(value: unknown): void {
changedWhileOpen.value = true
if (value == null) {
emit('update:modelValue', null)
emit('change', null)
return
}
if (Array.isArray(value)) {
} else if (Array.isArray(value)) {
const next = value.map((v) => String(v))
emit('update:modelValue', next)
emit('change', next)
return
} else {
const next = String(value)
emit('update:modelValue', next)
emit('change', next)
}
// Single select confirms immediately (Feishu-style); multiselect waits for
// the dropdown to close so the user can pick several options.
if (!props.multiple) {
isOpen.value = false
nextTick(() => confirmEdit())
}
}
function onDropdownVisibleChange(open: boolean): void {
isOpen.value = open
if (!open && changedWhileOpen.value) {
// Dropdown closed with a value change confirm the edit so vxe-grid
// emits edit-closed and saves.
nextTick(() => confirmEdit())
}
const next = String(value)
emit('update:modelValue', next)
emit('change', next)
}
</script>
<style scoped>
/* Feishu-style inline select editor blue focus ring, rounded border,
clean dropdown panel. */
:deep(.bitable-select-editor .ant-select-selector) {
border-color: var(--bitable-color-primary) !important;
background-color: var(--bitable-color-bg) !important;
border-radius: var(--bitable-radius-md) !important;
box-shadow: none !important;
}
:deep(.bitable-select-editor.ant-select-focused .ant-select-selector),
:deep(.bitable-select-editor.ant-select-open .ant-select-selector) {
border-color: var(--bitable-color-primary) !important;
}
:deep(.bitable-select-editor .ant-select-selection-item) {
color: var(--bitable-color-text);
font-size: var(--bitable-font-sm);
}
:deep(.bitable-select-editor .ant-select-selection-placeholder) {
color: var(--bitable-color-text-placeholder);
font-size: var(--bitable-font-sm);
}
:global(.bitable-select-dropdown) {
border-radius: var(--bitable-radius-md);
padding: var(--bitable-spacing-xs) 0;
background-color: var(--bitable-color-bg);
box-shadow: var(--bitable-shadow-md);
}
:global(.bitable-select-dropdown .ant-select-item) {
border-radius: var(--bitable-radius-sm);
margin: 0 var(--bitable-spacing-xs);
padding: var(--bitable-spacing-xs) var(--bitable-spacing-sm);
color: var(--bitable-color-text);
font-size: var(--bitable-font-sm);
}
:global(.bitable-select-dropdown .ant-select-item-option-active),
:global(.bitable-select-dropdown .ant-select-item-option-selected:not(.ant-select-item-option-disabled)) {
background-color: var(--bitable-color-bg-secondary);
color: var(--bitable-color-text);
}
:global(.bitable-select-dropdown .bitable-select-option-chip) {
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: var(--bitable-radius-full);
border: 1px solid;
font-size: var(--bitable-font-xs);
line-height: 1.5;
white-space: nowrap;
}
</style>

View File

@ -4,7 +4,7 @@
v-for="v in (value as string[] | null | undefined) ?? []"
:key="v"
class="select-display__chip"
:style="chipStyle(colorKeyFor(v))"
:style="chipStyle(colorKeyFor(v, normalizedOptions))"
>
{{ labelOf(v) }}
</span>
@ -12,7 +12,7 @@
<span
v-else-if="value != null && value !== ''"
class="select-display__chip"
:style="chipStyle(colorKeyFor(value as string))"
:style="chipStyle(colorKeyFor(value as string, normalizedOptions))"
>
{{ labelOf(value as string) }}
</span>
@ -20,12 +20,7 @@
<script setup lang="ts">
import { computed } from 'vue'
export interface ISelectOption {
label: string
value: string
color?: string
}
import { colorKeyFor, chipStyle, type ISelectOption } from './selectOptionStyle'
const props = defineProps<{
value: string | string[] | null | undefined
@ -33,21 +28,6 @@ const props = defineProps<{
multiple?: boolean
}>()
// 8 token key bitable-tokens.css --bitable-cf-*
const COLOR_KEYS = [
'red',
'orange',
'yellow',
'green',
'blue',
'purple',
'gray',
'neutral',
] as const
type ColorKey = (typeof COLOR_KEYS)[number]
const COLOR_KEY_SET = new Set<string>(COLOR_KEYS)
// Normalize options to a lookup map
const optionMap = computed<Map<string, ISelectOption>>(() => {
const m = new Map<string, ISelectOption>()
@ -67,35 +47,13 @@ function labelOf(value: string): string {
return optionMap.value.get(value)?.label ?? value
}
// ponytail: hash 8 ceiling: hash
// chip
// : hash FNV-1a
function hashColorKey(value: string): ColorKey {
let h = 0
for (let i = 0; i < value.length; i++) {
h = (h * 31 + value.charCodeAt(i)) | 0
}
return COLOR_KEYS[Math.abs(h) % COLOR_KEYS.length]
}
// 8 key Ant Design preset
// 'default'/'pink'退 hash
function colorKeyFor(value: string): ColorKey {
const explicit = optionMap.value.get(value)?.color
if (explicit && COLOR_KEY_SET.has(explicit)) {
return explicit as ColorKey
}
return hashColorKey(value)
}
// chip -bg + -fg / ~12:1WCAG AA 4.5:1
function chipStyle(key: ColorKey): Record<string, string> {
return {
backgroundColor: `var(--bitable-cf-${key}-bg)`,
color: `var(--bitable-cf-${key}-fg)`,
borderColor: `var(--bitable-cf-${key}-fg)`,
}
}
const normalizedOptions = computed<ISelectOption[]>(() => {
if (!props.options) return []
return (props.options as unknown[]).map((opt) => {
if (typeof opt === 'string') return { label: opt, value: opt }
return opt as ISelectOption
})
})
</script>
<style scoped>
@ -108,11 +66,11 @@ function chipStyle(key: ColorKey): Record<string, string> {
.select-display__chip {
display: inline-flex;
align-items: center;
padding: 0 6px;
border-radius: var(--bitable-radius-md);
padding: 0 8px;
border-radius: var(--bitable-radius-full);
border: 1px solid;
font-size: var(--bitable-font-xs);
line-height: 1.4;
line-height: 1.5;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,31 @@
<template>
<span class="system-field-display">{{ displayText }}</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
value: string | number | null | undefined
fieldName?: string
}>()
const displayText = computed(() => {
const v = props.value
if (v == null || v === '') return '—'
// Creator field: the backend stores 'system' for agent-generated records.
// Show a friendly label instead of the raw identifier.
const name = props.fieldName ?? ''
if (String(v).toLowerCase() === 'system' && name.includes('创建人')) {
return '系统'
}
return String(v)
})
</script>
<style scoped>
.system-field-display {
color: var(--bitable-color-text-secondary);
font-size: var(--bitable-font-sm);
}
</style>

View File

@ -0,0 +1,106 @@
<template>
<a-input
ref="inputRef"
:value="displayValue"
:type="type"
class="bitable-text-editor"
size="small"
@update:value="onUpdateValue"
@pressEnter="onPressEnter"
/>
</template>
<script setup lang="ts">
import { computed, inject, ref, onMounted, nextTick } from 'vue'
import { Input as AInput } from 'ant-design-vue'
const props = withDefaults(
defineProps<{
modelValue: string | number | null | undefined
type?: 'text' | 'number'
}>(),
{
type: 'text',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number | null): void
}>()
const inputRef = ref<InstanceType<typeof AInput> | null>(null)
const confirmEdit = inject<() => void>('bitableConfirmEdit', () => {})
const displayValue = computed(() => {
if (props.modelValue == null) return ''
return String(props.modelValue)
})
function onUpdateValue(value: string): void {
// ponytail: debug log to diagnose editor value propagation.
// eslint-disable-next-line no-console
console.log('[TextCellEditor onUpdateValue]', { type: props.type, value })
if (value === '') {
emit('update:modelValue', null)
return
}
if (props.type === 'number') {
const num = Number(value)
emit('update:modelValue', Number.isNaN(num) ? null : num)
return
}
emit('update:modelValue', value)
}
function onPressEnter(): void {
// vxe-grid does not emit edit-closed on internal input blur; ask the grid
// to clear the active edit so the edit-closed handler saves the value.
confirmEdit()
}
onMounted(() => {
nextTick(() => {
const el = inputRef.value?.$el as HTMLElement | undefined
const input = el?.querySelector('input') as HTMLInputElement | undefined
input?.focus()
input?.select()
})
})
</script>
<style scoped>
.bitable-text-editor {
width: 100% !important;
height: 100% !important;
min-height: 100% !important;
border: none !important;
border-radius: 0 !important;
background-color: transparent !important;
padding: 0 !important;
font-size: var(--bitable-font-sm) !important;
color: var(--bitable-color-text) !important;
box-shadow: none !important;
outline: none !important;
}
.bitable-text-editor:deep(.ant-input) {
width: 100% !important;
height: 100% !important;
min-height: 100% !important;
border: none !important;
border-radius: 0 !important;
background-color: transparent !important;
padding: 0 !important;
font-size: var(--bitable-font-sm) !important;
color: var(--bitable-color-text) !important;
box-shadow: none !important;
outline: none !important;
}
.bitable-text-editor:deep(.ant-input:focus),
.bitable-text-editor:deep(.ant-input-focused) {
border: none !important;
box-shadow: none !important;
outline: none !important;
}
</style>

View File

@ -0,0 +1,45 @@
export interface ISelectOption {
label: string
value: string
color?: string
}
const COLOR_KEYS = [
'red',
'orange',
'yellow',
'green',
'blue',
'purple',
'gray',
'neutral',
] as const
type ColorKey = (typeof COLOR_KEYS)[number]
const COLOR_KEY_SET = new Set<string>(COLOR_KEYS)
// ponytail: 简单确定性 hash → 8 色之一。已知 ceiling: hash 分布对短字符串
// 可能聚集(不抗冲突),但 chip 着色仅做视觉区分,冲突无功能影响。
function hashColorKey(value: string): ColorKey {
let h = 0
for (let i = 0; i < value.length; i++) {
h = (h * 31 + value.charCodeAt(i)) | 0
}
return COLOR_KEYS[Math.abs(h) % COLOR_KEYS.length]
}
export function colorKeyFor(value: string, options: ISelectOption[]): ColorKey {
const explicit = options.find((o) => o.value === value)?.color
if (explicit && COLOR_KEY_SET.has(explicit)) {
return explicit as ColorKey
}
return hashColorKey(value)
}
export function chipStyle(key: ColorKey): Record<string, string> {
return {
backgroundColor: `var(--bitable-cf-${key}-bg)`,
color: `var(--bitable-cf-${key}-fg)`,
borderColor: `var(--bitable-cf-${key}-fg)`,
}
}

View File

@ -21,6 +21,6 @@ import SideNav from './SideNav.vue'
.app-layout__main {
flex: 1;
overflow: hidden;
background: var(--bg-tertiary);
background: var(--bg-canvas);
}
</style>

View File

@ -2,7 +2,6 @@
<a-layout-sider
class="side-nav"
:width="240"
theme="dark"
:trigger="null"
>
<div class="side-nav__logo">
@ -10,7 +9,6 @@
</div>
<a-menu
v-model:selectedKeys="selectedKeys"
theme="dark"
mode="inline"
@click="handleMenuClick"
>
@ -99,6 +97,8 @@ function handleMenuClick({ key }: { key: string | number }): void {
overflow-y: auto;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
}
.side-nav :deep(.ant-layout-sider-children) {
@ -108,29 +108,77 @@ function handleMenuClick({ key }: { key: string | number }): void {
}
.side-nav__logo {
height: 64px;
height: var(--header-height, 56px);
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid var(--border-color);
}
.side-nav__title {
color: #ffffff;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
font-size: var(--font-md, 16px);
font-weight: var(--font-weight-semibold, 600);
margin: 0;
white-space: nowrap;
}
.side-nav :deep(.ant-menu) {
flex: 1;
background: transparent;
border-inline-end: none !important;
}
.side-nav :deep(.ant-menu-item) {
height: 40px;
line-height: 40px;
margin: var(--space-1, 4px) var(--space-3, 12px);
padding: 0 var(--space-3, 12px) !important;
border-radius: var(--radius-md, 6px);
color: var(--text-secondary);
width: auto;
}
.side-nav :deep(.ant-menu-item .anticon) {
color: var(--text-tertiary);
}
.side-nav :deep(.ant-menu-item:hover) {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.side-nav :deep(.ant-menu-item:hover .anticon) {
color: var(--text-primary);
}
.side-nav :deep(.ant-menu-item-selected) {
background: var(--bg-tertiary);
color: var(--text-primary);
font-weight: var(--font-weight-medium, 500);
}
.side-nav :deep(.ant-menu-item-selected .anticon) {
color: var(--text-primary);
}
.side-nav :deep(.ant-menu-item::after) {
display: none;
}
.side-nav :deep(.ant-menu-divider) {
background: var(--border-color);
margin: var(--space-2, 8px) var(--space-3, 12px);
}
.side-nav__footer {
margin-top: auto;
padding: 16px 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: var(--space-3, 12px) var(--space-4, 16px);
border-top: 1px solid var(--border-color);
}
.side-nav__footer :deep(.ant-badge-status-text) {
color: rgba(255, 255, 255, 0.65);
font-size: 12px;
color: var(--text-tertiary);
font-size: var(--font-xs, 12px);
}
</style>

View File

@ -0,0 +1,114 @@
<template>
<div
class="ui-card"
:class="{
'ui-card--hoverable': hoverable,
'ui-card--shadow-sm': shadow === 'sm',
'ui-card--shadow-none': shadow === 'none',
[`ui-card--padding-${padding}`]: true,
}"
>
<div v-if="title || $slots.extra" class="ui-card__header">
<div class="ui-card__title-wrap">
<h3 v-if="title" class="ui-card__title">{{ title }}</h3>
<p v-if="subtitle" class="ui-card__subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.extra" class="ui-card__extra">
<slot name="extra" />
</div>
</div>
<div class="ui-card__body">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
interface IProps {
title?: string
subtitle?: string
padding?: 'sm' | 'md' | 'lg'
shadow?: 'none' | 'sm'
hoverable?: boolean
}
withDefaults(defineProps<IProps>(), {
padding: 'md',
shadow: 'none',
hoverable: false,
})
</script>
<style scoped>
.ui-card {
background: var(--bg-primary);
border-radius: var(--radius-2xl, 16px);
border: 1px solid var(--border-color);
transition: border-color var(--transition-fast, 150ms ease),
box-shadow var(--transition-fast, 150ms ease);
}
.ui-card--padding-sm {
padding: var(--space-3, 12px);
}
.ui-card--padding-md {
padding: var(--space-4, 16px);
}
.ui-card--padding-lg {
padding: var(--space-5, 20px);
}
.ui-card--shadow-sm {
box-shadow: var(--shadow-sm);
}
.ui-card--shadow-none {
box-shadow: none;
}
.ui-card--hoverable {
cursor: pointer;
}
.ui-card--hoverable:hover {
border-color: var(--border-color-hover);
box-shadow: var(--shadow-md);
}
.ui-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3, 12px);
margin-bottom: var(--space-3, 12px);
}
.ui-card__title-wrap {
min-width: 0;
}
.ui-card__title {
margin: 0;
font-size: var(--font-md, 16px);
font-weight: var(--font-weight-medium, 500);
color: var(--text-primary);
line-height: 1.4;
}
.ui-card__subtitle {
margin: var(--space-1, 4px) 0 0;
font-size: var(--font-xs, 12px);
color: var(--text-tertiary);
line-height: 1.5;
}
.ui-card__extra {
flex-shrink: 0;
}
.ui-card__body {
min-width: 0;
}
</style>

View File

@ -0,0 +1,98 @@
<template>
<UiCard :hoverable="hoverable" padding="lg" @click="emit('click')">
<div class="ui-metric-card">
<div class="ui-metric-card__meta">
<span class="ui-metric-card__label">{{ label }}</span>
<UiTrendBadge
v-if="trend !== undefined"
:value="trend"
:label="trendLabel"
size="sm"
/>
</div>
<div class="ui-metric-card__value-row">
<span v-if="prefix" class="ui-metric-card__prefix">{{ prefix }}</span>
<span class="ui-metric-card__value">{{ displayValue }}</span>
<span v-if="suffix" class="ui-metric-card__suffix">{{ suffix }}</span>
</div>
<p v-if="caption" class="ui-metric-card__caption">{{ caption }}</p>
</div>
</UiCard>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import UiCard from './UiCard.vue'
import UiTrendBadge from './UiTrendBadge.vue'
interface IProps {
label: string
value: string | number
prefix?: string
suffix?: string
caption?: string
trend?: number
trendLabel?: string
hoverable?: boolean
}
const props = withDefaults(defineProps<IProps>(), {
hoverable: false,
})
const emit = defineEmits<{
click: []
}>()
const displayValue = computed(() => {
if (typeof props.value === 'number') {
return Number.isInteger(props.value) ? String(props.value) : props.value.toFixed(2)
}
return props.value
})
</script>
<style scoped>
.ui-metric-card {
display: flex;
flex-direction: column;
gap: var(--space-2, 8px);
}
.ui-metric-card__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2, 8px);
}
.ui-metric-card__label {
font-size: var(--font-sm, 13px);
color: var(--text-tertiary);
}
.ui-metric-card__value-row {
display: flex;
align-items: baseline;
gap: var(--space-1, 4px);
}
.ui-metric-card__value {
font-size: var(--font-2xl, 32px);
font-weight: var(--font-weight-semibold, 600);
color: var(--text-primary);
line-height: 1.2;
}
.ui-metric-card__prefix,
.ui-metric-card__suffix {
font-size: var(--font-md, 16px);
color: var(--text-secondary);
}
.ui-metric-card__caption {
margin: 0;
font-size: var(--font-xs, 12px);
color: var(--text-tertiary);
}
</style>

View File

@ -0,0 +1,96 @@
<template>
<header class="ui-page-header">
<div class="ui-page-header__left">
<a-button
v-if="back"
type="text"
size="small"
class="ui-page-header__back"
@click="emit('back')"
>
<template #icon><ArrowLeftOutlined /></template>
</a-button>
<div class="ui-page-header__title-wrap">
<h1 class="ui-page-header__title">{{ title }}</h1>
<p v-if="subtitle" class="ui-page-header__subtitle">{{ subtitle }}</p>
</div>
</div>
<div v-if="$slots.default" class="ui-page-header__right">
<slot />
</div>
</header>
</template>
<script setup lang="ts">
import { Button as AButton } from 'ant-design-vue'
import { ArrowLeftOutlined } from '@ant-design/icons-vue'
interface IProps {
title: string
subtitle?: string
back?: boolean
}
withDefaults(defineProps<IProps>(), {
back: false,
})
const emit = defineEmits<{
back: []
}>()
</script>
<style scoped>
.ui-page-header {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--header-height, 56px);
padding: 0 var(--space-6, 24px);
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
gap: var(--space-4, 16px);
}
.ui-page-header__left {
display: flex;
align-items: center;
gap: var(--space-2, 8px);
min-width: 0;
}
.ui-page-header__back {
color: var(--text-tertiary);
}
.ui-page-header__back:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.ui-page-header__title-wrap {
min-width: 0;
}
.ui-page-header__title {
margin: 0;
font-size: var(--font-lg, 20px);
font-weight: var(--font-weight-medium, 500);
color: var(--text-primary);
line-height: 1.3;
}
.ui-page-header__subtitle {
margin: var(--space-1, 4px) 0 0;
font-size: var(--font-xs, 12px);
color: var(--text-tertiary);
line-height: 1.4;
}
.ui-page-header__right {
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--space-2, 8px);
}
</style>

View File

@ -0,0 +1,50 @@
<template>
<div class="ui-page-shell">
<UiPageHeader
v-if="title"
:title="title"
:subtitle="subtitle"
:back="back"
@back="emit('back')"
>
<slot name="header-extra" />
</UiPageHeader>
<main class="ui-page-shell__content">
<slot />
</main>
</div>
</template>
<script setup lang="ts">
import UiPageHeader from './UiPageHeader.vue'
interface IProps {
title?: string
subtitle?: string
back?: boolean
}
withDefaults(defineProps<IProps>(), {
back: false,
})
const emit = defineEmits<{
back: []
}>()
</script>
<style scoped>
.ui-page-shell {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.ui-page-shell__content {
flex: 1;
overflow: auto;
padding: var(--space-6, 24px);
background: var(--bg-canvas);
}
</style>

View File

@ -0,0 +1,77 @@
<template>
<span
class="ui-trend-badge"
:class="{
'ui-trend-badge--positive': isPositive,
'ui-trend-badge--negative': isNegative,
'ui-trend-badge--neutral': isNeutral,
[`ui-trend-badge--${size}`]: true,
}"
>
<span class="ui-trend-badge__icon" aria-hidden="true">
{{ isPositive ? '↑' : isNegative ? '↓' : '' }}
</span>
<span class="ui-trend-badge__value">{{ formattedValue }}</span>
<span v-if="label" class="ui-trend-badge__label">{{ label }}</span>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface IProps {
value: number
label?: string
size?: 'sm' | 'md'
}
const props = withDefaults(defineProps<IProps>(), {
size: 'md',
})
const isPositive = computed(() => props.value > 0)
const isNegative = computed(() => props.value < 0)
const isNeutral = computed(() => props.value === 0)
const formattedValue = computed(() => {
const abs = Math.abs(props.value)
const sign = props.value > 0 ? '+' : props.value < 0 ? '' : ''
return `${sign}${abs}%`
})
</script>
<style scoped>
.ui-trend-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1, 4px);
border-radius: var(--radius-full, 9999px);
font-weight: var(--font-weight-medium, 500);
white-space: nowrap;
}
.ui-trend-badge--sm {
padding: 2px 8px;
font-size: var(--font-xs, 12px);
}
.ui-trend-badge--md {
padding: 4px 10px;
font-size: var(--font-sm, 13px);
}
.ui-trend-badge--positive {
background: var(--color-success-light);
color: var(--color-success);
}
.ui-trend-badge--negative {
background: var(--color-error-light);
color: var(--color-error);
}
.ui-trend-badge--neutral {
background: var(--bg-tertiary);
color: var(--text-tertiary);
}
</style>

View File

@ -0,0 +1,5 @@
export { default as UiCard } from './UiCard.vue'
export { default as UiMetricCard } from './UiMetricCard.vue'
export { default as UiPageHeader } from './UiPageHeader.vue'
export { default as UiPageShell } from './UiPageShell.vue'
export { default as UiTrendBadge } from './UiTrendBadge.vue'

View File

@ -270,6 +270,9 @@ export const useBitableStore = defineStore('bitable', () => {
fieldId: string,
value: unknown,
): Promise<void> {
// ponytail: debug log to diagnose save failures.
// eslint-disable-next-line no-console
console.log('[bitableStore updateCell start]', { recordId, fieldId, value })
try {
const resp = await bitableApi.updateRecord(recordId, { [fieldId]: value })
// Update local state
@ -277,7 +280,11 @@ export const useBitableStore = defineStore('bitable', () => {
if (idx >= 0) {
records.value[idx] = resp.record
}
// eslint-disable-next-line no-console
console.log('[bitableStore updateCell success]', { recordId, fieldId, value, record: resp.record })
} catch (err) {
// eslint-disable-next-line no-console
console.error('[bitableStore updateCell error]', { recordId, fieldId, value, err })
notification.error({
message: '更新失败',
description: err instanceof Error ? err.message : String(err),
@ -292,7 +299,7 @@ export const useBitableStore = defineStore('bitable', () => {
const resp = await bitableApi.createRecords(currentTable.value.id, [{}])
const record = resp.records[0]
if (record) {
records.value.unshift(record)
records.value.push(record)
return record.id
}
return null

View File

@ -25,6 +25,7 @@
--bitable-color-text-placeholder: var(--text-placeholder, #9b9b9a);
--bitable-color-border: var(--border-color, #ededec);
--bitable-color-border-hover: var(--border-color-hover, #dfdfde);
--bitable-color-border-strong: var(--border-color-strong, #d4d4d3);
--bitable-color-border-split: var(--border-color-split, #f2f2f0);
/* ── 颜色:条件格式 8 色(语义 key → bg/fg 配对) ── */
@ -71,6 +72,12 @@
--bitable-font-md: 14px;
--bitable-font-lg: 16px;
/* ── 阴影(映射全局 token ── */
--bitable-shadow-sm: var(--shadow-sm);
--bitable-shadow-md: var(--shadow-md);
--bitable-shadow-lg: var(--shadow-lg);
--bitable-shadow-xl: var(--shadow-xl);
/* ── 抽屉宽度KTD4: ≤10 字段 480px>10 字段 640px ── */
--bitable-drawer-width: 480px;
--bitable-drawer-width-wide: 640px;

View File

@ -34,8 +34,8 @@ export const themeConfig: ThemeConfig = {
// Background
colorBgContainer: readToken('--bg-primary', '#ffffff'),
colorBgLayout: readToken('--bg-secondary', '#fbfbfa'),
colorBgElevated: readToken('--bg-primary', '#ffffff'),
colorBgLayout: readToken('--bg-canvas', '#f3f4f6'),
colorBgElevated: readToken('--bg-elevated', '#ffffff'),
// Border
colorBorder: readToken('--border-color', '#ededec'),

View File

@ -61,6 +61,7 @@
--color-gray-900: #1a1a1a;
/* ── Background ── */
--bg-canvas: #f3f4f6;
--bg-primary: #ffffff;
--bg-secondary: #fbfbfa;
--bg-tertiary: #f7f7f5;
@ -69,6 +70,18 @@
/* 消息气泡背景,与 inline code/table 背景解耦 (F1-A) */
--bg-message-bubble: #ffffff;
/* ── Data Visualization ── */
--data-viz-primary: #22c55e;
--data-viz-secondary: #6b6b6a;
--data-viz-positive: #22c55e;
--data-viz-negative: #ef4444;
--data-viz-neutral: #9b9b9a;
--data-viz-palette-1: #22c55e;
--data-viz-palette-2: #3b82f6;
--data-viz-palette-3: #f59e0b;
--data-viz-palette-4: #a855f7;
--data-viz-palette-5: #ec4899;
/* ── Foreground / Text ── */
--text-primary: #1a1a1a;
--text-secondary: #4a4a4a;
@ -80,6 +93,7 @@
/* ── Border ── */
--border-color: #ededec;
--border-color-hover: #dfdfde;
--border-color-strong: #e0e0e0;
--border-color-active: var(--color-primary);
--border-color-split: #f2f2f0;
@ -99,6 +113,7 @@
--radius-md: 6px;
--radius-lg: 10px;
--radius-xl: 14px;
--radius-2xl: 16px;
--radius-card: 12px;
--radius-full: 9999px;
@ -109,6 +124,7 @@
--font-md: 16px;
--font-lg: 20px;
--font-xl: 24px;
--font-2xl: 32px;
/* ── Font Weight ── */
--font-weight-normal: 400;
@ -124,7 +140,11 @@
--leading-normal: 1.5;
--leading-relaxed: 1.75;
/* ── Sans Font ── */
--font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
/* ── Shadow (Notion-style: softer, more ambient) ── */
--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);
@ -156,8 +176,16 @@
/* ── Layout ── */
--topnav-height: 48px;
--sidebar-width: 280px;
--sidebar-collapsed-width: 64px;
--header-height: 56px;
--quadrant-min-size: 320px;
/* ── Breakpoints ── */
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
/* ── Code Theme (Catppuccin Mocha — warmer dark theme) ── */
--code-bg: #1e1e2e;
--code-fg: #cdd6f4;
@ -221,6 +249,7 @@
--color-gray-900: #fbfbfa;
/* ── Background ── */
--bg-canvas: #111111;
--bg-primary: #1a1a1a;
--bg-secondary: #1f1f1f;
--bg-tertiary: #2a2a2a;
@ -229,6 +258,18 @@
/* 消息气泡背景,与 inline code/table 背景解耦 (F1-A) */
--bg-message-bubble: #1f1f1f;
/* ── Data Visualization ── */
--data-viz-primary: #4ade80;
--data-viz-secondary: #9b9b9a;
--data-viz-positive: #4ade80;
--data-viz-negative: #f87171;
--data-viz-neutral: #6b6b6a;
--data-viz-palette-1: #4ade80;
--data-viz-palette-2: #60a5fa;
--data-viz-palette-3: #fbbf24;
--data-viz-palette-4: #c084fc;
--data-viz-palette-5: #f472b6;
/* ── Foreground / Text ── */
--text-primary: #fbfbfa;
--text-secondary: #cececd;
@ -240,6 +281,7 @@
/* ── Border ── */
--border-color: #3a3a3a;
--border-color-hover: #4a4a4a;
--border-color-strong: #555555;
--border-color-active: var(--color-primary);
--border-color-split: #2f2f2f;

View File

@ -121,9 +121,9 @@
:fields="visibleFields"
:records="store.records"
:loading="store.isLoading"
height="100%"
@edit-cell="handleEditCell"
@add-field="handleAddFieldFromGrid"
@add-record="handleAddRecord"
@config-field="handleConfigField"
@hide-field="handleHideField"
@delete-field="handleDeleteField"
@ -284,6 +284,9 @@ async function handleEditCell(payload: {
fieldId: string
value: unknown
}): Promise<void> {
// ponytail: debug log to diagnose save failures.
// eslint-disable-next-line no-console
console.log('[BitableFileDetailView handleEditCell]', payload)
await store.updateCell(payload.recordId, payload.fieldId, payload.value)
}
@ -443,7 +446,7 @@ async function handleDeleteField(field: IBitableField): Promise<void> {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
overflow: auto;
min-width: 0;
}
@ -507,8 +510,8 @@ async function handleDeleteField(field: IBitableField): Promise<void> {
}
.bitable-file-detail-view__grid-container {
flex: 1;
overflow: hidden;
flex: 0 0 auto;
overflow: visible;
padding: 0;
}

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fischer AgentKit</title>
<script type="module" crossorigin src="/assets/index-BYNVyxSE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ls4ZdRZM.css">
<script type="module" crossorigin src="/assets/index-CXfJVey0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-crDT6919.css">
</head>
<body>
<div id="app"></div>