mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
feat: webui检查更新&修复日志字体渲染
This commit is contained in:
@@ -10,8 +10,6 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.4.0",
|
||||
"@monaco-editor/react": "4.7.0-rc.0",
|
||||
"@heroui/avatar": "2.2.7",
|
||||
"@heroui/breadcrumbs": "2.2.7",
|
||||
"@heroui/button": "2.2.10",
|
||||
@@ -38,6 +36,8 @@
|
||||
"@heroui/tabs": "2.2.8",
|
||||
"@heroui/theme": "2.4.6",
|
||||
"@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",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
@@ -62,6 +62,7 @@
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "7.1.0",
|
||||
|
@@ -47,7 +47,6 @@ const RealTimeLogs = () => {
|
||||
}
|
||||
return logLevel.has(log.level)
|
||||
})
|
||||
.slice(-100)
|
||||
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
|
||||
.join('\r\n')
|
||||
Xterm.current?.clear()
|
||||
@@ -65,7 +64,6 @@ const RealTimeLogs = () => {
|
||||
useEffect(() => {
|
||||
const subscribeLogs = () => {
|
||||
try {
|
||||
console.log('subscribeLogs')
|
||||
const source = WebUIManager.getRealTimeLogs((data) => {
|
||||
setDataArr((prev) => {
|
||||
const newData = [...prev, ...data]
|
||||
|
@@ -13,12 +13,15 @@ export interface ModalProps {
|
||||
content: React.ReactNode
|
||||
title?: React.ReactNode
|
||||
size?: React.ComponentProps<typeof NextUIModal>['size']
|
||||
scrollBehavior?: React.ComponentProps<typeof NextUIModal>['scrollBehavior']
|
||||
onClose?: () => void
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
backdrop?: 'opaque' | 'blur' | 'transparent'
|
||||
showCancel?: boolean
|
||||
dismissible?: boolean
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
@@ -26,12 +29,14 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
backdrop = 'blur',
|
||||
title,
|
||||
content,
|
||||
size = 'md',
|
||||
showCancel = true,
|
||||
dismissible,
|
||||
confirmText = '确定',
|
||||
cancelText = '取消',
|
||||
onClose,
|
||||
onConfirm,
|
||||
onCancel
|
||||
onCancel,
|
||||
...rest
|
||||
} = props
|
||||
const { onClose: onNativeClose } = useDisclosure()
|
||||
|
||||
@@ -44,11 +49,11 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
onClose?.()
|
||||
onNativeClose()
|
||||
}}
|
||||
size={size}
|
||||
classNames={{
|
||||
backdrop: 'z-[99999999]',
|
||||
wrapper: 'z-[999999999]'
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<ModalContent>
|
||||
{(nativeClose) => (
|
||||
@@ -56,7 +61,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
{title && (
|
||||
<ModalHeader className="flex flex-col gap-1">{title}</ModalHeader>
|
||||
)}
|
||||
<ModalBody>{content}</ModalBody>
|
||||
<ModalBody className="break-all">{content}</ModalBody>
|
||||
<ModalFooter>
|
||||
{showCancel && (
|
||||
<Button
|
||||
@@ -67,17 +72,17 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
nativeClose()
|
||||
}}
|
||||
>
|
||||
取消
|
||||
{cancelText}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="primary"
|
||||
color="danger"
|
||||
onPress={() => {
|
||||
onConfirm?.()
|
||||
nativeClose()
|
||||
}}
|
||||
>
|
||||
确定
|
||||
{confirmText}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
|
@@ -1,45 +1,188 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Chip } from '@heroui/chip'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRequest } from 'ahooks'
|
||||
import { FaCircleInfo } from 'react-icons/fa6'
|
||||
import { FaQq } from 'react-icons/fa6'
|
||||
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6'
|
||||
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'
|
||||
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 { GithubRelease } from '@/types/github'
|
||||
|
||||
import packageJson from '../../package.json'
|
||||
import TailwindMarkdown from './tailwind_markdown'
|
||||
|
||||
export interface SystemInfoItemProps {
|
||||
title: string
|
||||
icon?: React.ReactNode
|
||||
value?: React.ReactNode
|
||||
endContent?: React.ReactNode
|
||||
}
|
||||
|
||||
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||
title,
|
||||
value = '--',
|
||||
icon
|
||||
icon,
|
||||
endContent
|
||||
}) => {
|
||||
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">
|
||||
{icon}
|
||||
<div className="w-24">{title}</div>
|
||||
<div className="text-danger-200">{value}</div>
|
||||
<div className="ml-auto">{endContent}</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 {
|
||||
archInfo?: string
|
||||
}
|
||||
const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
||||
const { archInfo } = props
|
||||
const {
|
||||
data: packageData,
|
||||
loading: packageLoading,
|
||||
error: packageError
|
||||
} = useRequest(WebUIManager.getPackageInfo)
|
||||
const {
|
||||
data: qqVersionData,
|
||||
loading: qqVersionLoading,
|
||||
@@ -53,19 +196,7 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
||||
</CardHeader>
|
||||
<CardBody className="flex-1">
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<SystemInfoItem
|
||||
title="NapCat 版本"
|
||||
icon={<IoLogoOctocat className="text-xl" />}
|
||||
value={
|
||||
packageError ? (
|
||||
`错误:${packageError.message}`
|
||||
) : packageLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
packageData?.version
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NapCatVersion />
|
||||
<SystemInfoItem
|
||||
title="WebUI 版本"
|
||||
icon={<IoLogoChrome className="text-xl" />}
|
||||
|
49
napcat.webui/src/components/tailwind_markdown.tsx
Normal file
49
napcat.webui/src/components/tailwind_markdown.tsx
Normal 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
|
@@ -21,6 +21,7 @@ export type XTermRef = {
|
||||
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
|
||||
(props, ref) => {
|
||||
const domRef = useRef<HTMLDivElement>(null)
|
||||
@@ -33,15 +34,26 @@ const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
|
||||
}
|
||||
const terminal = new Terminal({
|
||||
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
|
||||
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(new WebglAddon())
|
||||
terminal.open(domRef.current)
|
||||
|
||||
setTimeout(() => {
|
||||
fitAddon.fit()
|
||||
}, 0)
|
||||
|
||||
terminal.writeln(
|
||||
gradientText(
|
||||
@@ -76,14 +88,20 @@ const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.options.theme = {
|
||||
background:
|
||||
theme === 'dark' ? 'rgba(0, 0, 0, 0)' : 'rgba(255, 255, 255, 0)',
|
||||
background: theme === 'dark' ? '#00000000' : '#ffffff00',
|
||||
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',
|
||||
cursorAccent: theme === 'dark' ? '#000' : '#fff',
|
||||
black: theme === 'dark' ? '#fff' : '#000'
|
||||
}
|
||||
terminalRef.current.options.fontWeight =
|
||||
theme === 'dark' ? 'normal' : '600'
|
||||
terminalRef.current.options.fontWeightBold =
|
||||
theme === 'dark' ? 'bold' : '900'
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
|
@@ -1,22 +1,15 @@
|
||||
// Dialog Context
|
||||
import React from 'react'
|
||||
|
||||
import type { ModalProps } from '@/components/modal'
|
||||
import Modal from '@/components/modal'
|
||||
import type { ModalProps } from '@/components/modal'
|
||||
|
||||
export interface AlertProps {
|
||||
title?: React.ReactNode
|
||||
content: React.ReactNode
|
||||
size?: ModalProps['size']
|
||||
dismissible?: boolean
|
||||
export interface AlertProps
|
||||
extends Omit<ModalProps, 'onCancel' | 'showCancel' | 'cancelText'> {
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
export interface ConfirmProps {
|
||||
title?: React.ReactNode
|
||||
content: React.ReactNode
|
||||
size?: ModalProps['size']
|
||||
dismissible?: boolean
|
||||
export interface ConfirmProps extends ModalProps {
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
@@ -42,7 +35,7 @@ export const DialogContext = React.createContext<DialogContextProps>({
|
||||
const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
|
||||
const [dialogs, setDialogs] = React.useState<ModalItem[]>([])
|
||||
const alert = (config: AlertProps) => {
|
||||
const { title, content, dismissible, onConfirm, size = 'md' } = config
|
||||
const { onConfirm, size = 'md', ...rest } = config
|
||||
|
||||
setDialogs((before) => {
|
||||
const id = before[before.length - 1]?.id + 1 || 0
|
||||
@@ -51,32 +44,23 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
|
||||
...before,
|
||||
{
|
||||
id,
|
||||
content,
|
||||
size,
|
||||
title,
|
||||
backdrop: 'blur',
|
||||
showCancel: false,
|
||||
dismissible: dismissible,
|
||||
onConfirm: () => {
|
||||
onConfirm?.()
|
||||
setTimeout(() => {
|
||||
setDialogs((before) => before.filter((item) => item.id !== id))
|
||||
}, 300)
|
||||
}
|
||||
},
|
||||
...rest
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const confirm = (config: ConfirmProps) => {
|
||||
const {
|
||||
title,
|
||||
content,
|
||||
dismissible,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
size = 'md'
|
||||
} = config
|
||||
const { onConfirm, onCancel, size = 'md', ...rest } = config
|
||||
|
||||
setDialogs((before) => {
|
||||
const id = before[before.length - 1]?.id + 1 || 0
|
||||
@@ -85,12 +69,9 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
|
||||
...before,
|
||||
{
|
||||
id,
|
||||
content,
|
||||
size,
|
||||
title,
|
||||
backdrop: 'blur',
|
||||
showCancel: true,
|
||||
dismissible: dismissible,
|
||||
onConfirm: () => {
|
||||
onConfirm?.()
|
||||
setTimeout(() => {
|
||||
@@ -102,7 +83,8 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
|
||||
setTimeout(() => {
|
||||
setDialogs((before) => before.filter((item) => item.id !== id))
|
||||
}, 300)
|
||||
}
|
||||
},
|
||||
...rest
|
||||
}
|
||||
]
|
||||
})
|
||||
|
@@ -1,12 +1,10 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
import { Image } from '@heroui/image'
|
||||
import { Link } from '@heroui/link'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRequest } from 'ahooks'
|
||||
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 { title } from '@/components/primitives'
|
||||
|
||||
@@ -43,11 +41,6 @@ function VersionInfo() {
|
||||
data?.version
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
36
napcat.webui/src/utils/version.ts
Normal file
36
napcat.webui/src/utils/version.ts
Normal 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
|
||||
}
|
Reference in New Issue
Block a user