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]
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"home": "首页",
|
||||
"botMainConfig": "麦麦设置",
|
||||
"aiModelProvider": "模型厂商设置",
|
||||
"modelManagement": "模型管理与分配",
|
||||
"modelManagement": "模型管理",
|
||||
"promptManagement": "Prompt 管理",
|
||||
"adapterConfig": "麦麦适配器配置",
|
||||
"emojiManagement": "表情包",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 ?? '展开')
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user