From 0b17ac78a98922dcf6cb08e00b2577bb09f33ca3 Mon Sep 17 00:00:00 2001 From: abearxiong Date: Sat, 3 Jan 2026 18:59:37 +0800 Subject: [PATCH] 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. --- web/.github/prompts/astro.prompt.md | 29 +- web/.opencode/skill/git-release/SKILL.md | 19 ++ web/package.json | 5 +- web/pnpm-lock.yaml | 139 ++++++++- web/src/apps/chat/index.tsx | 46 ++- web/src/apps/cv/index.tsx | 4 +- web/src/apps/query-view/index.tsx | 304 +++++++++++++++++--- web/src/apps/setting/index.tsx | 5 +- web/src/apps/studio/index.tsx | 117 +++++++- web/src/apps/studio/store.ts | 85 +++++- web/src/apps/view/components/ViewEditor.tsx | 122 +++++--- web/src/apps/view/form.ts | 10 - web/src/apps/view/list.tsx | 91 +++++- web/src/components/ui/popover.tsx | 46 +++ web/src/components/ui/tabs.tsx | 64 +++++ web/src/components/ui/tooltip.tsx | 59 ++++ 16 files changed, 1005 insertions(+), 140 deletions(-) create mode 100644 web/.opencode/skill/git-release/SKILL.md delete mode 100644 web/src/apps/view/form.ts create mode 100644 web/src/components/ui/popover.tsx create mode 100644 web/src/components/ui/tabs.tsx create mode 100644 web/src/components/ui/tooltip.tsx diff --git a/web/.github/prompts/astro.prompt.md b/web/.github/prompts/astro.prompt.md index 99e86d2..1e83627 100644 --- a/web/.github/prompts/astro.prompt.md +++ b/web/.github/prompts/astro.prompt.md @@ -1,5 +1,32 @@ --- 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 组件库中的组件 diff --git a/web/.opencode/skill/git-release/SKILL.md b/web/.opencode/skill/git-release/SKILL.md new file mode 100644 index 0000000..c2a68a1 --- /dev/null +++ b/web/.opencode/skill/git-release/SKILL.md @@ -0,0 +1,19 @@ +--- +title: git-release +描述: 获取需要diff的代码,总结和提交代码的技能 +license: MIT +compatibility: opencode +metadata: + audience: maintainers + workflow: github +--- + +## 我的工作 + +- 获取代码变更的diff +- 总结代码变更内容 +- 创建git提交 + +## 何时使用我 + +在需要提交代码变更时使用我。 diff --git a/web/package.json b/web/package.json index 2fc76b6..3625bd6 100644 --- a/web/package.json +++ b/web/package.json @@ -32,7 +32,10 @@ "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", "@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", "@tanstack/react-table": "^8.21.3", "@uiw/react-md-editor": "^4.0.11", @@ -64,7 +67,7 @@ "access": "public" }, "devDependencies": { - "@kevisual/api": "^0.0.16", + "@kevisual/api": "^0.0.17", "@kevisual/types": "^0.0.10", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a7cc38c..c916b95 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -47,9 +47,18 @@ importers: '@radix-ui/react-label': 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) + '@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': specifier: ^1.2.4 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': 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)) @@ -130,8 +139,8 @@ importers: version: 5.0.9(@types/react@19.2.7)(react@19.2.3) devDependencies: '@kevisual/api': - specifier: ^0.0.16 - version: 0.0.16 + specifier: ^0.0.17 + version: 0.0.17 '@kevisual/types': specifier: ^0.0.10 version: 0.0.10 @@ -729,8 +738,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@kevisual/api@0.0.16': - resolution: {integrity: sha512-JInnqWHjUxos1oWHe8dmwxWOMCRgv5nI/7HbSrzvHDQxHE6Egc3xA5iALUcRDdkNOnPz98ErZnLmSgHHJDOwYQ==} + '@kevisual/api@0.0.17': + resolution: {integrity: sha512-hW3Q182Lm8wggWfHTEKVTKsmp8MWFINB9l82nEbnwTnd1Lh9DPeQo1hMft7aeL8aGe4vjFCTv4MHixXjmQTzGg==} '@kevisual/cache@0.0.3': resolution: {integrity: sha512-BWEck69KYL96/ywjYVkML974RHjDJTj2ITQND1zFPR+hlBV1H1p55QZgSYRJCObg3EAV1S9Zic/fR2T4pfe8yg==} @@ -976,6 +985,19 @@ packages: '@types/react-dom': 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': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -1072,6 +1094,32 @@ packages: '@types/react': 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': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -1144,6 +1192,19 @@ packages: '@types/react': 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': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} @@ -4421,7 +4482,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@kevisual/api@0.0.16': + '@kevisual/api@0.0.17': dependencies: '@kevisual/js-filter': 0.0.3 '@kevisual/load': 0.0.6 @@ -4778,6 +4839,29 @@ snapshots: '@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)': dependencies: '@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: '@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)': dependencies: react: 19.2.3 @@ -4919,6 +5039,15 @@ snapshots: optionalDependencies: '@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': {} '@rc-component/async-validator@5.0.4': diff --git a/web/src/apps/chat/index.tsx b/web/src/apps/chat/index.tsx index c2fc3a9..883b907 100644 --- a/web/src/apps/chat/index.tsx +++ b/web/src/apps/chat/index.tsx @@ -3,12 +3,20 @@ import { useStudioStore } from '../studio/store'; import { useShallow } from 'zustand/shallow'; import { useState } from 'react'; import { query } from '@/modules/query.ts' +import { QueryViewMessages } from '../query-view'; +import { toast } from 'react-toastify'; export const Chat = () => { const studioStore = useStudioStore(useShallow((state) => ({ routes: state.routes, + showRightPanel: state.showRightPanel, + setShowRightPanel: state.setShowRightPanel, + addMessage: state.addMessage, }))); const [text, setText] = useState(''); + const [isLoading, setIsLoading] = useState(false); const onSend = async () => { + if (!text.trim() || isLoading) return; + setIsLoading(true); const { routes } = studioStore; let callPrompts = ''; const toolsList = routes.map((r, index) => @@ -55,33 +63,48 @@ ${toolsList} isJson: true } }) + setText(''); console.log('发送消息', text, res); if (res.code === 200) { // 处理返回结果 const payload = res.data?.action; if (payload) { const route = routes.find(r => r.id === payload.id); - console.log('找到工具', route); const { path, key } = route || {}; const { id, ...otherParams } = payload.payload || {}; + const action = { path, key, ...otherParams } + let response; if (route) { - const r = await app.run({ path, key, ...otherParams }); - console.log('工具调用结果', r); + response = await app.run(action); + // toast.success('工具调用成功'); } else { 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
-
-
聊天
-
-
欢迎使用聊天功能!
+
智能体
-
- {/* 聊天内容区域 */} +
+
diff --git a/web/src/apps/cv/index.tsx b/web/src/apps/cv/index.tsx index d15309a..400cbd0 100644 --- a/web/src/apps/cv/index.tsx +++ b/web/src/apps/cv/index.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' 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 'github-markdown-css/github-markdown-light.css' import './index.css' @@ -71,7 +71,7 @@ export const AppProvider = () => { return (
- +
) } diff --git a/web/src/apps/query-view/index.tsx b/web/src/apps/query-view/index.tsx index 771f1c3..7367c6f 100644 --- a/web/src/apps/query-view/index.tsx +++ b/web/src/apps/query-view/index.tsx @@ -1,64 +1,159 @@ -import { QueryProxy } from '@kevisual/api/proxy' +import { QueryProxy, RouterViewItem } from '@kevisual/api/proxy' import { app } from '@/index.ts' -import { useEffect, useState } from 'react' -import { flexRender, useReactTable, getCoreRowModel } from '@tanstack/react-table'; +import { use, useEffect, useState } from 'react' +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 = { - data: any - type: 'component' | 'page' + type: 'component' | 'page', + viewData?: any } -export const QueryView = (props: Props) => { - - return
API 视图
-} - const queryProxy = new QueryProxy({ router: app as any }); -export const App = () => { +export const QueryView = (props: Props) => { const [data, setData] = useState([]) - const [columns, setColumns] = useState([]) + const [columns, setColumns] = useState[]>([]) + const [type] = useState<'component' | 'page'>(props.type || 'page') + const [viewData, setViewData] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [showMoreMenu, setShowMoreMenu] = useState(false) + const [selectedRow, setSelectedRow] = useState(null) + const [isList, setIsList] = useState(true) + const [obj, setObj] = useState(null) const table = useReactTable({ data, columns: columns, getCoreRowModel: getCoreRowModel(), }) + const studioStore = useStudioStore(useShallow((state) => ({ + deleteMessage: state.deleteMessage + }))) const main = async () => { - const res = await queryProxy.runByRouteView({ - id: 'getData', - description: '获取数据', - title: '获取数据', - type: 'api', - api: { - url: "/api/router", - }, - action: { - path: 'router', - key: 'list' + try { + setIsLoading(true) + const res = await queryProxy.runByRouteView(viewData!) + const response = res.response; + console.log('response', response, viewData); + const list = response.data?.list + if (!list) { + setIsList(false); + setObj(response.data); + return; } - }) - const response = res.response; - setData(response.data.list) - console.log('res', res); - const [firstItem] = response.data.list || [] - if (firstItem) { - const cols = Object.keys(firstItem).map(key => ({ - accessorKey: key, - header: key.toUpperCase(), - })) - setColumns(cols) + if (isList === false) { + setIsList(true); + } + setData(response.data.list) + console.log('res', res); + const [_, firstItem] = response.data.list || [] + if (firstItem) { + const cols: ColumnDef[] = Object.keys(firstItem).map(key => ({ + accessorKey: key, + header: key.toUpperCase(), + 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(
{str}
, { + 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(() => { - main() + if (viewData) { + main() + } + }, [viewData]) + + useEffect(() => { + props.viewData && setViewData(props.viewData as RouterViewItem) }, []) - return
- - + const RenderTable = () => { + if (!isList) { + return
+        {JSON.stringify(obj, null, 2)}
+      
+ } + return
+ {table.getHeaderGroups().map(headerGroup => ( {headerGroup.headers.map(header => ( - {table.getRowModel().rows.map((row, idx) => ( - {row.getVisibleCells().map(cell => ( -
@@ -73,14 +168,13 @@ export const App = () => {
@@ -91,11 +185,127 @@ export const App = () => { ))}
+ } + const isPage = type === 'page' + return
+
+
+

路由视图 - {viewData?.title || '未命名'}

+
+ + + + + + + + {!isPage && ( + <> + + + 保存 + + + )} + {!isPage && ( + <> + + + 保存并打开 + + + + )} + handleShowDetails()}> + + 详情 + + + + 编辑 + + + + {!isPage ? '移除' : '删除'} + + + + + 导出 + + + + 导出代码 + + + +
+
+
+
+ +
} - -export const AppProvider = (props: { children?: React.ReactNode }) => { +export const QueryViewMessages = (props: Props) => { + 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
+ {studioStore.messages.map((msg, index) => ( +
+ +
+ ))} +
+} +export const AppProvider = () => { return
- + +
} \ No newline at end of file diff --git a/web/src/apps/setting/index.tsx b/web/src/apps/setting/index.tsx index 4135cba..c5eaef7 100644 --- a/web/src/apps/setting/index.tsx +++ b/web/src/apps/setting/index.tsx @@ -1,4 +1,4 @@ -import { toast, ToastContainer } from 'react-toastify'; +import { toast, ToastContainer, Slide } from 'react-toastify'; export const AppProvider = () => { return
@@ -13,7 +13,8 @@ export const AppProvider = () => { pauseOnFocusLoss draggable pauseOnHover - theme="light" /> + theme="light" + transition={Slide} />
} diff --git a/web/src/apps/studio/index.tsx b/web/src/apps/studio/index.tsx index 51a11da..d326265 100644 --- a/web/src/apps/studio/index.tsx +++ b/web/src/apps/studio/index.tsx @@ -1,14 +1,16 @@ -import { toast, ToastContainer } from 'react-toastify'; +import { toast, ToastContainer, Slide } from 'react-toastify'; import { useStudioStore } from './store.ts'; -import { useEffect, useState } from 'react'; -import { MonitorPlay, Play, PanelLeft, PanelLeftClose } from 'lucide-react'; +import { use, useEffect, useState } from 'react'; +import { MonitorPlay, Play, PanelLeft, PanelLeftClose, PanelRight, PanelRightClose, Filter, FilterX, Search, X } from 'lucide-react'; import { Panel, Group } from 'react-resizable-panels' import { ViewList } from '../view/list.tsx'; import { useShallow } from 'zustand/shallow'; import { Chat } from '../chat/index.tsx'; +import { Input } from '@/components/ui/input.tsx'; export const AppProvider = () => { - const { showLeftPanel } = useStudioStore(useShallow((state) => ({ + const { showLeftPanel, showRightPanel } = useStudioStore(useShallow((state) => ({ showLeftPanel: state.showLeftPanel, + showRightPanel: state.showRightPanel, }))); return
@@ -21,9 +23,9 @@ export const AppProvider = () => { - + {showRightPanel && - + } @@ -39,19 +41,43 @@ export const AppProvider = () => { pauseOnFocusLoss draggable pauseOnHover - theme="light" /> + theme="light" + transition={Slide} />
} export const WrapperHeader = (props: { children: React.ReactNode }) => { 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
{ - setShowLeftPanel(!showLeftPanel); + store.setShowLeftPanel(!store.showLeftPanel); }}> {showLeftPanel ? : }
+
+ + +
{props.children} @@ -67,13 +93,61 @@ interface RouteItem { } 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>(new Set()); const [visibleIds, setVisibleIds] = useState>(new Set()); + const [searchKeyword, setSearchKeyword] = useState(''); useEffect(() => { 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) => { + if (e.key === 'Enter') { + await handleSearch(searchKeyword); + } + }; + + const handleClear = async () => { + setSearchKeyword(''); + await queryRouteList(); + }; const toggleDescription = (id: string) => { const newExpanded = new Set(expandedIds); @@ -99,6 +173,29 @@ export const App = () => { return (
{loading &&
加载中...
} + {store.showFilter && ( +
+
+ + setSearchKeyword(e.target.value)} + onKeyDown={handleKeyDown} + /> + {searchKeyword && ( + + )} +
+
+ )}
{routes.map((route: RouteItem) => { const isExpanded = expandedIds.has(route.id); diff --git a/web/src/apps/studio/store.ts b/web/src/apps/studio/store.ts index e646378..ef6aad0 100644 --- a/web/src/apps/studio/store.ts +++ b/web/src/apps/studio/store.ts @@ -1,11 +1,13 @@ 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 { toast } from 'react-toastify'; import { use } from '@kevisual/context' import { MyCache } from '@kevisual/cache' import { persist } from 'zustand/middleware'; import { app } from '@/index.ts' +import { cloneDeep, random } from 'es-toolkit' +import { nanoid } from 'nanoid'; const historyReplace = (url: string) => { if (window.history.replaceState) { window.history.replaceState(null, '', url); @@ -26,6 +28,7 @@ interface StudioState { loading: boolean; setLoading: (loading: boolean) => void; routes: Array; + searchRoutes: (keyword: string) => Promise; run: (route: RouteItem) => Promise; queryProxy?: QueryProxy; init: (force?: boolean) => Promise<{ queryProxy: QueryProxy }>; @@ -35,12 +38,19 @@ interface StudioState { getCurrentView: () => Promise; updateRouteView: (view: RouterViewData) => Promise; deleteRouteView: (id: string) => Promise; + deleteRouteViewItem: (id: string, viewId: string) => void; currentView?: RouterViewData; setCurrentView: (view?: RouterViewData) => Promise; showLeftPanel: boolean; setShowLeftPanel: (show: boolean) => void; + showFilter: boolean; + setShowFilter: (show: boolean) => void; showRightPanel: boolean; 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)); @@ -50,6 +60,13 @@ export const useStudioStore = create()( loading: false, setLoading: (loading: boolean) => set({ loading }), 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 () => { await get().getCurrentView(); const state = await get().init(); @@ -116,15 +133,50 @@ export const useStudioStore = create()( get().getViewList(); 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) => { const state = await get().init(); 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) { toast.error(`运行失败:${res.message || '未知错误'}`); } 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, router: undefined, @@ -137,7 +189,9 @@ export const useStudioStore = create()( return { queryProxy }; } let currentView: RouterViewData | undefined = get().currentView; + // @ts-ignore const routerViewData: RouterViewData = currentView || { + _id: nanoid(16), views: [{ id: '', title: '默认视图', @@ -163,6 +217,7 @@ export const useStudioStore = create()( routerViewData, router: app as any, }); + console.log('初始化 QueryProxy 完成', queryProxy.token); set({ loading: true }); await sleep(1000); // 保证 loading 状态更新 await queryProxy.init(); @@ -172,12 +227,34 @@ export const useStudioStore = create()( }, showLeftPanel: false, setShowLeftPanel: (show: boolean) => set({ showLeftPanel: show }), - showRightPanel: false, + showRightPanel: true, 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', - partialize: (state) => ({ showLeftPanel: state.showLeftPanel, showRightPanel: state.showRightPanel }), + partialize: (state) => ({ + showLeftPanel: state.showLeftPanel, + showRightPanel: state.showRightPanel, + showFilter: state.showFilter, + }), } ) ); diff --git a/web/src/apps/view/components/ViewEditor.tsx b/web/src/apps/view/components/ViewEditor.tsx index c0bd9ec..e45bebf 100644 --- a/web/src/apps/view/components/ViewEditor.tsx +++ b/web/src/apps/view/components/ViewEditor.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect } from "react" +import { useState, useEffect, useRef } from "react" import { Button } from "@/components/ui/button" import { Label } from "@/components/ui/label" import { Input } from "@/components/ui/input" 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 { ViewFormItem } from "@/apps/view/components/ViewFormItem" import { nanoid } from "nanoid" @@ -23,6 +24,8 @@ export const ViewEditor = ({ open, onOpenChange, data, onSave }: ViewEditorProps const [title, setTitle] = useState('') const [dataItems, setDataItems] = useState([]) const [views, setViews] = useState([]) + const dataItemsScrollRef = useRef(null) + const viewsScrollRef = useRef(null) const isUpdate = !!data?.id @@ -36,6 +39,15 @@ export const ViewEditor = ({ open, onOpenChange, data, onSave }: ViewEditorProps const handleAddDataItem = () => { 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) => { @@ -50,6 +62,15 @@ export const ViewEditor = ({ open, onOpenChange, data, onSave }: ViewEditorProps const handleAddView = () => { 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) => { @@ -96,12 +117,13 @@ export const ViewEditor = ({ open, onOpenChange, data, onSave }: ViewEditorProps return ( - + {isUpdate ? '编辑视图' : '新增视图'} -
+
+ {/* 固定的视图标题 */}
-
-
-

数据项配置 (data.items)

- -
- {dataItems.map((item, index) => ( - handleUpdateDataItem(index, newItem)} - onRemove={() => handleRemoveDataItem(index)} - /> - ))} -
+ {/* Tabs 容器 */} + + + 数据项配置 + 视图配置 + -
-
-

视图配置 (views)

- -
- {views.map((view, index) => ( - handleUpdateView(index, newView)} - onRemove={() => handleRemoveView(index)} - /> - ))} -
+ {/* 数据项配置 Tab */} + +
+

数据项配置 (data.items)

+ +
+
+ {dataItems.length === 0 ? ( +
+ 暂无数据项,点击"添加数据项"开始配置 +
+ ) : ( + dataItems.map((item, index) => ( + handleUpdateDataItem(index, newItem)} + onRemove={() => handleRemoveDataItem(index)} + /> + )) + )} +
+
+ + {/* 视图配置 Tab */} + +
+

视图配置 (views)

+ +
+
+ {views.length === 0 ? ( +
+ 暂无视图,点击"添加视图"开始配置 +
+ ) : ( + views.map((view, index) => ( + handleUpdateView(index, newView)} + onRemove={() => handleRemoveView(index)} + /> + )) + )} +
+
+
- - diff --git a/web/src/apps/view/form.ts b/web/src/apps/view/form.ts deleted file mode 100644 index 8900927..0000000 --- a/web/src/apps/view/form.ts +++ /dev/null @@ -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[]; -}; \ No newline at end of file diff --git a/web/src/apps/view/list.tsx b/web/src/apps/view/list.tsx index b4d719a..44229f1 100644 --- a/web/src/apps/view/list.tsx +++ b/web/src/apps/view/list.tsx @@ -9,10 +9,12 @@ import { DropdownMenuItem, DropdownMenuTrigger, } 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 { 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 studioStore = useStudioStore(); useEffect(() => { @@ -21,18 +23,78 @@ const ViewItem = ({ view, onEdit, onDelete }: { view: any; onEdit: (view: any) = setExpanded(true); } }, [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 currentViewId = studioStore.currentView?.viewId; + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [deleteTargetId, setDeleteTargetId] = useState(null); const isActiveView = (viewId: string) => { return viewId === currentViewId; } return
{props.views.map(v => ( -
{ - studioStore.setCurrentView({ ...view, viewId: v.id }) - }}> - {v.title || '未命名视图'} +
{ + studioStore.setCurrentView({ ...view, viewId: v.id }) + }} + > + + + + {v.title || '未命名视图'} + + +
+ {v.query ? v.query : '无查询字段'} +
+
+
+
+ { + if (!open) { + setDeleteConfirmOpen(false); + setDeleteTargetId(null); + } + }}> + + { + e.stopPropagation(); + setDeleteTargetId(v.id); + setDeleteConfirmOpen(true); + }} + /> + + +
+
+

删除视图

+

确定要删除这个视图吗?此操作无法撤销。

+
+
+ + +
+
+
+
))}
@@ -49,17 +111,25 @@ const ViewItem = ({ view, onEdit, onDelete }: { view: any; onEdit: (view: any) = - - onEdit(view)}> + { + e.stopPropagation(); + onEdit(view); + }}> 编辑 onDelete(view.id)} + onClick={(e) => { + e.stopPropagation(); + onDelete(view.id); + }} className="cursor-pointer text-red-600 focus:text-red-600" > @@ -75,7 +145,7 @@ const ViewItem = ({ view, onEdit, onDelete }: { view: any; onEdit: (view: any) =
} export const ViewList = () => { - const { routeViewList, updateRouteView, deleteRouteView, getViewList } = useStudioStore(); + const { routeViewList, updateRouteView, deleteRouteView, deleteRouteViewItem, getViewList } = useStudioStore(); const [searchTerm, setSearchTerm] = useState(""); const [editorOpen, setEditorOpen] = useState(false); const [editingView, setEditingView] = useState(null); @@ -145,6 +215,7 @@ export const ViewList = () => { view={view} onEdit={handleEdit} onDelete={handleDelete} + onDeleteViewItem={deleteRouteViewItem} /> )) )} diff --git a/web/src/components/ui/popover.tsx b/web/src/components/ui/popover.tsx new file mode 100644 index 0000000..6d51b6c --- /dev/null +++ b/web/src/components/ui/popover.tsx @@ -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) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/web/src/components/ui/tabs.tsx b/web/src/components/ui/tabs.tsx new file mode 100644 index 0000000..3d6f3ac --- /dev/null +++ b/web/src/components/ui/tabs.tsx @@ -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) { + return ( + + ) +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/web/src/components/ui/tooltip.tsx b/web/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..715bf76 --- /dev/null +++ b/web/src/components/ui/tooltip.tsx @@ -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) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }