Files
NapCatQQ/packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx
手瓜一十雪 1b73d68cbf Refactor extension tabs layout and styles
Remove the overflow wrapper around extension tabs and move max-w/full styling to the Tabs component. Simplify classNames (tabList, cursor) and clean up shrinking/truncation classes on tab items and plugin name to improve responsiveness and layout, while preserving the openInNewWindow click behavior.
2026-02-20 23:05:20 +08:00

223 lines
7.8 KiB
TypeScript

import { Tab, Tabs } from '@heroui/tabs';
import { Button } from '@heroui/button';
import { Spinner } from '@heroui/spinner';
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io';
import { MdExtension } from 'react-icons/md';
import PageLoading from '@/components/page_loading';
import pluginManager from '@/controllers/plugin_manager';
interface ExtensionPage {
pluginId: string;
pluginName: string;
path: string;
title: string;
icon?: string;
description?: string;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
export default function ExtensionPage () {
const [loading, setLoading] = useState(true);
const [extensionPages, setExtensionPages] = useState<ExtensionPage[]>([]);
const [selectedTab, setSelectedTab] = useState<string>('');
const [iframeLoading, setIframeLoading] = useState(false);
const fetchExtensionPages = async () => {
setLoading(true);
try {
const result = await pluginManager.getPluginList();
if (result.pluginManagerNotFound) {
setExtensionPages([]);
} else {
setExtensionPages(result.extensionPages || []);
// 默认选中第一个
if (result.extensionPages?.length > 0 && !selectedTab) {
setSelectedTab(`${result.extensionPages[0].pluginId}:${result.extensionPages[0].path}`);
}
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`获取扩展页面失败: ${msg}`);
} finally {
setLoading(false);
}
};
const refresh = async () => {
await fetchExtensionPages();
};
// 生成 tabs
const tabs = useMemo(() => {
return extensionPages.map(page => ({
key: `${page.pluginId}:${page.path}`,
title: page.title,
pluginId: page.pluginId,
pluginName: page.pluginName,
path: page.path,
icon: page.icon,
description: page.description,
}));
}, [extensionPages]);
// 获取当前选中页面的 iframe URL
// 新路由格式不需要鉴权: /plugin/:pluginId/page/:pagePath
const currentPageUrl = useMemo(() => {
if (!selectedTab) return '';
const [pluginId, ...pathParts] = selectedTab.split(':');
const path = pathParts.join(':').replace(/^\//, '');
return `/plugin/${pluginId}/page/${path}`;
}, [selectedTab]);
useEffect(() => {
fetchExtensionPages();
}, []);
useEffect(() => {
if (currentPageUrl) {
setIframeLoading(true);
}
}, [currentPageUrl]);
const handleIframeLoad = () => {
setIframeLoading(false);
};
// 在新窗口打开页面(新路由不需要鉴权)
const openInNewWindow = (pluginId: string, path: string) => {
const cleanPath = path.replace(/^\//, '');
const url = `/plugin/${pluginId}/page/${cleanPath}`;
window.open(url, '_blank');
};
// 拖拽滚动支持(鼠标 + 触摸)
const scrollRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const startX = useRef(0);
const scrollLeft = useRef(0);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
const el = scrollRef.current;
if (!el) return;
isDragging.current = true;
startX.current = e.clientX;
scrollLeft.current = el.scrollLeft;
el.setPointerCapture(e.pointerId);
el.style.cursor = 'grabbing';
el.style.userSelect = 'none';
}, []);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!isDragging.current || !scrollRef.current) return;
const dx = e.clientX - startX.current;
scrollRef.current.scrollLeft = scrollLeft.current - dx;
}, []);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (!isDragging.current || !scrollRef.current) return;
isDragging.current = false;
scrollRef.current.releasePointerCapture(e.pointerId);
scrollRef.current.style.cursor = 'grab';
scrollRef.current.style.userSelect = '';
}, []);
return (
<>
<title> - NapCat WebUI</title>
<div className='p-2 md:p-4 relative h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)] flex flex-col'>
<PageLoading loading={loading} />
<div className='flex mb-4 items-center gap-4 flex-nowrap min-w-0'>
<div className='flex items-center gap-4 shrink-0'>
<div className='flex items-center gap-2 text-default-600'>
<MdExtension size={24} />
<span className='text-lg font-medium'></span>
</div>
<Button
isIconOnly
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
radius='full'
onPress={refresh}
>
<IoMdRefresh size={24} />
</Button>
</div>
{extensionPages.length > 0 && (
<div
ref={scrollRef}
className='overflow-x-auto min-w-0 flex-1 scrollbar-thin scrollbar-thumb-default-300 scrollbar-track-transparent cursor-grab touch-pan-x'
style={{ WebkitOverflowScrolling: 'touch' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<Tabs
aria-label='Extension Pages'
className='w-max min-w-full'
selectedKey={selectedTab}
onSelectionChange={(key) => setSelectedTab(key as string)}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md flex-nowrap',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
panel: 'hidden',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={
<div className='flex items-center gap-2 whitespace-nowrap'>
{tab.icon && <span>{tab.icon}</span>}
<span
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none'
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
onClick={(e) => {
e.stopPropagation();
openInNewWindow(tab.pluginId, tab.path);
}}
>
{tab.title}
</span>
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
</div>
}
/>
))}
</Tabs>
</div>
)}
</div>
{extensionPages.length === 0 && !loading
? (
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
<MdExtension size={64} className='mb-4 opacity-50' />
<p className='text-lg'></p>
<p className='text-sm mt-2'> WebUI </p>
</div>
)
: (
<div className='flex-1 min-h-0 bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden relative'>
{iframeLoading && (
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
<Spinner size='lg' />
</div>
)}
<iframe
src={currentPageUrl}
className='w-full h-full border-0'
onLoad={handleIframeLoad}
title='extension-page'
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
/>
</div>
)}
</div>
</>
);
}