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]
)

View File

@@ -26,7 +26,7 @@
"home": "首页",
"botMainConfig": "麦麦设置",
"aiModelProvider": "模型厂商设置",
"modelManagement": "模型管理与分配",
"modelManagement": "模型管理",
"promptManagement": "Prompt 管理",
"adapterConfig": "麦麦适配器配置",
"emojiManagement": "表情包",

View File

@@ -101,6 +101,30 @@
}
/* JetBrains Mono 字体 - 用于代码编辑器 */
@keyframes config-tab-enter {
from {
opacity: 0;
transform: translateX(-0.5rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes config-tab-content-enter {
from {
opacity: 0;
transform: translateX(0.375rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono-Medium.ttf') format('truetype');

View File

@@ -42,3 +42,31 @@ export async function getMaiBotStatus(): Promise<{
return await response.json()
}
export interface DashboardVersionStatus {
current_version: string
latest_version: string | null
has_update: boolean
package_name: string
pypi_url: string
}
/**
* 检查 WebUI 是否有 PyPI 新版本
*/
export async function getDashboardVersionStatus(
currentVersion: string
): Promise<DashboardVersionStatus> {
const params = new URLSearchParams({ current_version: currentVersion })
const response = await fetchWithAuth(`/api/webui/system/dashboard-version?${params.toString()}`, {
method: 'GET',
headers: getAuthHeaders(),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || '获取 WebUI 版本失败')
}
return await response.json()
}

View File

@@ -25,10 +25,11 @@ import { fieldHooks } from '@/lib/field-hooks'
import { RestartProvider, useRestart } from '@/lib/restart-context'
import { cn } from '@/lib/utils'
import { ChevronDown, ChevronUp, Code2, Info, Layout, Power, RefreshCw, Save } from 'lucide-react'
import { ChevronLeft, ChevronRight, Code2, Info, Layout, Power, RefreshCw, Save } from 'lucide-react'
import type { ConfigSchema } from '@/types/config-schema'
import {
AliasNamesHook,
BotPlatformAccountsHook,
ChatPromptsHook,
ChatTalkValueRulesHook,
@@ -38,6 +39,7 @@ import {
HiddenFieldHook,
MCPRootItemsHook,
MCPServersHook,
MultipleReplyStyleHook,
RegexRulesHook,
useAutoSave,
useConfigAutoSave,
@@ -414,8 +416,10 @@ function BotConfigPageContent() {
useEffect(() => {
const hookEntries = [
['bot.platform', BotPlatformAccountsHook, 'replace'],
['bot.alias_names', AliasNamesHook],
['bot.qq_account', HiddenFieldHook, 'hidden'],
['bot.platforms', HiddenFieldHook, 'hidden'],
['personality.multiple_reply_style', MultipleReplyStyleHook],
['chat.chat_prompts', ChatPromptsHook],
['chat.talk_value_rules', ChatTalkValueRulesHook],
['expression.expression_groups', ExpressionGroupsHook],
@@ -959,6 +963,7 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
const { configSchema, sectionValues, setHasUnsavedChanges, setSectionValue, tabGroups } = props
const [expanded, setExpanded] = useState(false)
const [activeTab, setActiveTab] = useState(tabGroups[0]?.id ?? '')
const [advancedVisible, setAdvancedVisible] = useState(false)
useEffect(() => {
if (!tabGroups.some((tab) => tab.id === activeTab)) {
@@ -1028,6 +1033,7 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
setHasUnsavedChanges(true)
}}
hooks={fieldHooks}
advancedVisible={advancedVisible}
sectionColumns={2}
/>
)
@@ -1035,20 +1041,20 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="flex flex-wrap h-auto gap-1 p-1">
<TabsList className="flex flex-wrap h-auto gap-1 p-1 transition-all duration-300 ease-out">
{visibleTabGroups.map((tab) => {
const isExpandedOnlyTab = !DEFAULT_VISIBLE_TAB_IDS.has(tab.id)
return (
<Fragment key={tab.id}>
{tab.id === firstExpandedTabId && (
<span className="mx-1 hidden h-6 w-px bg-border/80 sm:block" />
<span className="mx-1 hidden h-6 w-px bg-border/80 transition-opacity duration-200 sm:block" />
)}
<TabsTrigger
value={tab.id}
className={cn(
"text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm",
"px-2 py-1.5 text-sm transition-all duration-200 ease-out sm:px-3 sm:py-2 data-[state=active]:shadow-sm",
isExpandedOnlyTab &&
"border border-dashed border-border/70 bg-background/45 text-muted-foreground/80 hover:bg-background/70 data-[state=active]:border-primary/45 data-[state=active]:bg-primary/10 data-[state=active]:text-primary data-[state=active]:shadow-none"
"border border-dashed border-border/70 bg-background/45 text-muted-foreground/80 motion-safe:animate-[config-tab-enter_180ms_ease-out_both] hover:bg-background/70 data-[state=active]:border-primary/45 data-[state=active]:bg-primary/10 data-[state=active]:text-primary data-[state=active]:shadow-none"
)}
>
{tab.label}
@@ -1061,20 +1067,29 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs sm:h-9 sm:px-3"
className="group h-8 px-2 text-xs transition-all duration-200 ease-out sm:h-9 sm:px-3"
onClick={toggleExpanded}
>
{expanded ? (
<ChevronUp className="mr-1 h-3.5 w-3.5" />
<ChevronLeft className="mr-1 h-3.5 w-3.5 transition-transform duration-200 group-hover:-translate-x-0.5" />
) : (
<ChevronDown className="mr-1 h-3.5 w-3.5" />
<ChevronRight className="mr-1 h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5" />
)}
{expanded ? '收起' : '更多'}
</Button>
)}
<Button
type="button"
variant={advancedVisible ? 'default' : 'outline'}
size="sm"
className="ml-auto h-8 px-2 text-xs transition-all duration-200 ease-out sm:h-9 sm:px-3"
onClick={() => setAdvancedVisible((current) => !current)}
>
</Button>
</TabsList>
{tabGroups.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
<TabsContent key={tab.id} value={tab.id} className="space-y-4 motion-safe:animate-[config-tab-content-enter_180ms_ease-out_both]">
{renderTabContent(tab)}
</TabsContent>
))}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react'
import * as LucideIcons from 'lucide-react'
import { Plus, Trash2 } from 'lucide-react'
import { ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
@@ -43,6 +43,7 @@ export interface ListItemEditorOptions {
collapsedText?: string
expandLabel?: string
collapseLabel?: string
collapseButtonDisplay?: 'text' | 'icon'
}
function resolveLabel(schema?: ConfigSchema | FieldSchema, fieldPath?: string): string {
@@ -293,6 +294,9 @@ export function createListItemEditorHook(
const shouldCollapse = options.collapseWhen?.({ parentValues }) ?? false
const [manuallyExpanded, setManuallyExpanded] = useState(false)
const collapsed = shouldCollapse && !manuallyExpanded
const collapseButtonLabel = collapsed
? (options.expandLabel ?? '灞曞紑')
: (options.collapseLabel ?? '鎶樺彔')
useEffect(() => {
if (!shouldCollapse) {
@@ -332,12 +336,31 @@ export function createListItemEditorHook(
{renderLucideIcon(iconName, 'h-5 w-5 flex-shrink-0 text-muted-foreground')}
<CardTitle className="truncate text-base">{label}</CardTitle>
</div>
{shouldCollapse && (
{shouldCollapse && options.collapseButtonDisplay === 'icon' && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setManuallyExpanded((current) => !current)}
aria-label={collapseButtonLabel}
title={collapseButtonLabel}
className="inline-flex items-center justify-center"
>
{collapsed ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronUp className="h-4 w-4" />
)}
</Button>
)}
{shouldCollapse && options.collapseButtonDisplay !== 'icon' && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setManuallyExpanded((current) => !current)}
aria-label={collapseButtonLabel}
title={collapseButtonLabel}
>
{collapsed
? (options.expandLabel ?? '展开')

View File

@@ -11,6 +11,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import type { FieldHookComponent } from '@/lib/field-hooks'
import { createJsonFieldHook } from './JsonFieldHookFactory'
@@ -131,6 +132,98 @@ const formatPlatformAccount = (row: PlatformAccountRow): string => {
return `${platform}:${account}`
}
interface StringListHookOptions {
addLabel: string
emptyText: string
label: string
multiline?: boolean
placeholder?: string
}
function createStringListHook(options: StringListHookOptions): FieldHookComponent {
return ({ onChange, value }) => {
const items = Array.isArray(value) ? value.map((item) => String(item ?? '')) : []
const updateItems = (nextItems: string[]) => {
onChange?.(nextItems)
}
const addItem = () => {
updateItems([...items, ''])
}
const removeItem = (itemIndex: number) => {
updateItems(items.filter((_, index) => index !== itemIndex))
}
const updateItem = (itemIndex: number, nextValue: string) => {
updateItems(items.map((item, index) => (index === itemIndex ? nextValue : item)))
}
const InputComponent = options.multiline ? Textarea : Input
return (
<div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Label className="text-[15px] leading-6">{options.label}</Label>
<Button type="button" size="sm" variant="outline" onClick={addItem}>
<Plus className="mr-2 h-4 w-4" />
{options.addLabel}
</Button>
</div>
{items.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 px-4 py-5 text-center text-sm text-muted-foreground">
{options.emptyText}
</div>
) : (
<div className="space-y-2">
{items.map((item, itemIndex) => (
<div
key={itemIndex}
className="grid gap-2 rounded-md border bg-muted/20 p-3 sm:grid-cols-[minmax(0,1fr)_2.5rem]"
>
<InputComponent
value={item}
placeholder={options.placeholder}
onChange={(event) => updateItem(itemIndex, event.target.value)}
{...(options.multiline ? { rows: 2 } : {})}
/>
<div className="flex items-start justify-end">
<Button
type="button"
size="icon"
variant="ghost"
aria-label={`删除${options.label} ${itemIndex + 1}`}
onClick={() => removeItem(itemIndex)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
)
}
}
export const AliasNamesHook = createStringListHook({
addLabel: '添加别名',
emptyText: '暂无别名。',
label: '别名',
placeholder: '小麦',
})
export const MultipleReplyStyleHook = createStringListHook({
addLabel: '添加表达风格',
emptyText: '暂无备用表达风格。',
label: '备用表达风格',
multiline: true,
placeholder: '输入一种备用表达风格',
})
export const ChatTalkValueRulesHook = createListItemEditorHook({
addLabel: '添加发言频率规则',
addButtonPlacement: 'top',
@@ -140,6 +233,7 @@ export const ChatTalkValueRulesHook = createListItemEditorHook({
collapseLabel: '折叠规则',
helperText: '可按平台/聊天流/时段分别配置发言频率,留空表示全局。',
emptyText: '尚未配置任何规则,将使用全局默认频率。',
collapseButtonDisplay: 'icon',
fieldRows: [
['platform', 'item_id', 'rule_type'],
['time', 'value'],

View File

@@ -11,6 +11,7 @@ export type {
UseAutoSaveReturnGeneric,
} from './useAutoSave'
export {
AliasNamesHook,
BotPlatformsHook,
BotPlatformAccountsHook,
ChatPromptsHook,
@@ -21,6 +22,7 @@ export {
HiddenFieldHook,
MCPRootItemsHook,
MCPServersHook,
MultipleReplyStyleHook,
RegexRulesHook,
} from './complexFieldHooks'
export { ChatSectionHook } from './ChatSectionHook'

View File

@@ -366,8 +366,9 @@ export const ExpressionSection = React.memo(function ExpressionSection({
<LearningRulePreview rule={rule} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost">
<Button size="icon" variant="ghost" aria-label={`删除学习规则 ${index + 1}`}>
<Trash2 className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>

View File

@@ -1312,7 +1312,7 @@ function ModelConfigPageContent() {
<p className="text-sm text-muted-foreground">
AI API
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="hidden">
{selectedProviders.size > 0 && (
<Button
onClick={openProviderBatchDeleteDialog}
@@ -1346,6 +1346,37 @@ function ModelConfigPageContent() {
testingProviders={testingProviders}
testResults={testResults}
selectedProviders={selectedProviders}
toolbarActions={(
<>
{selectedProviders.size > 0 && (
<Button
onClick={openProviderBatchDeleteDialog}
size="sm"
variant="destructive"
className="w-full sm:w-auto"
>
<Trash2 className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
<span className="text-sm"> ({selectedProviders.size})</span>
</Button>
)}
<Button
onClick={handleTestAllProviderConnections}
size="sm"
variant="outline"
className="w-full sm:w-auto"
disabled={apiProviders.length === 0 || testingProviders.size > 0}
>
<Zap className="mr-2 h-4 w-4" />
<span className="text-sm">
{testingProviders.size > 0 ? `测试中 (${testingProviders.size})` : '测试全部连接'}
</span>
</Button>
<Button onClick={() => openProviderDialog(null, null)} size="sm" variant="outline" className="w-full sm:w-auto" data-tour="add-provider-button">
<Plus className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
<span className="text-sm"></span>
</Button>
</>
)}
onEdit={openProviderDialog}
onDelete={openProviderDeleteDialog}
onTest={handleTestProviderConnection}
@@ -1359,7 +1390,7 @@ function ModelConfigPageContent() {
<p className="text-sm text-muted-foreground">
</p>
<div className="flex gap-2 w-full sm:w-auto">
<div className="hidden">
{selectedModels.size > 0 && (
<Button
onClick={openBatchDeleteDialog}
@@ -1379,7 +1410,7 @@ function ModelConfigPageContent() {
</div>
{/* 搜索框 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:flex-1 sm:max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
@@ -1394,9 +1425,27 @@ function ModelConfigPageContent() {
{filteredModels.length}
</p>
)}
</div>
{/* 模型列表 - 移动端卡片视图 */}
<div className="flex w-full flex-col gap-2 sm:ml-auto sm:w-auto sm:flex-row sm:items-center sm:justify-end">
{selectedModels.size > 0 && (
<Button
onClick={openBatchDeleteDialog}
size="sm"
variant="destructive"
className="w-full sm:w-auto"
>
<Trash2 className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
<span className="text-sm"> ({selectedModels.size})</span>
</Button>
)}
<Button onClick={() => openEditDialog(null, null)} size="sm" variant="outline" className="w-full sm:w-auto" data-tour="add-model-button">
<Plus className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
<span className="text-sm"></span>
</Button>
</div>
</div>
<ModelCardList
paginatedModels={paginatedModels}
allModels={models}
@@ -1834,8 +1883,8 @@ function ModelConfigPageContent() {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="price_in"> (¥/M token)</Label>
<div className="flex items-center gap-3">
<Label htmlFor="price_in" className="w-36 shrink-0"> (¥/M token)</Label>
<Input
id="price_in"
type="number"
@@ -1851,11 +1900,12 @@ function ModelConfigPageContent() {
)
}}
placeholder="默认: 0"
className="flex-1"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="price_out"> (¥/M token)</Label>
<div className="flex items-center gap-3">
<Label htmlFor="price_out" className="w-36 shrink-0"> (¥/M token)</Label>
<Input
id="price_out"
type="number"
@@ -1871,6 +1921,7 @@ function ModelConfigPageContent() {
)
}}
placeholder="默认: 0"
className="flex-1"
/>
</div>
</div>
@@ -1896,8 +1947,8 @@ function ModelConfigPageContent() {
</div>
{editingModel?.cache && (
<div className="grid gap-2 border-t pt-4">
<Label htmlFor="cache_price_in"> (¥/M token)</Label>
<div className="flex items-center gap-3 border-t pt-4">
<Label htmlFor="cache_price_in" className="w-40 shrink-0"> (¥/M token)</Label>
<Input
id="cache_price_in"
type="number"
@@ -1913,6 +1964,7 @@ function ModelConfigPageContent() {
)
}}
placeholder="默认: 0"
className="flex-1"
/>
</div>
)}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import type { TestConnectionResult } from '@/lib/config-api'
import { AlertCircle, CheckCircle2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2, Pencil, Search, Trash2, XCircle, Zap } from 'lucide-react'
@@ -18,6 +18,7 @@ interface ProviderListProps {
testingProviders: Set<string>
testResults: Map<string, TestConnectionResult>
selectedProviders: Set<number>
toolbarActions?: ReactNode
onEdit: (provider: APIProvider, index: number) => void
onDelete: (index: number) => void
onTest: (name: string) => void
@@ -30,6 +31,7 @@ export function ProviderList({
testingProviders,
testResults,
selectedProviders,
toolbarActions,
onEdit,
onDelete,
onTest,
@@ -125,20 +127,27 @@ export function ProviderList({
return (
<>
{/* 搜索框 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 mb-4">
<div className="relative w-full sm:flex-1 sm:max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索提供商名称、URL 或类型..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative w-full sm:flex-1 sm:max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索提供商名称、URL 或类型..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{searchQuery && (
<p className="text-sm text-muted-foreground whitespace-nowrap">
{filteredProviders.length}
</p>
)}
</div>
{searchQuery && (
<p className="text-sm text-muted-foreground whitespace-nowrap">
{filteredProviders.length}
</p>
{toolbarActions && (
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-end">
{toolbarActions}
</div>
)}
</div>

View File

@@ -745,7 +745,7 @@ function ModelProviderConfigPageContent() {
<h1 className="text-2xl sm:text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base"> AI API </p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<div className="hidden">
{selectedProviders.size > 0 && (
<Button
onClick={openBatchDeleteDialog}
@@ -838,6 +838,82 @@ function ModelProviderConfigPageContent() {
testingProviders={testingProviders}
testResults={testResults}
selectedProviders={selectedProviders}
toolbarActions={(
<>
{selectedProviders.size > 0 && (
<Button
onClick={openBatchDeleteDialog}
size="sm"
variant="destructive"
className="w-full sm:w-auto"
>
<Trash2 className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
<span className="text-sm"> ({selectedProviders.size})</span>
</Button>
)}
<Button
onClick={handleTestAllConnections}
size="sm"
variant="outline"
className="w-full sm:w-auto"
disabled={providers.length === 0 || testingProviders.size > 0}
>
<Zap className="mr-2 h-4 w-4" />
<span className="text-sm">
{testingProviders.size > 0 ? `测试中 (${testingProviders.size})` : '测试全部连接'}
</span>
</Button>
<Button onClick={() => openEditDialog(null, null)} size="sm" className="w-full sm:w-auto" data-tour="add-provider-button">
<Plus className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
<span className="text-sm"></span>
</Button>
<Button
onClick={saveConfig}
disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting}
size="sm"
variant="outline"
className="w-full sm:w-auto sm:min-w-[120px]"
>
<Save className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
<span className="text-sm">
{saving ? '保存中...' : autoSaving ? '自动保存中...' : hasUnsavedChanges ? '保存配置' : '已保存'}
</span>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
disabled={saving || autoSaving || isRestarting}
size="sm"
className="w-full sm:w-auto sm:min-w-[120px]"
>
<Power className="mr-2 h-4 w-4" />
{isRestarting ? '重启中...' : hasUnsavedChanges ? '保存并重启' : '重启麦麦'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
<p>
{hasUnsavedChanges
? '当前有未保存的配置更改。确认后会先保存配置,然后重启麦麦使新配置生效。'
: '即将重启麦麦主程序。配置将在重启后生效。'
}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={hasUnsavedChanges ? handleSaveAndRestart : handleRestart}>
{hasUnsavedChanges ? '保存并重启' : '确认重启'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
onEdit={openEditDialog}
onDelete={openDeleteDialog}
onTest={handleTestConnection}

View File

@@ -56,6 +56,8 @@ import { RestartOverlay } from '@/components/restart-overlay'
import { ExpressionReviewer } from '@/components/expression-reviewer'
import { getBotConfig, getModelConfig } from '@/lib/config-api'
import { getReviewStats } from '@/lib/expression-api'
import { getDashboardVersionStatus, type DashboardVersionStatus } from '@/lib/system-api'
import { APP_VERSION } from '@/lib/version'
import { ZoomableChart } from '@/components/ui/zoomable-chart'
// 主导出组件:包装 RestartProvider
@@ -75,6 +77,11 @@ interface BotStatus {
start_time: string
}
interface ReleaseStatus {
version: string
url: string
}
interface StatisticsSummary {
total_requests: number
total_cost: number
@@ -156,10 +163,13 @@ function IndexPageContent() {
const [loading, setLoading] = useState(true)
const [loadingProgress, setLoadingProgress] = useState(0)
const [timeRange, setTimeRange] = useState(24) // 默认24小时
const [autoRefresh, setAutoRefresh] = useState(true)
const [autoRefresh, setAutoRefresh] = useState(false)
const [hitokoto, setHitokoto] = useState<{ hitokoto: string; from: string } | null>(null)
const [hitokotoLoading, setHitokotoLoading] = useState(true)
const [botStatus, setBotStatus] = useState<BotStatus | null>(null)
const [maibotStableRelease, setMaibotStableRelease] = useState<ReleaseStatus | null>(null)
const [maibotTestRelease, setMaibotTestRelease] = useState<ReleaseStatus | null>(null)
const [dashboardVersionStatus, setDashboardVersionStatus] = useState<DashboardVersionStatus | null>(null)
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>({
memoryEnabled: false,
visualEnabled: false,
@@ -186,6 +196,61 @@ function IndexPageContent() {
}
}, [])
useEffect(() => {
let mounted = true
const loadLatestVersions = async () => {
try {
const response = await fetch('https://api.github.com/repos/Mai-with-u/MaiBot/releases?per_page=20', {
headers: { Accept: 'application/vnd.github+json' },
})
if (!response.ok) {
throw new Error(`GitHub release status ${response.status}`)
}
const releases = await response.json() as Array<{
draft?: boolean
prerelease?: boolean
tag_name?: string
html_url?: string
}>
const visibleReleases = releases.filter((release) => !release.draft)
const stableRelease = visibleReleases.find((release) => !release.prerelease)
const testRelease = visibleReleases[0]
if (mounted) {
if (stableRelease?.tag_name) {
setMaibotStableRelease({
version: String(stableRelease.tag_name).replace(/^v/i, '').trim(),
url: stableRelease.html_url || 'https://github.com/Mai-with-u/MaiBot/releases',
})
}
if (testRelease?.tag_name) {
setMaibotTestRelease({
version: String(testRelease.tag_name).replace(/^v/i, '').trim(),
url: testRelease.html_url || 'https://github.com/Mai-with-u/MaiBot/releases',
})
}
}
} catch (error) {
console.debug('检查 MaiBot 最新版本失败:', error)
}
try {
const status = await getDashboardVersionStatus(APP_VERSION)
if (mounted) {
setDashboardVersionStatus(status)
}
} catch (error) {
console.debug('妫€鏌?WebUI 鐗堟湰鏇存柊澶辫触:', error)
}
}
void loadLatestVersions()
return () => {
mounted = false
}
}, [])
// 获取审核统计
const fetchReviewStats = useCallback(async () => {
try {
@@ -538,8 +603,85 @@ function IndexPageContent() {
</div>
{/* 机器人状态和快速操作 */}
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.4fr)_max-content]">
{/* 机器人状态卡片 */}
<Card className="lg:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-muted-foreground"></span>
<Badge variant="secondary" className="border border-primary/20 bg-primary/10 px-2 py-0.5 font-semibold text-primary">
{botStatus?.version ? `v${botStatus.version}` : '未知'}
</Badge>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">WebUI </span>
<Badge variant="secondary" className="border border-primary/20 bg-primary/10 px-2 py-0.5 font-semibold text-primary">
v{APP_VERSION}
</Badge>
</div>
<div className="hidden">
v{dashboardVersionStatus?.latest_version || APP_VERSION}
</div>
<div className="hidden">
<a
href={maibotTestRelease?.url || 'https://github.com/Mai-with-u/MaiBot/releases'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 transition-colors hover:text-muted-foreground"
>
{maibotTestRelease ? `v${maibotTestRelease.version}` : 'GitHub Releases'}
<ExternalLink className="h-3 w-3" />
</a>
</div>
<div className="space-y-1 border-t border-border/50 pt-2 text-xs text-muted-foreground/60">
<a
href={maibotStableRelease?.url || 'https://github.com/Mai-with-u/MaiBot/releases'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between gap-2 transition-colors hover:text-muted-foreground"
>
<span></span>
<span className="inline-flex items-center gap-1">
{maibotStableRelease ? `v${maibotStableRelease.version}` : 'GitHub Releases'}
<ExternalLink className="h-3 w-3" />
</span>
</a>
<a
href={maibotTestRelease?.url || 'https://github.com/Mai-with-u/MaiBot/releases'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between gap-2 transition-colors hover:text-muted-foreground"
>
<span></span>
<span className="inline-flex items-center gap-1">
{maibotTestRelease ? `v${maibotTestRelease.version}` : 'GitHub Releases'}
<ExternalLink className="h-3 w-3" />
</span>
</a>
<a
href={dashboardVersionStatus?.pypi_url || 'https://pypi.org/project/maibot-dashboard/'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between gap-2 transition-colors hover:text-muted-foreground"
>
<span>WebUI </span>
<span className="inline-flex items-center gap-1">
v{dashboardVersionStatus?.latest_version || APP_VERSION}
<ExternalLink className="h-3 w-3" />
</span>
</a>
</div>
</div>
</CardContent>
</Card>
<Card className="lg:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
@@ -549,12 +691,12 @@ function IndexPageContent() {
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center gap-4">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
{botStatus?.running ? (
<>
<div className="h-3 w-3 rounded-full bg-green-500 animate-pulse" />
<Badge variant="outline" className="text-green-600 border-green-300 bg-green-50">
<Badge variant="outline" className="whitespace-nowrap text-green-600 border-green-300 bg-green-50">
<CheckCircle2 className="h-3 w-3 mr-1" />
{t('home.botStatus.running')}
</Badge>
@@ -562,7 +704,7 @@ function IndexPageContent() {
) : (
<>
<div className="h-3 w-3 rounded-full bg-red-500" />
<Badge variant="outline" className="text-red-600 border-red-300 bg-red-50">
<Badge variant="outline" className="whitespace-nowrap text-red-600 border-red-300 bg-red-50">
<AlertCircle className="h-3 w-3 mr-1" />
{t('home.botStatus.stopped')}
</Badge>
@@ -570,11 +712,7 @@ function IndexPageContent() {
)}
</div>
{botStatus && (
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary" className="border border-primary/20 bg-primary/10 px-2 py-0.5 font-semibold text-primary">
v{botStatus.version}
</Badge>
<span className="mx-2">|</span>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{t('home.botStatus.uptime', { time: formatTime(botStatus.uptime) })}</span>
</div>
)}
@@ -651,25 +789,22 @@ function IndexPageContent() {
</Card>
{/* 问卷调查卡片 */}
<Card>
<Card className="lg:w-[190px]">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<ClipboardList className="h-4 w-4" />
{t('home.survey.title')}
</CardTitle>
<CardDescription className="text-xs">
{t('home.survey.description')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild className="gap-2">
<div className="flex flex-col gap-2">
<Button variant="outline" size="sm" asChild className="w-full justify-start gap-2">
<Link to="/survey/webui-feedback">
<FileText className="h-4 w-4" />
{t('home.survey.webui')}
</Link>
</Button>
<Button variant="outline" size="sm" asChild className="gap-2">
<Button variant="outline" size="sm" asChild className="w-full justify-start gap-2">
<Link to="/survey/maibot-feedback">
<MessageSquare className="h-4 w-4" />
{t('home.survey.maibot')}