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:
2026-01-03 18:59:37 +08:00
parent d1439ed33f
commit 0b17ac78a9
16 changed files with 1005 additions and 140 deletions

View File

@@ -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 组件库中的组件

View File

@@ -0,0 +1,19 @@
---
title: git-release
描述: 获取需要diff的代码总结和提交代码的技能
license: MIT
compatibility: opencode
metadata:
audience: maintainers
workflow: github
---
## 我的工作
- 获取代码变更的diff
- 总结代码变更内容
- 创建git提交
## 何时使用我
在需要提交代码变更时使用我。

View File

@@ -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
View File

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

View File

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

View File

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

View File

@@ -1,64 +1,159 @@
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: '获取数据', const response = res.response;
type: 'api', console.log('response', response, viewData);
api: { const list = response.data?.list
url: "/api/router", if (!list) {
}, setIsList(false);
action: { setObj(response.data);
path: 'router', return;
key: 'list'
} }
}) if (isList === false) {
const response = res.response; setIsList(true);
setData(response.data.list) }
console.log('res', res); setData(response.data.list)
const [firstItem] = response.data.list || [] console.log('res', res);
if (firstItem) { const [_, firstItem] = response.data.list || []
const cols = Object.keys(firstItem).map(key => ({ if (firstItem) {
accessorKey: key, const cols: ColumnDef<any>[] = Object.keys(firstItem).map(key => ({
header: key.toUpperCase(), accessorKey: key,
})) header: key.toUpperCase(),
setColumns(cols) cell: info => info.getValue() + '',
}))
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(() => {
main() if (viewData) {
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 => (
<th <th
key={header.id} key={header.id}
className='px-4 py-3 text-left text-sm font-semibold text-gray-700 whitespace-nowrap' className='px-4 py-3 text-left text-sm font-semibold text-gray-700 whitespace-nowrap'
> >
@@ -73,14 +168,13 @@ export const App = () => {
</thead> </thead>
<tbody> <tbody>
{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 => (
<td <td
key={cell.id} key={cell.id}
className='px-4 py-3 text-sm text-gray-600' className='px-4 py-3 text-sm text-gray-600'
> >
@@ -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>
} }

View File

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

View File

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

View File

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

View File

@@ -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">
<h3 className="font-medium"> (data.items)</h3> <TabsList className="grid w-full grid-cols-2">
<Button type="button" variant="outline" size="sm" onClick={handleAddDataItem}> <TabsTrigger value="data"></TabsTrigger>
<TabsTrigger value="views"></TabsTrigger>
</Button> </TabsList>
</div>
{dataItems.map((item, index) => (
<DataItemForm
key={index}
item={item}
onChange={(newItem) => handleUpdateDataItem(index, newItem)}
onRemove={() => handleRemoveDataItem(index)}
/>
))}
</div>
<div className="space-y-4"> {/* 数据项配置 Tab */}
<div className="flex justify-between items-center"> <TabsContent value="data" className="flex flex-col mt-4">
<h3 className="font-medium"> (views)</h3> <div className="flex justify-between items-center mb-4">
<Button type="button" variant="outline" size="sm" onClick={handleAddView}> <h3 className="font-medium"> (data.items)</h3>
<Button type="button" variant="outline" size="sm" onClick={handleAddDataItem} className="cursor-pointer">
</Button>
</div> </Button>
{views.map((view, index) => ( </div>
<ViewFormItem <div ref={dataItemsScrollRef} className="space-y-4 max-h-[50vh] overflow-y-auto pr-4">
key={view.id || index} {dataItems.length === 0 ? (
view={view} <div className="text-center text-sm text-gray-500 py-8">
onChange={(newView) => handleUpdateView(index, newView)} "添加数据项"
onRemove={() => handleRemoveView(index)} </div>
/> ) : (
))} dataItems.map((item, index) => (
</div> <DataItemForm
key={index}
item={item}
onChange={(newItem) => handleUpdateDataItem(index, newItem)}
onRemove={() => handleRemoveDataItem(index)}
/>
))
)}
</div>
</TabsContent>
{/* 视图配置 Tab */}
<TabsContent value="views" className="flex flex-col mt-4">
<div className="flex justify-between items-center mb-4">
<h3 className="font-medium"> (views)</h3>
<Button type="button" variant="outline" size="sm" onClick={handleAddView} className="cursor-pointer">
</Button>
</div>
<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
key={view.id || index}
view={view}
onChange={(newView) => handleUpdateView(index, newView)}
onRemove={() => handleRemoveView(index)}
/>
))
)}
</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>

View File

@@ -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[];
};

View File

@@ -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
studioStore.setCurrentView({ ...view, viewId: v.id }) 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'}`}
{v.title || '未命名视图'} onClick={(e) => {
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);
}
}}>
<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}
/> />
)) ))
)} )}

View 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 }

View 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 }

View 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 }