mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-12-27 11:21:21 +08:00
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:
parent
8697061a90
commit
84f0e0f9a0
@ -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']);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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> = (
|
||||
|
||||
@ -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> = (
|
||||
|
||||
@ -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> = (
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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('已复制');
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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后,您可以更便捷地登录WebUI,无需每次输入token
|
||||
</div>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user