Refactor UI for network cards and improve theming

Redesigned network display cards and related components for a more modern, consistent look, including improved button styles, card layouts, and responsive design. Added support for background images and dynamic theming across cards, tables, and log views. Enhanced input and select components with unified styling. Improved file table responsiveness and log display usability. Refactored OneBot API debug and navigation UI for better usability and mobile support.
This commit is contained in:
手瓜一十雪 2025-12-22 12:27:56 +08:00
parent 8697061a90
commit 84f0e0f9a0
38 changed files with 919 additions and 565 deletions

View File

@ -18,7 +18,7 @@ import {
} from '../icons'; } from '../icons';
export interface AddButtonProps { export interface AddButtonProps {
onOpen: (key: keyof OneBotConfig['network']) => void onOpen: (key: keyof OneBotConfig['network']) => void;
} }
const AddButton: React.FC<AddButtonProps> = (props) => { const AddButton: React.FC<AddButtonProps> = (props) => {
@ -33,7 +33,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
> >
<DropdownTrigger> <DropdownTrigger>
<Button <Button
color='primary' className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
startContent={<IoAddCircleOutline className='text-2xl' />} startContent={<IoAddCircleOutline className='text-2xl' />}
> >
@ -41,7 +41,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu <DropdownMenu
aria-label='Create Network Config' aria-label='Create Network Config'
color='primary' color='default'
variant='flat' variant='flat'
onAction={(key) => { onAction={(key) => {
onOpen(key as keyof OneBotConfig['network']); onOpen(key as keyof OneBotConfig['network']);

View File

@ -4,11 +4,11 @@ import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io'; import { IoMdRefresh } from 'react-icons/io';
export interface SaveButtonsProps { export interface SaveButtonsProps {
onSubmit: () => void onSubmit: () => void;
reset: () => void reset: () => void;
refresh?: () => void refresh?: () => void;
isSubmitting: boolean isSubmitting: boolean;
className?: string className?: string;
} }
const SaveButtons: React.FC<SaveButtonsProps> = ({ const SaveButtons: React.FC<SaveButtonsProps> = ({
@ -20,13 +20,15 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
}) => ( }) => (
<div <div
className={clsx( className={clsx(
'max-w-full mx-3 w-96 flex flex-col justify-center gap-3', 'w-full flex flex-col justify-center gap-3',
className className
)} )}
> >
<div className='flex items-center justify-center gap-2 mt-5'> <div className='flex items-center justify-center gap-2 mt-5'>
<Button <Button
color='default' radius="full"
variant="flat"
className="font-medium bg-default-100 text-default-600 dark:bg-default-50/50"
onPress={() => { onPress={() => {
reset(); reset();
toast.success('重置成功'); toast.success('重置成功');
@ -36,6 +38,8 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
</Button> </Button>
<Button <Button
color='primary' color='primary'
radius="full"
className="font-medium shadow-md shadow-primary/20"
isLoading={isSubmitting} isLoading={isSubmitting}
onPress={() => onSubmit()} onPress={() => onSubmit()}
> >
@ -44,12 +48,12 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
{refresh && ( {refresh && (
<Button <Button
isIconOnly isIconOnly
color='secondary'
radius='full' radius='full'
variant='flat' variant='flat'
className="text-default-500 bg-default-100 dark:bg-default-50/50"
onPress={() => refresh()} onPress={() => refresh()}
> >
<IoMdRefresh size={24} /> <IoMdRefresh size={20} />
</Button> </Button>
)} )}
</div> </div>

View File

@ -15,8 +15,15 @@ export default function ChatInputModal () {
return ( return (
<> <>
<Button onPress={onOpen} color='primary' radius='full' variant='flat'> <Button
onPress={onOpen}
color='primary'
radius='full'
variant='flat'
size='sm'
className="bg-primary/10 text-primary"
>
</Button> </Button>
<Modal <Modal
size='4xl' size='4xl'

View File

@ -8,19 +8,10 @@ import monaco from '@/monaco';
loader.config({ loader.config({
monaco, monaco,
paths: {
vs: '/webui/monaco-editor/min/vs',
},
});
loader.config({
'vs/nls': {
availableLanguages: { '*': 'zh-cn' },
},
}); });
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> { export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
test?: string test?: string;
} }
export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor; export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor;

View File

@ -1,5 +1,6 @@
import { Button, ButtonGroup } from '@heroui/button'; import { Button } from '@heroui/button';
import { Switch } from '@heroui/switch'; import { Switch } from '@heroui/switch';
import clsx from 'clsx';
import { useState } from 'react'; import { useState } from 'react';
import { CgDebug } from 'react-icons/cg'; import { CgDebug } from 'react-icons/cg';
import { FiEdit3 } from 'react-icons/fi'; import { FiEdit3 } from 'react-icons/fi';
@ -10,27 +11,25 @@ import DisplayCardContainer from './container';
type NetworkType = OneBotConfig['network']; type NetworkType = OneBotConfig['network'];
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{ export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
label: string label: string;
value: NetworkType[T][0][keyof NetworkType[T][0]] value: NetworkType[T][0][keyof NetworkType[T][0]];
render?: ( render?: (
value: NetworkType[T][0][keyof NetworkType[T][0]] value: NetworkType[T][0][keyof NetworkType[T][0]]
) => React.ReactNode ) => React.ReactNode;
}>; }>;
export interface NetworkDisplayCardProps<T extends keyof NetworkType> { export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
data: NetworkType[T][0] data: NetworkType[T][0];
showType?: boolean typeLabel: string;
typeLabel: string fields: NetworkDisplayCardFields<T>;
fields: NetworkDisplayCardFields<T> onEdit: () => void;
onEdit: () => void onEnable: () => Promise<void>;
onEnable: () => Promise<void> onDelete: () => Promise<void>;
onDelete: () => Promise<void> onEnableDebug: () => Promise<void>;
onEnableDebug: () => Promise<void>
} }
const NetworkDisplayCard = <T extends keyof NetworkType>({ const NetworkDisplayCard = <T extends keyof NetworkType> ({
data, data,
showType,
typeLabel, typeLabel,
fields, fields,
onEdit, onEdit,
@ -56,79 +55,146 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
onEnableDebug().finally(() => setEditing(false)); onEnableDebug().finally(() => setEditing(false));
}; };
const isFullWidthField = (label: string) => ['URL', 'Token', 'AccessToken'].includes(label);
return ( return (
<DisplayCardContainer <DisplayCardContainer
className="w-full max-w-[420px]"
action={ action={
<ButtonGroup <div className="flex gap-2 w-full">
fullWidth
isDisabled={editing}
radius='sm'
size='sm'
variant='flat'
>
<Button <Button
color='warning' fullWidth
radius='full'
size='sm'
variant='flat'
className="flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-warning/20 hover:text-warning transition-colors"
startContent={<FiEdit3 size={16} />} startContent={<FiEdit3 size={16} />}
onPress={onEdit} onPress={onEdit}
isDisabled={editing}
> >
</Button> </Button>
<Button <Button
color={debug ? 'secondary' : 'success'} fullWidth
radius='full'
size='sm'
variant='flat' variant='flat'
startContent={ className={clsx(
<CgDebug "flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors",
style={{ debug
width: '16px', ? "hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary"
height: '16px', : "hover:bg-success/20 hover:text-success data-[hover=true]:text-success"
minWidth: '16px', )}
minHeight: '16px', startContent={<CgDebug size={16} />}
}}
/>
}
onPress={handleEnableDebug} onPress={handleEnableDebug}
isDisabled={editing}
> >
{debug ? '关闭调试' : '开启调试'} {debug ? '关闭调试' : '开启调试'}
</Button> </Button>
<Button <Button
className='bg-danger/20 text-danger hover:bg-danger/30 transition-colors' fullWidth
radius='full'
size='sm'
variant='flat' variant='flat'
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-danger/20 hover:text-danger transition-colors'
startContent={<MdDeleteForever size={16} />} startContent={<MdDeleteForever size={16} />}
onPress={handleDelete} onPress={handleDelete}
isDisabled={editing}
> >
</Button> </Button>
</ButtonGroup> </div>
} }
enableSwitch={ enableSwitch={
<Switch <Switch
isDisabled={editing} isDisabled={editing}
isSelected={enable} isSelected={enable}
onChange={handleEnable} onChange={handleEnable}
classNames={{
wrapper: "group-data-[selected=true]:bg-primary-400",
}}
/> />
} }
tag={showType && typeLabel} title={typeLabel}
title={name}
> >
<div className='grid grid-cols-2 gap-1'> <div className='grid grid-cols-2 gap-3'>
{fields.map((field, index) => ( {(() => {
<div const targetFullField = fields.find(f => isFullWidthField(f.label));
key={index}
className={`flex items-center gap-2 ${ if (targetFullField) {
field.label === 'URL' ? 'col-span-2' : '' // 模式1存在全宽字段如URL布局为
}`} // Row 1: 名称 (全宽)
> // Row 2: 全宽字段 (全宽)
<span className='text-default-400'>{field.label}</span> return (
{field.render <>
? ( <div
field.render(field.value) className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
) >
: ( <span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'></span>
<span>{field.value}</span> <div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
)} {name}
</div> </div>
))} </div>
<div
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{targetFullField.label}</span>
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
{targetFullField.render
? targetFullField.render(targetFullField.value)
: (
<span className={clsx(
typeof targetFullField.value === 'string' && (targetFullField.value.startsWith('http') || targetFullField.value.includes('.') || targetFullField.value.includes(':')) ? 'font-mono' : ''
)}>
{String(targetFullField.value)}
</span>
)}
</div>
</div>
</>
);
} else {
// 模式2无全宽字段布局为 4 个小块 (2行 x 2列)
// Row 1: 名称 | Field 0
// Row 2: Field 1 | Field 2
const displayFields = fields.slice(0, 3);
return (
<>
<div
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'></span>
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
{name}
</div>
</div>
{displayFields.map((field, index) => (
<div
key={index}
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{field.label}</span>
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
{field.render
? (
field.render(field.value)
)
: (
<span className={clsx(
typeof field.value === 'string' && (field.value.startsWith('http') || field.value.includes('.') || field.value.includes(':')) ? 'font-mono' : ''
)}>
{String(field.value)}
</span>
)}
</div>
</div>
))}
{/* 如果字段不足3个可以补充空白块占位吗或者是让它空着用户说要高度一致。只要是grid通常高度会被撑开。目前这样应该能保证最多2行。 */}
</>
);
}
})()}
</div> </div>
</DisplayCardContainer> </DisplayCardContainer>
); );

View File

@ -1,7 +1,8 @@
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card'; import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import key from '@/const/key';
import { title } from '../primitives';
export interface ContainerProps { export interface ContainerProps {
title: string; title: string;
@ -9,6 +10,7 @@ export interface ContainerProps {
action: React.ReactNode; action: React.ReactNode;
enableSwitch: React.ReactNode; enableSwitch: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
className?: string; // Add className prop
} }
export interface DisplayCardProps { export interface DisplayCardProps {
@ -25,31 +27,35 @@ const DisplayCardContainer: React.FC<ContainerProps> = ({
tag, tag,
enableSwitch, enableSwitch,
children, children,
className,
}) => { }) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card className='bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm'> <Card className={clsx(
<CardHeader className='pb-0 flex items-center'> 'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm rounded-2xl overflow-hidden transition-all',
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40',
className
)}
>
<CardHeader className='p-4 pb-2 flex items-center justify-between gap-3'>
{tag && ( {tag && (
<div className='text-center text-default-400 mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-sm pointer-events-none bg-warning-100 dark:bg-warning-50 px-2 rounded-b'> <div className='text-center text-default-500 font-medium mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-xs pointer-events-none bg-default-200/50 dark:bg-default-100/50 backdrop-blur-sm px-3 py-0.5 rounded-b-lg shadow-sm z-10'>
{tag} {tag}
</div> </div>
)} )}
<h2 <div className='flex-1 min-w-0 mr-2'>
className={clsx( <div className='inline-flex items-center px-3 py-1 rounded-lg bg-default-100/50 dark:bg-white/10 border border-transparent dark:border-white/5'>
title({ <span className='font-bold text-default-600 dark:text-white/90 text-sm truncate select-text'>
color: 'foreground', {_title}
size: 'xs', </span>
shadow: true, </div>
}), </div>
'truncate' <div className='flex-shrink-0'>{enableSwitch}</div>
)}
>
{_title}
</h2>
<div className='ml-auto'>{enableSwitch}</div>
</CardHeader> </CardHeader>
<CardBody className='text-sm'>{children}</CardBody> <CardBody className='px-4 py-2 text-sm text-default-600'>{children}</CardBody>
<CardFooter>{action}</CardFooter> <CardFooter className='px-4 pb-4 pt-2'>{action}</CardFooter>
</Card> </Card>
); );
}; };

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface HTTPClientDisplayCardProps { interface HTTPClientDisplayCardProps {
data: OneBotConfig['network']['httpClients'][0] data: OneBotConfig['network']['httpClients'][0];
showType?: boolean showType?: boolean;
onEdit: () => void onEdit: () => void;
onEnable: () => Promise<void> onEnable: () => Promise<void>;
onDelete: () => Promise<void> onDelete: () => Promise<void>;
onEnableDebug: () => Promise<void> onEnableDebug: () => Promise<void>;
} }
const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => { const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => {

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface HTTPServerDisplayCardProps { interface HTTPServerDisplayCardProps {
data: OneBotConfig['network']['httpServers'][0] data: OneBotConfig['network']['httpServers'][0];
showType?: boolean showType?: boolean;
onEdit: () => void onEdit: () => void;
onEnable: () => Promise<void> onEnable: () => Promise<void>;
onDelete: () => Promise<void> onDelete: () => Promise<void>;
onEnableDebug: () => Promise<void> onEnableDebug: () => Promise<void>;
} }
const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => { const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => {

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface HTTPSSEServerDisplayCardProps { interface HTTPSSEServerDisplayCardProps {
data: OneBotConfig['network']['httpSseServers'][0] data: OneBotConfig['network']['httpSseServers'][0];
showType?: boolean showType?: boolean;
onEdit: () => void onEdit: () => void;
onEnable: () => Promise<void> onEnable: () => Promise<void>;
onDelete: () => Promise<void> onDelete: () => Promise<void>;
onEnableDebug: () => Promise<void> onEnableDebug: () => Promise<void>;
} }
const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = ( const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = (

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface WebsocketClientDisplayCardProps { interface WebsocketClientDisplayCardProps {
data: OneBotConfig['network']['websocketClients'][0] data: OneBotConfig['network']['websocketClients'][0];
showType?: boolean showType?: boolean;
onEdit: () => void onEdit: () => void;
onEnable: () => Promise<void> onEnable: () => Promise<void>;
onDelete: () => Promise<void> onDelete: () => Promise<void>;
onEnableDebug: () => Promise<void> onEnableDebug: () => Promise<void>;
} }
const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = ( const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface WebsocketServerDisplayCardProps { interface WebsocketServerDisplayCardProps {
data: OneBotConfig['network']['websocketServers'][0] data: OneBotConfig['network']['websocketServers'][0];
showType?: boolean showType?: boolean;
onEdit: () => void onEdit: () => void;
onEnable: () => Promise<void> onEnable: () => Promise<void>;
onDelete: () => Promise<void> onDelete: () => Promise<void>;
onEnableDebug: () => Promise<void> onEnableDebug: () => Promise<void>;
} }
const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = ( const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = (

View File

@ -1,5 +1,7 @@
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import key from '@/const/key';
@ -14,10 +16,16 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
label, label,
size = 'md', size = 'md',
}) => { }) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card <Card
className={clsx( className={clsx(
'bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm transition-all hover:bg-white/70 dark:hover:bg-black/30', 'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all',
hasBackground
? 'bg-white/20 dark:bg-black/10 hover:bg-white/40 dark:hover:bg-black/20'
: 'bg-white/60 dark:bg-black/40 hover:bg-white/70 dark:hover:bg-black/30',
size === 'md' size === 'md'
? 'col-span-8 md:col-span-2' ? 'col-span-8 md:col-span-2'
: 'col-span-2 md:col-span-1' : 'col-span-2 md:col-span-1'

View File

@ -137,13 +137,13 @@ export default function FileTable ({
<TableColumn key='name' allowsSorting> <TableColumn key='name' allowsSorting>
</TableColumn> </TableColumn>
<TableColumn key='type' allowsSorting> <TableColumn key='type' allowsSorting className='hidden md:table-cell'>
</TableColumn> </TableColumn>
<TableColumn key='size' allowsSorting> <TableColumn key='size' allowsSorting className='hidden md:table-cell'>
</TableColumn> </TableColumn>
<TableColumn key='mtime' allowsSorting> <TableColumn key='mtime' allowsSorting className='hidden md:table-cell'>
</TableColumn> </TableColumn>
<TableColumn key='actions'></TableColumn> <TableColumn key='actions'></TableColumn>
@ -194,13 +194,13 @@ export default function FileTable ({
</Button> </Button>
)} )}
</TableCell> </TableCell>
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell> <TableCell className='hidden md:table-cell'>{file.isDirectory ? '目录' : '文件'}</TableCell>
<TableCell> <TableCell className='hidden md:table-cell'>
{isNaN(file.size) || file.isDirectory {isNaN(file.size) || file.isDirectory
? '-' ? '-'
: `${file.size} 字节`} : `${file.size} 字节`}
</TableCell> </TableCell>
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell> <TableCell className='hidden md:table-cell'>{new Date(file.mtime).toLocaleString()}</TableCell>
<TableCell> <TableCell>
<ButtonGroup size='sm' variant='light'> <ButtonGroup size='sm' variant='light'>
<Button <Button

View File

@ -1,10 +1,13 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { Tooltip } from '@heroui/tooltip'; import { Tooltip } from '@heroui/tooltip';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import clsx from 'clsx';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { IoMdQuote } from 'react-icons/io'; import { IoMdQuote } from 'react-icons/io';
import { IoCopy, IoRefresh } from 'react-icons/io5'; import { IoCopy, IoRefresh } from 'react-icons/io5';
import key from '@/const/key';
import { request } from '@/utils/request'; import { request } from '@/utils/request';
import PageLoading from './page_loading'; import PageLoading from './page_loading';
@ -19,7 +22,15 @@ export default function Hitokoto () {
pollingInterval: 10000, pollingInterval: 10000,
throttleWait: 1000, throttleWait: 1000,
}); });
const data = dataOri?.data; const backupData = {
hitokoto: '凡是过往,皆为序章。',
from: '暴风雨',
from_who: '莎士比亚',
};
const data = dataOri?.data || (error ? backupData : undefined);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const onCopy = () => { const onCopy = () => {
try { try {
const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`; const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`;
@ -32,28 +43,39 @@ export default function Hitokoto () {
return ( return (
<div> <div>
<div className='relative flex flex-col items-center justify-center p-6 min-h-[120px]'> <div className='relative flex flex-col items-center justify-center p-6 min-h-[120px]'>
{loading && <PageLoading />} {loading && !data && <PageLoading />}
{error {data && (
? ( <>
<div className='text-danger'>{error.message}</div> <IoMdQuote className={clsx(
) "text-4xl mb-4",
: ( hasBackground ? "text-white/30" : "text-primary/20"
<> )} />
<IoMdQuote className="text-4xl text-primary/20 mb-4" /> <div className={clsx(
<div className="text-xl font-medium text-default-700 dark:text-gray-200 tracking-wide leading-relaxed italic"> "text-xl font-medium tracking-wide leading-relaxed italic",
{data?.hitokoto} hasBackground ? "text-white drop-shadow-sm" : "text-default-700 dark:text-gray-200"
</div> )}>
<div className='mt-4 flex flex-col items-center text-sm'> " {data?.hitokoto} "
<span className='font-bold text-primary-500/80'> {data?.from}</span> </div>
{data?.from_who && <span className="text-default-400 text-xs mt-1">{data?.from_who}</span>} <div className='mt-4 flex flex-col items-center text-sm'>
</div> <span className={clsx(
</> 'font-bold',
)} hasBackground ? 'text-white/90' : 'text-primary-500/80'
)}> {data?.from}</span>
{data?.from_who && <span className={clsx(
"text-xs mt-1",
hasBackground ? "text-white/70" : "text-default-400"
)}>{data?.from_who}</span>}
</div>
</>
)}
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2'>
<Tooltip content='刷新' placement='top'> <Tooltip content='刷新' placement='top'>
<Button <Button
className="text-default-400 hover:text-primary transition-colors" className={clsx(
"transition-colors",
hasBackground ? "text-white/60 hover:text-white" : "text-default-400 hover:text-primary"
)}
onPress={run} onPress={run}
size='sm' size='sm'
isLoading={loading} isLoading={loading}
@ -66,7 +88,10 @@ export default function Hitokoto () {
</Tooltip> </Tooltip>
<Tooltip content='复制' placement='top'> <Tooltip content='复制' placement='top'>
<Button <Button
className="text-default-400 hover:text-success transition-colors" className={clsx(
"transition-colors",
hasBackground ? "text-white/60 hover:text-white" : "text-default-400 hover:text-success"
)}
onPress={onCopy} onPress={onCopy}
size='sm' size='sm'
isIconOnly isIconOnly

View File

@ -7,6 +7,7 @@ export interface FileInputProps {
onDelete?: () => Promise<void> | void; onDelete?: () => Promise<void> | void;
label?: string; label?: string;
accept?: string; accept?: string;
placeholder?: string;
} }
const FileInput: React.FC<FileInputProps> = ({ const FileInput: React.FC<FileInputProps> = ({
@ -14,6 +15,7 @@ const FileInput: React.FC<FileInputProps> = ({
onDelete, onDelete,
label, label,
accept, accept,
placeholder,
}) => { }) => {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -25,8 +27,13 @@ const FileInput: React.FC<FileInputProps> = ({
ref={inputRef} ref={inputRef}
label={label} label={label}
type='file' type='file'
placeholder='选择文件' placeholder={placeholder || '选择文件'}
accept={accept} accept={accept}
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
onChange={async (e) => { onChange={async (e) => {
try { try {
setIsLoading(true); setIsLoading(true);

View File

@ -4,9 +4,9 @@ import { Input } from '@heroui/input';
import { useRef } from 'react'; import { useRef } from 'react';
export interface ImageInputProps { export interface ImageInputProps {
onChange: (base64: string) => void onChange: (base64: string) => void;
value: string value: string;
label?: string label?: string;
} }
const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => { const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
@ -26,6 +26,11 @@ const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
type='file' type='file'
placeholder='选择图片' placeholder='选择图片'
accept='image/*' accept='image/*'
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
onChange={async (e) => { onChange={async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {

View File

@ -2,8 +2,11 @@ import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card'; import { Card, CardBody, CardHeader } from '@heroui/card';
import { Select, SelectItem } from '@heroui/select'; import { Select, SelectItem } from '@heroui/select';
import type { Selection } from '@react-types/shared'; import type { Selection } from '@react-types/shared';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import key from '@/const/key';
import { colorizeLogLevel } from '@/utils/terminal'; import { colorizeLogLevel } from '@/utils/terminal';
import PageLoading from '../page_loading'; import PageLoading from '../page_loading';
@ -12,15 +15,15 @@ import type { XTermRef } from '../xterm';
import LogLevelSelect from './log_level_select'; import LogLevelSelect from './log_level_select';
export interface HistoryLogsProps { export interface HistoryLogsProps {
list: string[] list: string[];
onSelect: (name: string) => void onSelect: (name: string) => void;
selectedLog?: string selectedLog?: string;
refreshList: () => void refreshList: () => void;
refreshLog: () => void refreshLog: () => void;
listLoading?: boolean listLoading?: boolean;
logLoading?: boolean logLoading?: boolean;
listError?: Error listError?: Error;
logContent?: string logContent?: string;
} }
const HistoryLogs: React.FC<HistoryLogsProps> = (props) => { const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
const { const {
@ -39,6 +42,8 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
const [logLevel, setLogLevel] = useState<Selection>( const [logLevel, setLogLevel] = useState<Selection>(
new Set(['info', 'warn', 'error']) new Set(['info', 'warn', 'error'])
); );
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const logToColored = (log: string) => { const logToColored = (log: string) => {
const logs = log const logs = log
@ -83,7 +88,10 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
return ( return (
<> <>
<title> - NapCat WebUI</title> <title> - NapCat WebUI</title>
<Card className='max-w-full h-full bg-opacity-50 backdrop-blur-sm'> <Card className={clsx(
'max-w-full h-full backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm',
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
)}>
<CardHeader className='flex-row justify-start gap-3'> <CardHeader className='flex-row justify-start gap-3'>
<Select <Select
label='选择日志' label='选择日志'
@ -92,7 +100,7 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
errorMessage={listError?.message} errorMessage={listError?.message}
classNames={{ classNames={{
trigger: trigger:
'hover:!bg-content3 bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60', 'bg-default-100/50 backdrop-blur-sm hover:!bg-default-200/50',
}} }}
placeholder='选择日志' placeholder='选择日志'
onChange={(e) => { onChange={(e) => {
@ -118,11 +126,13 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
selectedKeys={logLevel} selectedKeys={logLevel}
onSelectionChange={setLogLevel} onSelectionChange={setLogLevel}
/> />
<Button className='flex-shrink-0' onPress={onDownloadLog}> <div className='flex gap-2 ml-auto'>
<Button className='flex-shrink-0' onPress={onDownloadLog} size='sm' variant='flat' color='primary'>
</Button>
<Button onPress={refreshList}></Button> </Button>
<Button onPress={refreshLog}></Button> <Button onPress={refreshList} size='sm' variant='flat'></Button>
<Button onPress={refreshLog} size='sm' variant='flat'></Button>
</div>
</CardHeader> </CardHeader>
<CardBody className='relative'> <CardBody className='relative'>
<PageLoading loading={logLoading} /> <PageLoading loading={logLoading} />

View File

@ -6,17 +6,17 @@ import type { Selection } from '@react-types/shared';
import { LogLevel } from '@/const/enum'; import { LogLevel } from '@/const/enum';
export interface LogLevelSelectProps { export interface LogLevelSelectProps {
selectedKeys: Selection selectedKeys: Selection;
onSelectionChange: (keys: SharedSelection) => void onSelectionChange: (keys: SharedSelection) => void;
} }
const logLevelColor: { const logLevelColor: {
[key in LogLevel]: [key in LogLevel]:
| 'default' | 'default'
| 'primary' | 'primary'
| 'secondary' | 'secondary'
| 'success' | 'success'
| 'warning' | 'warning'
| 'primary' | 'primary'
} = { } = {
[LogLevel.DEBUG]: 'default', [LogLevel.DEBUG]: 'default',
[LogLevel.INFO]: 'primary', [LogLevel.INFO]: 'primary',
@ -40,7 +40,7 @@ const LogLevelSelect = (props: LogLevelSelectProps) => {
aria-label='Log Level' aria-label='Log Level'
classNames={{ classNames={{
label: 'mb-2', label: 'mb-2',
trigger: 'bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60', trigger: 'bg-default-100/50 backdrop-blur-sm hover:!bg-default-200/50',
popoverContent: 'bg-opacity-50 backdrop-blur-sm', popoverContent: 'bg-opacity-50 backdrop-blur-sm',
}} }}
size='sm' size='sm'

View File

@ -1,9 +1,12 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import type { Selection } from '@react-types/shared'; import type { Selection } from '@react-types/shared';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { IoDownloadOutline } from 'react-icons/io5'; import { IoDownloadOutline } from 'react-icons/io5';
import key from '@/const/key';
import { colorizeLogLevelWithTag } from '@/utils/terminal'; import { colorizeLogLevelWithTag } from '@/utils/terminal';
import WebUIManager, { Log } from '@/controllers/webui_manager'; import WebUIManager, { Log } from '@/controllers/webui_manager';
@ -18,6 +21,8 @@ const RealTimeLogs = () => {
new Set(['info', 'warn', 'error']) new Set(['info', 'warn', 'error'])
); );
const [dataArr, setDataArr] = useState<Log[]>([]); const [dataArr, setDataArr] = useState<Log[]>([]);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const onDownloadLog = () => { const onDownloadLog = () => {
const logContent = dataArr const logContent = dataArr
@ -91,7 +96,10 @@ const RealTimeLogs = () => {
return ( return (
<> <>
<title> - NapCat WebUI</title> <title> - NapCat WebUI</title>
<div className='flex items-center gap-2'> <div className={clsx(
'flex items-center gap-2 p-2 rounded-2xl border backdrop-blur-sm transition-all shadow-sm mb-4',
hasBackground ? 'bg-white/20 dark:bg-black/10 border-white/40 dark:border-white/10' : 'bg-white/60 dark:bg-black/40 border-white/40 dark:border-white/10'
)}>
<LogLevelSelect <LogLevelSelect
selectedKeys={logLevel} selectedKeys={logLevel}
onSelectionChange={setLogLevel} onSelectionChange={setLogLevel}
@ -100,6 +108,8 @@ const RealTimeLogs = () => {
className='flex-shrink-0' className='flex-shrink-0'
onPress={onDownloadLog} onPress={onDownloadLog}
startContent={<IoDownloadOutline className='text-lg' />} startContent={<IoDownloadOutline className='text-lg' />}
color='primary'
variant='flat'
> >
</Button> </Button>

View File

@ -109,6 +109,11 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
isDisabled={field.isDisabled} isDisabled={field.isDisabled}
label={field.label} label={field.label}
placeholder={field.placeholder} placeholder={field.placeholder}
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
/> />
); );
case 'select': case 'select':
@ -121,6 +126,10 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
placeholder={field.placeholder} placeholder={field.placeholder}
selectedKeys={[controllerField.value as string]} selectedKeys={[controllerField.value as string]}
value={controllerField.value.toString()} value={controllerField.value.toString()}
classNames={{
trigger: 'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
value: 'text-default-700',
}}
> >
{field.options?.map((option) => ( {field.options?.map((option) => (
<SelectItem key={option.key} value={option.value}> <SelectItem key={option.key} value={option.value}>

View File

@ -8,7 +8,7 @@ import { useLocalStorage } from '@uidotdev/usehooks';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { IoLink, IoSend, IoSettingsSharp } from 'react-icons/io5'; import { IoLink, IoSend, IoSettingsSharp } from 'react-icons/io5';
import { PiCatDuotone } from 'react-icons/pi'; import { TbApi, TbCode } from 'react-icons/tb';
import key from '@/const/key'; import key from '@/const/key';
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api'; import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api';
@ -89,42 +89,58 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
}, [path]); }, [path]);
return ( return (
<section className='p-6 pt-14 rounded-2xl bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm mx-4 mt-4 flex flex-col gap-4 h-[calc(100vh-6rem)] overflow-hidden'> <section className='h-full flex flex-col gap-3 md:gap-4 p-3 md:p-6 bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm rounded-2xl overflow-hidden'>
<div className='flex flex-col gap-4'> <div className='flex flex-col md:flex-row md:items-center justify-between border-b border-white/10 pb-3 md:pb-4 gap-3'>
<div className='flex items-center justify-between'> <div className='flex items-center gap-2 md:gap-4 overflow-hidden'>
<h1 className='text-2xl font-bold flex items-center gap-2 text-primary-500'> <h1 className='text-lg md:text-xl font-bold flex items-center gap-2 text-primary-500 flex-shrink-0'>
<PiCatDuotone /> <TbApi size={24} />
{data.description} <span className='truncate'>{data.description}</span>
</h1> </h1>
<Snippet <Snippet
className='bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20' className='bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20 hidden md:flex'
symbol={<IoLink size={18} className='inline-block mr-1' />} symbol={<IoLink size={16} className='inline-block mr-1' />}
tooltipProps={{ content: '点击复制地址' }} tooltipProps={{ content: '点击复制地址' }}
size="sm"
> >
{path} {path}
</Snippet> </Snippet>
<Button
size='sm'
variant='ghost'
color='primary'
className='border-primary/20 hover:bg-primary/10'
onPress={() => setIsStructOpen(true)}
>
</Button>
</div> </div>
<div className='flex gap-2 items-center justify-end'> <div className='flex gap-2 items-center flex-shrink-0'>
<Button
size='sm'
variant='flat'
color='default'
radius='full'
isIconOnly
className='bg-white/40 dark:bg-white/10 md:hidden font-medium text-default-700'
onPress={() => setIsStructOpen(true)}
>
<TbCode className="text-lg" />
</Button>
<Button
size='sm'
variant='flat'
color='default'
radius='full'
className='bg-white/40 dark:bg-white/10 hidden md:flex font-medium text-default-700'
startContent={<TbCode className="text-lg" />}
onPress={() => setIsStructOpen(true)}
>
</Button>
<Popover placement='bottom-end'> <Popover placement='bottom-end'>
<PopoverTrigger> <PopoverTrigger>
<Button <Button
variant='ghost' size='sm'
variant='flat'
color='default' color='default'
isIconOnly
radius='full' radius='full'
className='border-white/20 hover:bg-white/20 text-default-600' className='bg-white/40 dark:bg-white/10 text-default-700 font-medium'
startContent={<IoSettingsSharp className="animate-spin-slow-on-hover text-lg" />}
> >
<IoSettingsSharp className="animate-spin-slow-on-hover" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className='w-[340px] p-4 bg-white/80 dark:bg-black/80 backdrop-blur-xl border border-white/20 shadow-xl rounded-2xl'> <PopoverContent className='w-[340px] p-4 bg-white/80 dark:bg-black/80 backdrop-blur-xl border border-white/20 shadow-xl rounded-2xl'>
@ -159,18 +175,17 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
<Button <Button
onPress={sendRequest} onPress={sendRequest}
color='primary' color='primary'
size='lg'
radius='full' radius='full'
className='font-bold px-8 shadow-lg shadow-primary/30' className='font-bold px-6 shadow-lg shadow-primary/30'
isLoading={isFetching} isLoading={isFetching}
startContent={!isFetching && <IoSend />} startContent={!isFetching && <IoSend />}
> >
</Button> </Button>
</div> </div>
</div> </div>
<div className='flex-1 grid grid-cols-1 xl:grid-cols-2 gap-4 min-h-0 overflow-hidden'> <div className='flex-1 grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 min-h-0 overflow-auto'>
{/* Request Column */} {/* Request Column */}
<Card className='bg-white/40 dark:bg-white/5 backdrop-blur-md border border-white/20 shadow-sm h-full flex flex-col'> <Card className='bg-white/40 dark:bg-white/5 backdrop-blur-md border border-white/20 shadow-sm h-full flex flex-col'>
<CardHeader className='font-bold text-lg gap-2 pb-2 px-4 pt-4 border-b border-white/10 flex-shrink-0 justify-between items-center'> <CardHeader className='font-bold text-lg gap-2 pb-2 px-4 pt-4 border-b border-white/10 flex-shrink-0 justify-between items-center'>
@ -183,7 +198,9 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
<Button <Button
size='sm' size='sm'
color='primary' color='primary'
variant='light' variant='flat'
radius='full'
className="bg-primary/10 text-primary"
onPress={() => setRequestBody(generateDefaultJson(data.request))} onPress={() => setRequestBody(generateDefaultJson(data.request))}
> >
@ -207,7 +224,6 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
</CardBody> </CardBody>
</Card> </Card>
{/* Response Column */}
<Card className='bg-white/40 dark:bg-white/5 backdrop-blur-md border border-white/20 shadow-sm h-full flex flex-col'> <Card className='bg-white/40 dark:bg-white/5 backdrop-blur-md border border-white/20 shadow-sm h-full flex flex-col'>
<PageLoading loading={isFetching} /> <PageLoading loading={isFetching} />
<CardHeader className='font-bold text-lg gap-2 pb-2 px-4 pt-4 border-b border-white/10 flex-shrink-0 justify-between items-center'> <CardHeader className='font-bold text-lg gap-2 pb-2 px-4 pt-4 border-b border-white/10 flex-shrink-0 justify-between items-center'>
@ -217,8 +233,10 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
</div> </div>
<Button <Button
size='sm' size='sm'
color='success' color='primary'
variant='light' variant='flat'
radius='full'
className="bg-primary/10 text-primary"
onPress={() => { onPress={() => {
navigator.clipboard.writeText(responseContent); navigator.clipboard.writeText(responseContent);
toast.success('已复制'); toast.success('已复制');

View File

@ -1,8 +1,11 @@
import { Button } from '@heroui/button';
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { Input } from '@heroui/input'; import { Input } from '@heroui/input';
import clsx from 'clsx'; import clsx from 'clsx';
import { ScrollShadow } from "@heroui/scroll-shadow";
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { useState } from 'react'; import { useState } from 'react';
import { TbApi, TbLayoutSidebarLeftCollapseFilled, TbSearch } from 'react-icons/tb';
import type { OneBotHttpApi, OneBotHttpApiPath } from '@/const/ob_api'; import type { OneBotHttpApi, OneBotHttpApiPath } from '@/const/ob_api';
@ -11,75 +14,116 @@ export interface OneBotApiNavListProps {
selectedApi: OneBotHttpApiPath; selectedApi: OneBotHttpApiPath;
onSelect: (apiName: OneBotHttpApiPath) => void; onSelect: (apiName: OneBotHttpApiPath) => void;
openSideBar: boolean; openSideBar: boolean;
onToggle?: (isOpen: boolean) => void;
} }
const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => { const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
const { data, selectedApi, onSelect, openSideBar } = props; const { data, selectedApi, onSelect, openSideBar, onToggle } = props;
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
return ( return (
<motion.div <>
className={clsx( {/* Mobile backdrop overlay */}
'h-[calc(100vh-3.5rem)] left-0 !overflow-hidden md:w-auto z-20 top-[3.3rem] md:top-[3rem] absolute md:sticky md:float-start rounded-r-xl border-r border-white/20', {openSideBar && (
openSideBar && 'bg-white/40 dark:bg-black/40 backdrop-blur-2xl border-white/20 shadow-xl' <div
)} className="fixed inset-0 bg-black/20 backdrop-blur-[1px] z-10 md:hidden"
initial={{ width: 0 }} onClick={() => onToggle?.(false)}
transition={{
type: openSideBar ? 'spring' : 'tween',
stiffness: 150,
damping: 15,
}}
animate={{ width: openSideBar ? '16rem' : '0rem' }}
style={{ overflowY: openSideBar ? 'auto' : 'hidden' }}
>
<div className='w-64 h-full overflow-y-auto px-2 pt-2 pb-10 md:pb-0'>
<Input
className='sticky top-0 z-10 text-default-600'
classNames={{
inputWrapper:
'bg-white/40 dark:bg-white/10 backdrop-blur-md border border-white/20 mb-2 hover:bg-white/60 dark:hover:bg-white/20 transition-all',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
radius='full'
placeholder='搜索 API'
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
isClearable
onClear={() => setSearchValue('')}
/> />
{Object.entries(data).map(([apiName, api]) => ( )}
<Card <motion.div
key={apiName} className={clsx(
shadow='none' 'h-full z-20 flex-shrink-0 border border-white/10 dark:border-white/5 bg-white/60 dark:bg-black/60 backdrop-blur-2xl shadow-xl overflow-hidden rounded-2xl',
className={clsx( 'fixed md:relative left-0 top-0 md:top-auto md:left-auto'
'w-full border border-transparent rounded-xl mb-1 bg-transparent hover:bg-white/40 dark:hover:bg-white/10 transition-all text-default-600 dark:text-gray-300', )}
{ initial={false}
hidden: !( animate={{ width: openSideBar ? 280 : 0, opacity: openSideBar ? 1 : 0 }}
apiName.includes(searchValue) || transition={{ type: 'spring', stiffness: 300, damping: 30 }}
api.description?.includes(searchValue) >
), <div className='w-[280px] h-full flex flex-col'>
}, <div className='p-3 md:p-4 flex justify-between items-center border-b border-white/10'>
{ <span className='font-bold text-lg px-2 flex items-center gap-2'>
'!bg-white/60 dark:!bg-white/10 !border-white/20 shadow-sm !text-primary font-medium': <TbApi className="text-primary" /> API
apiName === selectedApi, </span>
} {onToggle && (
)} <Button
isPressable isIconOnly
onPress={() => onSelect(apiName as OneBotHttpApiPath)} size='sm'
> variant='light'
<CardBody> onPress={() => onToggle(false)}
<h2 className='font-bold'>{api.description}</h2> className="text-default-500 hover:text-default-800"
<div
className={clsx('text-sm text-default-400', {
'!text-primary': apiName === selectedApi,
})}
> >
{apiName} <TbLayoutSidebarLeftCollapseFilled size={20} />
</div> </Button>
</CardBody> )}
</Card> </div>
))}
</div> <div className='p-3 pb-0'>
</motion.div> <Input
classNames={{
inputWrapper:
'bg-white/40 dark:bg-white/10 backdrop-blur-md border border-white/20 hover:bg-white/60 dark:hover:bg-white/20 transition-all shadow-sm',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
isClearable
radius='lg'
placeholder='搜索 API...'
startContent={<TbSearch className="text-default-400" />}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onClear={() => setSearchValue('')}
size="sm"
/>
</div>
<ScrollShadow className='flex-1 p-3 flex flex-col gap-2 overflow-y-auto scroll-smooth' size={40}>
{Object.entries(data).map(([apiName, api]) => {
const isMatch = apiName.toLowerCase().includes(searchValue.toLowerCase()) ||
api.description?.toLowerCase().includes(searchValue.toLowerCase());
if (!isMatch) return null;
const isSelected = apiName === selectedApi;
return (
<div
key={apiName}
role="button"
tabIndex={0}
onClick={() => onSelect(apiName as OneBotHttpApiPath)}
onKeyDown={(e) => e.key === 'Enter' && onSelect(apiName as OneBotHttpApiPath)}
className="cursor-pointer focus:outline-none"
>
<Card
shadow='none'
className={clsx(
'w-full border border-transparent transition-all duration-200 group min-h-[60px]',
isSelected
? 'bg-primary/10 border-primary/20 shadow-sm'
: 'bg-transparent hover:bg-white/40 dark:hover:bg-white/5'
)}
>
<CardBody className='p-3 text-left'>
<div className='flex flex-col gap-1'>
<span className={clsx(
'font-medium text-sm transition-colors',
isSelected ? 'text-primary-600 dark:text-primary-400' : 'text-default-700 dark:text-default-200 group-hover:text-default-900'
)}>
{api.description}
</span>
<span className={clsx(
'text-xs font-mono truncate transition-colors',
isSelected ? 'text-primary-400 dark:text-primary-300' : 'text-default-400 group-hover:text-default-500'
)}>
{apiName}
</span>
</div>
</CardBody>
</Card>
</div>
);
})}
</ScrollShadow>
</div>
</motion.div>
</>
); );
}; };

View File

@ -1,8 +1,10 @@
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { Image } from '@heroui/image'; import { Image } from '@heroui/image';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { BsTencentQq } from 'react-icons/bs'; import { BsTencentQq } from 'react-icons/bs';
import key from '@/const/key';
import { SelfInfo } from '@/types/user'; import { SelfInfo } from '@/types/user';
import PageLoading from './page_loading'; import PageLoading from './page_loading';
@ -14,9 +16,14 @@ export interface QQInfoCardProps {
} }
const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => { const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card <Card
className='relative bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 overflow-hidden flex-shrink-0 shadow-sm' className={clsx(
'relative backdrop-blur-sm border border-white/40 dark:border-white/10 overflow-hidden flex-shrink-0 shadow-sm',
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
)}
shadow='none' shadow='none'
radius='lg' radius='lg'
> >
@ -32,9 +39,11 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
) )
: ( : (
<CardBody className='flex-row items-center gap-4 overflow-hidden relative p-4'> <CardBody className='flex-row items-center gap-4 overflow-hidden relative p-4'>
<div className='absolute right-[-10px] bottom-[-10px] text-7xl text-default-400/10 rotate-12 pointer-events-none'> {!hasBackground && (
<BsTencentQq /> <div className='absolute right-[-10px] bottom-[-10px] text-7xl text-default-400/10 rotate-12 pointer-events-none'>
</div> <BsTencentQq />
</div>
)}
<div className='relative flex-shrink-0 z-10'> <div className='relative flex-shrink-0 z-10'>
<Image <Image
src={ src={
@ -51,10 +60,16 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
/> />
</div> </div>
<div className='flex-col justify-center z-10'> <div className='flex-col justify-center z-10'>
<div className='text-xl font-bold text-default-800 dark:text-gray-100 truncate mb-0.5'> <div className={clsx(
'text-xl font-bold truncate mb-0.5',
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-800 dark:text-gray-100'
)}>
{data?.nick || '未知用户'} {data?.nick || '未知用户'}
</div> </div>
<div className='text-default-500 font-mono text-xs tracking-wider opacity-80'> <div className={clsx(
'font-mono text-xs tracking-wider',
hasBackground ? 'text-white/80' : 'text-default-500 opacity-80'
)}>
{data?.uin || 'Unknown'} {data?.uin || 'Unknown'}
</div> </div>
</div> </div>

View File

@ -1,15 +1,17 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import React from 'react'; import React from 'react';
import { IoMdLogOut } from 'react-icons/io'; import { IoMdLogOut } from 'react-icons/io';
import { MdDarkMode, MdLightMode } from 'react-icons/md'; import { MdDarkMode, MdLightMode } from 'react-icons/md';
import key from '@/const/key';
import useAuth from '@/hooks/auth'; import useAuth from '@/hooks/auth';
import useDialog from '@/hooks/use-dialog'; import useDialog from '@/hooks/use-dialog';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
import type { MenuItem } from '@/config/site'; import type { MenuItem } from '@/config/site';
import Menus from './menus'; import Menus from './menus';
interface SideBarProps { interface SideBarProps {
@ -22,6 +24,7 @@ const SideBar: React.FC<SideBarProps> = (props) => {
const { open, items, onClose } = props; const { open, items, onClose } = props;
const { toggleTheme, isDark } = useTheme(); const { toggleTheme, isDark } = useTheme();
const { revokeAuth } = useAuth(); const { revokeAuth } = useAuth();
const [b64img] = useLocalStorage(key.backgroundImage, '');
const dialog = useDialog(); const dialog = useDialog();
const onRevokeAuth = () => { const onRevokeAuth = () => {
dialog.confirm({ dialog.confirm({
@ -47,7 +50,9 @@ const SideBar: React.FC<SideBarProps> = (props) => {
</AnimatePresence> </AnimatePresence>
<motion.div <motion.div
className={clsx( className={clsx(
'overflow-hidden fixed top-0 left-0 h-full z-50 bg-background md:bg-transparent md:static shadow-md md:shadow-none rounded-r-md md:rounded-none' 'overflow-hidden fixed top-0 left-0 h-full z-50 md:static shadow-md md:shadow-none rounded-r-md md:rounded-none',
b64img ? 'bg-black/20 backdrop-blur-md border-r border-white/10' : 'bg-background',
'md:bg-transparent md:border-r-0 md:backdrop-blur-none'
)} )}
initial={{ width: 0 }} initial={{ width: 0 }}
animate={{ width: open ? '16rem' : 0 }} animate={{ width: open ? '16rem' : 0 }}

View File

@ -3,14 +3,14 @@ import clsx from 'clsx';
import React, { forwardRef } from 'react'; import React, { forwardRef } from 'react';
export interface SwitchCardProps { export interface SwitchCardProps {
label?: string label?: string;
description?: string description?: string;
value?: boolean value?: boolean;
onValueChange?: (value: boolean) => void onValueChange?: (value: boolean) => void;
name?: string name?: string;
onBlur?: React.FocusEventHandler onBlur?: React.FocusEventHandler;
disabled?: boolean disabled?: boolean;
onChange?: React.ChangeEventHandler<HTMLInputElement> onChange?: React.ChangeEventHandler<HTMLInputElement>;
} }
const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>( const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>(
@ -22,9 +22,9 @@ const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>(
<Switch <Switch
classNames={{ classNames={{
base: clsx( base: clsx(
'inline-flex flex-row-reverse w-full max-w-md bg-content1 hover:bg-content2 items-center', 'inline-flex flex-row-reverse w-full max-w-full bg-default-100/50 dark:bg-white/5 hover:bg-default-200/50 dark:hover:bg-white/10 items-center',
'justify-between cursor-pointer rounded-lg gap-2 p-3 border-2 border-transparent', 'justify-between cursor-pointer rounded-xl gap-2 p-4 border border-transparent transition-all duration-200',
'data-[selected=true]:border-primary bg-opacity-50 backdrop-blur-sm' 'data-[selected=true]:border-primary/50 data-[selected=true]:bg-primary/5 backdrop-blur-md'
), ),
}} }}
{...props} {...props}

View File

@ -3,15 +3,16 @@ import { Button } from '@heroui/button';
import { Chip } from '@heroui/chip'; import { Chip } from '@heroui/chip';
import { Spinner } from '@heroui/spinner'; import { Spinner } from '@heroui/spinner';
import { Tooltip } from '@heroui/tooltip'; import { Tooltip } from '@heroui/tooltip';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import clsx from 'clsx';
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6'; import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6';
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'; import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io';
import { RiMacFill } from 'react-icons/ri'; import { RiMacFill } from 'react-icons/ri';
import { useState } from 'react'; import { useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import key from '@/const/key';
import WebUIManager from '@/controllers/webui_manager'; import WebUIManager from '@/controllers/webui_manager';
import useDialog from '@/hooks/use-dialog'; import useDialog from '@/hooks/use-dialog';
@ -21,6 +22,7 @@ export interface SystemInfoItemProps {
icon?: React.ReactNode; icon?: React.ReactNode;
value?: React.ReactNode; value?: React.ReactNode;
endContent?: React.ReactNode; endContent?: React.ReactNode;
hasBackground?: boolean;
} }
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
@ -28,12 +30,21 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
value = '--', value = '--',
icon, icon,
endContent, endContent,
hasBackground = false,
}) => { }) => {
return ( return (
<div className='flex text-sm gap-2 p-3 items-center rounded-lg text-default-600 dark:text-gray-300 bg-white/50 dark:bg-white/5 border border-white/20 transition-colors hover:bg-white/70 dark:hover:bg-white/10'> <div className={clsx(
'flex text-sm gap-2 p-3 items-center rounded-lg border border-white/20 transition-colors',
hasBackground
? 'bg-white/10 hover:bg-white/20 text-white/90'
: 'bg-white/50 dark:bg-white/5 hover:bg-white/70 dark:hover:bg-white/10 text-default-600 dark:text-gray-300'
)}>
<div className="text-lg opacity-80">{icon}</div> <div className="text-lg opacity-80">{icon}</div>
<div className='w-24 font-medium'>{title}</div> <div className='w-24 font-medium'>{title}</div>
<div className='text-default-500 text-xs font-mono'>{value}</div> <div className={clsx(
'text-xs font-mono',
hasBackground ? 'text-white/70' : 'text-default-500'
)}>{value}</div>
<div className='ml-auto'>{endContent}</div> <div className='ml-auto'>{endContent}</div>
</div> </div>
); );
@ -261,7 +272,11 @@ const NewVersionTip = (props: NewVersionTipProps) => {
); );
}; };
const NapCatVersion = () => { interface NapCatVersionProps {
hasBackground?: boolean;
}
const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false }) => {
const { const {
data: packageData, data: packageData,
loading: packageLoading, loading: packageLoading,
@ -274,6 +289,7 @@ const NapCatVersion = () => {
<SystemInfoItem <SystemInfoItem
title='NapCat 版本' title='NapCat 版本'
icon={<IoLogoOctocat className='text-xl' />} icon={<IoLogoOctocat className='text-xl' />}
hasBackground={hasBackground}
value={ value={
packageError packageError
? ( ? (
@ -302,18 +318,28 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
loading: qqVersionLoading, loading: qqVersionLoading,
error: qqVersionError, error: qqVersionError,
} = useRequest(WebUIManager.getQQVersion); } = useRequest(WebUIManager.getQQVersion);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card className='bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm overflow-visible flex-1'> <Card className={clsx(
<CardHeader className='pb-0 items-center gap-2 text-default-700 dark:text-white font-bold px-4 pt-4'> 'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm overflow-visible flex-1',
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
)}>
<CardHeader className={clsx(
'pb-0 items-center gap-2 font-bold px-4 pt-4',
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-white'
)}>
<FaCircleInfo className='text-lg opacity-80' /> <FaCircleInfo className='text-lg opacity-80' />
<span></span> <span></span>
</CardHeader> </CardHeader>
<CardBody className='flex-1'> <CardBody className='flex-1'>
<div className='flex flex-col gap-2 justify-between h-full'> <div className='flex flex-col gap-2 justify-between h-full'>
<NapCatVersion /> <NapCatVersion hasBackground={hasBackground} />
<SystemInfoItem <SystemInfoItem
title='QQ 版本' title='QQ 版本'
icon={<FaQq className='text-lg' />} icon={<FaQq className='text-lg' />}
hasBackground={hasBackground}
value={ value={
qqVersionError qqVersionError
? ( ? (
@ -332,11 +358,13 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
title='WebUI 版本' title='WebUI 版本'
icon={<IoLogoChrome className='text-xl' />} icon={<IoLogoChrome className='text-xl' />}
value='Next' value='Next'
hasBackground={hasBackground}
/> />
<SystemInfoItem <SystemInfoItem
title='系统版本' title='系统版本'
icon={<RiMacFill className='text-xl' />} icon={<RiMacFill className='text-xl' />}
value={archInfo} value={archInfo}
hasBackground={hasBackground}
/> />
</div> </div>
</CardBody> </CardBody>

View File

@ -1,10 +1,12 @@
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { Image } from '@heroui/image'; import { Image } from '@heroui/image';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { BiSolidMemoryCard } from 'react-icons/bi'; import { BiSolidMemoryCard } from 'react-icons/bi';
import { GiCpu } from 'react-icons/gi'; import { GiCpu } from 'react-icons/gi';
import bkg from '@/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png'; import bkg from '@/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png';
import key from '@/const/key';
import UsagePie from './usage_pie'; import UsagePie from './usage_pie';
@ -13,6 +15,7 @@ export interface SystemStatusItemProps {
value?: string | number; value?: string | number;
size?: 'md' | 'lg'; size?: 'md' | 'lg';
unit?: string; unit?: string;
hasBackground?: boolean;
} }
const SystemStatusItem: React.FC<SystemStatusItemProps> = ({ const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
@ -20,16 +23,26 @@ const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
value = '-', value = '-',
size = 'md', size = 'md',
unit, unit,
hasBackground = false,
}) => { }) => {
return ( return (
<div <div
className={clsx( className={clsx(
'p-2 rounded-lg text-sm bg-white/50 dark:bg-white/5 border border-white/20 transition-colors hover:bg-white/70 dark:hover:bg-white/10', 'p-2 rounded-lg text-sm border border-white/20 transition-colors',
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between' size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between',
hasBackground
? 'bg-white/10 hover:bg-white/20'
: 'bg-white/50 dark:bg-white/5 hover:bg-white/70 dark:hover:bg-white/10'
)} )}
> >
<div className='w-24 text-default-600 font-medium'>{title}</div> <div className={clsx(
<div className='text-default-500 font-mono text-xs'> 'w-24 font-medium',
hasBackground ? 'text-white/90' : 'text-default-600'
)}>{title}</div>
<div className={clsx(
'font-mono text-xs',
hasBackground ? 'text-white/70' : 'text-default-500'
)}>
{value} {value}
{unit && <span className="ml-0.5 opacity-70">{unit}</span>} {unit && <span className="ml-0.5 opacity-70">{unit}</span>}
</div> </div>
@ -53,9 +66,14 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
memoryUsage.system = (systemUsage / system) * 100; memoryUsage.system = (systemUsage / system) * 100;
memoryUsage.qq = (qqUsage / system) * 100; memoryUsage.qq = (qqUsage / system) * 100;
} }
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card className='bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm col-span-1 lg:col-span-2 relative overflow-hidden'> <Card className={clsx(
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm col-span-1 lg:col-span-2 relative overflow-hidden',
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
)}>
<div className='absolute h-full right-0 top-0'> <div className='absolute h-full right-0 top-0'>
<Image <Image
src={bkg} src={bkg}
@ -69,26 +87,34 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
</div> </div>
<CardBody className='overflow-visible md:flex-row gap-4 items-center justify-stretch z-10'> <CardBody className='overflow-visible md:flex-row gap-4 items-center justify-stretch z-10'>
<div className='flex-1 w-full md:max-w-96'> <div className='flex-1 w-full md:max-w-96'>
<h2 className='text-lg font-semibold flex items-center gap-2 text-default-700 dark:text-gray-200 mb-2'> <h2 className={clsx(
'text-lg font-semibold flex items-center gap-2 mb-2',
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
)}>
<GiCpu className='text-xl opacity-80' /> <GiCpu className='text-xl opacity-80' />
<span>CPU</span> <span>CPU</span>
</h2> </h2>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
<SystemStatusItem title='型号' value={data?.cpu.model} size='lg' /> <SystemStatusItem title='型号' value={data?.cpu.model} size='lg' hasBackground={hasBackground} />
<SystemStatusItem title='内核数' value={data?.cpu.core} /> <SystemStatusItem title='内核数' value={data?.cpu.core} hasBackground={hasBackground} />
<SystemStatusItem title='主频' value={data?.cpu.speed} unit='GHz' /> <SystemStatusItem title='主频' value={data?.cpu.speed} unit='GHz' hasBackground={hasBackground} />
<SystemStatusItem <SystemStatusItem
title='使用率' title='使用率'
value={data?.cpu.usage.system} value={data?.cpu.usage.system}
unit='%' unit='%'
hasBackground={hasBackground}
/> />
<SystemStatusItem <SystemStatusItem
title='QQ主线程' title='QQ主线程'
value={data?.cpu.usage.qq} value={data?.cpu.usage.qq}
unit='%' unit='%'
hasBackground={hasBackground}
/> />
</div> </div>
<h2 className='text-lg font-semibold flex items-center gap-2 text-default-700 dark:text-gray-200 mb-2 mt-4'> <h2 className={clsx(
'text-lg font-semibold flex items-center gap-2 mb-2 mt-4',
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
)}>
<BiSolidMemoryCard className='text-xl opacity-80' /> <BiSolidMemoryCard className='text-xl opacity-80' />
<span></span> <span></span>
</h2> </h2>
@ -98,16 +124,19 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
value={data?.memory.total} value={data?.memory.total}
size='lg' size='lg'
unit='MB' unit='MB'
hasBackground={hasBackground}
/> />
<SystemStatusItem <SystemStatusItem
title='使用量' title='使用量'
value={data?.memory.usage.system} value={data?.memory.usage.system}
unit='MB' unit='MB'
hasBackground={hasBackground}
/> />
<SystemStatusItem <SystemStatusItem
title='QQ主线程' title='QQ主线程'
value={data?.memory.usage.qq} value={data?.memory.usage.qq}
unit='MB' unit='MB'
hasBackground={hasBackground}
/> />
</div> </div>
</div> </div>

View File

@ -12,21 +12,21 @@ import { useTheme } from '@/hooks/use-theme';
export type XTermRef = { export type XTermRef = {
write: ( write: (
...args: Parameters<Terminal['write']> ...args: Parameters<Terminal['write']>
) => ReturnType<Terminal['write']> ) => ReturnType<Terminal['write']>;
writeAsync: (data: Parameters<Terminal['write']>[0]) => Promise<void> writeAsync: (data: Parameters<Terminal['write']>[0]) => Promise<void>;
writeln: ( writeln: (
...args: Parameters<Terminal['writeln']> ...args: Parameters<Terminal['writeln']>
) => ReturnType<Terminal['writeln']> ) => ReturnType<Terminal['writeln']>;
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void> writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>;
clear: () => void clear: () => void;
terminalRef: React.RefObject<Terminal | null> terminalRef: React.RefObject<Terminal | null>;
}; };
export interface XTermProps export interface XTermProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> { extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> {
onInput?: (data: string) => void onInput?: (data: string) => void;
onKey?: (key: string, event: KeyboardEvent) => void onKey?: (key: string, event: KeyboardEvent) => void;
onResize?: (cols: number, rows: number) => void // 新增属性 onResize?: (cols: number, rows: number) => void; // 新增属性
} }
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => { const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {

View File

@ -1,9 +1,11 @@
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { Tab, Tabs } from '@heroui/tabs'; import { Tab, Tabs } from '@heroui/tabs';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { useMediaQuery } from 'react-responsive';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import key from '@/const/key';
import ChangePasswordCard from './change_password'; import ChangePasswordCard from './change_password';
import LoginConfigCard from './login'; import LoginConfigCard from './login';
import OneBotConfigCard from './onebot'; import OneBotConfigCard from './onebot';
@ -12,24 +14,29 @@ import ThemeConfigCard from './theme';
import WebUIConfigCard from './webui'; import WebUIConfigCard from './webui';
export interface ConfigPageProps { export interface ConfigPageProps {
children?: React.ReactNode children?: React.ReactNode;
size?: 'sm' | 'md' | 'lg' size?: 'sm' | 'md' | 'lg';
} }
const ConfingPageItem: React.FC<ConfigPageProps> = ({ const ConfigPageItem: React.FC<ConfigPageProps> = ({
children, children,
size = 'md', size = 'md',
}) => { }) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card className='bg-opacity-50 backdrop-blur-sm'> <Card className={clsx(
<CardBody className='items-center py-5'> 'w-full mx-auto backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm rounded-2xl transition-all',
<div hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40',
className={clsx('max-w-full flex flex-col gap-2', { {
'w-72': size === 'sm', 'max-w-xl': size === 'sm',
'w-96': size === 'md', 'max-w-3xl': size === 'md',
'w-[32rem]': size === 'lg', 'max-w-6xl': size === 'lg',
})} }
> )}>
<CardBody className='py-6 px-4 md:py-8 md:px-12'>
<div className='w-full flex flex-col gap-5'>
{children} {children}
</div> </div>
</CardBody> </CardBody>
@ -38,7 +45,6 @@ const ConfingPageItem: React.FC<ConfigPageProps> = ({
}; };
export default function ConfigPage () { export default function ConfigPage () {
const isMediumUp = useMediaQuery({ minWidth: 768 });
const navigate = useNavigate(); const navigate = useNavigate();
const search = useSearchParams({ const search = useSearchParams({
tab: 'onebot', tab: 'onebot',
@ -46,53 +52,55 @@ export default function ConfigPage () {
const tab = search.get('tab') ?? 'onebot'; const tab = search.get('tab') ?? 'onebot';
return ( return (
<section className='w-[1000px] max-w-full md:mx-auto gap-4 py-8 px-2 md:py-10'> <section className='w-full max-w-[1200px] mx-auto py-4 md:py-8 px-2 md:px-6 relative'>
<title> - NapCat WebUI</title>
<Tabs <Tabs
aria-label='config tab' aria-label='config tab'
fullWidth fullWidth={false}
className='w-full' className='w-full'
isVertical={isMediumUp}
selectedKey={tab} selectedKey={tab}
onSelectionChange={(key) => { onSelectionChange={(key) => {
navigate(`/config?tab=${key}`); navigate(`/config?tab=${key}`);
}} }}
classNames={{ classNames={{
tabList: 'sticky flex top-14 bg-opacity-50 backdrop-blur-sm', base: 'w-full flex-col items-center',
panel: 'w-full relative', tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-2xl p-1.5 shadow-sm border border-white/20 dark:border-white/5 mb-4 md:mb-8 w-full md:w-fit mx-auto overflow-x-auto hide-scrollbar',
base: 'md:!w-auto flex-grow-0 flex-shrink-0 mr-0', cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm rounded-xl',
cursor: 'bg-opacity-60 backdrop-blur-sm', tab: 'h-9 px-4 md:px-6',
tabContent: 'text-default-600 dark:text-default-300 font-medium group-data-[selected=true]:text-primary',
panel: 'w-full relative p-0',
}} }}
> >
<Tab title='OneBot配置' key='onebot'> <Tab title='OneBot配置' key='onebot'>
<ConfingPageItem> <ConfigPageItem>
<OneBotConfigCard /> <OneBotConfigCard />
</ConfingPageItem> </ConfigPageItem>
</Tab> </Tab>
<Tab title='服务器配置' key='server'> <Tab title='服务器配置' key='server'>
<ConfingPageItem> <ConfigPageItem>
<ServerConfigCard /> <ServerConfigCard />
</ConfingPageItem> </ConfigPageItem>
</Tab> </Tab>
<Tab title='WebUI配置' key='webui'> <Tab title='WebUI配置' key='webui'>
<ConfingPageItem> <ConfigPageItem>
<WebUIConfigCard /> <WebUIConfigCard />
</ConfingPageItem> </ConfigPageItem>
</Tab> </Tab>
<Tab title='登录配置' key='login'> <Tab title='登录配置' key='login'>
<ConfingPageItem> <ConfigPageItem>
<LoginConfigCard /> <LoginConfigCard />
</ConfingPageItem> </ConfigPageItem>
</Tab> </Tab>
<Tab title='修改密码' key='token'> <Tab title='修改密码' key='token'>
<ConfingPageItem> <ConfigPageItem size='sm'>
<ChangePasswordCard /> <ChangePasswordCard />
</ConfingPageItem> </ConfigPageItem>
</Tab> </Tab>
<Tab title='主题配置' key='theme'> <Tab title='主题配置' key='theme'>
<ConfingPageItem size='lg'> <ConfigPageItem size='lg'>
<ThemeConfigCard /> <ThemeConfigCard />
</ConfingPageItem> </ConfigPageItem>
</Tab> </Tab>
</Tabs> </Tabs>
</section> </section>

View File

@ -74,6 +74,11 @@ const OneBotConfigCard = () => {
{...field} {...field}
label='音乐签名地址' label='音乐签名地址'
placeholder='请输入音乐签名地址' placeholder='请输入音乐签名地址'
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
/> />
)} )}
/> />

View File

@ -1,5 +1,4 @@
import { Input } from '@heroui/input'; import { Input } from '@heroui/input';
import { Switch } from '@heroui/switch';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
@ -7,6 +6,7 @@ import toast from 'react-hot-toast';
import SaveButtons from '@/components/button/save_buttons'; import SaveButtons from '@/components/button/save_buttons';
import PageLoading from '@/components/page_loading'; import PageLoading from '@/components/page_loading';
import SwitchCard from '@/components/switch_card';
import WebUIManager from '@/controllers/webui_manager'; import WebUIManager from '@/controllers/webui_manager';
@ -79,8 +79,8 @@ const ServerConfigCard = () => {
<> <>
<title> - NapCat WebUI</title> <title> - NapCat WebUI</title>
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-3'>
<div className='flex-shrink-0 w-full'></div> <div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'></div>
<Controller <Controller
control={control} control={control}
name='host' name='host'
@ -92,6 +92,11 @@ const ServerConfigCard = () => {
description='服务器监听的IP地址0.0.0.0表示监听所有网卡' description='服务器监听的IP地址0.0.0.0表示监听所有网卡'
isDisabled={!!configError} isDisabled={!!configError}
errorMessage={configError ? '获取配置失败' : undefined} errorMessage={configError ? '获取配置失败' : undefined}
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
/> />
)} )}
/> />
@ -109,6 +114,11 @@ const ServerConfigCard = () => {
isDisabled={!!configError} isDisabled={!!configError}
errorMessage={configError ? '获取配置失败' : undefined} errorMessage={configError ? '获取配置失败' : undefined}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-gray-800 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500',
}}
/> />
)} )}
/> />
@ -126,47 +136,42 @@ const ServerConfigCard = () => {
isDisabled={!!configError} isDisabled={!!configError}
errorMessage={configError ? '获取配置失败' : undefined} errorMessage={configError ? '获取配置失败' : undefined}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-gray-800 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500',
}}
/> />
)} )}
/> />
</div> </div>
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-3'>
<div className='flex-shrink-0 w-full'></div> <div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'></div>
<Controller <Controller
control={control} control={control}
name='disableWebUI' name='disableWebUI'
render={({ field }) => ( render={({ field }) => (
<Switch <SwitchCard
isSelected={field.value} value={field.value}
onValueChange={(value) => field.onChange(value)} onValueChange={(value: boolean) => field.onChange(value)}
isDisabled={!!configError} disabled={!!configError}
> label='禁用WebUI'
<div className='flex flex-col'> description='启用后将完全禁用WebUI服务需要重启生效'
<span>WebUI</span> />
<span className='text-sm text-default-400'>
WebUI服务
</span>
</div>
</Switch>
)} )}
/> />
<Controller <Controller
control={control} control={control}
name='disableNonLANAccess' name='disableNonLANAccess'
render={({ field }) => ( render={({ field }) => (
<Switch <SwitchCard
isSelected={field.value} value={field.value}
onValueChange={(value) => field.onChange(value)} onValueChange={(value: boolean) => field.onChange(value)}
isDisabled={!!configError} disabled={!!configError}
> label='禁用非局域网访问'
<div className='flex flex-col'> description='启用后只允许局域网内的设备访问WebUI提高安全性'
<span>访</span> />
<span className='text-sm text-default-400'>
访WebUI
</span>
</div>
</Switch>
)} )}
/> />
</div> </div>

View File

@ -93,11 +93,13 @@ const WebUIConfigCard = () => {
<> <>
<title>WebUI配置 - NapCat WebUI</title> <title>WebUI配置 - NapCat WebUI</title>
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full'>WebUI字体</div> <div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>WebUI字体</div>
<div className='text-sm text-default-400'> <div className='text-sm text-default-400'>
<FileInput <FileInput
label='中文字体' label='中文字体'
placeholder='选择字体文件'
accept='.ttf,.otf,.woff,.woff2'
onChange={async (file) => { onChange={async (file) => {
try { try {
await FileManager.uploadWebUIFont(file); await FileManager.uploadWebUIFont(file);
@ -124,26 +126,35 @@ const WebUIConfigCard = () => {
</div> </div>
</div> </div>
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full'></div> <div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'></div>
<Controller <Controller
control={control} control={control}
name='background' name='background'
render={({ field }) => <ImageInput {...field} />} render={({ field }) => (
<ImageInput
{...field}
/>
)}
/> />
</div> </div>
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>
<div></div> <div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'></div>
{siteConfig.navItems.map((item) => ( {siteConfig.navItems.map((item) => (
<Controller <Controller
key={item.label} key={item.label}
control={control} control={control}
name={`customIcons.${item.label}`} name={`customIcons.${item.label}`}
render={({ field }) => <ImageInput {...field} label={item.label} />} render={({ field }) => (
<ImageInput
{...field}
label={item.label}
/>
)}
/> />
))} ))}
</div> </div>
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full'>Passkey认证</div> <div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>Passkey认证</div>
<div className='text-sm text-default-400 mb-2'> <div className='text-sm text-default-400 mb-2'>
Passkey后便WebUItoken Passkey后便WebUItoken
</div> </div>

View File

@ -1,6 +1,5 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import clsx from 'clsx'; import clsx from 'clsx';
import { motion } from 'motion/react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { TbSquareRoundedChevronLeftFilled } from 'react-icons/tb'; import { TbSquareRoundedChevronLeftFilled } from 'react-icons/tb';
@ -27,36 +26,39 @@ export default function HttpDebug () {
return ( return (
<> <>
<title>HTTP调试 - NapCat WebUI</title> <title>HTTP调试 - NapCat WebUI</title>
<OneBotApiNavList <div className='flex h-[calc(100vh-3.5rem)] overflow-hidden relative p-2 md:p-4 gap-2 md:gap-4'>
data={oneBotHttpApi} <OneBotApiNavList
selectedApi={selectedApi} data={oneBotHttpApi}
onSelect={setSelectedApi} selectedApi={selectedApi}
openSideBar={openSideBar} onSelect={(api) => {
/> setSelectedApi(api);
<div ref={contentRef} className='flex-1 h-full overflow-x-hidden'> // Auto-close sidebar on mobile after selection
<motion.div if (window.innerWidth < 768) {
className='absolute top-16 z-30 md:!ml-4' setOpenSideBar(false);
animate={{ marginLeft: openSideBar ? '16rem' : '1rem' }} }
transition={{ type: 'spring', stiffness: 150, damping: 15 }} }}
openSideBar={openSideBar}
onToggle={setOpenSideBar}
/>
<div
ref={contentRef}
className='flex-1 h-full overflow-hidden flex flex-col relative'
> >
<Button {/* Toggle Button Container - positioned on top-left of content if sidebar is closed */}
isIconOnly <div className='absolute top-2 left-2 z-30'>
color='primary' <Button
radius='md' isIconOnly
variant='shadow' size="sm"
size='sm' variant="flat"
onPress={() => setOpenSideBar(!openSideBar)} className={clsx("bg-white/40 dark:bg-black/40 backdrop-blur-md transition-opacity rounded-full shadow-sm", openSideBar ? "opacity-0 pointer-events-none md:opacity-0" : "opacity-100")}
> onPress={() => setOpenSideBar(true)}
<TbSquareRoundedChevronLeftFilled >
size={24} <TbSquareRoundedChevronLeftFilled className="transform rotate-180" />
className={clsx( </Button>
'transition-transform', </div>
openSideBar ? '' : 'transform rotate-180'
)} <OneBotApiDebug path={selectedApi} data={data} />
/> </div>
</Button>
</motion.div>
<OneBotApiDebug path={selectedApi} data={data} />
</div> </div>
</> </>
); );

View File

@ -48,8 +48,8 @@ export default function WSDebug () {
return ( return (
<> <>
<title>Websocket调试 - NapCat WebUI</title> <title>Websocket调试 - NapCat WebUI</title>
<div className='h-[calc(100vh-4rem)] overflow-hidden flex flex-col'> <div className='h-[calc(100vh-4rem)] overflow-hidden flex flex-col p-2 md:p-0'>
<Card className='mx-2 mt-2 flex-shrink-0 bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm'> <Card className='md:mx-2 md:mt-2 flex-shrink-0 bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm'>
<CardBody className='gap-2'> <CardBody className='gap-2'>
<div className='grid gap-2 items-center md:grid-cols-5'> <div className='grid gap-2 items-center md:grid-cols-5'>
<Input <Input

View File

@ -2,6 +2,7 @@ import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs';
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { Input } from '@heroui/input'; import { Input } from '@heroui/input';
import type { Selection, SortDescriptor } from '@react-types/shared'; import type { Selection, SortDescriptor } from '@react-types/shared';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import path from 'path-browserify'; import path from 'path-browserify';
@ -14,6 +15,7 @@ import { TbTrash } from 'react-icons/tb';
import { TiArrowBack } from 'react-icons/ti'; import { TiArrowBack } from 'react-icons/ti';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import key from '@/const/key';
import CreateFileModal from '@/components/file_manage/create_file_modal'; import CreateFileModal from '@/components/file_manage/create_file_modal';
import FileEditModal from '@/components/file_manage/file_edit_modal'; import FileEditModal from '@/components/file_manage/file_edit_modal';
import FilePreviewModal from '@/components/file_manage/file_preview_modal'; import FilePreviewModal from '@/components/file_manage/file_preview_modal';
@ -328,123 +330,139 @@ export default function FileManagerPage () {
useFsAccessApi: false, // 添加此选项以避免某些浏览器的文件系统API问题 useFsAccessApi: false, // 添加此选项以避免某些浏览器的文件系统API问题
}); });
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<div className='h-full flex flex-col relative gap-4 w-full p-4'> <div className='h-full flex flex-col relative gap-4 w-full p-2 md:p-4'>
<div className='mb-4 flex items-center gap-4 sticky top-14 z-10 bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm py-2 px-4 rounded-xl'> <div className={clsx(
<Button 'mb-4 flex flex-col md:flex-row items-stretch md:items-center gap-4 sticky top-14 z-10 backdrop-blur-sm shadow-sm py-2 px-4 rounded-xl transition-colors',
color='primary' hasBackground
size='sm' ? 'bg-white/20 dark:bg-black/10 border border-white/40 dark:border-white/10'
isIconOnly : 'bg-white/60 dark:bg-black/40 border border-white/40 dark:border-white/10'
variant='flat' )}>
onPress={() => handleDirectoryClick('..')} <div className='flex items-center gap-2 overflow-x-auto hide-scrollbar pb-1 md:pb-0'>
className='text-lg' <Button
> color='primary'
<TiArrowBack /> size='sm'
</Button> isIconOnly
variant='flat'
onPress={() => handleDirectoryClick('..')}
className='text-lg min-w-8'
>
<TiArrowBack />
</Button>
<Button <Button
color='primary' color='primary'
size='sm' size='sm'
isIconOnly isIconOnly
variant='flat' variant='flat'
onPress={() => setIsCreateModalOpen(true)} onPress={() => setIsCreateModalOpen(true)}
className='text-lg' className='text-lg min-w-8'
> >
<FiPlus /> <FiPlus />
</Button> </Button>
<Button <Button
color='primary' color='primary'
isLoading={loading} isLoading={loading}
size='sm' size='sm'
isIconOnly isIconOnly
variant='flat' variant='flat'
onPress={loadFiles} onPress={loadFiles}
className='text-lg' className='text-lg min-w-8'
> >
<MdRefresh /> <MdRefresh />
</Button> </Button>
<Button <Button
color='primary' color='primary'
size='sm' size='sm'
isIconOnly isIconOnly
variant='flat' variant='flat'
onPress={() => setShowUpload((prev) => !prev)} onPress={() => setShowUpload((prev) => !prev)}
className='text-lg' className='text-lg min-w-8'
> >
<FiUpload /> <FiUpload />
</Button> </Button>
{((selectedFiles instanceof Set && selectedFiles.size > 0) || {((selectedFiles instanceof Set && selectedFiles.size > 0) ||
selectedFiles === 'all') && ( selectedFiles === 'all') && (
<> <>
<Button <Button
color='primary' color='primary'
size='sm' size='sm'
variant='flat' variant='flat'
onPress={handleBatchDelete} onPress={handleBatchDelete}
className='text-sm' className='text-sm px-2 min-w-fit'
startContent={<TbTrash className='text-lg' />} startContent={<TbTrash className='text-lg' />}
> >
( (
{selectedFiles instanceof Set ? selectedFiles.size : files.length} {selectedFiles instanceof Set ? selectedFiles.size : files.length}
) )
</Button> </Button>
<Button <Button
color='primary' color='primary'
size='sm' size='sm'
variant='flat' variant='flat'
onPress={() => {
setMoveTargetPath('');
setIsMoveModalOpen(true);
}}
className='text-sm px-2 min-w-fit'
startContent={<FiMove className='text-lg' />}
>
(
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
)
</Button>
<Button
color='primary'
size='sm'
variant='flat'
onPress={handleBatchDownload}
className='text-sm px-2 min-w-fit'
startContent={<FiDownload className='text-lg' />}
>
(
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
)
</Button>
</>
)}
</div>
<div className='flex flex-col md:flex-row flex-1 gap-2 overflow-hidden items-stretch md:items-center'>
<Breadcrumbs className='flex-1 bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20 px-2 py-2 rounded-lg overflow-x-auto hide-scrollbar whitespace-nowrap'>
{currentPath.split('/').map((part, index, parts) => (
<BreadcrumbItem
key={part}
isCurrent={index === parts.length - 1}
onPress={() => { onPress={() => {
setMoveTargetPath(''); const newPath = parts.slice(0, index + 1).join('/');
setIsMoveModalOpen(true); navigate(`/file_manager#${encodeURIComponent(newPath)}`);
}} }}
className='text-sm'
startContent={<FiMove className='text-lg' />}
> >
( {part}
{selectedFiles instanceof Set ? selectedFiles.size : files.length} </BreadcrumbItem>
) ))}
</Button> </Breadcrumbs>
<Button <Input
color='primary' type='text'
size='sm' placeholder='输入跳转路径'
variant='flat' value={jumpPath}
onPress={handleBatchDownload} onChange={(e) => setJumpPath(e.target.value)}
className='text-sm' onKeyDown={(e) => {
startContent={<FiDownload className='text-lg' />} if (e.key === 'Enter' && jumpPath.trim() !== '') {
> navigate(`/file_manager#${encodeURIComponent(jumpPath.trim())}`);
( }
{selectedFiles instanceof Set ? selectedFiles.size : files.length} }}
) className='w-full md:w-64'
</Button> classNames={{
</> inputWrapper: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
)} }}
<Breadcrumbs className='flex-1 bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20 px-2 py-2 rounded-lg'> />
{currentPath.split('/').map((part, index, parts) => ( </div>
<BreadcrumbItem
key={part}
isCurrent={index === parts.length - 1}
onPress={() => {
const newPath = parts.slice(0, index + 1).join('/');
navigate(`/file_manager#${encodeURIComponent(newPath)}`);
}}
>
{part}
</BreadcrumbItem>
))}
</Breadcrumbs>
<Input
type='text'
placeholder='输入跳转路径'
value={jumpPath}
onChange={(e) => setJumpPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && jumpPath.trim() !== '') {
navigate(`/file_manager#${encodeURIComponent(jumpPath.trim())}`);
}
}}
className='ml-auto w-64'
/>
</div> </div>
<motion.div <motion.div

View File

@ -1,6 +1,9 @@
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import clsx from 'clsx';
import { useCallback, useEffect, useState, useRef } from 'react'; import { useCallback, useEffect, useState, useRef } from 'react';
import key from '@/const/key';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@ -92,6 +95,9 @@ const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => {
const DashboardIndexPage: React.FC = () => { const DashboardIndexPage: React.FC = () => {
const [archInfo, setArchInfo] = useState<string>(); const [archInfo, setArchInfo] = useState<string>();
// @ts-ignore
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<> <>
@ -105,7 +111,10 @@ const DashboardIndexPage: React.FC = () => {
<SystemStatusCard setArchInfo={setArchInfo} /> <SystemStatusCard setArchInfo={setArchInfo} />
</div> </div>
<Networks /> <Networks />
<Card className='bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm'> <Card className={clsx(
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all',
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
)}>
<CardBody> <CardBody>
<Hitokoto /> <Hitokoto />
</CardBody> </CardBody>

View File

@ -375,9 +375,8 @@ export default function NetworkPage () {
<AddButton onOpen={handleClickCreate} /> <AddButton onOpen={handleClickCreate} />
<Button <Button
isIconOnly isIconOnly
color='primary' className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
radius='full' radius='full'
variant='flat'
onPress={refresh} onPress={refresh}
> >
<IoMdRefresh size={24} /> <IoMdRefresh size={24} />

View File

@ -12,10 +12,13 @@ import {
horizontalListSortingStrategy, horizontalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { IoAdd, IoClose } from 'react-icons/io5'; import { IoAdd, IoClose } from 'react-icons/io5';
import key from '@/const/key';
import { TabList, TabPanel, Tabs } from '@/components/tabs'; import { TabList, TabPanel, Tabs } from '@/components/tabs';
import { SortableTab } from '@/components/tabs/sortable_tab.tsx'; import { SortableTab } from '@/components/tabs/sortable_tab.tsx';
import { TerminalInstance } from '@/components/terminal/terminal-instance'; import { TerminalInstance } from '@/components/terminal/terminal-instance';
@ -30,6 +33,8 @@ interface TerminalTab {
export default function TerminalPage () { export default function TerminalPage () {
const [tabs, setTabs] = useState<TerminalTab[]>([]); const [tabs, setTabs] = useState<TerminalTab[]>([]);
const [selectedTab, setSelectedTab] = useState<string>(''); const [selectedTab, setSelectedTab] = useState<string>('');
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
useEffect(() => { useEffect(() => {
// 获取已存在的终端列表 // 获取已存在的终端列表
@ -112,35 +117,40 @@ export default function TerminalPage () {
className='h-full overflow-hidden' className='h-full overflow-hidden'
> >
<div className='flex items-center gap-2 flex-shrink-0 flex-grow-0'> <div className='flex items-center gap-2 flex-shrink-0 flex-grow-0'>
<TabList className='flex-1 !overflow-x-auto w-full hide-scrollbar bg-white/40 dark:bg-black/20 backdrop-blur-md p-1 rounded-lg border border-white/20'> {tabs.length > 0 && (
<SortableContext <TabList className={clsx(
items={tabs} 'flex-1 !overflow-x-auto w-full hide-scrollbar backdrop-blur-sm p-1 rounded-lg border border-white/20',
strategy={horizontalListSortingStrategy} hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/40 dark:bg-black/20'
> )}>
{tabs.map((tab) => ( <SortableContext
<SortableTab items={tabs}
key={tab.id} strategy={horizontalListSortingStrategy}
id={tab.id} >
value={tab.id} {tabs.map((tab) => (
isSelected={selectedTab === tab.id} <SortableTab
className='flex gap-2 items-center flex-shrink-0' key={tab.id}
> id={tab.id}
{tab.title} value={tab.id}
<Button isSelected={selectedTab === tab.id}
isIconOnly className='flex gap-2 items-center flex-shrink-0'
radius='full'
variant='flat'
size='sm'
className='min-w-0 w-4 h-4 flex-shrink-0'
onPress={() => closeTerminal(tab.id)}
color={selectedTab === tab.id ? 'primary' : 'default'}
> >
<IoClose /> {tab.title}
</Button> <Button
</SortableTab> isIconOnly
))} radius='full'
</SortableContext> variant='flat'
</TabList> size='sm'
className='min-w-0 w-4 h-4 flex-shrink-0'
onPress={() => closeTerminal(tab.id)}
color={selectedTab === tab.id ? 'primary' : 'default'}
>
<IoClose />
</Button>
</SortableTab>
))}
</SortableContext>
</TabList>
)}
<Button <Button
isIconOnly isIconOnly
color='primary' color='primary'