feat: webui检查更新&修复日志字体渲染

This commit is contained in:
bietiaop
2025-01-26 21:48:45 +08:00
parent 520cec0eaa
commit 3917cb0dc9
9 changed files with 287 additions and 74 deletions

View File

@@ -10,8 +10,6 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@monaco-editor/loader": "^1.4.0",
"@monaco-editor/react": "4.7.0-rc.0",
"@heroui/avatar": "2.2.7", "@heroui/avatar": "2.2.7",
"@heroui/breadcrumbs": "2.2.7", "@heroui/breadcrumbs": "2.2.7",
"@heroui/button": "2.2.10", "@heroui/button": "2.2.10",
@@ -38,6 +36,8 @@
"@heroui/tabs": "2.2.8", "@heroui/tabs": "2.2.8",
"@heroui/theme": "2.4.6", "@heroui/theme": "2.4.6",
"@heroui/tooltip": "2.2.8", "@heroui/tooltip": "2.2.8",
"@monaco-editor/loader": "^1.4.0",
"@monaco-editor/react": "4.7.0-rc.0",
"@react-aria/visually-hidden": "3.8.18", "@react-aria/visually-hidden": "3.8.18",
"@reduxjs/toolkit": "^2.5.0", "@reduxjs/toolkit": "^2.5.0",
"@uidotdev/usehooks": "^2.4.1", "@uidotdev/usehooks": "^2.4.1",
@@ -62,6 +62,7 @@
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-markdown": "^9.0.3",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-responsive": "^10.0.0", "react-responsive": "^10.0.0",
"react-router-dom": "7.1.0", "react-router-dom": "7.1.0",

View File

@@ -47,7 +47,6 @@ const RealTimeLogs = () => {
} }
return logLevel.has(log.level) return logLevel.has(log.level)
}) })
.slice(-100)
.map((log) => colorizeLogLevelWithTag(log.message, log.level)) .map((log) => colorizeLogLevelWithTag(log.message, log.level))
.join('\r\n') .join('\r\n')
Xterm.current?.clear() Xterm.current?.clear()
@@ -65,7 +64,6 @@ const RealTimeLogs = () => {
useEffect(() => { useEffect(() => {
const subscribeLogs = () => { const subscribeLogs = () => {
try { try {
console.log('subscribeLogs')
const source = WebUIManager.getRealTimeLogs((data) => { const source = WebUIManager.getRealTimeLogs((data) => {
setDataArr((prev) => { setDataArr((prev) => {
const newData = [...prev, ...data] const newData = [...prev, ...data]

View File

@@ -13,12 +13,15 @@ export interface ModalProps {
content: React.ReactNode content: React.ReactNode
title?: React.ReactNode title?: React.ReactNode
size?: React.ComponentProps<typeof NextUIModal>['size'] size?: React.ComponentProps<typeof NextUIModal>['size']
scrollBehavior?: React.ComponentProps<typeof NextUIModal>['scrollBehavior']
onClose?: () => void onClose?: () => void
onConfirm?: () => void onConfirm?: () => void
onCancel?: () => void onCancel?: () => void
backdrop?: 'opaque' | 'blur' | 'transparent' backdrop?: 'opaque' | 'blur' | 'transparent'
showCancel?: boolean showCancel?: boolean
dismissible?: boolean dismissible?: boolean
confirmText?: string
cancelText?: string
} }
const Modal: React.FC<ModalProps> = React.memo((props) => { const Modal: React.FC<ModalProps> = React.memo((props) => {
@@ -26,12 +29,14 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
backdrop = 'blur', backdrop = 'blur',
title, title,
content, content,
size = 'md',
showCancel = true, showCancel = true,
dismissible, dismissible,
confirmText = '确定',
cancelText = '取消',
onClose, onClose,
onConfirm, onConfirm,
onCancel onCancel,
...rest
} = props } = props
const { onClose: onNativeClose } = useDisclosure() const { onClose: onNativeClose } = useDisclosure()
@@ -44,11 +49,11 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
onClose?.() onClose?.()
onNativeClose() onNativeClose()
}} }}
size={size}
classNames={{ classNames={{
backdrop: 'z-[99999999]', backdrop: 'z-[99999999]',
wrapper: 'z-[999999999]' wrapper: 'z-[999999999]'
}} }}
{...rest}
> >
<ModalContent> <ModalContent>
{(nativeClose) => ( {(nativeClose) => (
@@ -56,7 +61,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
{title && ( {title && (
<ModalHeader className="flex flex-col gap-1">{title}</ModalHeader> <ModalHeader className="flex flex-col gap-1">{title}</ModalHeader>
)} )}
<ModalBody>{content}</ModalBody> <ModalBody className="break-all">{content}</ModalBody>
<ModalFooter> <ModalFooter>
{showCancel && ( {showCancel && (
<Button <Button
@@ -67,17 +72,17 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
nativeClose() nativeClose()
}} }}
> >
{cancelText}
</Button> </Button>
)} )}
<Button <Button
color="primary" color="danger"
onPress={() => { onPress={() => {
onConfirm?.() onConfirm?.()
nativeClose() nativeClose()
}} }}
> >
{confirmText}
</Button> </Button>
</ModalFooter> </ModalFooter>
</> </>

View File

@@ -1,45 +1,188 @@
import { Button } from '@heroui/button'
import { Card, CardBody, CardHeader } from '@heroui/card' import { Card, CardBody, CardHeader } from '@heroui/card'
import { Chip } from '@heroui/chip'
import { Spinner } from '@heroui/spinner' import { Spinner } from '@heroui/spinner'
import { Tooltip } from '@heroui/tooltip'
import { useRequest } from 'ahooks' import { useRequest } from 'ahooks'
import { FaCircleInfo } from 'react-icons/fa6' import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6'
import { FaQq } from 'react-icons/fa6'
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io' import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'
import { RiMacFill } from 'react-icons/ri' import { RiMacFill } from 'react-icons/ri'
import useDialog from '@/hooks/use-dialog'
import { request } from '@/utils/request'
import { compareVersion } from '@/utils/version'
import WebUIManager from '@/controllers/webui_manager' import WebUIManager from '@/controllers/webui_manager'
import { GithubRelease } from '@/types/github'
import packageJson from '../../package.json' import packageJson from '../../package.json'
import TailwindMarkdown from './tailwind_markdown'
export interface SystemInfoItemProps { export interface SystemInfoItemProps {
title: string title: string
icon?: React.ReactNode icon?: React.ReactNode
value?: React.ReactNode value?: React.ReactNode
endContent?: React.ReactNode
} }
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
title, title,
value = '--', value = '--',
icon icon,
endContent
}) => { }) => {
return ( return (
<div className="flex text-sm gap-1 p-2 items-center shadow-sm shadow-danger-50 dark:shadow-danger-100 rounded text-danger-400"> <div className="flex text-sm gap-1 p-2 items-center shadow-sm shadow-danger-50 dark:shadow-danger-100 rounded text-danger-400">
{icon} {icon}
<div className="w-24">{title}</div> <div className="w-24">{title}</div>
<div className="text-danger-200">{value}</div> <div className="text-danger-200">{value}</div>
<div className="ml-auto">{endContent}</div>
</div> </div>
) )
} }
export interface NewVersionTipProps {
currentVersion?: string
}
const NewVersionTip = (props: NewVersionTipProps) => {
const { currentVersion } = props
const dialog = useDialog()
const { data: releaseData, error } = useRequest(() =>
request.get<GithubRelease[]>(
'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
)
)
if (error) {
return (
<Tooltip content="检查新版本失败">
<Button
isIconOnly
radius="full"
color="danger"
variant="shadow"
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
onPress={() => {
dialog.alert({
title: '检查新版本失败',
content: error.message
})
}}
>
<FaInfo />
</Button>
</Tooltip>
)
}
const latestVersion = releaseData?.data?.[0]?.tag_name
if (!latestVersion || !currentVersion) {
return null
}
if (compareVersion(latestVersion, currentVersion) <= 0) {
return null
}
const middleVersions: GithubRelease[] = []
for (let i = 0; i < releaseData.data.length; i++) {
const versionInfo = releaseData.data[i]
if (compareVersion(versionInfo.tag_name, currentVersion) > 0) {
middleVersions.push(versionInfo)
} else {
break
}
}
return (
<Tooltip content="有新版本可用">
<Button
isIconOnly
radius="full"
color="danger"
variant="shadow"
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
onPress={() => {
dialog.confirm({
title: '有新版本可用',
content: (
<div className="space-y-2">
<div className="text-sm space-x-2">
<span></span>
<Chip color="primary" variant="flat">
v{currentVersion}
</Chip>
</div>
<div className="text-sm space-x-2">
<span></span>
<Chip color="primary">{latestVersion}</Chip>
</div>
<div className="text-sm space-y-2 !mt-4">
{middleVersions.map((versionInfo) => (
<div
key={versionInfo.tag_name}
className="p-4 bg-content1 rounded-md shadow-small"
>
<TailwindMarkdown content={versionInfo.body} />
</div>
))}
</div>
</div>
),
scrollBehavior: 'inside',
size: '3xl',
confirmText: '前往下载',
onConfirm() {
window.open(
'https://github.com/NapNeko/NapCatQQ/releases',
'_blank'
)
}
})
}}
>
<FaInfo />
</Button>
</Tooltip>
)
}
const NapCatVersion = () => {
const {
data: packageData,
loading: packageLoading,
error: packageError
} = useRequest(WebUIManager.getPackageInfo)
const currentVersion = packageData?.version
return (
<SystemInfoItem
title="NapCat 版本"
icon={<IoLogoOctocat className="text-xl" />}
value={
packageError ? (
`错误:${packageError.message}`
) : packageLoading ? (
<Spinner size="sm" />
) : (
currentVersion
)
}
endContent={<NewVersionTip currentVersion={currentVersion} />}
/>
)
}
export interface SystemInfoProps { export interface SystemInfoProps {
archInfo?: string archInfo?: string
} }
const SystemInfo: React.FC<SystemInfoProps> = (props) => { const SystemInfo: React.FC<SystemInfoProps> = (props) => {
const { archInfo } = props const { archInfo } = props
const {
data: packageData,
loading: packageLoading,
error: packageError
} = useRequest(WebUIManager.getPackageInfo)
const { const {
data: qqVersionData, data: qqVersionData,
loading: qqVersionLoading, loading: qqVersionLoading,
@@ -53,19 +196,7 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
</CardHeader> </CardHeader>
<CardBody className="flex-1"> <CardBody className="flex-1">
<div className="flex flex-col justify-between h-full"> <div className="flex flex-col justify-between h-full">
<SystemInfoItem <NapCatVersion />
title="NapCat 版本"
icon={<IoLogoOctocat className="text-xl" />}
value={
packageError ? (
`错误:${packageError.message}`
) : packageLoading ? (
<Spinner size="sm" />
) : (
packageData?.version
)
}
/>
<SystemInfoItem <SystemInfoItem
title="WebUI 版本" title="WebUI 版本"
icon={<IoLogoChrome className="text-xl" />} icon={<IoLogoChrome className="text-xl" />}

View File

@@ -0,0 +1,49 @@
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
return (
<Markdown
className="prose prose-sm sm:prose lg:prose-lg xl:prose-xl"
remarkPlugins={[remarkGfm]}
components={{
h1: ({ node, ...props }) => (
<h1 className="text-2xl font-bold" {...props} />
),
h2: ({ node, ...props }) => (
<h2 className="text-xl font-bold" {...props} />
),
h3: ({ node, ...props }) => (
<h3 className="text-lg font-bold" {...props} />
),
p: ({ node, ...props }) => <p className="m-0" {...props} />,
a: ({ node, ...props }) => (
<a
className="text-blue-500 hover:underline"
target="_blank"
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul className="list-disc list-inside" {...props} />
),
ol: ({ node, ...props }) => (
<ol className="list-decimal list-inside" {...props} />
),
blockquote: ({ node, ...props }) => (
<blockquote
className="border-l-4 border-gray-300 pl-4 italic"
{...props}
/>
),
code: ({ node, ...props }) => (
<code className="bg-gray-100 p-1 rounded" {...props} />
)
}}
>
{content}
</Markdown>
)
}
export default TailwindMarkdown

View File

@@ -21,6 +21,7 @@ export type XTermRef = {
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void> writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
clear: () => void clear: () => void
} }
const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>( const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
(props, ref) => { (props, ref) => {
const domRef = useRef<HTMLDivElement>(null) const domRef = useRef<HTMLDivElement>(null)
@@ -33,15 +34,26 @@ const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
} }
const terminal = new Terminal({ const terminal = new Terminal({
allowTransparency: true, allowTransparency: true,
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace' fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
cursorInactiveStyle: 'outline',
drawBoldTextInBrightColors: false
}) })
terminalRef.current = terminal terminalRef.current = terminal
const fitAddon = new FitAddon() const fitAddon = new FitAddon()
terminal.loadAddon(new WebLinksAddon()) terminal.loadAddon(
new WebLinksAddon((event, uri) => {
if (event.ctrlKey) {
window.open(uri, '_blank')
}
})
)
terminal.loadAddon(fitAddon) terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebglAddon()) terminal.loadAddon(new WebglAddon())
terminal.open(domRef.current) terminal.open(domRef.current)
setTimeout(() => {
fitAddon.fit() fitAddon.fit()
}, 0)
terminal.writeln( terminal.writeln(
gradientText( gradientText(
@@ -76,14 +88,20 @@ const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
useEffect(() => { useEffect(() => {
if (terminalRef.current) { if (terminalRef.current) {
terminalRef.current.options.theme = { terminalRef.current.options.theme = {
background: background: theme === 'dark' ? '#00000000' : '#ffffff00',
theme === 'dark' ? 'rgba(0, 0, 0, 0)' : 'rgba(255, 255, 255, 0)',
foreground: theme === 'dark' ? '#fff' : '#000', foreground: theme === 'dark' ? '#fff' : '#000',
selectionBackground: theme === 'dark' ? '#666' : '#ddd', selectionBackground:
theme === 'dark'
? 'rgba(179, 0, 0, 0.3)'
: 'rgba(255, 167, 167, 0.3)',
cursor: theme === 'dark' ? '#fff' : '#000', cursor: theme === 'dark' ? '#fff' : '#000',
cursorAccent: theme === 'dark' ? '#000' : '#fff', cursorAccent: theme === 'dark' ? '#000' : '#fff',
black: theme === 'dark' ? '#fff' : '#000' black: theme === 'dark' ? '#fff' : '#000'
} }
terminalRef.current.options.fontWeight =
theme === 'dark' ? 'normal' : '600'
terminalRef.current.options.fontWeightBold =
theme === 'dark' ? 'bold' : '900'
} }
}, [theme]) }, [theme])

View File

@@ -1,22 +1,15 @@
// Dialog Context // Dialog Context
import React from 'react' import React from 'react'
import type { ModalProps } from '@/components/modal'
import Modal from '@/components/modal' import Modal from '@/components/modal'
import type { ModalProps } from '@/components/modal'
export interface AlertProps { export interface AlertProps
title?: React.ReactNode extends Omit<ModalProps, 'onCancel' | 'showCancel' | 'cancelText'> {
content: React.ReactNode
size?: ModalProps['size']
dismissible?: boolean
onConfirm?: () => void onConfirm?: () => void
} }
export interface ConfirmProps { export interface ConfirmProps extends ModalProps {
title?: React.ReactNode
content: React.ReactNode
size?: ModalProps['size']
dismissible?: boolean
onConfirm?: () => void onConfirm?: () => void
onCancel?: () => void onCancel?: () => void
} }
@@ -42,7 +35,7 @@ export const DialogContext = React.createContext<DialogContextProps>({
const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => { const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
const [dialogs, setDialogs] = React.useState<ModalItem[]>([]) const [dialogs, setDialogs] = React.useState<ModalItem[]>([])
const alert = (config: AlertProps) => { const alert = (config: AlertProps) => {
const { title, content, dismissible, onConfirm, size = 'md' } = config const { onConfirm, size = 'md', ...rest } = config
setDialogs((before) => { setDialogs((before) => {
const id = before[before.length - 1]?.id + 1 || 0 const id = before[before.length - 1]?.id + 1 || 0
@@ -51,32 +44,23 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
...before, ...before,
{ {
id, id,
content,
size, size,
title,
backdrop: 'blur', backdrop: 'blur',
showCancel: false, showCancel: false,
dismissible: dismissible,
onConfirm: () => { onConfirm: () => {
onConfirm?.() onConfirm?.()
setTimeout(() => { setTimeout(() => {
setDialogs((before) => before.filter((item) => item.id !== id)) setDialogs((before) => before.filter((item) => item.id !== id))
}, 300) }, 300)
} },
...rest
} }
] ]
}) })
} }
const confirm = (config: ConfirmProps) => { const confirm = (config: ConfirmProps) => {
const { const { onConfirm, onCancel, size = 'md', ...rest } = config
title,
content,
dismissible,
onConfirm,
onCancel,
size = 'md'
} = config
setDialogs((before) => { setDialogs((before) => {
const id = before[before.length - 1]?.id + 1 || 0 const id = before[before.length - 1]?.id + 1 || 0
@@ -85,12 +69,9 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
...before, ...before,
{ {
id, id,
content,
size, size,
title,
backdrop: 'blur', backdrop: 'blur',
showCancel: true, showCancel: true,
dismissible: dismissible,
onConfirm: () => { onConfirm: () => {
onConfirm?.() onConfirm?.()
setTimeout(() => { setTimeout(() => {
@@ -102,7 +83,8 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
setTimeout(() => { setTimeout(() => {
setDialogs((before) => before.filter((item) => item.id !== id)) setDialogs((before) => before.filter((item) => item.id !== id))
}, 300) }, 300)
} },
...rest
} }
] ]
}) })

View File

@@ -1,12 +1,10 @@
import { Chip } from '@heroui/chip' import { Chip } from '@heroui/chip'
import { Image } from '@heroui/image' import { Image } from '@heroui/image'
import { Link } from '@heroui/link'
import { Spinner } from '@heroui/spinner' import { Spinner } from '@heroui/spinner'
import { Tooltip } from '@heroui/tooltip'
import { useRequest } from 'ahooks' import { useRequest } from 'ahooks'
import clsx from 'clsx' import clsx from 'clsx'
import { BietiaopIcon, GithubIcon, WebUIIcon } from '@/components/icons' import { BietiaopIcon, WebUIIcon } from '@/components/icons'
import NapCatRepoInfo from '@/components/napcat_repo_info' import NapCatRepoInfo from '@/components/napcat_repo_info'
import { title } from '@/components/primitives' import { title } from '@/components/primitives'
@@ -43,11 +41,6 @@ function VersionInfo() {
data?.version data?.version
)} )}
</Chip> </Chip>
<Tooltip content="查看WebUI源码" placement="bottom" showArrow>
<Link isExternal href="https://github.com/bietiaop/NextNapCatWebUI">
<GithubIcon className="text-default-900 hover:text-default-600 w-8 h-8 hover:drop-shadow-lg transition-all" />
</Link>
</Tooltip>
</div> </div>
) )
} }

View File

@@ -0,0 +1,36 @@
/**
* 版本号转为数字
* @param version 版本号
* @returns 版本号数字
*/
export const versionToNumber = (version: string): number => {
const finalVersionString = version.replace(/^v/, '')
const versionArray = finalVersionString.split('.')
const versionNumber =
parseInt(versionArray[2]) +
parseInt(versionArray[1]) * 100 +
parseInt(versionArray[0]) * 10000
return versionNumber
}
/**
* 比较版本号
* @param version1 版本号1
* @param version2 版本号2
* @returns 比较结果
* 0: 相等
* 1: version1 > version2
* -1: version1 < version2
*/
export const compareVersion = (version1: string, version2: string): number => {
const versionNumber1 = versionToNumber(version1)
const versionNumber2 = versionToNumber(version2)
if (versionNumber1 === versionNumber2) {
return 0
}
return versionNumber1 > versionNumber2 ? 1 : -1
}