Files
router-studio/src/pages/query-view/components/DetailsDialog.tsx
xiongxiao 3269b2eef3 fix: 修复单条数据不显示表格的问题,优化弹窗UI布局
- 修复数组解构错误导致单条数据时不显示表格
- 优化DetailsDialog弹窗布局,内容过多时按钮始终可见
2026-03-13 05:23:05 +08:00

590 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { DetailsTab, useQueryViewStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { useStudioStore, filterRouteInfo, getPayload } from '@/pages/studio/store';
import { QueryView } from '..';
import { useCallback, useMemo, useState } from 'react';
import { pickRouterViewData, RouterViewData, RouterViewItem } from '@kevisual/api/proxy';
import { RouteInfo, fromJSONSchema } from '@kevisual/router/browser';
import { pick } from 'es-toolkit';
import { toast } from 'sonner';
import { Play } from 'lucide-react';
// 视图信息表格组件
export const ViewInfoTable = ({ currentView }: { currentView?: RouterViewData }) => {
if (!currentView || !currentView.views || currentView.views.length === 0) {
return (
<div className="text-sm text-gray-500 text-center py-8">
</div>
);
}
return (
<div className="space-y-4">
{/* 当前选中的视图 ID */}
{currentView.viewId && (
<div className="border-b border-gray-200 pb-3">
<label className="text-sm font-semibold text-gray-700 block mb-1"> ID</label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md">
{currentView.viewId}
</div>
</div>
)}
{/* Link */}
{currentView.link && (
<div className="border-b border-gray-200 pb-3">
<label className="text-sm font-semibold text-gray-700 block mb-1"></label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md break-all">
{currentView.link}
</div>
</div>
)}
{/* Summary */}
{currentView.summary && (
<div className="border-b border-gray-200 pb-3">
<label className="text-sm font-semibold text-gray-700 block mb-1"></label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md">
{currentView.summary}
</div>
</div>
)}
{/* Tags */}
{currentView.tags && currentView.tags.length > 0 && (
<div className="border-b border-gray-200 pb-3">
<label className="text-sm font-semibold text-gray-700 block mb-1"></label>
<div className="flex flex-wrap gap-2">
{currentView.tags.map((tag: string, index: number) => (
<span
key={index}
className="px-2 py-1 text-xs bg-gray-900 text-white rounded-md"
>
{tag}
</span>
))}
</div>
</div>
)}
{/* Title */}
{currentView.title && (
<div className="border-b border-gray-200 pb-3">
<label className="text-sm font-semibold text-gray-700 block mb-1"></label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md">
{currentView.title}
</div>
</div>
)}
{/* Description */}
{currentView.description && (
<div className="border-b border-gray-200 pb-3">
<label className="text-sm font-semibold text-gray-700 block mb-1"></label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md whitespace-pre-wrap">
{currentView.description}
</div>
</div>
)}
{/* 子视图列表表格 */}
<div>
<label className="text-sm font-semibold text-gray-700 block mb-2"></label>
<div className="border border-gray-300 rounded-md overflow-hidden">
<table className="w-full">
<thead className="bg-gray-100 border-b border-gray-300">
<tr>
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-700">ID</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-700"></th>
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-700"></th>
</tr>
</thead>
<tbody>
{currentView.views.map((view: any, index: number) => {
const isSelected = view.id === currentView.viewId;
return (
<tr
key={view.id || index}
className={`border-b border-gray-200 transition-colors ${isSelected
? 'bg-gray-500 hover:bg-gray-900'
: index % 2 === 0
? 'bg-white hover:bg-gray-50'
: 'bg-gray-50 hover:bg-gray-100'
}`}
>
<td className={`px-4 py-2 text-sm ${isSelected ? 'text-white font-semibold' : 'text-gray-900'}`}>
<div className="flex items-center gap-2">
{view.id || '-'}
{isSelected && (
<span className="px-2 py-0.5 text-xs bg-white text-gray-900 rounded-full font-medium">
</span>
)}
</div>
</td>
<td className={`px-4 py-2 text-sm ${isSelected ? 'text-white' : 'text-gray-900'}`}>{view.title || '-'}</td>
<td className={`px-4 py-2 text-sm font-mono ${isSelected ? 'text-gray-200' : 'text-gray-600'}`}>
{view.query || '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
};
export const DetailsInfoPanel = ({ detailsData }: { detailsData: RouterViewItem | null }) => {
const queryViewStore = useQueryViewStore(useShallow((state) => ({
editing: state.editing,
setEditing: state.setEditing,
setDetailsData: state.setDetailsData,
setDetailsActiveTab: state.setDetailsActiveTab
})));
const studioStore = useStudioStore(useShallow((state) => ({
queryProxy: state.queryProxy,
})));
if (!detailsData) {
return (
<div className="text-sm text-gray-500 text-center py-8">
</div>
);
}
const [action, setAction] = useState(detailsData.action ? JSON.stringify(detailsData.action, null, 2) : '');
const otherFilds = useMemo(() => {
const { type } = detailsData;
if (type === 'api') {
{/* 其他字段 */ }
return <>{
detailsData.api && (
<div className="border-b border-gray-200 pb-3 w-full scrollbar">
<label className="text-sm font-semibold text-gray-700 block mb-1">API</label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md">
<pre className="text-xs w-full">
{JSON.stringify(detailsData.api, null, 2)}
</pre>
</div>
</div>
)
}
</>
}
if (type === 'context') {
return <>{
detailsData.context && (
<div className="border-b border-gray-200 pb-3 w-full scrollbar">
<label className="text-sm font-semibold text-gray-700 block mb-1"></label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md">
<pre className="text-xs w-full">
{JSON.stringify(detailsData.context, null, 2)}
</pre>
</div>
</div>
)
}
</>
}
if (type === 'page') {
return <>{
detailsData.page && (
<div className="border-b border-gray-200 pb-3 w-full scrollbar">
<label className="text-sm font-semibold text-gray-700 block mb-1"></label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md">
<pre className="text-xs w-full">
{JSON.stringify(detailsData.page, null, 2)}
</pre>
</div>
</div>
)
}
</>
}
return null;
}, [detailsData]);
const onRun = useCallback(async () => {
let _action = detailsData?.action;
const isEditing = queryViewStore.editing;
if (isEditing) {
try {
_action = JSON.parse(action as string);
} catch (error) {
toast.error('操作信息必须是合法的 JSON 格式');
return;
}
}
if (!_action) {
toast.error('没有操作信息可供执行');
return;
}
if (!studioStore.queryProxy) {
toast.error('没有可用的查询代理,无法执行操作');
return;
}
const payload = getPayload(_action as any);
const res = await studioStore.queryProxy.run({
..._action,
// @ts-ignore
payload,
})
console.log('执行结果', res);
if (res?.code === 200) {
queryViewStore.setDetailsData({
...detailsData,
action: _action,
response: res,
});
toast.success('操作执行成功', {
action: {
label: '查看数据',
onClick: () => queryViewStore.setDetailsActiveTab('response'),
},
closeButton: true,
duration: 2000,
position: 'top-center',
});
} else {
toast.error(`操作执行失败: ${res?.message || '未知错误'}`);
}
}, [queryViewStore.editing, studioStore.queryProxy, action]);
const runCom = (
<button
className="inline-flex items-center gap-1 ml-2 px-2 py-0.5 text-xs rounded-md bg-gray-900 text-white hover:bg-gray-700 transition-colors align-middle cursor-pointer"
onClick={() => {
console.log('点击执行', detailsData.action);
onRun();
}}
>
<Play className="w-3 h-3" />
</button>
)
const editCom = (<>
{queryViewStore.editing && <button
className="inline-flex items-center gap-1 ml-2 px-2 py-0.5 text-xs rounded-md bg-gray-900 text-white hover:bg-gray-700 transition-colors align-middle cursor-pointer"
onClick={() => {
if (queryViewStore.editing) {
// 保存操作
try {
const parsedAction = JSON.parse(action);
detailsData.action = parsedAction;
queryViewStore.setEditing(false);
queryViewStore.setDetailsData({ ...detailsData });
toast.success('操作信息已更新');
} catch (error) {
toast.error('操作信息必须是合法的 JSON 格式');
return;
}
}
}}></button>}
<button
className="inline-flex items-center gap-1 ml-2 px-2 py-0.5 text-xs rounded-md border border-gray-300 hover:bg-gray-50 transition-colors align-middle cursor-pointer"
onClick={() => {
if (queryViewStore.editing) {
setAction(detailsData.action ? JSON.stringify(detailsData.action, null, 2) : '');
}
queryViewStore.setEditing(!queryViewStore.editing);
}}
>
{queryViewStore.editing ? '取消' : '编辑'}
</button>
</>)
return (
<div className="space-y-4">
{/* Type */}
{detailsData.type && (
<div className="border-b border-gray-200 pb-3">
<label className="text-sm font-semibold text-gray-700 block mb-1"></label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md">
{detailsData.type}
</div>
</div>
)}
{/* Title */}
{detailsData.title && (
<div className="border-b border-gray-200 pb-3">
<label className="text-sm font-semibold text-gray-700 block mb-1"></label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md">
{detailsData.title}
</div>
</div>
)}
{/* Description */}
{detailsData.description && (
<div className="border-b border-gray-200 pb-3">
<label className="text-sm font-semibold text-gray-700 block mb-1"></label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md whitespace-pre-wrap">
{detailsData.description}
</div>
</div>
)}
{/* Action */}
{!queryViewStore.editing && detailsData.action && (
<div className="border-b border-gray-200 pb-3">
<div className="text-sm font-semibold text-gray-700 block mb-1 py-2 cursor-pointer"> {runCom} {editCom}</div>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md">
<pre className="text-xs overflow-auto cursor-pointer" onClick={() => queryViewStore.setEditing(true)}>
{JSON.stringify(detailsData.action, null, 2)}
</pre>
</div>
</div>
)}
{queryViewStore.editing && (
<div className="border-b border-gray-200 pb-3">
<div className="text-sm font-semibold text-gray-700 block mb-1 py-2"> {runCom} {editCom} </div>
<textarea
className="text-sm text-gray-900 bg-gray-100 px-3 py-2 rounded-md w-full h-32 font-mono border-gray-600 focus:ring-2 focus:ring-gray-500 focus:outline-none scrollbar"
value={action}
onChange={(e) => {
setAction(e.target.value);
}}
/>
</div>
)}
{otherFilds}
</div>
);
};
export const RouterInfoPanel = ({ routeInfo }: { routeInfo: RouteInfo | null }) => {
if (!routeInfo) {
return (
<div className="text-sm text-gray-500 text-center py-8">
</div>
);
}
const _routeInfo = pick(routeInfo, ['id', 'path', 'key', 'description', 'metadata']);
const metadata = useMemo(() => {
if (!_routeInfo.metadata) return null;
const _metadata = _routeInfo.metadata;
if (_metadata.viewItem) {
_metadata.viewItem = filterRouteInfo(_metadata.viewItem);
}
return _metadata;
}, [_routeInfo.metadata]);
return (
<div className="space-y-4">
{/* ID */}
{_routeInfo.id && (
<div className="border-b border-gray-200 pb-3">
<label className="text-sm font-semibold text-gray-700 block mb-1">ID{_routeInfo?.id.startsWith("rand") ? ' (当前id会随机变化)' : ''}</label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md">
{_routeInfo.id}
</div>
</div>
)}
{/* Path */}
{_routeInfo.path && (
<div className="border-b border-gray-200 pb-3">
<label className="text-sm font-semibold text-gray-700 block mb-1"></label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md">
{_routeInfo.path}
</div>
</div>
)}
{/* Key */}
{_routeInfo.key && (
<div className="border-b border-gray-200 pb-3">
<label className="text-sm font-semibold text-gray-700 block mb-1">Key</label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md">
{_routeInfo.key}
</div>
</div>
)}
{/* Description */}
{_routeInfo.description && (
<div className="border-b border-gray-200 pb-3">
<label className="text-sm font-semibold text-gray-700 block mb-1"></label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md whitespace-pre-wrap">
{_routeInfo.description}
</div>
</div>
)}
{/* Metadata */}
{metadata && (
<div className="border-b border-gray-200 pb-3">
<label className="text-sm font-semibold text-gray-700 block mb-1">Metadata</label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md">
<pre className="text-xs overflow-auto">
{JSON.stringify(metadata, null, 2)}
</pre>
</div>
</div>
)}
</div>);
}
export const DetailsDialog = () => {
const queryViewStore = useQueryViewStore(
useShallow((state) => ({
showDetailsDialog: state.showDetailsDialog,
setShowDetailsDialog: state.setShowDetailsDialog,
detailsData: state.detailsData,
detailsActiveTab: state.detailsActiveTab,
setDetailsActiveTab: state.setDetailsActiveTab,
allDetailsTabs: state.allDetailsTabs,
setAllDetailsTabs: state.setAllDetailsTabs,
editing: state.editing,
setEditing: state.setEditing,
}))
);
const { currentView, queryProxy } = useStudioStore(useShallow((state) => ({
currentView: state.currentView,
queryProxy: state.queryProxy,
})));
const [isFullscreen, setIsFullscreen] = useState(false)
const [forceViewDialogOpen, setForceViewDialogOpen] = useState(false)
const routeInfo = useMemo(() => {
const action = queryViewStore?.detailsData?.action;
if (!action) return null;
if (!queryProxy) return null;
const router = queryProxy!.router?.findRoute?.(action)
if (!router) return null;
return router as RouteInfo;
}, [queryProxy, queryViewStore.detailsData]);
console.log('metadata', queryViewStore.detailsData?._id, queryViewStore.detailsData);
const onChangeTab = useCallback((key) => {
if (key !== 'response') {
queryViewStore.setDetailsActiveTab(key);
return;
}
let needCheck = true;
const action = queryViewStore?.detailsData?.action;
if (!action) {
needCheck = false
toast.error('没有操作信息,无法查看响应数据');
return
}
const args = routeInfo?.metadata?.args || [];
const keys = Object.keys(args);
if (keys.length === 0) {
needCheck = false;
}
if (!needCheck) {
queryViewStore.setDetailsActiveTab(key);
return;
}
console.log('args', args);
const payload = getPayload(action as any);
payload.data = {}
const schema = fromJSONSchema<true>(args, { mergeObject: true });
console.log('payload', payload);
console.log('schema', schema);
const validateResult = schema.safeParse(payload);
console.log('validateResult', validateResult);
if (!validateResult.success) {
// 参数不合法,无法查看响应数据,需要提示用户强制查看还是取消,如果用户选择强制查看,则直接切换到响应标签页,如果用户选择取消,则保持在当前标签页
setForceViewDialogOpen(true);
} else {
queryViewStore.setDetailsActiveTab(key);
}
}, [routeInfo])
if (!queryViewStore.detailsData) return null;
return (
<>
<Dialog open={forceViewDialogOpen} onOpenChange={setForceViewDialogOpen}>
<DialogContent className="max-w-sm!">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="text-sm text-gray-600 py-2">
</div>
<div className="flex justify-end gap-2 mt-2">
<button
className="px-4 py-2 text-sm rounded-md border border-gray-300 hover:bg-gray-50 transition-colors"
onClick={() => {
setForceViewDialogOpen(false);
queryViewStore.setEditing(true);
queryViewStore.setDetailsActiveTab('details');
}}
>
</button>
<button
className="px-4 py-2 text-sm rounded-md border border-gray-300 hover:bg-gray-50 transition-colors"
onClick={() => setForceViewDialogOpen(false)}
>
</button>
<button
className="px-4 py-2 text-sm rounded-md bg-gray-900 text-white hover:bg-gray-700 transition-colors"
onClick={() => {
setForceViewDialogOpen(false);
queryViewStore.setDetailsActiveTab('response');
}}
>
</button>
</div>
</DialogContent>
</Dialog>
<Dialog open={queryViewStore.showDetailsDialog} onOpenChange={queryViewStore.setShowDetailsDialog}>
<DialogContent className={`flex flex-col max-h-[85vh] ${isFullscreen ? 'w-screen! h-screen! max-w-screen! max-h-screen! ' : 'max-w-4xl! '}`}>
<DialogHeader className="flex-shrink-0">
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex gap-2 border-b border-gray-200 flex-shrink-0">
{queryViewStore.allDetailsTabs.map((tab) => (
<button
key={tab.key}
onClick={() => onChangeTab(tab.key as DetailsTab)}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${queryViewStore.detailsActiveTab === tab.key
? 'text-gray-900 border-b-2 border-gray-900'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="flex-1 overflow-auto scrollbar px-2 min-h-0">
{/* 第一个标签页:详情信息 */}
{queryViewStore.detailsActiveTab === 'details' && (
<DetailsInfoPanel detailsData={queryViewStore.detailsData} />
)}
{/* 第二个标签页:当前视图 */}
{queryViewStore.detailsActiveTab === 'view' && (
<ViewInfoTable currentView={currentView} />
)}
{/* 第三个标签页:路由信息 */}
{queryViewStore.detailsActiveTab === 'router' && (
<RouterInfoPanel routeInfo={routeInfo} />
)}
{/* 第四个标签页:响应 */}
{queryViewStore.detailsActiveTab === 'response' && (
<div className="space-y-4 h-full">
<QueryView viewData={queryViewStore.detailsData} type={'message'} setIsFullscreen={setIsFullscreen} />
</div>
)}
</div>
</DialogContent>
</Dialog >
</>
);
};