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

View File

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

View File

@ -15,8 +15,15 @@ export default function ChatInputModal () {
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>
<Modal
size='4xl'

View File

@ -8,19 +8,10 @@ import monaco from '@/monaco';
loader.config({
monaco,
paths: {
vs: '/webui/monaco-editor/min/vs',
},
});
loader.config({
'vs/nls': {
availableLanguages: { '*': 'zh-cn' },
},
});
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
test?: string
test?: string;
}
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 clsx from 'clsx';
import { useState } from 'react';
import { CgDebug } from 'react-icons/cg';
import { FiEdit3 } from 'react-icons/fi';
@ -10,27 +11,25 @@ import DisplayCardContainer from './container';
type NetworkType = OneBotConfig['network'];
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
label: string
value: NetworkType[T][0][keyof NetworkType[T][0]]
label: string;
value: NetworkType[T][0][keyof NetworkType[T][0]];
render?: (
value: NetworkType[T][0][keyof NetworkType[T][0]]
) => React.ReactNode
) => React.ReactNode;
}>;
export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
data: NetworkType[T][0]
showType?: boolean
typeLabel: string
fields: NetworkDisplayCardFields<T>
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
data: NetworkType[T][0];
typeLabel: string;
fields: NetworkDisplayCardFields<T>;
onEdit: () => void;
onEnable: () => Promise<void>;
onDelete: () => Promise<void>;
onEnableDebug: () => Promise<void>;
}
const NetworkDisplayCard = <T extends keyof NetworkType>({
const NetworkDisplayCard = <T extends keyof NetworkType> ({
data,
showType,
typeLabel,
fields,
onEdit,
@ -56,79 +55,146 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
onEnableDebug().finally(() => setEditing(false));
};
const isFullWidthField = (label: string) => ['URL', 'Token', 'AccessToken'].includes(label);
return (
<DisplayCardContainer
className="w-full max-w-[420px]"
action={
<ButtonGroup
fullWidth
isDisabled={editing}
radius='sm'
size='sm'
variant='flat'
>
<div className="flex gap-2 w-full">
<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} />}
onPress={onEdit}
isDisabled={editing}
>
</Button>
<Button
color={debug ? 'secondary' : 'success'}
fullWidth
radius='full'
size='sm'
variant='flat'
startContent={
<CgDebug
style={{
width: '16px',
height: '16px',
minWidth: '16px',
minHeight: '16px',
}}
/>
}
className={clsx(
"flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors",
debug
? "hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary"
: "hover:bg-success/20 hover:text-success data-[hover=true]:text-success"
)}
startContent={<CgDebug size={16} />}
onPress={handleEnableDebug}
isDisabled={editing}
>
{debug ? '关闭调试' : '开启调试'}
</Button>
<Button
className='bg-danger/20 text-danger hover:bg-danger/30 transition-colors'
fullWidth
radius='full'
size='sm'
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} />}
onPress={handleDelete}
isDisabled={editing}
>
</Button>
</ButtonGroup>
</div>
}
enableSwitch={
<Switch
isDisabled={editing}
isSelected={enable}
onChange={handleEnable}
classNames={{
wrapper: "group-data-[selected=true]:bg-primary-400",
}}
/>
}
tag={showType && typeLabel}
title={name}
title={typeLabel}
>
<div className='grid grid-cols-2 gap-1'>
{fields.map((field, index) => (
<div
key={index}
className={`flex items-center gap-2 ${
field.label === 'URL' ? 'col-span-2' : ''
}`}
>
<span className='text-default-400'>{field.label}</span>
{field.render
? (
field.render(field.value)
)
: (
<span>{field.value}</span>
)}
</div>
))}
<div className='grid grid-cols-2 gap-3'>
{(() => {
const targetFullField = fields.find(f => isFullWidthField(f.label));
if (targetFullField) {
// 模式1存在全宽字段如URL布局为
// Row 1: 名称 (全宽)
// Row 2: 全宽字段 (全宽)
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 col-span-2'
>
<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>
<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>
</DisplayCardContainer>
);

View File

@ -1,7 +1,8 @@
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import key from '@/const/key';
import { title } from '../primitives';
export interface ContainerProps {
title: string;
@ -9,6 +10,7 @@ export interface ContainerProps {
action: React.ReactNode;
enableSwitch: React.ReactNode;
children: React.ReactNode;
className?: string; // Add className prop
}
export interface DisplayCardProps {
@ -25,31 +27,35 @@ const DisplayCardContainer: React.FC<ContainerProps> = ({
tag,
enableSwitch,
children,
className,
}) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return (
<Card className='bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm'>
<CardHeader className='pb-0 flex items-center'>
<Card className={clsx(
'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 && (
<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}
</div>
)}
<h2
className={clsx(
title({
color: 'foreground',
size: 'xs',
shadow: true,
}),
'truncate'
)}
>
{_title}
</h2>
<div className='ml-auto'>{enableSwitch}</div>
<div className='flex-1 min-w-0 mr-2'>
<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'>
<span className='font-bold text-default-600 dark:text-white/90 text-sm truncate select-text'>
{_title}
</span>
</div>
</div>
<div className='flex-shrink-0'>{enableSwitch}</div>
</CardHeader>
<CardBody className='text-sm'>{children}</CardBody>
<CardFooter>{action}</CardFooter>
<CardBody className='px-4 py-2 text-sm text-default-600'>{children}</CardBody>
<CardFooter className='px-4 pb-4 pt-2'>{action}</CardFooter>
</Card>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
import { Card, CardBody } from '@heroui/card';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import key from '@/const/key';
@ -14,10 +16,16 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
label,
size = 'md',
}) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return (
<Card
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'
? 'col-span-8 md:col-span-2'
: 'col-span-2 md:col-span-1'

View File

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

View File

@ -1,10 +1,13 @@
import { Button } from '@heroui/button';
import { Tooltip } from '@heroui/tooltip';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks';
import clsx from 'clsx';
import toast from 'react-hot-toast';
import { IoMdQuote } from 'react-icons/io';
import { IoCopy, IoRefresh } from 'react-icons/io5';
import key from '@/const/key';
import { request } from '@/utils/request';
import PageLoading from './page_loading';
@ -19,7 +22,15 @@ export default function Hitokoto () {
pollingInterval: 10000,
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 = () => {
try {
const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`;
@ -32,28 +43,39 @@ export default function Hitokoto () {
return (
<div>
<div className='relative flex flex-col items-center justify-center p-6 min-h-[120px]'>
{loading && <PageLoading />}
{error
? (
<div className='text-danger'>{error.message}</div>
)
: (
<>
<IoMdQuote className="text-4xl text-primary/20 mb-4" />
<div className="text-xl font-medium text-default-700 dark:text-gray-200 tracking-wide leading-relaxed italic">
{data?.hitokoto}
</div>
<div className='mt-4 flex flex-col items-center text-sm'>
<span className='font-bold text-primary-500/80'> {data?.from}</span>
{data?.from_who && <span className="text-default-400 text-xs mt-1">{data?.from_who}</span>}
</div>
</>
)}
{loading && !data && <PageLoading />}
{data && (
<>
<IoMdQuote className={clsx(
"text-4xl mb-4",
hasBackground ? "text-white/30" : "text-primary/20"
)} />
<div className={clsx(
"text-xl font-medium tracking-wide leading-relaxed italic",
hasBackground ? "text-white drop-shadow-sm" : "text-default-700 dark:text-gray-200"
)}>
" {data?.hitokoto} "
</div>
<div className='mt-4 flex flex-col items-center text-sm'>
<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 className='flex gap-2'>
<Tooltip content='刷新' placement='top'>
<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}
size='sm'
isLoading={loading}
@ -66,7 +88,10 @@ export default function Hitokoto () {
</Tooltip>
<Tooltip content='复制' placement='top'>
<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}
size='sm'
isIconOnly

View File

@ -7,6 +7,7 @@ export interface FileInputProps {
onDelete?: () => Promise<void> | void;
label?: string;
accept?: string;
placeholder?: string;
}
const FileInput: React.FC<FileInputProps> = ({
@ -14,6 +15,7 @@ const FileInput: React.FC<FileInputProps> = ({
onDelete,
label,
accept,
placeholder,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
@ -25,8 +27,13 @@ const FileInput: React.FC<FileInputProps> = ({
ref={inputRef}
label={label}
type='file'
placeholder='选择文件'
placeholder={placeholder || '选择文件'}
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) => {
try {
setIsLoading(true);

View File

@ -4,9 +4,9 @@ import { Input } from '@heroui/input';
import { useRef } from 'react';
export interface ImageInputProps {
onChange: (base64: string) => void
value: string
label?: string
onChange: (base64: string) => void;
value: string;
label?: string;
}
const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
@ -26,6 +26,11 @@ const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
type='file'
placeholder='选择图片'
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) => {
const file = e.target.files?.[0];
if (file) {

View File

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

View File

@ -6,17 +6,17 @@ import type { Selection } from '@react-types/shared';
import { LogLevel } from '@/const/enum';
export interface LogLevelSelectProps {
selectedKeys: Selection
onSelectionChange: (keys: SharedSelection) => void
selectedKeys: Selection;
onSelectionChange: (keys: SharedSelection) => void;
}
const logLevelColor: {
[key in LogLevel]:
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'primary'
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'primary'
} = {
[LogLevel.DEBUG]: 'default',
[LogLevel.INFO]: 'primary',
@ -40,7 +40,7 @@ const LogLevelSelect = (props: LogLevelSelectProps) => {
aria-label='Log Level'
classNames={{
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',
}}
size='sm'

View File

@ -1,9 +1,12 @@
import { Button } from '@heroui/button';
import type { Selection } from '@react-types/shared';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast';
import { IoDownloadOutline } from 'react-icons/io5';
import key from '@/const/key';
import { colorizeLogLevelWithTag } from '@/utils/terminal';
import WebUIManager, { Log } from '@/controllers/webui_manager';
@ -18,6 +21,8 @@ const RealTimeLogs = () => {
new Set(['info', 'warn', 'error'])
);
const [dataArr, setDataArr] = useState<Log[]>([]);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const onDownloadLog = () => {
const logContent = dataArr
@ -91,7 +96,10 @@ const RealTimeLogs = () => {
return (
<>
<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
selectedKeys={logLevel}
onSelectionChange={setLogLevel}
@ -100,6 +108,8 @@ const RealTimeLogs = () => {
className='flex-shrink-0'
onPress={onDownloadLog}
startContent={<IoDownloadOutline className='text-lg' />}
color='primary'
variant='flat'
>
</Button>

View File

@ -109,6 +109,11 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
isDisabled={field.isDisabled}
label={field.label}
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':
@ -121,6 +126,10 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
placeholder={field.placeholder}
selectedKeys={[controllerField.value as string]}
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) => (
<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 toast from 'react-hot-toast';
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 { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api';
@ -89,42 +89,58 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
}, [path]);
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'>
<div className='flex flex-col gap-4'>
<div className='flex items-center justify-between'>
<h1 className='text-2xl font-bold flex items-center gap-2 text-primary-500'>
<PiCatDuotone />
{data.description}
<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 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 gap-2 md:gap-4 overflow-hidden'>
<h1 className='text-lg md:text-xl font-bold flex items-center gap-2 text-primary-500 flex-shrink-0'>
<TbApi size={24} />
<span className='truncate'>{data.description}</span>
</h1>
<Snippet
className='bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20'
symbol={<IoLink size={18} className='inline-block mr-1' />}
className='bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20 hidden md:flex'
symbol={<IoLink size={16} className='inline-block mr-1' />}
tooltipProps={{ content: '点击复制地址' }}
size="sm"
>
{path}
</Snippet>
<Button
size='sm'
variant='ghost'
color='primary'
className='border-primary/20 hover:bg-primary/10'
onPress={() => setIsStructOpen(true)}
>
</Button>
</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'>
<PopoverTrigger>
<Button
variant='ghost'
size='sm'
variant='flat'
color='default'
isIconOnly
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>
</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'>
@ -159,18 +175,17 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
<Button
onPress={sendRequest}
color='primary'
size='lg'
radius='full'
className='font-bold px-8 shadow-lg shadow-primary/30'
className='font-bold px-6 shadow-lg shadow-primary/30'
isLoading={isFetching}
startContent={!isFetching && <IoSend />}
>
</Button>
</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 */}
<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'>
@ -183,7 +198,9 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
<Button
size='sm'
color='primary'
variant='light'
variant='flat'
radius='full'
className="bg-primary/10 text-primary"
onPress={() => setRequestBody(generateDefaultJson(data.request))}
>
@ -207,7 +224,6 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
</CardBody>
</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'>
<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'>
@ -217,8 +233,10 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
</div>
<Button
size='sm'
color='success'
variant='light'
color='primary'
variant='flat'
radius='full'
className="bg-primary/10 text-primary"
onPress={() => {
navigator.clipboard.writeText(responseContent);
toast.success('已复制');

View File

@ -1,8 +1,11 @@
import { Button } from '@heroui/button';
import { Card, CardBody } from '@heroui/card';
import { Input } from '@heroui/input';
import clsx from 'clsx';
import { ScrollShadow } from "@heroui/scroll-shadow";
import { motion } from 'motion/react';
import { useState } from 'react';
import { TbApi, TbLayoutSidebarLeftCollapseFilled, TbSearch } from 'react-icons/tb';
import type { OneBotHttpApi, OneBotHttpApiPath } from '@/const/ob_api';
@ -11,75 +14,116 @@ export interface OneBotApiNavListProps {
selectedApi: OneBotHttpApiPath;
onSelect: (apiName: OneBotHttpApiPath) => void;
openSideBar: boolean;
onToggle?: (isOpen: boolean) => void;
}
const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
const { data, selectedApi, onSelect, openSideBar } = props;
const { data, selectedApi, onSelect, openSideBar, onToggle } = props;
const [searchValue, setSearchValue] = useState('');
return (
<motion.div
className={clsx(
'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 && 'bg-white/40 dark:bg-black/40 backdrop-blur-2xl border-white/20 shadow-xl'
)}
initial={{ width: 0 }}
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('')}
<>
{/* Mobile backdrop overlay */}
{openSideBar && (
<div
className="fixed inset-0 bg-black/20 backdrop-blur-[1px] z-10 md:hidden"
onClick={() => onToggle?.(false)}
/>
{Object.entries(data).map(([apiName, api]) => (
<Card
key={apiName}
shadow='none'
className={clsx(
'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',
{
hidden: !(
apiName.includes(searchValue) ||
api.description?.includes(searchValue)
),
},
{
'!bg-white/60 dark:!bg-white/10 !border-white/20 shadow-sm !text-primary font-medium':
apiName === selectedApi,
}
)}
isPressable
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
>
<CardBody>
<h2 className='font-bold'>{api.description}</h2>
<div
className={clsx('text-sm text-default-400', {
'!text-primary': apiName === selectedApi,
})}
)}
<motion.div
className={clsx(
'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',
'fixed md:relative left-0 top-0 md:top-auto md:left-auto'
)}
initial={false}
animate={{ width: openSideBar ? 280 : 0, opacity: openSideBar ? 1 : 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
<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'>
<TbApi className="text-primary" /> API
</span>
{onToggle && (
<Button
isIconOnly
size='sm'
variant='light'
onPress={() => onToggle(false)}
className="text-default-500 hover:text-default-800"
>
{apiName}
</div>
</CardBody>
</Card>
))}
</div>
</motion.div>
<TbLayoutSidebarLeftCollapseFilled size={20} />
</Button>
)}
</div>
<div className='p-3 pb-0'>
<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 { Image } from '@heroui/image';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { BsTencentQq } from 'react-icons/bs';
import key from '@/const/key';
import { SelfInfo } from '@/types/user';
import PageLoading from './page_loading';
@ -14,9 +16,14 @@ export interface QQInfoCardProps {
}
const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return (
<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'
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'>
<div className='absolute right-[-10px] bottom-[-10px] text-7xl text-default-400/10 rotate-12 pointer-events-none'>
<BsTencentQq />
</div>
{!hasBackground && (
<div className='absolute right-[-10px] bottom-[-10px] text-7xl text-default-400/10 rotate-12 pointer-events-none'>
<BsTencentQq />
</div>
)}
<div className='relative flex-shrink-0 z-10'>
<Image
src={
@ -51,10 +60,16 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
/>
</div>
<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 || '未知用户'}
</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'}
</div>
</div>

View File

@ -1,15 +1,17 @@
import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { AnimatePresence, motion } from 'motion/react';
import React from 'react';
import { IoMdLogOut } from 'react-icons/io';
import { MdDarkMode, MdLightMode } from 'react-icons/md';
import key from '@/const/key';
import useAuth from '@/hooks/auth';
import useDialog from '@/hooks/use-dialog';
import { useTheme } from '@/hooks/use-theme';
import type { MenuItem } from '@/config/site';
import Menus from './menus';
interface SideBarProps {
@ -22,6 +24,7 @@ const SideBar: React.FC<SideBarProps> = (props) => {
const { open, items, onClose } = props;
const { toggleTheme, isDark } = useTheme();
const { revokeAuth } = useAuth();
const [b64img] = useLocalStorage(key.backgroundImage, '');
const dialog = useDialog();
const onRevokeAuth = () => {
dialog.confirm({
@ -47,7 +50,9 @@ const SideBar: React.FC<SideBarProps> = (props) => {
</AnimatePresence>
<motion.div
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 }}
animate={{ width: open ? '16rem' : 0 }}

View File

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

View File

@ -3,15 +3,16 @@ import { Button } from '@heroui/button';
import { Chip } from '@heroui/chip';
import { Spinner } from '@heroui/spinner';
import { Tooltip } from '@heroui/tooltip';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks';
import clsx from 'clsx';
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6';
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io';
import { RiMacFill } from 'react-icons/ri';
import { useState } from 'react';
import toast from 'react-hot-toast';
import key from '@/const/key';
import WebUIManager from '@/controllers/webui_manager';
import useDialog from '@/hooks/use-dialog';
@ -21,6 +22,7 @@ export interface SystemInfoItemProps {
icon?: React.ReactNode;
value?: React.ReactNode;
endContent?: React.ReactNode;
hasBackground?: boolean;
}
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
@ -28,12 +30,21 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
value = '--',
icon,
endContent,
hasBackground = false,
}) => {
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='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>
);
@ -261,7 +272,11 @@ const NewVersionTip = (props: NewVersionTipProps) => {
);
};
const NapCatVersion = () => {
interface NapCatVersionProps {
hasBackground?: boolean;
}
const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false }) => {
const {
data: packageData,
loading: packageLoading,
@ -274,6 +289,7 @@ const NapCatVersion = () => {
<SystemInfoItem
title='NapCat 版本'
icon={<IoLogoOctocat className='text-xl' />}
hasBackground={hasBackground}
value={
packageError
? (
@ -302,18 +318,28 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
loading: qqVersionLoading,
error: qqVersionError,
} = useRequest(WebUIManager.getQQVersion);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
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'>
<CardHeader className='pb-0 items-center gap-2 text-default-700 dark:text-white font-bold px-4 pt-4'>
<Card className={clsx(
'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' />
<span></span>
</CardHeader>
<CardBody className='flex-1'>
<div className='flex flex-col gap-2 justify-between h-full'>
<NapCatVersion />
<NapCatVersion hasBackground={hasBackground} />
<SystemInfoItem
title='QQ 版本'
icon={<FaQq className='text-lg' />}
hasBackground={hasBackground}
value={
qqVersionError
? (
@ -332,11 +358,13 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
title='WebUI 版本'
icon={<IoLogoChrome className='text-xl' />}
value='Next'
hasBackground={hasBackground}
/>
<SystemInfoItem
title='系统版本'
icon={<RiMacFill className='text-xl' />}
value={archInfo}
hasBackground={hasBackground}
/>
</div>
</CardBody>

View File

@ -1,10 +1,12 @@
import { Card, CardBody } from '@heroui/card';
import { Image } from '@heroui/image';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { BiSolidMemoryCard } from 'react-icons/bi';
import { GiCpu } from 'react-icons/gi';
import bkg from '@/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png';
import key from '@/const/key';
import UsagePie from './usage_pie';
@ -13,6 +15,7 @@ export interface SystemStatusItemProps {
value?: string | number;
size?: 'md' | 'lg';
unit?: string;
hasBackground?: boolean;
}
const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
@ -20,16 +23,26 @@ const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
value = '-',
size = 'md',
unit,
hasBackground = false,
}) => {
return (
<div
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',
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between'
'p-2 rounded-lg text-sm border border-white/20 transition-colors',
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='text-default-500 font-mono text-xs'>
<div className={clsx(
'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}
{unit && <span className="ml-0.5 opacity-70">{unit}</span>}
</div>
@ -53,9 +66,14 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
memoryUsage.system = (systemUsage / system) * 100;
memoryUsage.qq = (qqUsage / system) * 100;
}
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
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'>
<Image
src={bkg}
@ -69,26 +87,34 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
</div>
<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'>
<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' />
<span>CPU</span>
</h2>
<div className='grid grid-cols-2 gap-2'>
<SystemStatusItem title='型号' value={data?.cpu.model} size='lg' />
<SystemStatusItem title='内核数' value={data?.cpu.core} />
<SystemStatusItem title='主频' value={data?.cpu.speed} unit='GHz' />
<SystemStatusItem title='型号' value={data?.cpu.model} size='lg' hasBackground={hasBackground} />
<SystemStatusItem title='内核数' value={data?.cpu.core} hasBackground={hasBackground} />
<SystemStatusItem title='主频' value={data?.cpu.speed} unit='GHz' hasBackground={hasBackground} />
<SystemStatusItem
title='使用率'
value={data?.cpu.usage.system}
unit='%'
hasBackground={hasBackground}
/>
<SystemStatusItem
title='QQ主线程'
value={data?.cpu.usage.qq}
unit='%'
hasBackground={hasBackground}
/>
</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' />
<span></span>
</h2>
@ -98,16 +124,19 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
value={data?.memory.total}
size='lg'
unit='MB'
hasBackground={hasBackground}
/>
<SystemStatusItem
title='使用量'
value={data?.memory.usage.system}
unit='MB'
hasBackground={hasBackground}
/>
<SystemStatusItem
title='QQ主线程'
value={data?.memory.usage.qq}
unit='MB'
hasBackground={hasBackground}
/>
</div>
</div>

View File

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

View File

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

View File

@ -74,6 +74,11 @@ const OneBotConfigCard = () => {
{...field}
label='音乐签名地址'
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 { Switch } from '@heroui/switch';
import { useRequest } from 'ahooks';
import { useEffect } from 'react';
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 PageLoading from '@/components/page_loading';
import SwitchCard from '@/components/switch_card';
import WebUIManager from '@/controllers/webui_manager';
@ -79,8 +79,8 @@ const ServerConfigCard = () => {
<>
<title> - NapCat WebUI</title>
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full'></div>
<div className='flex flex-col gap-3'>
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'></div>
<Controller
control={control}
name='host'
@ -92,6 +92,11 @@ const ServerConfigCard = () => {
description='服务器监听的IP地址0.0.0.0表示监听所有网卡'
isDisabled={!!configError}
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}
errorMessage={configError ? '获取配置失败' : undefined}
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}
errorMessage={configError ? '获取配置失败' : undefined}
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 className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full'></div>
<div className='flex flex-col gap-3'>
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'></div>
<Controller
control={control}
name='disableWebUI'
render={({ field }) => (
<Switch
isSelected={field.value}
onValueChange={(value) => field.onChange(value)}
isDisabled={!!configError}
>
<div className='flex flex-col'>
<span>WebUI</span>
<span className='text-sm text-default-400'>
WebUI服务
</span>
</div>
</Switch>
<SwitchCard
value={field.value}
onValueChange={(value: boolean) => field.onChange(value)}
disabled={!!configError}
label='禁用WebUI'
description='启用后将完全禁用WebUI服务需要重启生效'
/>
)}
/>
<Controller
control={control}
name='disableNonLANAccess'
render={({ field }) => (
<Switch
isSelected={field.value}
onValueChange={(value) => field.onChange(value)}
isDisabled={!!configError}
>
<div className='flex flex-col'>
<span>访</span>
<span className='text-sm text-default-400'>
访WebUI
</span>
</div>
</Switch>
<SwitchCard
value={field.value}
onValueChange={(value: boolean) => field.onChange(value)}
disabled={!!configError}
label='禁用非局域网访问'
description='启用后只允许局域网内的设备访问WebUI提高安全性'
/>
)}
/>
</div>

View File

@ -93,11 +93,13 @@ const WebUIConfigCard = () => {
<>
<title>WebUI配置 - NapCat WebUI</title>
<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'>
<FileInput
label='中文字体'
placeholder='选择字体文件'
accept='.ttf,.otf,.woff,.woff2'
onChange={async (file) => {
try {
await FileManager.uploadWebUIFont(file);
@ -124,26 +126,35 @@ const WebUIConfigCard = () => {
</div>
</div>
<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
control={control}
name='background'
render={({ field }) => <ImageInput {...field} />}
render={({ field }) => (
<ImageInput
{...field}
/>
)}
/>
</div>
<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) => (
<Controller
key={item.label}
control={control}
name={`customIcons.${item.label}`}
render={({ field }) => <ImageInput {...field} label={item.label} />}
render={({ field }) => (
<ImageInput
{...field}
label={item.label}
/>
)}
/>
))}
</div>
<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'>
Passkey后便WebUItoken
</div>

View File

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

View File

@ -48,8 +48,8 @@ export default function WSDebug () {
return (
<>
<title>Websocket调试 - NapCat WebUI</title>
<div className='h-[calc(100vh-4rem)] overflow-hidden flex flex-col'>
<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'>
<div className='h-[calc(100vh-4rem)] overflow-hidden flex flex-col p-2 md:p-0'>
<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'>
<div className='grid gap-2 items-center md:grid-cols-5'>
<Input

View File

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

View File

@ -1,6 +1,9 @@
import { Card, CardBody } from '@heroui/card';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks';
import clsx from 'clsx';
import { useCallback, useEffect, useState, useRef } from 'react';
import key from '@/const/key';
import toast from 'react-hot-toast';
@ -92,6 +95,9 @@ const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => {
const DashboardIndexPage: React.FC = () => {
const [archInfo, setArchInfo] = useState<string>();
// @ts-ignore
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return (
<>
@ -105,7 +111,10 @@ const DashboardIndexPage: React.FC = () => {
<SystemStatusCard setArchInfo={setArchInfo} />
</div>
<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>
<Hitokoto />
</CardBody>

View File

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

View File

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