perf:优化webui交互体验,优化统计逻辑,优化log展示
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user