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" />
/// <reference types="next/image-types/global" /> /// <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 // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // 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 " "ui": "pnpm dlx shadcn@latest add "
}, },
"dependencies": { "dependencies": {
"@kevisual/api": "^0.0.17", "@kevisual/api": "^0.0.20",
"@kevisual/context": "^0.0.4", "@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", "@kevisual/router": "^0.0.60",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -25,15 +26,18 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@uiw/react-md-editor": "^4.0.11",
"antd": "^6.2.1", "antd": "^6.2.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"es-toolkit": "^1.44.0", "es-toolkit": "^1.44.0",
"fuse.js": "^7.1.0",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next": "16.1.4", "next": "16.1.4",
"next-themes": "^0.4.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-resizable-panels": "^4.4.1", "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 { app } from '@/agent/index.ts'
import { use, useEffect, useState } from 'react' import { use, useEffect, useState } from 'react'
import { flexRender, useReactTable, getCoreRowModel, ColumnDef } from '@tanstack/react-table' import { flexRender, useReactTable, getCoreRowModel, ColumnDef } from '@tanstack/react-table'
import { RefreshCw, Info, MoreVertical, Edit, Trash2, Download, Save, ExternalLink, Code } from 'lucide-react' import { RefreshCw, Info, MoreVertical, Edit, Trash2, Download, Save, ExternalLink, Code, Delete } from 'lucide-react'
import { toast, ToastContainer, Slide } from 'react-toastify' import { toast, ToastContainer, Slide } from 'react-toastify'
import { import {
DropdownMenu, DropdownMenu,
@@ -192,14 +192,22 @@ export const QueryView = (props: Props) => {
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<h2 className={`font-bold ${type === 'component' ? 'text-lg' : 'text-2xl'} truncate`} title={`路由视图 - ${viewData?.title || '未命名'}`}> - {viewData?.title || '未命名'}</h2> <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'> <div className='flex items-center gap-2 relative'>
<button <button
onClick={handleRefresh} onClick={handleRefresh}
disabled={isLoading} disabled={isLoading}
className='p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50' className='p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50'
title='刷新' title='刷新'
> >
<RefreshCw size={20} className={isLoading ? 'animate-spin' : ''} /> <RefreshCw size={20} className={isLoading ? 'animate-spin' : 'cursor-pointer'} />
</button> </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}> <DropdownMenu open={showMoreMenu} onOpenChange={setShowMoreMenu}>
<DropdownMenuTrigger asChild> <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" <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="高级运行" title="高级运行"
onClick={() => run(route)}> onClick={() => run(route, 'custom')}>
<MonitorPlay size={14} strokeWidth={2.5} /> <MonitorPlay size={14} strokeWidth={2.5} />
</button> </button>
</div> </div>

View File

@@ -1,14 +1,16 @@
'use client'; 'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import { QueryProxy, RouterViewData, RouterViewItem, pickRouterViewData } from '@kevisual/api' 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 { toast } from 'react-toastify';
import { use } from '@kevisual/context' import { use } from '@kevisual/context'
import { MyCache } from '@kevisual/cache' // import { MyCache } from '@kevisual/cache'
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { app } from '@/agent/index.ts' import { app } from '@/agent/index.ts'
import { cloneDeep, random } from 'es-toolkit' import { cloneDeep, random } from 'es-toolkit'
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { filter } from '@kevisual/js-filter';
import Fuse from 'fuse.js';
const historyReplace = (url: string) => { const historyReplace = (url: string) => {
if (window.history.replaceState) { if (window.history.replaceState) {
window.history.replaceState(null, '', url); window.history.replaceState(null, '', url);
@@ -30,7 +32,7 @@ interface StudioState {
setLoading: (loading: boolean) => void; setLoading: (loading: boolean) => void;
routes: Array<RouteItem>; routes: Array<RouteItem>;
searchRoutes: (keyword: string) => Promise<void>; searchRoutes: (keyword: string) => Promise<void>;
run: (route: RouteItem) => Promise<void>; run: (route: RouteItem, type?: 'normal' | 'custom') => Promise<void>;
queryProxy?: QueryProxy; queryProxy?: QueryProxy;
init: (force?: boolean) => Promise<{ queryProxy: QueryProxy }>; init: (force?: boolean) => Promise<{ queryProxy: QueryProxy }>;
routeViewList: RouteViewList; routeViewList: RouteViewList;
@@ -64,8 +66,20 @@ export const useStudioStore = create<StudioState>()(
searchRoutes: async (keyword: string) => { searchRoutes: async (keyword: string) => {
const state = await get().init(); const state = await get().init();
let queryProxy = state.queryProxy; let queryProxy = state.queryProxy;
const routes: any[] = await queryProxy.listRoutes(() => true, { query: keyword }); console.log('搜索路由', keyword);
set({ routes }); // 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, currentView: undefined,
queryRouteList: async () => { queryRouteList: async () => {
@@ -152,10 +166,11 @@ export const useStudioStore = create<StudioState>()(
set({ routeViewList: [...newList] }); set({ routeViewList: [...newList] });
console.log('删除视图项', id, newList); console.log('删除视图项', id, newList);
}, },
run: async (route: RouteItem) => { run: async (route: RouteItem, type?: 'normal' | 'custom') => {
const state = await get().init(); const state = await get().init();
const isCustom = type === 'custom';
let queryProxy = state.queryProxy; let queryProxy = state.queryProxy;
const showRightPanel = get().showRightPanel; let showRightPanel = get().showRightPanel;
const action = { const action = {
path: route.path, path: route.path,
key: route.key key: route.key
@@ -164,7 +179,14 @@ export const useStudioStore = create<StudioState>()(
if (res.code !== 200) { if (res.code !== 200) {
toast.error(`运行失败:${res.message || '未知错误'}`); toast.error(`运行失败:${res.message || '未知错误'}`);
} else if (res.code === 200) { } else if (res.code === 200) {
// if (!showRightPanel) {
if (isCustom) {
showRightPanel = true;
set({ showRightPanel: true });
} else {
toast.success('运行成功');
}
}
} }
if (showRightPanel) { if (showRightPanel) {
if (route.metadata && route.metadata?.viewItem) { if (route.metadata && route.metadata?.viewItem) {
@@ -190,6 +212,14 @@ export const useStudioStore = create<StudioState>()(
return { queryProxy }; return { queryProxy };
} }
let currentView: RouterViewData | undefined = get().currentView; 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 // @ts-ignore
const routerViewData: RouterViewData = currentView || { const routerViewData: RouterViewData = currentView || {
_id: nanoid(16), _id: nanoid(16),
@@ -206,7 +236,7 @@ export const useStudioStore = create<StudioState>()(
description: '', description: '',
type: 'api', type: 'api',
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 { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Query } from "@kevisual/query" import { Query } from "@kevisual/query"
import { QueryRouterServer } from "@kevisual/router" import { QueryRouterServer } from "@kevisual/router/browser"
import { nanoid } from "nanoid" import { nanoid } from "nanoid"
export type RouterViewItem = RouterViewApi | RouterViewContext | RouterViewWorker; 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 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> = { const newParentValue: Record<string, any> = {
...(parentValue || {}), ...(parentValue || {}),
[field]: value [field]: value
@@ -81,7 +81,7 @@ export const DataItemForm = ({ item, onChange, onRemove }: DataItemFormProps) =>
} }
const handleNestedDeepChange = (parent: string, nestedParent: string, field: string, value: any) => { 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 nestedValue = parentValue?.[nestedParent] as Record<string, any> | undefined
const newNestedValue: Record<string, any> = { const newNestedValue: Record<string, any> = {
...(nestedValue || {}), ...(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'; 'use client';
import { QueryClient } from '@kevisual/query' import { QueryClient } from '@kevisual/query'
const getUrl = () => { export const getUrl = () => {
const _url = new URL(window.location.href) if (typeof window === 'undefined') return '/client/router';
const _url = new URL(location.href)
const pathname = _url.pathname const pathname = _url.pathname
const [user, repo] = pathname.split('/').filter(Boolean) const [user, repo] = pathname.split('/').filter(Boolean)
if (repo === 'v1') { if (repo === 'v1') {

View File

@@ -46,12 +46,19 @@ async function proxyRequest(request: NextRequest, targetHost: string): Promise<N
export async function proxy(request: NextRequest) { export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; 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 请求 // 代理 /api 请求
if (pathname.startsWith('/api')) { if (pathname.startsWith('/api')) {
return proxyRequest(request, API_URL); return proxyRequest(request, API_URL);
} }
// 代理 /root/home 或 /root/home/ 请求 (第二个路径段后可以为空或以 / 结尾) // 代理 /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); return proxyRequest(request, API_URL);
} }

View File

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