perf:优化webui交互体验,优化统计逻辑,优化log展示

This commit is contained in:
SengokuCola
2026-05-07 00:05:35 +08:00
parent 1bb6f514e7
commit 5846f6e0c4
41 changed files with 1723 additions and 619 deletions

View File

@@ -5,7 +5,6 @@ import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
@@ -31,20 +30,10 @@ function buildFieldPath(basePath: string, fieldName: string) {
return basePath ? `${basePath}.${fieldName}` : fieldName
}
function hasTopLevelAdvancedFields(schema: ConfigSchema) {
return schema.fields.some((field) => field.advanced && !schema.nested?.[field.name])
}
function resolveSectionTitle(schema: ConfigSchema) {
return schema.uiLabel || schema.classDoc || schema.className
}
function resolveSectionDescription(schema: ConfigSchema, sectionTitle: string) {
return schema.classDoc && schema.classDoc !== sectionTitle
? schema.classDoc
: undefined
}
function SectionIcon({ iconName }: { iconName?: string }) {
if (!iconName) return null
const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as
@@ -54,7 +43,7 @@ function SectionIcon({ iconName }: { iconName?: string }) {
return <IconComponent className="h-5 w-5 text-muted-foreground" />
}
function AdvancedSettingsButton({
export function AdvancedSettingsButton({
active,
onClick,
}: {
@@ -74,51 +63,39 @@ function AdvancedSettingsButton({
}
function DynamicConfigSection({
advancedVisible,
basePath,
hooks,
level,
nestedSchema,
onChange,
sectionDescription,
sectionKey,
sectionTitle,
values,
}: {
advancedVisible: boolean
basePath: string
hooks: FieldHookRegistry
level: number
nestedSchema: ConfigSchema
onChange: (field: string, value: unknown) => void
sectionDescription?: string
sectionKey: string
sectionTitle: string
values: Record<string, unknown>
}) {
const [advancedVisible, setAdvancedVisible] = React.useState(false)
const hasAdvanced = hasTopLevelAdvancedFields(nestedSchema)
return (
<Card>
<CardHeader className="pb-4">
<CardHeader className="border-b border-border/50 pb-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<SectionIcon iconName={nestedSchema.uiIcon} />
<CardTitle className="text-lg">{sectionTitle}</CardTitle>
<CardTitle className="text-lg text-primary">{sectionTitle}</CardTitle>
</div>
{sectionDescription && (
<CardDescription>{sectionDescription}</CardDescription>
)}
</div>
{hasAdvanced && (
<AdvancedSettingsButton
active={advancedVisible}
onClick={() => setAdvancedVisible((current) => !current)}
/>
)}
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-4">
<DynamicConfigForm
schema={nestedSchema}
values={values}
@@ -126,7 +103,7 @@ function DynamicConfigSection({
basePath={basePath}
hooks={hooks}
level={level}
advancedVisible={hasAdvanced ? advancedVisible : undefined}
advancedVisible={advancedVisible}
sectionColumns={1}
/>
</CardContent>
@@ -154,8 +131,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
advancedVisible,
sectionColumns = 1,
}) => {
const [localAdvancedVisible, setLocalAdvancedVisible] = React.useState(false)
const resolvedAdvancedVisible = advancedVisible ?? localAdvancedVisible
const resolvedAdvancedVisible = advancedVisible ?? false
const fieldMap = React.useMemo(
() => new Map(schema.fields.map((field) => [field.name, field])),
@@ -230,6 +206,51 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
return hooks.get(fieldPath)?.type === 'replace'
}
const schemaHasVisibleContent = React.useCallback(
(targetSchema: ConfigSchema, targetBasePath: string): boolean => {
const targetFields = targetSchema.fields ?? []
const hasVisibleInlineField = targetFields.some((field) => {
const fieldPath = buildFieldPath(targetBasePath, field.name)
const hookEntry = hooks.get(fieldPath)
if (hookEntry?.type === 'hidden') {
return false
}
if (targetSchema.nested?.[field.name] && hookEntry?.type !== 'replace') {
return false
}
return resolvedAdvancedVisible || !field.advanced
})
if (hasVisibleInlineField) {
return true
}
return Object.entries(targetSchema.nested ?? {}).some(([key, nestedSchema]) => {
const nestedField = targetFields.find((field) => field.name === key)
const nestedFieldPath = buildFieldPath(targetBasePath, key)
const hookEntry = hooks.get(nestedFieldPath)
if (hookEntry?.type === 'hidden') {
return false
}
if (nestedField?.advanced && !resolvedAdvancedVisible) {
return false
}
if (hookEntry?.type === 'replace') {
return true
}
return schemaHasVisibleContent(nestedSchema, nestedFieldPath)
})
},
[hooks, resolvedAdvancedVisible],
)
const inlineFields = schema.fields.filter(shouldRenderFieldInline)
const inlineNestedFieldNames = new Set(
inlineFields
@@ -302,16 +323,8 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
return (
<div className="space-y-6">
{inlineFields.length > 0 && (
{visibleFields.length > 0 && (
<div>
{advancedVisible === undefined && advancedFields.length > 0 && (
<div className="flex justify-end pb-2">
<AdvancedSettingsButton
active={localAdvancedVisible}
onClick={() => setLocalAdvancedVisible((current) => !current)}
/>
</div>
)}
{renderFieldList(visibleFields)}
</div>
)}
@@ -327,6 +340,15 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
if (hooks.has(nestedFieldPath)) {
const hookEntry = hooks.get(nestedFieldPath)
if (!hookEntry) return null
if (hookEntry.type === 'hidden') return null
if (nestedField?.advanced && !resolvedAdvancedVisible) return null
if (
hookEntry.type !== 'replace' &&
nestedSchema &&
!schemaHasVisibleContent(nestedSchema, nestedFieldPath)
) {
return null
}
const HookComponent = hookEntry.component
if (hookEntry.type === 'replace') {
@@ -363,6 +385,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
advancedVisible={resolvedAdvancedVisible}
sectionColumns={1}
/>
</HookComponent>
@@ -371,12 +394,15 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
}
const sectionTitle = resolveSectionTitle(nestedSchema)
const sectionDescription = resolveSectionDescription(nestedSchema, sectionTitle)
if (!schemaHasVisibleContent(nestedSchema, nestedFieldPath)) {
return null
}
if (level === 0) {
return (
<DynamicConfigSection
key={key}
advancedVisible={resolvedAdvancedVisible}
nestedSchema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={onChange}
@@ -385,29 +411,23 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
level={level + 1}
sectionKey={key}
sectionTitle={sectionTitle}
sectionDescription={sectionDescription}
/>
)
}
return (
<Card key={key} className="border-border/70 bg-muted/20 shadow-none">
<CardHeader className="px-4 py-3">
<CardHeader className="border-b border-border/50 px-4 py-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<SectionIcon iconName={nestedSchema.uiIcon} />
<CardTitle className="text-sm">{sectionTitle}</CardTitle>
<CardTitle className="text-sm text-primary">{sectionTitle}</CardTitle>
</div>
{sectionDescription && (
<CardDescription className="text-xs">
{sectionDescription}
</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
<CardContent className="px-4 pb-4 pt-4">
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
@@ -415,6 +435,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
advancedVisible={resolvedAdvancedVisible}
sectionColumns={1}
/>
</CardContent>

View File

@@ -158,10 +158,10 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
const label = (
<Label
className={cn(
"inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[15px] font-semibold leading-6",
"inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[15px] leading-6",
descriptionDisplay === 'label-hover' && fieldDescription && "cursor-help",
schema.advanced
? "text-amber-800 dark:text-amber-200"
? "text-sky-700 dark:text-sky-300"
: "text-foreground",
)}
>

View File

@@ -1,13 +1,41 @@
import { useEffect, useState } from 'react'
import { getDashboardVersionStatus, type DashboardVersionStatus } from '@/lib/system-api'
import { cn } from '@/lib/utils'
import { formatVersion } from '@/lib/version'
import { APP_VERSION, formatVersion } from '@/lib/version'
interface LogoAreaProps {
sidebarOpen: boolean
}
export function LogoArea({ sidebarOpen }: LogoAreaProps) {
const [versionStatus, setVersionStatus] = useState<DashboardVersionStatus | null>(null)
useEffect(() => {
let mounted = true
const loadVersionStatus = async () => {
try {
const status = await getDashboardVersionStatus(APP_VERSION)
if (mounted) {
setVersionStatus(status)
}
} catch (error) {
console.debug('检查 WebUI 版本更新失败:', error)
}
}
void loadVersionStatus()
return () => {
mounted = false
}
}, [])
const hasUpdate = versionStatus?.has_update === true && Boolean(versionStatus.latest_version)
return (
<div className="flex h-16 items-center border-b px-4">
<div className="flex h-20 items-center border-b px-4">
<div
className={cn(
'relative flex items-center justify-center flex-1 transition-all overflow-hidden',
@@ -18,13 +46,51 @@ export function LogoArea({ sidebarOpen }: LogoAreaProps) {
>
{/* 移动端始终显示完整 Logo桌面端根据 sidebarOpen 切换 */}
<div className={cn(
"flex items-baseline gap-2",
"flex min-w-0 flex-col items-start justify-center gap-1",
!sidebarOpen && "lg:hidden"
)}>
<span className="font-bold text-xl text-primary-gradient whitespace-nowrap">MaiBot WebUI</span>
<span className="text-xs text-primary/60 whitespace-nowrap">
{formatVersion()}
<span className="max-w-full truncate whitespace-nowrap text-xl font-bold text-primary-gradient">
MaiBot WebUI
</span>
<div className="flex max-w-full items-center gap-2 overflow-hidden">
<span className="shrink-0 whitespace-nowrap text-sm font-semibold text-primary/70">
{formatVersion()}
</span>
{hasUpdate && (
<a
href={versionStatus?.pypi_url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex h-5 min-w-0 items-center rounded-md border border-amber-400/50 px-2",
"bg-amber-400/10 text-[11px] font-semibold text-amber-700",
"transition-colors hover:bg-amber-400/20 dark:text-amber-300"
)}
>
<span className="truncate"> v{versionStatus?.latest_version}</span>
</a>
)}
</div>
{false && hasUpdate && (
<a
href={versionStatus?.pypi_url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex h-5 items-center rounded-md border border-amber-400/50 px-2",
"bg-amber-400/10 text-[11px] font-semibold text-amber-700",
"transition-colors hover:bg-amber-400/20 dark:text-amber-300"
)}
>
v{versionStatus?.latest_version}
</a>
)}
<div className="hidden">
<span className="font-bold text-xl text-primary-gradient whitespace-nowrap">MaiBot WebUI</span>
<span className="text-base font-semibold text-primary/70 whitespace-nowrap">
{formatVersion()}
</span>
</div>
</div>
{/* 折叠时的 Logo - 仅桌面端显示 */}
{!sidebarOpen && (

View File

@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useEffect, useState } from "react"
import {
Dialog,
DialogContent,
@@ -27,6 +27,12 @@ export function ExtraParamsDialog({
}: ExtraParamsDialogProps) {
const [editingValue, setEditingValue] = useState<Record<string, unknown>>(value)
useEffect(() => {
if (open) {
setEditingValue(value)
}
}, [open, value])
// 当对话框打开状态改变时的处理
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {

View File

@@ -1,6 +1,6 @@
"use client"
import { useState, useCallback } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { Plus, Trash2, ChevronRight, ChevronDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -292,12 +292,23 @@ export function NestedKeyValueEditor({
placeholder = "添加参数...",
}: NestedKeyValueEditorProps) {
const [nodes, setNodes] = useState<TreeNode[]>(() => recordToTree(value || {}))
const lastEmittedValueRef = useRef<string | null>(null)
useEffect(() => {
const nextValueJson = JSON.stringify(value || {})
if (lastEmittedValueRef.current === nextValueJson) {
return
}
setNodes(recordToTree(value || {}))
}, [value])
// 同步到父组件
const syncToParent = useCallback(
(newNodes: TreeNode[]) => {
const nextValue = treeToRecord(newNodes)
lastEmittedValueRef.current = JSON.stringify(nextValue)
setNodes(newNodes)
onChange(treeToRecord(newNodes))
onChange(nextValue)
},
[onChange]
)