feat: update routing and API handling, improve query functionality, and enhance UI components

- Changed import path for routes in next-env.d.ts
- Updated dependencies in package.json, including @kevisual/api and @kevisual/query
- Added delete functionality in QueryView component
- Modified run function in studio to accept a custom type
- Enhanced searchRoutes function in studio store with Fuse.js for better filtering
- Updated DataItemForm to use QueryRouterServer from the browser
- Added dynamic URL handling in query module
- Improved proxy request handling based on app key
- Adjusted TypeScript configuration for stricter checks
- Created a new page component for dynamic routing
- Introduced a new Toaster component for notifications
This commit is contained in:
2026-01-21 18:51:03 +08:00
parent 8ba90c00be
commit 8e29fe4545
12 changed files with 1502 additions and 41 deletions

1393
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
web/next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./dist/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -10,9 +10,10 @@
"ui": "pnpm dlx shadcn@latest add "
},
"dependencies": {
"@kevisual/api": "^0.0.17",
"@kevisual/api": "^0.0.20",
"@kevisual/context": "^0.0.4",
"@kevisual/query": "^0.0.35",
"@kevisual/js-filter": "^0.0.5",
"@kevisual/query": "^0.0.37",
"@kevisual/router": "^0.0.60",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -25,15 +26,18 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@uiw/react-md-editor": "^4.0.11",
"antd": "^6.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^17.2.3",
"es-toolkit": "^1.44.0",
"fuse.js": "^7.1.0",
"idb-keyval": "^6.2.2",
"lucide-react": "^0.562.0",
"next": "16.1.4",
"next-themes": "^0.4.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-resizable-panels": "^4.4.1",

View File

@@ -0,0 +1,14 @@
import { AppProvider } from '../../../studio/index.tsx';
interface PageProps {
params: Promise<{
root: string
appId: string
}>
}
export default async function Page({ params }: PageProps) {
const { root, appId } = await params;
console.log('root', root, 'appId', appId);
return <AppProvider />;
}

View File

@@ -2,7 +2,7 @@ import { QueryProxy, RouterViewItem } from '@kevisual/api/proxy'
import { app } from '@/agent/index.ts'
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 { RefreshCw, Info, MoreVertical, Edit, Trash2, Download, Save, ExternalLink, Code, Delete } from 'lucide-react'
import { toast, ToastContainer, Slide } from 'react-toastify'
import {
DropdownMenu,
@@ -192,14 +192,22 @@ export const QueryView = (props: Props) => {
<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' : ''} />
<RefreshCw size={20} className={isLoading ? 'animate-spin' : 'cursor-pointer'} />
</button>
{!isPage && <button
onClick={handleDelete}
disabled={isLoading}
className='p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50'
>
<Trash2 size={20} className='cursor-pointer' />
</button>}
<DropdownMenu open={showMoreMenu} onOpenChange={setShowMoreMenu}>
<DropdownMenuTrigger asChild>

View File

@@ -237,7 +237,7 @@ export const App = () => {
<button className="p-1.5 rounded-md text-gray-20 hover:text-gray-900 hover:bg-gray-200 transition-all duration-200 cursor-pointer"
title="高级运行"
onClick={() => run(route)}>
onClick={() => run(route, 'custom')}>
<MonitorPlay size={14} strokeWidth={2.5} />
</button>
</div>

View File

@@ -1,14 +1,16 @@
'use client';
import { create } from 'zustand';
import { QueryProxy, RouterViewData, RouterViewItem, pickRouterViewData } from '@kevisual/api'
import { query } from '@/modules/query.ts'
import { getUrl, query } from '@/modules/query.ts'
import { toast } from 'react-toastify';
import { use } from '@kevisual/context'
import { MyCache } from '@kevisual/cache'
// import { MyCache } from '@kevisual/cache'
import { persist } from 'zustand/middleware';
import { app } from '@/agent/index.ts'
import { cloneDeep, random } from 'es-toolkit'
import { nanoid } from 'nanoid';
import { filter } from '@kevisual/js-filter';
import Fuse from 'fuse.js';
const historyReplace = (url: string) => {
if (window.history.replaceState) {
window.history.replaceState(null, '', url);
@@ -30,7 +32,7 @@ interface StudioState {
setLoading: (loading: boolean) => void;
routes: Array<RouteItem>;
searchRoutes: (keyword: string) => Promise<void>;
run: (route: RouteItem) => Promise<void>;
run: (route: RouteItem, type?: 'normal' | 'custom') => Promise<void>;
queryProxy?: QueryProxy;
init: (force?: boolean) => Promise<{ queryProxy: QueryProxy }>;
routeViewList: RouteViewList;
@@ -64,8 +66,20 @@ export const useStudioStore = create<StudioState>()(
searchRoutes: async (keyword: string) => {
const state = await get().init();
let queryProxy = state.queryProxy;
const routes: any[] = await queryProxy.listRoutes(() => true, { query: keyword });
set({ routes });
console.log('搜索路由', keyword);
// const routes: any[] = await queryProxy.listRoutes(() => true, { query: keyword });
if (keyword.toLocaleUpperCase().startsWith('WHERE')) {
const routes = filter(queryProxy.router.routes, keyword);
set({ routes });
} else {
const fuse = new Fuse(queryProxy.router.routes, {
keys: ['path', 'key', 'description'],
threshold: 0.4,
});
const result = fuse.search(keyword);
const routes = result.map(r => r.item) as any[];
set({ routes });
}
},
currentView: undefined,
queryRouteList: async () => {
@@ -152,10 +166,11 @@ export const useStudioStore = create<StudioState>()(
set({ routeViewList: [...newList] });
console.log('删除视图项', id, newList);
},
run: async (route: RouteItem) => {
run: async (route: RouteItem, type?: 'normal' | 'custom') => {
const state = await get().init();
const isCustom = type === 'custom';
let queryProxy = state.queryProxy;
const showRightPanel = get().showRightPanel;
let showRightPanel = get().showRightPanel;
const action = {
path: route.path,
key: route.key
@@ -164,7 +179,14 @@ export const useStudioStore = create<StudioState>()(
if (res.code !== 200) {
toast.error(`运行失败:${res.message || '未知错误'}`);
} else if (res.code === 200) {
//
if (!showRightPanel) {
if (isCustom) {
showRightPanel = true;
set({ showRightPanel: true });
} else {
toast.success('运行成功');
}
}
}
if (showRightPanel) {
if (route.metadata && route.metadata?.viewItem) {
@@ -190,6 +212,14 @@ export const useStudioStore = create<StudioState>()(
return { queryProxy };
}
let currentView: RouterViewData | undefined = get().currentView;
let url = '/api/router';
const _url = new URL(location.href)
const pathname = _url.pathname
const [user, repo] = pathname.split('/').filter(Boolean)
if (repo === 'v1') {
url = pathname;
}
console.log('初始化 QueryProxyURL:', url);
// @ts-ignore
const routerViewData: RouterViewData = currentView || {
_id: nanoid(16),
@@ -206,7 +236,7 @@ export const useStudioStore = create<StudioState>()(
description: '',
type: 'api',
api: {
url: '/api/router'
url: url
}
}
]

View File

@@ -2,7 +2,7 @@ import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox"
import { Query } from "@kevisual/query"
import { QueryRouterServer } from "@kevisual/router"
import { QueryRouterServer } from "@kevisual/router/browser"
import { nanoid } from "nanoid"
export type RouterViewItem = RouterViewApi | RouterViewContext | RouterViewWorker;
@@ -72,7 +72,7 @@ export const DataItemForm = ({ item, onChange, onRemove }: DataItemFormProps) =>
}
const handleNestedChange = (parent: string, field: string, value: any) => {
const parentValue = item[parent as keyof RouterViewItem] as Record<string, any> | undefined
const parentValue = item[parent as keyof RouterViewItem] as any
const newParentValue: Record<string, any> = {
...(parentValue || {}),
[field]: value
@@ -81,7 +81,7 @@ export const DataItemForm = ({ item, onChange, onRemove }: DataItemFormProps) =>
}
const handleNestedDeepChange = (parent: string, nestedParent: string, field: string, value: any) => {
const parentValue = item[parent as keyof RouterViewItem] as Record<string, any> | undefined
const parentValue = item[parent as keyof RouterViewItem] as any
const nestedValue = parentValue?.[nestedParent] as Record<string, any> | undefined
const newNestedValue: Record<string, any> = {
...(nestedValue || {}),

View File

@@ -0,0 +1,40 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -1,8 +1,9 @@
'use client';
import { QueryClient } from '@kevisual/query'
const getUrl = () => {
const _url = new URL(window.location.href)
export const getUrl = () => {
if (typeof window === 'undefined') return '/client/router';
const _url = new URL(location.href)
const pathname = _url.pathname
const [user, repo] = pathname.split('/').filter(Boolean)
if (repo === 'v1') {

View File

@@ -46,12 +46,19 @@ async function proxyRequest(request: NextRequest, targetHost: string): Promise<N
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const method = request.method;
const [username, appKey] = pathname.split('/').filter(Boolean);
const searchParams = request.nextUrl.searchParams;
const path = searchParams.get('path') || '';
// 代理 /api 请求
if (pathname.startsWith('/api')) {
return proxyRequest(request, API_URL);
}
// 代理 /root/home 或 /root/home/ 请求 (第二个路径段后可以为空或以 / 结尾)
if (pathname.startsWith('/root')) {
if (pathname.startsWith('/root') && appKey !== 'v1') {
return proxyRequest(request, API_URL);
}
if (pathname.startsWith('/root') && path && appKey === 'v1') {
return proxyRequest(request, API_URL);
}

View File

@@ -1,6 +1,8 @@
{
"extends": "@kevisual/types/json/next.json",
"compilerOptions": {
"skipLibCheck": true,
"strict": false,
"paths": {
"@/*": [
"./src/*"
@@ -9,7 +11,7 @@
"baseUrl": "./",
"typeRoots": [
"node_modules/@types"
]
],
},
"include": [
"src",