feat: enhance UI components and application features
Add new shadcn/ui components (popover, tabs, tooltip) and improve multiple application modules including chat, query-view, and studio. Update dependencies and refactor code for better user experience.
This commit is contained in:
29
web/.github/prompts/astro.prompt.md
vendored
29
web/.github/prompts/astro.prompt.md
vendored
@@ -1,5 +1,32 @@
|
|||||||
---
|
---
|
||||||
agent: agent
|
agent: agent
|
||||||
|
tags: ["astro", "react", "tailwindcss", "shadcn/ui", "typescript"]
|
||||||
|
createdAt: 2026-01-03
|
||||||
---
|
---
|
||||||
当前项目使用的是astro和react,使用了tailwindcss,和shadcn/ui组件库。都已经安装好了。
|
|
||||||
|
|
||||||
|
# 项目技术栈和上下文
|
||||||
|
|
||||||
|
## 核心框架和库
|
||||||
|
- **Astro** - 静态站点生成框架,用于构建高性能网站
|
||||||
|
- **React** - 用于构建交互式 UI 组件
|
||||||
|
- **TypeScript** - 项目使用 TypeScript 编写,有 tsconfig.json 配置
|
||||||
|
|
||||||
|
## UI 和样式
|
||||||
|
- **TailwindCSS** - CSS 框架,已集成
|
||||||
|
- **shadcn/ui** - 高质量 React 组件库,已安装
|
||||||
|
|
||||||
|
## 项目结构特点
|
||||||
|
- 使用 pnpm 工作区管理
|
||||||
|
- `src/` 目录包含主要源代码
|
||||||
|
- `apps/` - 应用模块(chat、cv、studio、query-view 等)
|
||||||
|
- `components/` - React组件
|
||||||
|
- `pages/` - Astro 页面
|
||||||
|
- `layouts/` - Astro 布局
|
||||||
|
- `slides/` - 演示幻灯片内容
|
||||||
|
|
||||||
|
## 开发指南
|
||||||
|
- 修改代码时遵循项目现有的代码结构和命名约定
|
||||||
|
- React 组件通常使用 `.tsx` 后缀
|
||||||
|
- Astro 组件使用 `.astro` 后缀
|
||||||
|
- 样式优先使用 TailwindCSS 类
|
||||||
|
- 复用已有的 shadcn/ui 组件库中的组件
|
||||||
|
|||||||
19
web/.opencode/skill/git-release/SKILL.md
Normal file
19
web/.opencode/skill/git-release/SKILL.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
title: git-release
|
||||||
|
描述: 获取需要diff的代码,总结和提交代码的技能
|
||||||
|
license: MIT
|
||||||
|
compatibility: opencode
|
||||||
|
metadata:
|
||||||
|
audience: maintainers
|
||||||
|
workflow: github
|
||||||
|
---
|
||||||
|
|
||||||
|
## 我的工作
|
||||||
|
|
||||||
|
- 获取代码变更的diff
|
||||||
|
- 总结代码变更内容
|
||||||
|
- 创建git提交
|
||||||
|
|
||||||
|
## 何时使用我
|
||||||
|
|
||||||
|
在需要提交代码变更时使用我。
|
||||||
@@ -32,7 +32,10 @@
|
|||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@uiw/react-md-editor": "^4.0.11",
|
"@uiw/react-md-editor": "^4.0.11",
|
||||||
@@ -64,7 +67,7 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kevisual/api": "^0.0.16",
|
"@kevisual/api": "^0.0.17",
|
||||||
"@kevisual/types": "^0.0.10",
|
"@kevisual/types": "^0.0.10",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
139
web/pnpm-lock.yaml
generated
139
web/pnpm-lock.yaml
generated
@@ -47,9 +47,18 @@ importers:
|
|||||||
'@radix-ui/react-label':
|
'@radix-ui/react-label':
|
||||||
specifier: ^2.1.8
|
specifier: ^2.1.8
|
||||||
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-popover':
|
||||||
|
specifier: ^1.1.15
|
||||||
|
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.4
|
specifier: ^1.2.4
|
||||||
version: 1.2.4(@types/react@19.2.7)(react@19.2.3)
|
version: 1.2.4(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-tabs':
|
||||||
|
specifier: ^1.1.13
|
||||||
|
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-tooltip':
|
||||||
|
specifier: ^1.2.8
|
||||||
|
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.18
|
specifier: ^4.1.18
|
||||||
version: 4.1.18(vite@6.4.1(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.2))
|
version: 4.1.18(vite@6.4.1(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||||
@@ -130,8 +139,8 @@ importers:
|
|||||||
version: 5.0.9(@types/react@19.2.7)(react@19.2.3)
|
version: 5.0.9(@types/react@19.2.7)(react@19.2.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@kevisual/api':
|
'@kevisual/api':
|
||||||
specifier: ^0.0.16
|
specifier: ^0.0.17
|
||||||
version: 0.0.16
|
version: 0.0.17
|
||||||
'@kevisual/types':
|
'@kevisual/types':
|
||||||
specifier: ^0.0.10
|
specifier: ^0.0.10
|
||||||
version: 0.0.10
|
version: 0.0.10
|
||||||
@@ -729,8 +738,8 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
'@kevisual/api@0.0.16':
|
'@kevisual/api@0.0.17':
|
||||||
resolution: {integrity: sha512-JInnqWHjUxos1oWHe8dmwxWOMCRgv5nI/7HbSrzvHDQxHE6Egc3xA5iALUcRDdkNOnPz98ErZnLmSgHHJDOwYQ==}
|
resolution: {integrity: sha512-hW3Q182Lm8wggWfHTEKVTKsmp8MWFINB9l82nEbnwTnd1Lh9DPeQo1hMft7aeL8aGe4vjFCTv4MHixXjmQTzGg==}
|
||||||
|
|
||||||
'@kevisual/cache@0.0.3':
|
'@kevisual/cache@0.0.3':
|
||||||
resolution: {integrity: sha512-BWEck69KYL96/ywjYVkML974RHjDJTj2ITQND1zFPR+hlBV1H1p55QZgSYRJCObg3EAV1S9Zic/fR2T4pfe8yg==}
|
resolution: {integrity: sha512-BWEck69KYL96/ywjYVkML974RHjDJTj2ITQND1zFPR+hlBV1H1p55QZgSYRJCObg3EAV1S9Zic/fR2T4pfe8yg==}
|
||||||
@@ -976,6 +985,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-popover@1.1.15':
|
||||||
|
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-popper@1.2.8':
|
'@radix-ui/react-popper@1.2.8':
|
||||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1072,6 +1094,32 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-tabs@1.1.13':
|
||||||
|
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-tooltip@1.2.8':
|
||||||
|
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1144,6 +1192,19 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-visually-hidden@1.2.3':
|
||||||
|
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/rect@1.1.1':
|
'@radix-ui/rect@1.1.1':
|
||||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||||
|
|
||||||
@@ -4421,7 +4482,7 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
'@kevisual/api@0.0.16':
|
'@kevisual/api@0.0.17':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@kevisual/js-filter': 0.0.3
|
'@kevisual/js-filter': 0.0.3
|
||||||
'@kevisual/load': 0.0.6
|
'@kevisual/load': 0.0.6
|
||||||
@@ -4778,6 +4839,29 @@ snapshots:
|
|||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
aria-hidden: 1.2.6
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
react-remove-scroll: 2.7.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -4865,6 +4949,42 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
|
|
||||||
|
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.7)(react@19.2.3)':
|
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.7)(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
@@ -4919,6 +5039,15 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
|
|
||||||
|
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/rect@1.1.1': {}
|
'@radix-ui/rect@1.1.1': {}
|
||||||
|
|
||||||
'@rc-component/async-validator@5.0.4':
|
'@rc-component/async-validator@5.0.4':
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ import { useStudioStore } from '../studio/store';
|
|||||||
import { useShallow } from 'zustand/shallow';
|
import { useShallow } from 'zustand/shallow';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { query } from '@/modules/query.ts'
|
import { query } from '@/modules/query.ts'
|
||||||
|
import { QueryViewMessages } from '../query-view';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
export const Chat = () => {
|
export const Chat = () => {
|
||||||
const studioStore = useStudioStore(useShallow((state) => ({
|
const studioStore = useStudioStore(useShallow((state) => ({
|
||||||
routes: state.routes,
|
routes: state.routes,
|
||||||
|
showRightPanel: state.showRightPanel,
|
||||||
|
setShowRightPanel: state.setShowRightPanel,
|
||||||
|
addMessage: state.addMessage,
|
||||||
})));
|
})));
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const onSend = async () => {
|
const onSend = async () => {
|
||||||
|
if (!text.trim() || isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
const { routes } = studioStore;
|
const { routes } = studioStore;
|
||||||
let callPrompts = '';
|
let callPrompts = '';
|
||||||
const toolsList = routes.map((r, index) =>
|
const toolsList = routes.map((r, index) =>
|
||||||
@@ -55,33 +63,48 @@ ${toolsList}
|
|||||||
isJson: true
|
isJson: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
setText('');
|
||||||
console.log('发送消息', text, res);
|
console.log('发送消息', text, res);
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
// 处理返回结果
|
// 处理返回结果
|
||||||
const payload = res.data?.action;
|
const payload = res.data?.action;
|
||||||
if (payload) {
|
if (payload) {
|
||||||
const route = routes.find(r => r.id === payload.id);
|
const route = routes.find(r => r.id === payload.id);
|
||||||
console.log('找到工具', route);
|
|
||||||
const { path, key } = route || {};
|
const { path, key } = route || {};
|
||||||
const { id, ...otherParams } = payload.payload || {};
|
const { id, ...otherParams } = payload.payload || {};
|
||||||
|
const action = { path, key, ...otherParams }
|
||||||
|
let response;
|
||||||
if (route) {
|
if (route) {
|
||||||
const r = await app.run({ path, key, ...otherParams });
|
response = await app.run(action);
|
||||||
console.log('工具调用结果', r);
|
// toast.success('工具调用成功');
|
||||||
} else {
|
} else {
|
||||||
console.error('未找到对应工具', payload.id);
|
console.error('未找到对应工具', payload.id);
|
||||||
|
toast.error('未找到对应工具');
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (route?.metadata?.viewItem) {
|
||||||
|
// 自动打开右侧面板
|
||||||
|
if (!studioStore.showRightPanel) {
|
||||||
|
studioStore.setShowRightPanel(true);
|
||||||
|
}
|
||||||
|
const viewItem = route.metadata.viewItem;
|
||||||
|
viewItem.response = response;
|
||||||
|
viewItem.action = action;
|
||||||
|
viewItem.description = route.description || viewItem.description;
|
||||||
|
// @ts-ignore
|
||||||
|
viewItem._id = Date.now();
|
||||||
|
studioStore.addMessage(viewItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
return <div className="h-full flex flex-col border-l border-gray-300 bg-white">
|
return <div className="h-full flex flex-col border-l border-gray-300 bg-white">
|
||||||
<div className="h-12 flex items-center justify-between px-4 border-b border-gray-300 bg-white">
|
|
||||||
<div className="text-lg font-medium text-black">聊天</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ height: '3rem' }} className="flex items-center justify-between px-4 border-b border-gray-300 bg-gray-50">
|
<div style={{ height: '3rem' }} className="flex items-center justify-between px-4 border-b border-gray-300 bg-gray-50">
|
||||||
<div className="text-sm text-gray-600">欢迎使用聊天功能!</div>
|
<div className="text-sm text-gray-600">智能体</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 'calc(100% - 6rem)' }} className="overflow-auto">
|
<div style={{ height: 'calc(100% - 3rem)' }} className="overflow-auto">
|
||||||
{/* 聊天内容区域 */}
|
<QueryViewMessages type="component" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-4 py-3 border-t border-gray-300 bg-white">
|
<div className="flex items-center gap-2 px-4 py-3 border-t border-gray-300 bg-white">
|
||||||
<input
|
<input
|
||||||
@@ -94,9 +117,10 @@ ${toolsList}
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={onSend}
|
onClick={onSend}
|
||||||
className="px-4 py-2 bg-black hover:bg-gray-900 text-white font-medium rounded-md transition-colors duration-200 flex-shrink-0"
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 bg-black hover:bg-gray-900 disabled:bg-gray-400 text-white font-medium rounded-md transition-colors duration-200 flex-shrink-0"
|
||||||
>
|
>
|
||||||
发送
|
{isLoading ? '发送中...' : '发送'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import MDEditor from '@uiw/react-md-editor'
|
import MDEditor from '@uiw/react-md-editor'
|
||||||
import { ToastContainer } from 'react-toastify'
|
import { ToastContainer, Slide } from 'react-toastify'
|
||||||
import { Save, Download, Printer, Eye, Edit, RotateCcw } from 'lucide-react'
|
import { Save, Download, Printer, Eye, Edit, RotateCcw } from 'lucide-react'
|
||||||
import 'github-markdown-css/github-markdown-light.css'
|
import 'github-markdown-css/github-markdown-light.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
@@ -71,7 +71,7 @@ export const AppProvider = () => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<App />
|
<App />
|
||||||
<ToastContainer />
|
<ToastContainer transition={Slide} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,155 @@
|
|||||||
import { QueryProxy } from '@kevisual/api/proxy'
|
import { QueryProxy, RouterViewItem } from '@kevisual/api/proxy'
|
||||||
import { app } from '@/index.ts'
|
import { app } from '@/index.ts'
|
||||||
import { useEffect, useState } from 'react'
|
import { use, useEffect, useState } from 'react'
|
||||||
import { flexRender, useReactTable, getCoreRowModel } from '@tanstack/react-table';
|
import { flexRender, useReactTable, getCoreRowModel, ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { RefreshCw, Info, MoreVertical, Edit, Trash2, Download, Save, ExternalLink, Code } from 'lucide-react'
|
||||||
|
import { toast, ToastContainer, Slide } from 'react-toastify'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { useStudioStore } from '../studio/store'
|
||||||
|
import { useShallow } from 'zustand/shallow'
|
||||||
|
import { cloneDeep } from 'es-toolkit'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: any
|
type: 'component' | 'page',
|
||||||
type: 'component' | 'page'
|
viewData?: any
|
||||||
}
|
}
|
||||||
export const QueryView = (props: Props) => {
|
|
||||||
|
|
||||||
return <div>API 视图 </div>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const queryProxy = new QueryProxy({
|
const queryProxy = new QueryProxy({
|
||||||
router: app as any
|
router: app as any
|
||||||
});
|
});
|
||||||
export const App = () => {
|
export const QueryView = (props: Props) => {
|
||||||
const [data, setData] = useState<any[]>([])
|
const [data, setData] = useState<any[]>([])
|
||||||
const [columns, setColumns] = useState<any[]>([])
|
const [columns, setColumns] = useState<ColumnDef<any>[]>([])
|
||||||
|
const [type] = useState<'component' | 'page'>(props.type || 'page')
|
||||||
|
const [viewData, setViewData] = useState<RouterViewItem | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [showMoreMenu, setShowMoreMenu] = useState(false)
|
||||||
|
const [selectedRow, setSelectedRow] = useState<any | null>(null)
|
||||||
|
const [isList, setIsList] = useState(true)
|
||||||
|
const [obj, setObj] = useState<any>(null)
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns: columns,
|
columns: columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
})
|
})
|
||||||
|
const studioStore = useStudioStore(useShallow((state) => ({
|
||||||
|
deleteMessage: state.deleteMessage
|
||||||
|
})))
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
const res = await queryProxy.runByRouteView({
|
try {
|
||||||
id: 'getData',
|
setIsLoading(true)
|
||||||
description: '获取数据',
|
const res = await queryProxy.runByRouteView(viewData!)
|
||||||
title: '获取数据',
|
|
||||||
type: 'api',
|
|
||||||
api: {
|
|
||||||
url: "/api/router",
|
|
||||||
},
|
|
||||||
action: {
|
|
||||||
path: 'router',
|
|
||||||
key: 'list'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const response = res.response;
|
const response = res.response;
|
||||||
|
console.log('response', response, viewData);
|
||||||
|
const list = response.data?.list
|
||||||
|
if (!list) {
|
||||||
|
setIsList(false);
|
||||||
|
setObj(response.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isList === false) {
|
||||||
|
setIsList(true);
|
||||||
|
}
|
||||||
setData(response.data.list)
|
setData(response.data.list)
|
||||||
console.log('res', res);
|
console.log('res', res);
|
||||||
const [firstItem] = response.data.list || []
|
const [_, firstItem] = response.data.list || []
|
||||||
if (firstItem) {
|
if (firstItem) {
|
||||||
const cols = Object.keys(firstItem).map(key => ({
|
const cols: ColumnDef<any>[] = Object.keys(firstItem).map(key => ({
|
||||||
accessorKey: key,
|
accessorKey: key,
|
||||||
header: key.toUpperCase(),
|
header: key.toUpperCase(),
|
||||||
|
cell: info => info.getValue() + '',
|
||||||
}))
|
}))
|
||||||
setColumns(cols)
|
setColumns(cols)
|
||||||
}
|
}
|
||||||
|
toast.success('数据获取成功')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (viewData) {
|
||||||
|
setViewData({ ...viewData, response: undefined }) // 触发刷新
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShowDetails = () => {
|
||||||
|
console.log('Show details for row:', props.viewData)
|
||||||
|
const data = cloneDeep(props.viewData)
|
||||||
|
delete data.api?.proxy;
|
||||||
|
delete data.context?.router;
|
||||||
|
delete data.worker?.worker;
|
||||||
|
const str = JSON.stringify(data, null, 2)
|
||||||
|
toast.info(<pre className='max-h-96 overflow-auto'>{str}</pre>, {
|
||||||
|
autoClose: 5000,
|
||||||
|
closeOnClick: true,
|
||||||
|
pauseOnHover: true,
|
||||||
|
draggable: true,
|
||||||
|
icon: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (selectedRow) {
|
||||||
|
console.log('Edit row:', selectedRow)
|
||||||
|
// 在这里添加编辑逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (selectedRow) {
|
||||||
|
console.log('Delete row:', selectedRow)
|
||||||
|
// 在这里添加删除逻辑
|
||||||
|
}
|
||||||
|
studioStore.deleteMessage(props.viewData!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!viewData) return
|
||||||
|
console.log('Export viewData:', viewData)
|
||||||
|
}
|
||||||
|
const handleExportCode = () => {
|
||||||
|
if (!viewData) return
|
||||||
|
console.log('Export code for viewData:', viewData)
|
||||||
|
}
|
||||||
|
const handleSave = () => {
|
||||||
|
if (selectedRow) {
|
||||||
|
console.log('Save row:', selectedRow)
|
||||||
|
toast.success('保存成功')
|
||||||
|
// 在这里添加保存逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveAndOpen = () => {
|
||||||
|
if (selectedRow) {
|
||||||
|
console.log('Save and open row:', selectedRow)
|
||||||
|
toast.success('保存并打开成功')
|
||||||
|
// 在这里添加保存并打开逻辑
|
||||||
|
}
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (viewData) {
|
||||||
main()
|
main()
|
||||||
|
}
|
||||||
|
}, [viewData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
props.viewData && setViewData(props.viewData as RouterViewItem)
|
||||||
}, [])
|
}, [])
|
||||||
return <div id='route-view' className='w-full h-full overflow-auto p-4'>
|
const RenderTable = () => {
|
||||||
<table className='w-full border-collapse border border-gray-300 rounded-lg overflow-hidden shadow-md'>
|
if (!isList) {
|
||||||
<thead className='bg-gray-100 border-b-2 border-gray-300'>
|
return <pre className='bg-gray-100 p-4 rounded-lg overflow-auto'>
|
||||||
|
{JSON.stringify(obj, null, 2)}
|
||||||
|
</pre>
|
||||||
|
}
|
||||||
|
return <table className='w-full border-collapse min-w-max md:w-full'>
|
||||||
|
<thead className='bg-gray-100 border-b-2 border-gray-300 sticky top-0'>
|
||||||
{table.getHeaderGroups().map(headerGroup => (
|
{table.getHeaderGroups().map(headerGroup => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map(header => (
|
{headerGroup.headers.map(header => (
|
||||||
@@ -75,8 +170,7 @@ export const App = () => {
|
|||||||
{table.getRowModel().rows.map((row, idx) => (
|
{table.getRowModel().rows.map((row, idx) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.id}
|
key={row.id}
|
||||||
className={`border-b border-gray-200 transition-colors duration-200 ${
|
className={`border-b border-gray-200 transition-colors duration-200 ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'
|
||||||
idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'
|
|
||||||
} hover:bg-blue-50`}
|
} hover:bg-blue-50`}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map(cell => (
|
{row.getVisibleCells().map(cell => (
|
||||||
@@ -91,11 +185,127 @@ export const App = () => {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
}
|
||||||
|
const isPage = type === 'page'
|
||||||
|
return <div id='route-view' className={`w-full ${type === 'component' ? 'max-h-[600px] overflow-y-auto' : 'h-full overflow-auto'} p-4`}>
|
||||||
|
<div className='mb-4'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<h2 className={`font-bold ${type === 'component' ? 'text-lg' : 'text-2xl'} truncate`} title={`路由视图 - ${viewData?.title || '未命名'}`}>路由视图 - {viewData?.title || '未命名'}</h2>
|
||||||
|
<div className='flex items-center gap-2 relative'>
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
className='p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50'
|
||||||
|
title='刷新'
|
||||||
|
>
|
||||||
|
<RefreshCw size={20} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<DropdownMenu open={showMoreMenu} onOpenChange={setShowMoreMenu}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
className='p-2 hover:bg-gray-200 rounded-lg transition-colors'
|
||||||
|
title='更多选项'
|
||||||
|
>
|
||||||
|
<MoreVertical size={20} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align='end' className='w-48 border-gray-300'>
|
||||||
|
{!isPage && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem onClick={handleSave}>
|
||||||
|
<Save size={16} className='mr-2' />
|
||||||
|
<span>保存</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isPage && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem onClick={handleSaveAndOpen}>
|
||||||
|
<ExternalLink size={16} className='mr-2' />
|
||||||
|
<span>保存并打开</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={() => handleShowDetails()}>
|
||||||
|
<Info size={16} className='mr-2' />
|
||||||
|
<span>详情</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleEdit}>
|
||||||
|
<Edit size={16} className='mr-2' />
|
||||||
|
<span>编辑</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleDelete} variant='destructive'>
|
||||||
|
<Trash2 size={16} className='mr-2' />
|
||||||
|
<span>{!isPage ? '移除' : '删除'}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleExport}>
|
||||||
|
<Download size={16} className='mr-2' />
|
||||||
|
<span>导出</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleExportCode}>
|
||||||
|
<Code size={16} className='mr-2' />
|
||||||
|
<span>导出代码</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='w-full overflow-x-auto rounded-lg shadow-md border border-gray-300'>
|
||||||
|
<RenderTable />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
export const QueryViewMessages = (props: Props) => {
|
||||||
export const AppProvider = (props: { children?: React.ReactNode }) => {
|
const studioStore = useStudioStore(useShallow((state) => ({
|
||||||
|
messages: state.messages,
|
||||||
|
setMessages: state.setMessages
|
||||||
|
})))
|
||||||
|
const initPage = async () => {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
const id = url.searchParams.get('id') || ''
|
||||||
|
if (!id) {
|
||||||
|
toast.error('缺少查询视图ID参数')
|
||||||
|
// return
|
||||||
|
}
|
||||||
|
// 查询query-view的保存的id,赋值后然后执行查询
|
||||||
|
// @ts-ignore
|
||||||
|
const DemoRouterView: RouterViewItem = {
|
||||||
|
id: 'getData',
|
||||||
|
description: '获取数据',
|
||||||
|
title: '获取数据',
|
||||||
|
type: 'api',
|
||||||
|
api: {
|
||||||
|
// url: "/api/router",
|
||||||
|
url: "http://localhost:52000/api/router",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
path: 'router',
|
||||||
|
key: 'list'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
studioStore.setMessages([DemoRouterView])
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
const type = props.type || 'page'
|
||||||
|
if (type === 'page') {
|
||||||
|
initPage()
|
||||||
|
}
|
||||||
|
}, [props.type])
|
||||||
|
return <div>
|
||||||
|
{studioStore.messages.map((msg, index) => (
|
||||||
|
<div key={msg._id || msg.id} className="p-4 border-b border-gray-200">
|
||||||
|
<QueryView viewData={msg} type={props.type} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
export const AppProvider = () => {
|
||||||
return <main className='w-full h-screen flex flex-col overflow-auto'>
|
return <main className='w-full h-screen flex flex-col overflow-auto'>
|
||||||
<App />
|
<QueryViewMessages type="page" />
|
||||||
|
<ToastContainer position="top-center" autoClose={1000} hideProgressBar={false} newestOnTop={false} closeOnClick rtl={false} pauseOnFocusLoss draggable pauseOnHover transition={Slide} />
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { toast, ToastContainer } from 'react-toastify';
|
import { toast, ToastContainer, Slide } from 'react-toastify';
|
||||||
|
|
||||||
export const AppProvider = () => {
|
export const AppProvider = () => {
|
||||||
return <main className='w-full'>
|
return <main className='w-full'>
|
||||||
@@ -13,7 +13,8 @@ export const AppProvider = () => {
|
|||||||
pauseOnFocusLoss
|
pauseOnFocusLoss
|
||||||
draggable
|
draggable
|
||||||
pauseOnHover
|
pauseOnHover
|
||||||
theme="light" />
|
theme="light"
|
||||||
|
transition={Slide} />
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { toast, ToastContainer } from 'react-toastify';
|
import { toast, ToastContainer, Slide } from 'react-toastify';
|
||||||
import { useStudioStore } from './store.ts';
|
import { useStudioStore } from './store.ts';
|
||||||
import { useEffect, useState } from 'react';
|
import { use, useEffect, useState } from 'react';
|
||||||
import { MonitorPlay, Play, PanelLeft, PanelLeftClose } from 'lucide-react';
|
import { MonitorPlay, Play, PanelLeft, PanelLeftClose, PanelRight, PanelRightClose, Filter, FilterX, Search, X } from 'lucide-react';
|
||||||
import { Panel, Group } from 'react-resizable-panels'
|
import { Panel, Group } from 'react-resizable-panels'
|
||||||
import { ViewList } from '../view/list.tsx';
|
import { ViewList } from '../view/list.tsx';
|
||||||
import { useShallow } from 'zustand/shallow';
|
import { useShallow } from 'zustand/shallow';
|
||||||
import { Chat } from '../chat/index.tsx';
|
import { Chat } from '../chat/index.tsx';
|
||||||
|
import { Input } from '@/components/ui/input.tsx';
|
||||||
export const AppProvider = () => {
|
export const AppProvider = () => {
|
||||||
const { showLeftPanel } = useStudioStore(useShallow((state) => ({
|
const { showLeftPanel, showRightPanel } = useStudioStore(useShallow((state) => ({
|
||||||
showLeftPanel: state.showLeftPanel,
|
showLeftPanel: state.showLeftPanel,
|
||||||
|
showRightPanel: state.showRightPanel,
|
||||||
})));
|
})));
|
||||||
return <main className='w-full h-screen flex flex-col overflow-hidden'>
|
return <main className='w-full h-screen flex flex-col overflow-hidden'>
|
||||||
<Group className="h-full flex-1 overflow-hidden">
|
<Group className="h-full flex-1 overflow-hidden">
|
||||||
@@ -21,9 +23,9 @@ export const AppProvider = () => {
|
|||||||
<Panel >
|
<Panel >
|
||||||
<App />
|
<App />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel defaultSize={500} minSize={300} maxSize={600} className="border-l border-gray-300 overflow-auto">
|
{showRightPanel && <Panel defaultSize={500} minSize={300} maxSize={600} className="border-l border-gray-300 overflow-auto">
|
||||||
<Chat />
|
<Chat />
|
||||||
</Panel>
|
</Panel>}
|
||||||
</Group>
|
</Group>
|
||||||
</WrapperHeader>
|
</WrapperHeader>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -39,19 +41,43 @@ export const AppProvider = () => {
|
|||||||
pauseOnFocusLoss
|
pauseOnFocusLoss
|
||||||
draggable
|
draggable
|
||||||
pauseOnHover
|
pauseOnHover
|
||||||
theme="light" />
|
theme="light"
|
||||||
|
transition={Slide} />
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
export const WrapperHeader = (props: { children: React.ReactNode }) => {
|
export const WrapperHeader = (props: { children: React.ReactNode }) => {
|
||||||
const showLeftPanel = useStudioStore(state => state.showLeftPanel);
|
const showLeftPanel = useStudioStore(state => state.showLeftPanel);
|
||||||
const setShowLeftPanel = useStudioStore(state => state.setShowLeftPanel);
|
const store = useStudioStore(useShallow((state) => ({
|
||||||
|
showLeftPanel: state.showLeftPanel,
|
||||||
|
setShowLeftPanel: state.setShowLeftPanel,
|
||||||
|
showFilter: state.showFilter,
|
||||||
|
setShowFilter: state.setShowFilter,
|
||||||
|
showRightPanel: state.showRightPanel,
|
||||||
|
setShowRightPanel: state.setShowRightPanel,
|
||||||
|
})));
|
||||||
return <div className='h-full'>
|
return <div className='h-full'>
|
||||||
<div className="w-full h-12 flex items-center justify-between px-4 border-b border-gray-200 bg-white">
|
<div className="w-full h-12 flex items-center justify-between px-4 border-b border-gray-200 bg-white">
|
||||||
<div className="cursor-pointer text-gray-600 hover:text-gray-900 transition-colors" title="Kevisual Router Studio" onClick={() => {
|
<div className="cursor-pointer text-gray-600 hover:text-gray-900 transition-colors" title="Kevisual Router Studio" onClick={() => {
|
||||||
setShowLeftPanel(!showLeftPanel);
|
store.setShowLeftPanel(!store.showLeftPanel);
|
||||||
}}>
|
}}>
|
||||||
{showLeftPanel ? <PanelLeftClose size={16} /> : <PanelLeft size={16} />}
|
{showLeftPanel ? <PanelLeftClose size={16} /> : <PanelLeft size={16} />}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="p-1.5 rounded-md text-gray-500 hover:text-gray-900 hover:bg-gray-200 transition-all duration-200 cursor-pointer"
|
||||||
|
title={store.showFilter ? "隐藏过滤器" : "显示过滤器"}
|
||||||
|
onClick={() => store.setShowFilter(!store.showFilter)}
|
||||||
|
>
|
||||||
|
{store.showFilter ? <FilterX size={16} /> : <Filter size={16} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-1.5 rounded-md text-gray-500 hover:text-gray-900 hover:bg-gray-200 transition-all duration-200 cursor-pointer"
|
||||||
|
title={store.showRightPanel ? "隐藏右侧面板" : "显示右侧面板"}
|
||||||
|
onClick={() => store.setShowRightPanel(!store.showRightPanel)}
|
||||||
|
>
|
||||||
|
{store.showRightPanel ? <PanelRightClose size={16} /> : <PanelRight size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 'calc(100% - 3rem)' }} className="overflow-auto">
|
<div style={{ height: 'calc(100% - 3rem)' }} className="overflow-auto">
|
||||||
{props.children}
|
{props.children}
|
||||||
@@ -67,13 +93,61 @@ interface RouteItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
const { routes, queryRouteList, run, loading } = useStudioStore();
|
const { routes, queryRouteList, run, loading, searchRoutes, ...store } = useStudioStore(useShallow((state) => ({
|
||||||
|
routes: state.routes,
|
||||||
|
searchRoutes: state.searchRoutes,
|
||||||
|
queryRouteList: state.queryRouteList,
|
||||||
|
run: state.run,
|
||||||
|
loading: state.loading,
|
||||||
|
showFilter: state.showFilter,
|
||||||
|
currentView: state.currentView,
|
||||||
|
setShowFilter: state.setShowFilter,
|
||||||
|
})));
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
const [visibleIds, setVisibleIds] = useState<Set<string>>(new Set());
|
const [visibleIds, setVisibleIds] = useState<Set<string>>(new Set());
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queryRouteList();
|
queryRouteList();
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (store.showFilter) {
|
||||||
|
// 当显示过滤器时,自动聚焦输入框
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const input = document.querySelector('input[placeholder="过滤路由..."]') as HTMLInputElement;
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
if (!store.currentView) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const viewId = store.currentView.viewId;
|
||||||
|
const viewItem = store.currentView.views.find(v => v.id === viewId);
|
||||||
|
if (viewItem && viewItem.query) {
|
||||||
|
setSearchKeyword(viewItem.query);
|
||||||
|
}
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [store.showFilter, store.currentView?.viewId]);
|
||||||
|
const handleSearch = async (keyword: string) => {
|
||||||
|
if (keyword.trim()) {
|
||||||
|
await searchRoutes(keyword);
|
||||||
|
} else {
|
||||||
|
await queryRouteList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
await handleSearch(searchKeyword);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = async () => {
|
||||||
|
setSearchKeyword('');
|
||||||
|
await queryRouteList();
|
||||||
|
};
|
||||||
|
|
||||||
const toggleDescription = (id: string) => {
|
const toggleDescription = (id: string) => {
|
||||||
const newExpanded = new Set(expandedIds);
|
const newExpanded = new Set(expandedIds);
|
||||||
@@ -99,6 +173,29 @@ export const App = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto p-6 h-full overflow-auto">
|
<div className="max-w-5xl mx-auto p-6 h-full overflow-auto">
|
||||||
{loading && <div className="text-center text-gray-500 mb-4">加载中...</div>}
|
{loading && <div className="text-center text-gray-500 mb-4">加载中...</div>}
|
||||||
|
{store.showFilter && (
|
||||||
|
<div className="mb-3 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-gray-300 bg-white focus-within:ring-2 focus-within:ring-gray-400 focus-within:ring-offset-1 focus-within:border-gray-400">
|
||||||
|
<Search size={16} className="text-gray-700 flex-shrink-0" strokeWidth={2} />
|
||||||
|
<Input
|
||||||
|
placeholder="输入路由关键词进行搜索..."
|
||||||
|
className="w-full !border-0 !shadow-none !outline-none bg-transparent focus-visible:!outline-none focus-visible:!ring-0 focus-visible:!ring-offset-0 text-sm text-gray-900 placeholder:text-gray-500"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
{searchKeyword && (
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
className="p-1 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-200 active:bg-gray-300 transition-all duration-150 cursor-pointer flex-shrink-0"
|
||||||
|
title="清空搜索"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={`space-y-1 ${loading ? "opacity-50 pointer-events-none" : ""}`}>
|
<div className={`space-y-1 ${loading ? "opacity-50 pointer-events-none" : ""}`}>
|
||||||
{routes.map((route: RouteItem) => {
|
{routes.map((route: RouteItem) => {
|
||||||
const isExpanded = expandedIds.has(route.id);
|
const isExpanded = expandedIds.has(route.id);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { QueryProxy, RouterViewData } from '@kevisual/api'
|
import { QueryProxy, RouterViewData, RouterViewItem, pickRouterViewData } from '@kevisual/api'
|
||||||
import { query } from '@/modules/query.ts'
|
import { query } from '@/modules/query.ts'
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { use } from '@kevisual/context'
|
import { use } from '@kevisual/context'
|
||||||
import { MyCache } from '@kevisual/cache'
|
import { MyCache } from '@kevisual/cache'
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { app } from '@/index.ts'
|
import { app } from '@/index.ts'
|
||||||
|
import { cloneDeep, random } from 'es-toolkit'
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
const historyReplace = (url: string) => {
|
const historyReplace = (url: string) => {
|
||||||
if (window.history.replaceState) {
|
if (window.history.replaceState) {
|
||||||
window.history.replaceState(null, '', url);
|
window.history.replaceState(null, '', url);
|
||||||
@@ -26,6 +28,7 @@ interface StudioState {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
setLoading: (loading: boolean) => void;
|
setLoading: (loading: boolean) => void;
|
||||||
routes: Array<RouteItem>;
|
routes: Array<RouteItem>;
|
||||||
|
searchRoutes: (keyword: string) => Promise<void>;
|
||||||
run: (route: RouteItem) => Promise<void>;
|
run: (route: RouteItem) => Promise<void>;
|
||||||
queryProxy?: QueryProxy;
|
queryProxy?: QueryProxy;
|
||||||
init: (force?: boolean) => Promise<{ queryProxy: QueryProxy }>;
|
init: (force?: boolean) => Promise<{ queryProxy: QueryProxy }>;
|
||||||
@@ -35,12 +38,19 @@ interface StudioState {
|
|||||||
getCurrentView: () => Promise<void>;
|
getCurrentView: () => Promise<void>;
|
||||||
updateRouteView: (view: RouterViewData) => Promise<void>;
|
updateRouteView: (view: RouterViewData) => Promise<void>;
|
||||||
deleteRouteView: (id: string) => Promise<void>;
|
deleteRouteView: (id: string) => Promise<void>;
|
||||||
|
deleteRouteViewItem: (id: string, viewId: string) => void;
|
||||||
currentView?: RouterViewData;
|
currentView?: RouterViewData;
|
||||||
setCurrentView: (view?: RouterViewData) => Promise<void>;
|
setCurrentView: (view?: RouterViewData) => Promise<void>;
|
||||||
showLeftPanel: boolean;
|
showLeftPanel: boolean;
|
||||||
setShowLeftPanel: (show: boolean) => void;
|
setShowLeftPanel: (show: boolean) => void;
|
||||||
|
showFilter: boolean;
|
||||||
|
setShowFilter: (show: boolean) => void;
|
||||||
showRightPanel: boolean;
|
showRightPanel: boolean;
|
||||||
setShowRightPanel: (show: boolean) => void;
|
setShowRightPanel: (show: boolean) => void;
|
||||||
|
messages: any[];
|
||||||
|
setMessages: (messages: any[]) => void;
|
||||||
|
addMessage: (message: any) => void;
|
||||||
|
deleteMessage: (message: any) => void;
|
||||||
}
|
}
|
||||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
@@ -50,6 +60,13 @@ export const useStudioStore = create<StudioState>()(
|
|||||||
loading: false,
|
loading: false,
|
||||||
setLoading: (loading: boolean) => set({ loading }),
|
setLoading: (loading: boolean) => set({ loading }),
|
||||||
routes: [],
|
routes: [],
|
||||||
|
searchRoutes: async (keyword: string) => {
|
||||||
|
const state = await get().init();
|
||||||
|
let queryProxy = state.queryProxy;
|
||||||
|
const routes: any[] = await queryProxy.listRoutes(() => true, { query: keyword });
|
||||||
|
set({ routes });
|
||||||
|
},
|
||||||
|
currentView: undefined,
|
||||||
queryRouteList: async () => {
|
queryRouteList: async () => {
|
||||||
await get().getCurrentView();
|
await get().getCurrentView();
|
||||||
const state = await get().init();
|
const state = await get().init();
|
||||||
@@ -116,15 +133,50 @@ export const useStudioStore = create<StudioState>()(
|
|||||||
get().getViewList();
|
get().getViewList();
|
||||||
toast.success('视图删除成功');
|
toast.success('视图删除成功');
|
||||||
},
|
},
|
||||||
|
deleteRouteViewItem: (id: string, viewId: string) => {
|
||||||
|
const routeViewList = get().routeViewList;
|
||||||
|
const viewItem = routeViewList.find(view => view.id === id);
|
||||||
|
if (!viewItem) {
|
||||||
|
toast.error('视图项不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewItem.views = viewItem.views?.filter(v => v.id !== viewId);
|
||||||
|
get().updateRouteView(viewItem);
|
||||||
|
const newList = routeViewList.map(view => {
|
||||||
|
if (view.id === viewItem.id) {
|
||||||
|
return viewItem;
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
});
|
||||||
|
set({ routeViewList: [...newList] });
|
||||||
|
console.log('删除视图项', id, newList);
|
||||||
|
},
|
||||||
run: async (route: RouteItem) => {
|
run: async (route: RouteItem) => {
|
||||||
const state = await get().init();
|
const state = await get().init();
|
||||||
let queryProxy = state.queryProxy;
|
let queryProxy = state.queryProxy;
|
||||||
const res = await queryProxy.run({ path: route.path, key: route.key });
|
const showRightPanel = get().showRightPanel;
|
||||||
|
const action = {
|
||||||
|
path: route.path,
|
||||||
|
key: route.key
|
||||||
|
}
|
||||||
|
const res = await queryProxy.run(action);
|
||||||
if (res.code !== 200) {
|
if (res.code !== 200) {
|
||||||
toast.error(`运行失败:${res.message || '未知错误'}`);
|
toast.error(`运行失败:${res.message || '未知错误'}`);
|
||||||
} else if (res.code === 200) {
|
} else if (res.code === 200) {
|
||||||
//
|
//
|
||||||
}
|
}
|
||||||
|
if (showRightPanel) {
|
||||||
|
if (route.metadata && route.metadata?.viewItem) {
|
||||||
|
const messages = get().messages
|
||||||
|
const viewItem = cloneDeep(route.metadata.viewItem) as RouterViewItem;
|
||||||
|
viewItem.response = res;
|
||||||
|
viewItem.action = action
|
||||||
|
viewItem.description = route.description || viewItem.description;
|
||||||
|
// @ts-ignore
|
||||||
|
viewItem._id = nanoid(16);
|
||||||
|
set({ messages: [...messages, viewItem] });
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
queryProxy: undefined,
|
queryProxy: undefined,
|
||||||
router: undefined,
|
router: undefined,
|
||||||
@@ -137,7 +189,9 @@ export const useStudioStore = create<StudioState>()(
|
|||||||
return { queryProxy };
|
return { queryProxy };
|
||||||
}
|
}
|
||||||
let currentView: RouterViewData | undefined = get().currentView;
|
let currentView: RouterViewData | undefined = get().currentView;
|
||||||
|
// @ts-ignore
|
||||||
const routerViewData: RouterViewData = currentView || {
|
const routerViewData: RouterViewData = currentView || {
|
||||||
|
_id: nanoid(16),
|
||||||
views: [{
|
views: [{
|
||||||
id: '',
|
id: '',
|
||||||
title: '默认视图',
|
title: '默认视图',
|
||||||
@@ -163,6 +217,7 @@ export const useStudioStore = create<StudioState>()(
|
|||||||
routerViewData,
|
routerViewData,
|
||||||
router: app as any,
|
router: app as any,
|
||||||
});
|
});
|
||||||
|
console.log('初始化 QueryProxy 完成', queryProxy.token);
|
||||||
set({ loading: true });
|
set({ loading: true });
|
||||||
await sleep(1000); // 保证 loading 状态更新
|
await sleep(1000); // 保证 loading 状态更新
|
||||||
await queryProxy.init();
|
await queryProxy.init();
|
||||||
@@ -172,12 +227,34 @@ export const useStudioStore = create<StudioState>()(
|
|||||||
},
|
},
|
||||||
showLeftPanel: false,
|
showLeftPanel: false,
|
||||||
setShowLeftPanel: (show: boolean) => set({ showLeftPanel: show }),
|
setShowLeftPanel: (show: boolean) => set({ showLeftPanel: show }),
|
||||||
showRightPanel: false,
|
showRightPanel: true,
|
||||||
setShowRightPanel: (show: boolean) => set({ showRightPanel: show }),
|
setShowRightPanel: (show: boolean) => set({ showRightPanel: show }),
|
||||||
|
showFilter: false,
|
||||||
|
setShowFilter: (show: boolean) => set({ showFilter: show }),
|
||||||
|
messages: [],
|
||||||
|
setMessages: (messages: any[]) => set({ messages }),
|
||||||
|
deleteMessage: (message: any) => {
|
||||||
|
const messages = get().messages;
|
||||||
|
const index = messages.findIndex(m => {
|
||||||
|
return m._id === message._id || m.id === message.id;
|
||||||
|
});
|
||||||
|
if (index !== -1) {
|
||||||
|
messages.splice(index, 1);
|
||||||
|
set({ messages: [...messages] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addMessage: (message: any) => {
|
||||||
|
const messages = get().messages;
|
||||||
|
set({ messages: [...messages, message] });
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'studio-storage',
|
name: 'studio-storage',
|
||||||
partialize: (state) => ({ showLeftPanel: state.showLeftPanel, showRightPanel: state.showRightPanel }),
|
partialize: (state) => ({
|
||||||
|
showLeftPanel: state.showLeftPanel,
|
||||||
|
showRightPanel: state.showRightPanel,
|
||||||
|
showFilter: state.showFilter,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect, useRef } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { DataItemForm } from "@/apps/view/components/DataItemForm"
|
import { DataItemForm } from "@/apps/view/components/DataItemForm"
|
||||||
import { ViewFormItem } from "@/apps/view/components/ViewFormItem"
|
import { ViewFormItem } from "@/apps/view/components/ViewFormItem"
|
||||||
import { nanoid } from "nanoid"
|
import { nanoid } from "nanoid"
|
||||||
@@ -23,6 +24,8 @@ export const ViewEditor = ({ open, onOpenChange, data, onSave }: ViewEditorProps
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [dataItems, setDataItems] = useState<any[]>([])
|
const [dataItems, setDataItems] = useState<any[]>([])
|
||||||
const [views, setViews] = useState<any[]>([])
|
const [views, setViews] = useState<any[]>([])
|
||||||
|
const dataItemsScrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const viewsScrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const isUpdate = !!data?.id
|
const isUpdate = !!data?.id
|
||||||
|
|
||||||
@@ -36,6 +39,15 @@ export const ViewEditor = ({ open, onOpenChange, data, onSave }: ViewEditorProps
|
|||||||
|
|
||||||
const handleAddDataItem = () => {
|
const handleAddDataItem = () => {
|
||||||
setDataItems([...dataItems, { type: 'api', api: { url: '' } }])
|
setDataItems([...dataItems, { type: 'api', api: { url: '' } }])
|
||||||
|
// 异步滚动到底部,使用 smooth 平滑滚动
|
||||||
|
setTimeout(() => {
|
||||||
|
if (dataItemsScrollRef.current) {
|
||||||
|
dataItemsScrollRef.current.scrollTo({
|
||||||
|
top: dataItemsScrollRef.current.scrollHeight,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateDataItem = (index: number, item: any) => {
|
const handleUpdateDataItem = (index: number, item: any) => {
|
||||||
@@ -50,6 +62,15 @@ export const ViewEditor = ({ open, onOpenChange, data, onSave }: ViewEditorProps
|
|||||||
|
|
||||||
const handleAddView = () => {
|
const handleAddView = () => {
|
||||||
setViews([...views, { id: nanoid(16), title: '', query: '' }])
|
setViews([...views, { id: nanoid(16), title: '', query: '' }])
|
||||||
|
// 异步滚动到底部,使用 smooth 平滑滚动
|
||||||
|
setTimeout(() => {
|
||||||
|
if (viewsScrollRef.current) {
|
||||||
|
viewsScrollRef.current.scrollTo({
|
||||||
|
top: viewsScrollRef.current.scrollHeight,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateView = (index: number, view: any) => {
|
const handleUpdateView = (index: number, view: any) => {
|
||||||
@@ -96,12 +117,13 @@ export const ViewEditor = ({ open, onOpenChange, data, onSave }: ViewEditorProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isUpdate ? '编辑视图' : '新增视图'}</DialogTitle>
|
<DialogTitle>{isUpdate ? '编辑视图' : '新增视图'}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6 max-h-[70vh] overflow-y-auto">
|
<div className="space-y-4">
|
||||||
|
{/* 固定的视图标题 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">视图标题</Label>
|
<Label htmlFor="title">视图标题</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -112,46 +134,72 @@ export const ViewEditor = ({ open, onOpenChange, data, onSave }: ViewEditorProps
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Tabs 容器 */}
|
||||||
<div className="flex justify-between items-center">
|
<Tabs defaultValue="data" className="w-full flex flex-col">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="data">数据项配置</TabsTrigger>
|
||||||
|
<TabsTrigger value="views">视图配置</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 数据项配置 Tab */}
|
||||||
|
<TabsContent value="data" className="flex flex-col mt-4">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="font-medium">数据项配置 (data.items)</h3>
|
<h3 className="font-medium">数据项配置 (data.items)</h3>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAddDataItem}>
|
<Button type="button" variant="outline" size="sm" onClick={handleAddDataItem} className="cursor-pointer">
|
||||||
添加数据项
|
添加数据项
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{dataItems.map((item, index) => (
|
<div ref={dataItemsScrollRef} className="space-y-4 max-h-[50vh] overflow-y-auto pr-4">
|
||||||
|
{dataItems.length === 0 ? (
|
||||||
|
<div className="text-center text-sm text-gray-500 py-8">
|
||||||
|
暂无数据项,点击"添加数据项"开始配置
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
dataItems.map((item, index) => (
|
||||||
<DataItemForm
|
<DataItemForm
|
||||||
key={index}
|
key={index}
|
||||||
item={item}
|
item={item}
|
||||||
onChange={(newItem) => handleUpdateDataItem(index, newItem)}
|
onChange={(newItem) => handleUpdateDataItem(index, newItem)}
|
||||||
onRemove={() => handleRemoveDataItem(index)}
|
onRemove={() => handleRemoveDataItem(index)}
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* 视图配置 Tab */}
|
||||||
<div className="flex justify-between items-center">
|
<TabsContent value="views" className="flex flex-col mt-4">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="font-medium">视图配置 (views)</h3>
|
<h3 className="font-medium">视图配置 (views)</h3>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAddView}>
|
<Button type="button" variant="outline" size="sm" onClick={handleAddView} className="cursor-pointer">
|
||||||
添加视图
|
添加视图
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{views.map((view, index) => (
|
<div ref={viewsScrollRef} className="space-y-4 max-h-[50vh] overflow-y-auto pr-4">
|
||||||
|
{views.length === 0 ? (
|
||||||
|
<div className="text-center text-sm text-gray-500 py-8">
|
||||||
|
暂无视图,点击"添加视图"开始配置
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
views.map((view, index) => (
|
||||||
<ViewFormItem
|
<ViewFormItem
|
||||||
key={view.id || index}
|
key={view.id || index}
|
||||||
view={view}
|
view={view}
|
||||||
onChange={(newView) => handleUpdateView(index, newView)}
|
onChange={(newView) => handleUpdateView(index, newView)}
|
||||||
onRemove={() => handleRemoveView(index)}
|
onRemove={() => handleRemoveView(index)}
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={handleClose}>
|
<Button type="button" variant="outline" onClick={handleClose} className="cursor-pointer">
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={handleSave}>
|
<Button type="button" onClick={handleSave} className="cursor-pointer">
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { RouterViewQuery, RouterViewItem } from '@kevisual/api';
|
|
||||||
type ViewFormData = {
|
|
||||||
id?: string;
|
|
||||||
title: string;
|
|
||||||
data?: {
|
|
||||||
items: RouterViewItem[];
|
|
||||||
},
|
|
||||||
views: RouterViewQuery[];
|
|
||||||
};
|
|
||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { ViewEditor } from "@/apps/view/components/ViewEditor.tsx";
|
import { ViewEditor } from "@/apps/view/components/ViewEditor.tsx";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
const ViewItem = ({ view, onEdit, onDelete }: { view: any; onEdit: (view: any) => void; onDelete: (id: string) => void }) => {
|
const ViewItem = ({ view, onEdit, onDelete, onDeleteViewItem }: { view: any; onEdit: (view: any) => void; onDelete: (id: string) => void; onDeleteViewItem: (id: string, viewId: string) => void }) => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const studioStore = useStudioStore();
|
const studioStore = useStudioStore();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -21,18 +23,78 @@ const ViewItem = ({ view, onEdit, onDelete }: { view: any; onEdit: (view: any) =
|
|||||||
setExpanded(true);
|
setExpanded(true);
|
||||||
}
|
}
|
||||||
}, [studioStore.currentView?.viewId]);
|
}, [studioStore.currentView?.viewId]);
|
||||||
const ShowViews = (props: { views: { id: string, title: string }[] }) => {
|
const ShowViews = (props: { views: { id: string, title: string, query?: any }[] }) => {
|
||||||
const studioStore = useStudioStore();
|
const studioStore = useStudioStore();
|
||||||
const currentViewId = studioStore.currentView?.viewId;
|
const currentViewId = studioStore.currentView?.viewId;
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||||
const isActiveView = (viewId: string) => {
|
const isActiveView = (viewId: string) => {
|
||||||
return viewId === currentViewId;
|
return viewId === currentViewId;
|
||||||
}
|
}
|
||||||
return <div className="mt-2 ml-4 w-full border-l-2 border-l-gray-300 border-gray-300 pl-3 space-y-1">
|
return <div className="mt-2 ml-4 w-full border-l-2 border-l-gray-300 border-gray-300 pl-3 space-y-1">
|
||||||
{props.views.map(v => (
|
{props.views.map(v => (
|
||||||
<div key={v.id} className={`text-sm px-2 py-1 rounded cursor-pointer transition-colors ${isActiveView(v.id) ? 'text-black bg-gray-100' : 'text-gray-600 hover:text-black hover:bg-gray-100'}`} onClick={(e) => {
|
<div
|
||||||
|
key={v.id}
|
||||||
|
className={`text-sm px-2 py-1 rounded cursor-pointer transition-colors flex items-center justify-between group ${isActiveView(v.id) ? 'text-black bg-gray-100' : 'text-gray-600 hover:text-black hover:bg-gray-100'}`}
|
||||||
|
onClick={(e) => {
|
||||||
studioStore.setCurrentView({ ...view, viewId: v.id })
|
studioStore.setCurrentView({ ...view, viewId: v.id })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>{v.title || '未命名视图'}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="max-w-xs">
|
||||||
|
<div className="text-xs">
|
||||||
|
{v.query ? v.query : '无查询字段'}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<Popover open={deleteConfirmOpen && deleteTargetId === v.id} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
{v.title || '未命名视图'}
|
<PopoverTrigger asChild>
|
||||||
|
<Trash2
|
||||||
|
className="h-4 w-4 text-gray-400 hover:text-gray-600 transition-colors cursor-pointer opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteTargetId(v.id);
|
||||||
|
setDeleteConfirmOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side="bottom" align="end" sideOffset={8} className="w-80 border-gray-300">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-sm">删除视图</h4>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">确定要删除这个视图吗?此操作无法撤销。</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button className="border-gray-300" variant="outline" size="sm" onClick={() => setDeleteConfirmOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (deleteTargetId) {
|
||||||
|
onDeleteViewItem(view.id, deleteTargetId);
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -49,17 +111,25 @@ const ViewItem = ({ view, onEdit, onDelete }: { view: any; onEdit: (view: any) =
|
|||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="border-gray-300">
|
<DropdownMenuContent align="end" className="border-gray-300">
|
||||||
<DropdownMenuItem className="cursor-pointer" onClick={() => onEdit(view)}>
|
<DropdownMenuItem className="cursor-pointer" onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(view);
|
||||||
|
}}>
|
||||||
<Edit2 className="h-4 w-4 mr-2" />
|
<Edit2 className="h-4 w-4 mr-2" />
|
||||||
编辑
|
编辑
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onDelete(view.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(view.id);
|
||||||
|
}}
|
||||||
className="cursor-pointer text-red-600 focus:text-red-600"
|
className="cursor-pointer text-red-600 focus:text-red-600"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
@@ -75,7 +145,7 @@ const ViewItem = ({ view, onEdit, onDelete }: { view: any; onEdit: (view: any) =
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
export const ViewList = () => {
|
export const ViewList = () => {
|
||||||
const { routeViewList, updateRouteView, deleteRouteView, getViewList } = useStudioStore();
|
const { routeViewList, updateRouteView, deleteRouteView, deleteRouteViewItem, getViewList } = useStudioStore();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [editingView, setEditingView] = useState<any>(null);
|
const [editingView, setEditingView] = useState<any>(null);
|
||||||
@@ -145,6 +215,7 @@ export const ViewList = () => {
|
|||||||
view={view}
|
view={view}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onDeleteViewItem={deleteRouteViewItem}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
46
web/src/components/ui/popover.tsx
Normal file
46
web/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
64
web/src/components/ui/tabs.tsx
Normal file
64
web/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
59
web/src/components/ui/tooltip.tsx
Normal file
59
web/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
Reference in New Issue
Block a user