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:
parent
7e7fcda3fe
commit
0979612a35
|
|
@ -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/`。
|
||||
|
|
@ -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
|
||||
<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
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export type FieldType =
|
|||
| 'image'
|
||||
| 'formula'
|
||||
| 'lookup'
|
||||
| 'relation'
|
||||
| 'rollup'
|
||||
|
||||
export type FieldOwner = 'agent' | 'user'
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -186,6 +186,8 @@ function typeLabel(t: FieldType): string {
|
|||
image: '图片',
|
||||
formula: '公式',
|
||||
lookup: '引用',
|
||||
relation: '关联',
|
||||
rollup: '汇总',
|
||||
}
|
||||
return labels[t] ?? t
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ const ICON_MAP: Record<FieldType, Component> = {
|
|||
image: PictureOutlined,
|
||||
formula: FunctionOutlined,
|
||||
lookup: LinkOutlined,
|
||||
relation: LinkOutlined,
|
||||
rollup: LinkOutlined,
|
||||
}
|
||||
|
||||
const iconComponent = computed<Component>(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:1(WCAG 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)`,
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue