feat:优化webui多个页面的人机交互,修复插件地址问题,放宽插件id限制,增加高级页面缩进,统计页面快捷按钮,优化新手引导

This commit is contained in:
SengokuCola
2026-05-04 12:46:55 +08:00
parent 75665a4d38
commit 75e9453495
29 changed files with 1101 additions and 831 deletions

View File

@@ -1,6 +1,7 @@
import * as React from 'react'
import * as LucideIcons from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
@@ -9,8 +10,8 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
import { fieldHooks, type FieldHookRegistry } from '@/lib/field-hooks'
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
import { DynamicField } from './DynamicField'
@@ -20,53 +21,142 @@ export interface DynamicConfigFormProps {
onChange: (field: string, value: unknown) => void
basePath?: string
hooks?: FieldHookRegistry
/** 嵌套层级0 = tab 内容层, 1 = section 内容层, 2+ = 更深嵌套 */
/** 嵌套层级0 = tab 内容层1 = section 内容层2+ = 更深嵌套 */
level?: number
advancedVisible?: boolean
}
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 SectionIcon({ iconName }: { iconName?: string }) {
if (!iconName) return null
const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as
| React.ComponentType<{ className?: string }>
| undefined
if (!IconComponent) return null
return <IconComponent className="h-5 w-5 text-muted-foreground" />
}
function AdvancedSettingsButton({
active,
onClick,
}: {
active: boolean
onClick: () => void
}) {
return (
<Button
type="button"
variant={active ? 'secondary' : 'outline'}
size="sm"
onClick={onClick}
>
</Button>
)
}
function DynamicConfigSection({
basePath,
hooks,
level,
nestedSchema,
onChange,
sectionDescription,
sectionTitle,
values,
}: {
basePath: string
hooks: FieldHookRegistry
level: number
nestedSchema: ConfigSchema
onChange: (field: string, value: unknown) => void
sectionDescription?: string
sectionTitle: string
values: Record<string, unknown>
}) {
const [advancedVisible, setAdvancedVisible] = React.useState(false)
const hasAdvanced = hasTopLevelAdvancedFields(nestedSchema)
return (
<Card>
<CardHeader className="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>
</div>
{sectionDescription && (
<CardDescription>{sectionDescription}</CardDescription>
)}
</div>
{hasAdvanced && (
<AdvancedSettingsButton
active={advancedVisible}
onClick={() => setAdvancedVisible((current) => !current)}
/>
)}
</div>
</CardHeader>
<CardContent>
<DynamicConfigForm
schema={nestedSchema}
values={values}
onChange={onChange}
basePath={basePath}
hooks={hooks}
level={level}
advancedVisible={hasAdvanced ? advancedVisible : undefined}
/>
</CardContent>
</Card>
)
}
/**
* DynamicConfigForm - 动态配置表单组件
*
*
* 根据 ConfigSchema 渲染表单字段,支持:
* 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染
* - replace 模式:完全替换默认渲染
* - wrapper 模式:包装默认渲染(通过 children 传递)
* 2. 嵌套 schema递归渲染 schema.nested 中的子配置,使用 Card 容器区分层级
* 3. 默认渲染:使用 DynamicField 组件
* 2. 嵌套 schema递归渲染 schema.nested 中的子配置
* 3. 高级设置:由栏目标题右侧按钮控制显示
*/
export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
schema,
values,
onChange,
basePath = '',
hooks = fieldHooks, // 默认使用全局单例
hooks = fieldHooks,
level = 0,
advancedVisible,
}) => {
const [localAdvancedVisible, setLocalAdvancedVisible] = React.useState(false)
const resolvedAdvancedVisible = advancedVisible ?? localAdvancedVisible
const fieldMap = React.useMemo(
() => new Map(schema.fields.map((field) => [field.name, field])),
[schema.fields]
[schema.fields],
)
const buildFieldPath = (fieldName: string) => {
return basePath ? `${basePath}.${fieldName}` : fieldName
}
/**
* 渲染单个字段
* 检查是否有注册的 Hook根据 Hook 类型选择渲染方式
*/
const renderField = (field: FieldSchema) => {
const fieldPath = buildFieldPath(field.name)
const fieldPath = buildFieldPath(basePath, field.name)
// 检查是否有注册的 Hook
if (hooks.has(fieldPath)) {
const hookEntry = hooks.get(fieldPath)
if (!hookEntry) return null // Type guard理论上不会发生
if (!hookEntry) return null
const HookComponent = hookEntry.component
if (hookEntry.type === 'replace') {
// replace 模式:完全替换默认渲染
return (
<HookComponent
fieldPath={fieldPath}
@@ -75,27 +165,25 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
schema={field}
/>
)
} else {
// wrapper 模式:包装默认渲染
return (
<HookComponent
fieldPath={fieldPath}
}
return (
<HookComponent
fieldPath={fieldPath}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
schema={field}
>
<DynamicField
schema={field}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
schema={field}
>
<DynamicField
schema={field}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
fieldPath={fieldPath}
/>
</HookComponent>
)
}
fieldPath={fieldPath}
/>
</HookComponent>
)
}
// 无 Hook使用默认渲染
return (
<DynamicField
schema={field}
@@ -106,44 +194,49 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
)
}
/** 渲染 section 图标 */
const renderSectionIcon = (iconName?: string) => {
if (!iconName) return null
const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as
| React.ComponentType<{ className?: string }>
| undefined
if (!IconComponent) return null
return <IconComponent className="h-5 w-5 text-muted-foreground" />
}
// 过滤出不属于 nested 的顶层字段
const topLevelFields = schema.fields.filter(
(field) => !schema.nested?.[field.name]
(field) => !schema.nested?.[field.name],
)
const normalFields = topLevelFields.filter((field) => !field.advanced)
const advancedFields = topLevelFields.filter((field) => field.advanced)
const visibleFields = resolvedAdvancedVisible
? [...normalFields, ...advancedFields]
: normalFields
const renderFieldList = (fields: FieldSchema[]) => (
<>
{fields.map((field, index) => (
<React.Fragment key={field.name}>
{index > 0 && field.type !== 'boolean' && fields[index - 1]?.type !== 'boolean' && (
<Separator className="my-1" />
)}
<div>{renderField(field)}</div>
</React.Fragment>
))}
</>
)
return (
<div className="space-y-6">
{/* 渲染顶层字段 */}
{topLevelFields.length > 0 && (
<div className="space-y-1">
{topLevelFields.map((field, index) => (
<React.Fragment key={field.name}>
{index > 0 && field.type !== 'boolean' && topLevelFields[index - 1]?.type !== 'boolean' && (
<Separator className="my-1" />
)}
<div>{renderField(field)}</div>
</React.Fragment>
))}
{advancedVisible === undefined && advancedFields.length > 0 && (
<div className="flex justify-end pb-2">
<AdvancedSettingsButton
active={localAdvancedVisible}
onClick={() => setLocalAdvancedVisible((current) => !current)}
/>
</div>
)}
{renderFieldList(visibleFields)}
</div>
)}
{/* 渲染嵌套 schema */}
{schema.nested &&
Object.entries(schema.nested).map(([key, nestedSchema]) => {
const nestedField = fieldMap.get(key)
const nestedFieldPath = buildFieldPath(key)
const nestedFieldPath = buildFieldPath(basePath, key)
// Hook 系统处理
if (hooks.has(nestedFieldPath)) {
const hookEntry = hooks.get(nestedFieldPath)
if (!hookEntry) return null
@@ -192,49 +285,39 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
? nestedSchema.classDoc
: undefined
// 一级嵌套:使用 Card 包裹,清晰的 section 边界
if (level === 0) {
return (
<Card key={key}>
<CardHeader className="pb-4">
<div className="flex items-center gap-2">
{renderSectionIcon(nestedSchema.uiIcon)}
<CardTitle className="text-lg">{sectionTitle}</CardTitle>
</div>
{sectionDescription && (
<CardDescription>{sectionDescription}</CardDescription>
)}
</CardHeader>
<CardContent>
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
/>
</CardContent>
</Card>
<DynamicConfigSection
key={key}
nestedSchema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
sectionTitle={sectionTitle}
sectionDescription={sectionDescription}
/>
)
}
// 二级及更深嵌套:使用左侧指示条 + 轻量分组
return (
<div
key={key}
className="relative space-y-4 rounded-lg border-l-2 border-muted-foreground/20 pl-4 pt-1 pb-1"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
{renderSectionIcon(nestedSchema.uiIcon)}
<h4 className="text-sm font-semibold">{sectionTitle}</h4>
<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} />
<h4 className="text-sm font-semibold">{sectionTitle}</h4>
</div>
{sectionDescription && (
<p className="text-xs text-muted-foreground">
{sectionDescription}
</p>
)}
</div>
{sectionDescription && (
<p className="text-xs text-muted-foreground">
{sectionDescription}
</p>
)}
</div>
<DynamicConfigForm

View File

@@ -313,20 +313,22 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
return (
<div className="space-y-2">
{/* Label with icon */}
<Label className="text-sm font-medium flex items-center gap-2">
{renderIcon()}
{schema.label}
{schema.required && <span className="text-destructive">*</span>}
</Label>
<div className="space-y-0.5">
{/* Label with icon */}
<Label className="text-sm font-medium flex items-center gap-2">
{renderIcon()}
{schema.label}
{schema.required && <span className="text-destructive">*</span>}
</Label>
{/* Description */}
{schema.description && (
<p className="text-[13px] text-muted-foreground whitespace-pre-line">{schema.description}</p>
)}
</div>
{/* Input component */}
{renderInputComponent()}
{/* Description */}
{schema.description && (
<p className="text-[13px] text-muted-foreground whitespace-pre-line">{schema.description}</p>
)}
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, LayoutGrid, MessageSquare, Network, Package, Server, Settings, Sliders, Smile, UserCircle } from 'lucide-react'
import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, Server, Settings, Sliders, Smile, UserCircle } from 'lucide-react'
import type { MenuSection } from './types'
@@ -15,7 +15,6 @@ export const menuSections: MenuSection[] = [
{ icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot', searchDescription: 'search.items.botConfigDesc' },
{ icon: Server, label: 'sidebar.menu.aiModelProvider', path: '/config/modelProvider', searchDescription: 'search.items.modelProviderDesc', tourId: 'sidebar-model-provider' },
{ icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', searchDescription: 'search.items.modelDesc', tourId: 'sidebar-model-management' },
{ icon: Sliders, label: 'sidebar.menu.adapterConfig', path: '/config/adapter' },
],
},
{
@@ -33,7 +32,6 @@ export const menuSections: MenuSection[] = [
title: 'sidebar.groups.extensionsMonitor',
items: [
{ icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins', searchDescription: 'search.items.pluginsDesc' },
{ icon: LayoutGrid, label: 'sidebar.menu.configTemplate', path: '/config/pack-market' },
{ icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' },
{ icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' },
{ icon: Activity, label: 'sidebar.menu.maisakaMonitor', path: '/planner-monitor' },