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
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-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",

139
web/pnpm-lock.yaml generated
View File

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

View File

@@ -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 <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 className="text-sm text-gray-600">使</div>
<div className="text-sm text-gray-600"></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 className="flex items-center gap-2 px-4 py-3 border-t border-gray-300 bg-white">
<input
@@ -94,9 +117,10 @@ ${toolsList}
/>
<button
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>
</div>
</div>

View File

@@ -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 (
<div>
<App />
<ToastContainer />
<ToastContainer transition={Slide} />
</div>
)
}

View File

@@ -1,60 +1,155 @@
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 <div>API </div>
}
const queryProxy = new QueryProxy({
router: app as any
});
export const App = () => {
export const QueryView = (props: Props) => {
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({
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;
}
if (isList === false) {
setIsList(true);
}
setData(response.data.list)
console.log('res', res);
const [firstItem] = response.data.list || []
const [_, firstItem] = response.data.list || []
if (firstItem) {
const cols = Object.keys(firstItem).map(key => ({
const cols: ColumnDef<any>[] = 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(<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(() => {
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'>
<table className='w-full border-collapse border border-gray-300 rounded-lg overflow-hidden shadow-md'>
<thead className='bg-gray-100 border-b-2 border-gray-300'>
const RenderTable = () => {
if (!isList) {
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 => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
@@ -75,8 +170,7 @@ export const App = () => {
{table.getRowModel().rows.map((row, idx) => (
<tr
key={row.id}
className={`border-b border-gray-200 transition-colors duration-200 ${
idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'
className={`border-b border-gray-200 transition-colors duration-200 ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'
} hover:bg-blue-50`}
>
{row.getVisibleCells().map(cell => (
@@ -91,11 +185,127 @@ export const App = () => {
))}
</tbody>
</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>
}
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 <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'>
<App />
<QueryViewMessages type="page" />
<ToastContainer position="top-center" autoClose={1000} hideProgressBar={false} newestOnTop={false} closeOnClick rtl={false} pauseOnFocusLoss draggable pauseOnHover transition={Slide} />
</main>
}

View File

@@ -1,4 +1,4 @@
import { toast, ToastContainer } from 'react-toastify';
import { toast, ToastContainer, Slide } from 'react-toastify';
export const AppProvider = () => {
return <main className='w-full'>
@@ -13,7 +13,8 @@ export const AppProvider = () => {
pauseOnFocusLoss
draggable
pauseOnHover
theme="light" />
theme="light"
transition={Slide} />
</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 { 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 <main className='w-full h-screen flex flex-col overflow-hidden'>
<Group className="h-full flex-1 overflow-hidden">
@@ -21,9 +23,9 @@ export const AppProvider = () => {
<Panel >
<App />
</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 />
</Panel>
</Panel>}
</Group>
</WrapperHeader>
</Panel>
@@ -39,19 +41,43 @@ export const AppProvider = () => {
pauseOnFocusLoss
draggable
pauseOnHover
theme="light" />
theme="light"
transition={Slide} />
</main>
}
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 <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="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} />}
</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 style={{ height: 'calc(100% - 3rem)' }} className="overflow-auto">
{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<Set<string>>(new Set());
const [visibleIds, setVisibleIds] = useState<Set<string>>(new Set());
const [searchKeyword, setSearchKeyword] = useState<string>('');
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<HTMLInputElement>) => {
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 (
<div className="max-w-5xl mx-auto p-6 h-full overflow-auto">
{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" : ""}`}>
{routes.map((route: RouteItem) => {
const isExpanded = expandedIds.has(route.id);

View File

@@ -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<RouteItem>;
searchRoutes: (keyword: string) => Promise<void>;
run: (route: RouteItem) => Promise<void>;
queryProxy?: QueryProxy;
init: (force?: boolean) => Promise<{ queryProxy: QueryProxy }>;
@@ -35,12 +38,19 @@ interface StudioState {
getCurrentView: () => Promise<void>;
updateRouteView: (view: RouterViewData) => Promise<void>;
deleteRouteView: (id: string) => Promise<void>;
deleteRouteViewItem: (id: string, viewId: string) => void;
currentView?: RouterViewData;
setCurrentView: (view?: RouterViewData) => Promise<void>;
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<StudioState>()(
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<StudioState>()(
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<StudioState>()(
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<StudioState>()(
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<StudioState>()(
},
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,
}),
}
)
);

View File

@@ -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<any[]>([])
const [views, setViews] = useState<any[]>([])
const dataItemsScrollRef = useRef<HTMLDivElement>(null)
const viewsScrollRef = useRef<HTMLDivElement>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="">
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{isUpdate ? '编辑视图' : '新增视图'}</DialogTitle>
</DialogHeader>
<div className="space-y-6 max-h-[70vh] overflow-y-auto">
<div className="space-y-4">
{/* 固定的视图标题 */}
<div className="space-y-2">
<Label htmlFor="title"></Label>
<Input
@@ -112,46 +134,72 @@ export const ViewEditor = ({ open, onOpenChange, data, onSave }: ViewEditorProps
/>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center">
{/* Tabs 容器 */}
<Tabs defaultValue="data" className="w-full flex flex-col">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="data"></TabsTrigger>
<TabsTrigger value="views"></TabsTrigger>
</TabsList>
{/* 数据项配置 Tab */}
<TabsContent value="data" className="flex flex-col mt-4">
<div className="flex justify-between items-center mb-4">
<h3 className="font-medium"> (data.items)</h3>
<Button type="button" variant="outline" size="sm" onClick={handleAddDataItem}>
<Button type="button" variant="outline" size="sm" onClick={handleAddDataItem} className="cursor-pointer">
</Button>
</div>
{dataItems.map((item, index) => (
<div ref={dataItemsScrollRef} className="space-y-4 max-h-[50vh] overflow-y-auto pr-4">
{dataItems.length === 0 ? (
<div className="text-center text-sm text-gray-500 py-8">
"添加数据项"
</div>
) : (
dataItems.map((item, index) => (
<DataItemForm
key={index}
item={item}
onChange={(newItem) => handleUpdateDataItem(index, newItem)}
onRemove={() => handleRemoveDataItem(index)}
/>
))}
))
)}
</div>
</TabsContent>
<div className="space-y-4">
<div className="flex justify-between items-center">
{/* 视图配置 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}>
<Button type="button" variant="outline" size="sm" onClick={handleAddView} className="cursor-pointer">
</Button>
</div>
{views.map((view, index) => (
<div ref={viewsScrollRef} className="space-y-4 max-h-[50vh] overflow-y-auto pr-4">
{views.length === 0 ? (
<div className="text-center text-sm text-gray-500 py-8">
"添加视图"
</div>
) : (
views.map((view, index) => (
<ViewFormItem
key={view.id || index}
view={view}
onChange={(newView) => handleUpdateView(index, newView)}
onRemove={() => handleRemoveView(index)}
/>
))}
))
)}
</div>
</TabsContent>
</Tabs>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
<Button type="button" variant="outline" onClick={handleClose} className="cursor-pointer">
</Button>
<Button type="button" onClick={handleSave}>
<Button type="button" onClick={handleSave} className="cursor-pointer">
</Button>
</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,
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<string | null>(null);
const isActiveView = (viewId: string) => {
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">
{props.views.map(v => (
<div key={v.id} className={`text-sm px-2 py-1 rounded cursor-pointer transition-colors ${isActiveView(v.id) ? 'text-black bg-gray-100' : 'text-gray-600 hover:text-black hover:bg-gray-100'}`} onClick={(e) => {
<div
key={v.id}
className={`text-sm px-2 py-1 rounded cursor-pointer transition-colors flex items-center justify-between group ${isActiveView(v.id) ? 'text-black bg-gray-100' : 'text-gray-600 hover:text-black hover:bg-gray-100'}`}
onClick={(e) => {
studioStore.setCurrentView({ ...view, viewId: v.id })
}}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>{v.title || '未命名视图'}</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<div className="text-xs">
{v.query ? v.query : '无查询字段'}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Popover open={deleteConfirmOpen && deleteTargetId === v.id} onOpenChange={(open) => {
if (!open) {
setDeleteConfirmOpen(false);
setDeleteTargetId(null);
}
}}>
{v.title || '未命名视图'}
<PopoverTrigger asChild>
<Trash2
className="h-4 w-4 text-gray-400 hover:text-gray-600 transition-colors cursor-pointer opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
setDeleteTargetId(v.id);
setDeleteConfirmOpen(true);
}}
/>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" sideOffset={8} className="w-80 border-gray-300">
<div className="space-y-4">
<div>
<h4 className="font-semibold text-sm"></h4>
<p className="text-xs text-gray-600 mt-1"></p>
</div>
<div className="flex justify-end gap-2">
<Button className="border-gray-300" variant="outline" size="sm" onClick={() => setDeleteConfirmOpen(false)}>
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
if (deleteTargetId) {
onDeleteViewItem(view.id, deleteTargetId);
setDeleteConfirmOpen(false);
setDeleteTargetId(null);
}
}}
>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
))}
</div>
@@ -49,17 +111,25 @@ const ViewItem = ({ view, onEdit, onDelete }: { view: any; onEdit: (view: any) =
<DropdownMenu>
<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" />
</Button>
</DropdownMenuTrigger>
<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" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(view.id)}
onClick={(e) => {
e.stopPropagation();
onDelete(view.id);
}}
className="cursor-pointer text-red-600 focus:text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
@@ -75,7 +145,7 @@ const ViewItem = ({ view, onEdit, onDelete }: { view: any; onEdit: (view: any) =
</div>
}
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<any>(null);
@@ -145,6 +215,7 @@ export const ViewList = () => {
view={view}
onEdit={handleEdit}
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 }