WebUI 前端 & 后端超级大重构
This commit is contained in:
@@ -14,13 +14,13 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useBackendConnections } from '@/hooks/useBackendConnections'
|
||||
import { isElectron } from '@/lib/runtime'
|
||||
import type { BackendConnection } from '@/types/electron'
|
||||
@@ -78,7 +78,7 @@ export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md sm:max-w-[425px]">
|
||||
<DialogContent className="max-w-md sm:max-w-106.25">
|
||||
<DialogHeader>
|
||||
<DialogTitle>后端连接管理</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -88,7 +88,7 @@ export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="max-h-[60vh] pr-4">
|
||||
<DialogBody className="pr-4">
|
||||
<div className="flex flex-col gap-3 py-4">
|
||||
{backends.map((backend) => {
|
||||
const isActive = backend.id === activeId
|
||||
@@ -100,7 +100,7 @@ export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-3 overflow-hidden">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="shrink-0">
|
||||
{isActive ? (
|
||||
<Check className="h-5 w-5 text-blue-500" />
|
||||
) : (
|
||||
@@ -156,7 +156,7 @@ export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogBody>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4 border-t">
|
||||
@@ -173,7 +173,7 @@ export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
|
||||
|
||||
{/* Edit/Add Dialog */}
|
||||
<Dialog open={!!editConn} onOpenChange={(open) => !open && setEditConn(null)}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="sm:max-w-106.25" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editConn?.id ? '编辑连接' : '添加连接'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -212,6 +212,7 @@ export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
|
||||
!editConn?.url ||
|
||||
!/^https?:\/\//.test(editConn.url)
|
||||
}
|
||||
data-dialog-action="confirm"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ShortcutKbd } from '@/components/ui/kbd'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
@@ -1689,19 +1690,19 @@ if (isCurrent) {
|
||||
{/* 底部快捷键提示(桌面端) */}
|
||||
<div className="hidden sm:flex items-center justify-center gap-6 px-6 py-3 border-t text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-xs">←</kbd>
|
||||
<ShortcutKbd size="sm" keys={['left']} />
|
||||
<span>拒绝</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-xs">→</kbd>
|
||||
<ShortcutKbd size="sm" keys={['right']} />
|
||||
<span>通过</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-xs">↑</kbd>
|
||||
<ShortcutKbd size="sm" keys={['up']} />
|
||||
<span>上一条</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-xs">↓</kbd>
|
||||
<ShortcutKbd size="sm" keys={['down']} />
|
||||
<span>下一条</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { BookOpen, ChevronLeft, Globe, LogOut, Menu, Moon, PieChart, Search, Server, Sun } from 'lucide-react'
|
||||
import { BookOpen, ChevronLeft, Globe, LogOut, Menu, Moon, Search, Server, Sun } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { ShortcutKbd } from '@/components/ui/kbd'
|
||||
import { toggleThemeWithTransition } from '@/components/use-theme'
|
||||
import { useBackground } from '@/hooks/use-background'
|
||||
import { logout } from '@/lib/fetch-with-auth'
|
||||
@@ -99,7 +98,7 @@ export function Header({
|
||||
title={t('header.toggleConnection')}
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="hidden sm:inline text-xs text-muted-foreground truncate max-w-[100px]">
|
||||
<span className="hidden sm:inline text-xs text-muted-foreground truncate max-w-25">
|
||||
{activeBackendName}
|
||||
</span>
|
||||
</Button>
|
||||
@@ -107,19 +106,6 @@ export function Header({
|
||||
<div className="h-6 w-px bg-border" />
|
||||
</>
|
||||
)}
|
||||
{/* 年度总结入口 */}
|
||||
<Link to="/annual-report">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 bg-gradient-to-r from-pink-500/10 to-purple-500/10 hover:from-pink-500/20 hover:to-purple-500/20 border border-pink-500/20"
|
||||
title={t('header.viewAnnualSummary')}
|
||||
>
|
||||
<PieChart className="h-4 w-4 text-pink-500" />
|
||||
<span className="hidden sm:inline bg-gradient-to-r from-pink-500 to-purple-500 bg-clip-text text-transparent font-medium">{t('header.annualSummary')}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<button
|
||||
onClick={() => onSearchOpenChange(true)}
|
||||
@@ -128,9 +114,7 @@ export function Header({
|
||||
>
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="text-sm text-muted-foreground">{t('header.searchPlaceholder')}</span>
|
||||
<Kbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<span className="text-xs">⌘</span>K
|
||||
</Kbd>
|
||||
<ShortcutKbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2" keys={['mod', 'k']} />
|
||||
</button>
|
||||
|
||||
{/* 搜索对话框 */}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useAuthGuard } from '@/hooks/use-auth'
|
||||
import { useBackground } from '@/hooks/use-background'
|
||||
|
||||
import { TitleBar } from '@/components/electron/TitleBar'
|
||||
import { matchesShortcut } from '@/lib/keyboard'
|
||||
import { isElectron } from '@/lib/runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { menuSections } from './constants'
|
||||
@@ -49,7 +50,7 @@ export function Layout({ children }: LayoutProps) {
|
||||
// 搜索快捷键监听(Cmd/Ctrl + K)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
if (matchesShortcut(e, ['mod', 'k'])) {
|
||||
e.preventDefault()
|
||||
setSearchOpen(true)
|
||||
}
|
||||
@@ -68,9 +69,8 @@ export function Layout({ children }: LayoutProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = router.subscribe('onResolved', () => {
|
||||
const pathname = router.state.location.pathname
|
||||
const pageTitle = pathToLabel[pathname] ?? 'MaiBot Dashboard'
|
||||
return router.subscribe('onResolved', () => {
|
||||
const pageTitle = pathToLabel[router.state.location.pathname] ?? 'MaiBot Dashboard'
|
||||
const fullTitle = pageTitle === 'MaiBot Dashboard'
|
||||
? 'MaiBot Dashboard'
|
||||
: `${pageTitle} — MaiBot Dashboard`
|
||||
@@ -90,8 +90,6 @@ export function Layout({ children }: LayoutProps) {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [router, announce, t])
|
||||
|
||||
// 获取实际应用的主题(处理 system 情况)
|
||||
|
||||
@@ -6,25 +6,25 @@ export const menuSections: MenuSection[] = [
|
||||
{
|
||||
title: 'sidebar.groups.overview',
|
||||
items: [
|
||||
{ icon: Home, label: 'sidebar.menu.home', path: '/' },
|
||||
{ icon: Home, label: 'sidebar.menu.home', path: '/', searchDescription: 'search.items.homeDesc' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'sidebar.groups.botConfig',
|
||||
items: [
|
||||
{ icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot' },
|
||||
{ icon: Server, label: 'sidebar.menu.aiModelProvider', path: '/config/modelProvider', tourId: 'sidebar-model-provider' },
|
||||
{ icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', tourId: 'sidebar-model-management' },
|
||||
{ icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot', searchDescription: 'search.items.botConfigDesc' },
|
||||
{ icon: Server, label: 'sidebar.menu.aiModelProvider', path: '/config/modelProvider', searchDescription: 'search.items.modelProviderDesc', tourId: 'sidebar-model-provider' },
|
||||
{ icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', searchDescription: 'search.items.modelDesc', tourId: 'sidebar-model-management' },
|
||||
{ icon: Sliders, label: 'sidebar.menu.adapterConfig', path: '/config/adapter' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'sidebar.groups.botResources',
|
||||
items: [
|
||||
{ icon: Smile, label: 'sidebar.menu.emojiManagement', path: '/resource/emoji' },
|
||||
{ icon: MessageSquare, label: 'sidebar.menu.expressionManagement', path: '/resource/expression' },
|
||||
{ icon: Hash, label: 'sidebar.menu.slangManagement', path: '/resource/jargon' },
|
||||
{ icon: UserCircle, label: 'sidebar.menu.personInfo', path: '/resource/person' },
|
||||
{ icon: Smile, label: 'sidebar.menu.emojiManagement', path: '/resource/emoji', searchDescription: 'search.items.emojiDesc' },
|
||||
{ icon: MessageSquare, label: 'sidebar.menu.expressionManagement', path: '/resource/expression', searchDescription: 'search.items.expressionDesc' },
|
||||
{ icon: Hash, label: 'sidebar.menu.slangManagement', path: '/resource/jargon', searchDescription: 'search.items.jargonDesc' },
|
||||
{ icon: UserCircle, label: 'sidebar.menu.personInfo', path: '/resource/person', searchDescription: 'search.items.personDesc' },
|
||||
{ icon: Network, label: 'sidebar.menu.knowledgeGraph', path: '/resource/knowledge-graph' },
|
||||
{ icon: Database, label: 'sidebar.menu.knowledgeBase', path: '/resource/knowledge-base' },
|
||||
],
|
||||
@@ -32,10 +32,10 @@ export const menuSections: MenuSection[] = [
|
||||
{
|
||||
title: 'sidebar.groups.extensionsMonitor',
|
||||
items: [
|
||||
{ icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins' },
|
||||
{ icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins', searchDescription: 'search.items.pluginsDesc' },
|
||||
{ icon: LayoutGrid, label: 'sidebar.menu.configTemplate', path: '/config/pack-market' },
|
||||
{ icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' },
|
||||
{ icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs' },
|
||||
{ icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' },
|
||||
{ icon: Activity, label: 'sidebar.menu.plannerMonitor', path: '/planner-monitor' },
|
||||
{ icon: MessageSquare, label: 'sidebar.menu.localChat', path: '/chat' },
|
||||
],
|
||||
@@ -43,7 +43,7 @@ export const menuSections: MenuSection[] = [
|
||||
{
|
||||
title: 'sidebar.groups.system',
|
||||
items: [
|
||||
{ icon: Settings, label: 'sidebar.menu.settings', path: '/settings' },
|
||||
{ icon: Settings, label: 'sidebar.menu.settings', path: '/settings', searchDescription: 'search.items.settingsDesc' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface MenuItem {
|
||||
icon: ComponentType<LucideProps>
|
||||
label: string
|
||||
path: string
|
||||
searchDescription?: string
|
||||
tourId?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { Search, FileText, Server, Boxes, Smile, MessageSquare, UserCircle, FileSearch, BarChart3, Package, Settings, Home, Hash } from 'lucide-react'
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { LucideProps } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { ShortcutKbd } from '@/components/ui/kbd'
|
||||
import { menuSections } from '@/components/layout/constants'
|
||||
import { registeredRoutePaths } from '@/router'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SearchDialogProps {
|
||||
@@ -19,7 +23,7 @@ interface SearchDialogProps {
|
||||
}
|
||||
|
||||
interface SearchItem {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
icon: React.ComponentType<LucideProps>
|
||||
title: string
|
||||
description: string
|
||||
path: string
|
||||
@@ -29,95 +33,37 @@ interface SearchItem {
|
||||
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const searchItems: SearchItem[] = useMemo(() => [
|
||||
{
|
||||
icon: Home,
|
||||
title: t('search.items.home'),
|
||||
description: t('search.items.homeDesc'),
|
||||
path: '/',
|
||||
category: t('search.categories.overview'),
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: t('search.items.botConfig'),
|
||||
description: t('search.items.botConfigDesc'),
|
||||
path: '/config/bot',
|
||||
category: t('search.categories.config'),
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
title: t('search.items.modelProvider'),
|
||||
description: t('search.items.modelProviderDesc'),
|
||||
path: '/config/modelProvider',
|
||||
category: t('search.categories.config'),
|
||||
},
|
||||
{
|
||||
icon: Boxes,
|
||||
title: t('search.items.model'),
|
||||
description: t('search.items.modelDesc'),
|
||||
path: '/config/model',
|
||||
category: t('search.categories.config'),
|
||||
},
|
||||
{
|
||||
icon: Smile,
|
||||
title: t('search.items.emoji'),
|
||||
description: t('search.items.emojiDesc'),
|
||||
path: '/resource/emoji',
|
||||
category: t('search.categories.resources'),
|
||||
},
|
||||
{
|
||||
icon: MessageSquare,
|
||||
title: t('search.items.expression'),
|
||||
description: t('search.items.expressionDesc'),
|
||||
path: '/resource/expression',
|
||||
category: t('search.categories.resources'),
|
||||
},
|
||||
{
|
||||
icon: UserCircle,
|
||||
title: t('search.items.person'),
|
||||
description: t('search.items.personDesc'),
|
||||
path: '/resource/person',
|
||||
category: t('search.categories.resources'),
|
||||
},
|
||||
{
|
||||
icon: Hash,
|
||||
title: t('search.items.jargon'),
|
||||
description: t('search.items.jargonDesc'),
|
||||
path: '/resource/jargon',
|
||||
category: t('search.categories.resources'),
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: t('search.items.statistics'),
|
||||
description: t('search.items.statisticsDesc'),
|
||||
path: '/statistics',
|
||||
category: t('search.categories.monitor'),
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
title: t('search.items.plugins'),
|
||||
description: t('search.items.pluginsDesc'),
|
||||
path: '/plugins',
|
||||
category: t('search.categories.extensions'),
|
||||
},
|
||||
{
|
||||
icon: FileSearch,
|
||||
title: t('search.items.logs'),
|
||||
description: t('search.items.logsDesc'),
|
||||
path: '/logs',
|
||||
category: t('search.categories.monitor'),
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: t('search.items.settings'),
|
||||
description: t('search.items.settingsDesc'),
|
||||
path: '/settings',
|
||||
category: t('search.categories.system'),
|
||||
},
|
||||
], [t])
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [open])
|
||||
|
||||
const searchItems: SearchItem[] = useMemo(
|
||||
() =>
|
||||
menuSections.flatMap((section) =>
|
||||
section.items
|
||||
.filter((item) => registeredRoutePaths.has(item.path))
|
||||
.map((item) => ({
|
||||
icon: item.icon,
|
||||
title: t(item.label),
|
||||
description: item.searchDescription ? t(item.searchDescription) : item.path,
|
||||
path: item.path,
|
||||
category: t(section.title),
|
||||
}))
|
||||
),
|
||||
[t]
|
||||
)
|
||||
|
||||
// 过滤搜索结果
|
||||
const filteredItems = searchItems.filter(
|
||||
@@ -155,12 +101,13 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl p-0 gap-0">
|
||||
<DialogContent className="max-w-2xl p-0 gap-0" confirmOnEnter>
|
||||
<DialogHeader className="px-4 pt-4 pb-0">
|
||||
<DialogTitle className="sr-only">{t('search.title')}</DialogTitle>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
@@ -169,13 +116,12 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('search.placeholder')}
|
||||
className="h-12 pl-11 text-base border-0 focus-visible:ring-0 shadow-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border-t">
|
||||
<ScrollArea className="h-[400px]">
|
||||
<DialogBody className="h-100" viewportClassName="px-0">
|
||||
{filteredItems.length > 0 ? (
|
||||
<div className="p-2">
|
||||
{filteredItems.map((item, index) => {
|
||||
@@ -192,7 +138,7 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
: 'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{item.title}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
@@ -214,22 +160,22 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogBody>
|
||||
</div>
|
||||
|
||||
<div className="border-t px-4 py-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded border">↑</kbd>
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded border">↓</kbd>
|
||||
<ShortcutKbd size="sm" keys={['up']} />
|
||||
<ShortcutKbd size="sm" keys={['down']} />
|
||||
{t('search.navigate')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded border">Enter</kbd>
|
||||
<ShortcutKbd size="sm" keys={['enter']} />
|
||||
{t('search.select')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded border">Esc</kbd>
|
||||
<ShortcutKbd size="sm" keys={['esc']} />
|
||||
{t('search.close')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import {
|
||||
createPack,
|
||||
@@ -340,7 +340,7 @@ export function SharePackDialog({ trigger }: SharePackDialogProps) {
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogContent className="max-w-2xl flex flex-col" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Package className="w-5 h-5" />
|
||||
@@ -353,7 +353,7 @@ export function SharePackDialog({ trigger }: SharePackDialogProps) {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-[calc(85vh-220px)] pr-4">
|
||||
<DialogBody>
|
||||
{loading ? (
|
||||
<div className="py-8 text-center">
|
||||
<Loader2 className="w-8 h-8 mx-auto animate-spin text-primary" />
|
||||
@@ -639,7 +639,7 @@ export function SharePackDialog({ trigger }: SharePackDialogProps) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="flex justify-between pt-4 border-t">
|
||||
<div>
|
||||
@@ -662,6 +662,7 @@ export function SharePackDialog({ trigger }: SharePackDialogProps) {
|
||||
</Button>
|
||||
{step < totalSteps ? (
|
||||
<Button
|
||||
data-dialog-action="confirm"
|
||||
onClick={() => setStep(step + 1)}
|
||||
disabled={
|
||||
loading ||
|
||||
@@ -671,7 +672,7 @@ export function SharePackDialog({ trigger }: SharePackDialogProps) {
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
<Button data-dialog-action="confirm" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
提交审核
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { isEditableTarget, matchesShortcut } from "@/lib/keyboard"
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
@@ -32,22 +37,48 @@ interface DialogContentProps
|
||||
preventOutsideClose?: boolean
|
||||
/** 隐藏默认关闭按钮(当使用自定义关闭按钮时) */
|
||||
hideCloseButton?: boolean
|
||||
/** 回车触发主操作按钮 */
|
||||
confirmOnEnter?: boolean
|
||||
}
|
||||
|
||||
interface DialogBodyProps extends React.ComponentPropsWithoutRef<typeof ScrollArea> {
|
||||
allowHorizontalScroll?: boolean
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(({ className, children, preventOutsideClose = false, hideCloseButton = false, ...props }, ref) => (
|
||||
>(({ className, children, preventOutsideClose = false, hideCloseButton = false, confirmOnEnter = false, onKeyDownCapture, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-[min(calc(100vw-2rem),var(--dialog-width,32rem))] max-h-[calc(100vh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-hidden border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
onPointerDownOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
|
||||
onInteractOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
|
||||
onKeyDownCapture={(event) => {
|
||||
onKeyDownCapture?.(event)
|
||||
if (
|
||||
!confirmOnEnter ||
|
||||
event.defaultPrevented ||
|
||||
!matchesShortcut(event, ['enter']) ||
|
||||
event.nativeEvent.isComposing ||
|
||||
isEditableTarget(event.target)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const confirmButton = event.currentTarget.querySelector<HTMLElement>('[data-dialog-action="confirm"]:not([disabled])')
|
||||
if (!confirmButton) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
confirmButton.click()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -62,6 +93,22 @@ const DialogContent = React.forwardRef<
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogBody = React.forwardRef<HTMLDivElement, DialogBodyProps>(
|
||||
({ className, children, allowHorizontalScroll = false, contentClassName, scrollbars, viewportClassName, ...props }, ref) => (
|
||||
<ScrollArea
|
||||
ref={ref as never}
|
||||
className={cn("min-h-0 flex-1", className)}
|
||||
contentClassName={cn(allowHorizontalScroll && "min-w-full w-max", contentClassName)}
|
||||
scrollbars={scrollbars ?? (allowHorizontalScroll ? "both" : "vertical")}
|
||||
viewportClassName={cn("pr-4", viewportClassName)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ScrollArea>
|
||||
)
|
||||
)
|
||||
DialogBody.displayName = "DialogBody"
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
@@ -125,6 +172,7 @@ export {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { getPlatformModifierAriaLabel, getShortcutKeyLabel, type ShortcutKey } from "@/lib/keyboard"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const kbdVariants = cva(
|
||||
@@ -25,6 +26,10 @@ export interface KbdProps
|
||||
abbrTitle?: string
|
||||
}
|
||||
|
||||
interface ShortcutKbdProps extends Omit<KbdProps, "children"> {
|
||||
keys: ShortcutKey[]
|
||||
}
|
||||
|
||||
const Kbd = React.forwardRef<HTMLElement, KbdProps>(
|
||||
({ className, size, abbrTitle, children, ...props }, ref) => {
|
||||
return (
|
||||
@@ -40,4 +45,20 @@ const Kbd = React.forwardRef<HTMLElement, KbdProps>(
|
||||
)
|
||||
Kbd.displayName = "Kbd"
|
||||
|
||||
export { Kbd }
|
||||
function ShortcutKbd({ keys, className, size, ...props }: ShortcutKbdProps) {
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1", className)}>
|
||||
{keys.map((key) => {
|
||||
const label = getShortcutKeyLabel(key)
|
||||
const abbrTitle = key === 'mod' ? getPlatformModifierAriaLabel() : undefined
|
||||
return (
|
||||
<Kbd key={`${key}-${label}`} size={size} abbrTitle={abbrTitle} {...props}>
|
||||
{label}
|
||||
</Kbd>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, ShortcutKbd }
|
||||
|
||||
@@ -5,22 +5,25 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
|
||||
viewportRef?: React.RefObject<HTMLDivElement | null>
|
||||
viewportClassName?: string
|
||||
contentClassName?: string
|
||||
scrollbars?: "vertical" | "horizontal" | "both"
|
||||
}
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
ScrollAreaProps
|
||||
>(({ className, children, viewportRef, ...props }, ref) => (
|
||||
>(({ className, children, viewportRef, viewportClassName, contentClassName, scrollbars = "both", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport ref={viewportRef} className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
<ScrollAreaPrimitive.Viewport ref={viewportRef} className={cn("h-full w-full rounded-[inherit]", viewportClassName)}>
|
||||
<div className={contentClassName}>{children}</div>
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
{scrollbars !== "horizontal" && <ScrollBar />}
|
||||
{scrollbars !== "vertical" && <ScrollBar orientation="horizontal" />}
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
@@ -36,9 +39,9 @@ const ScrollBar = React.forwardRef<
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
"h-full w-2.5 border-l border-l-transparent p-px",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
"h-2.5 flex-col border-t border-t-transparent p-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { fetchWithAuth } from './fetch-with-auth'
|
||||
|
||||
export interface TimeFootprintData {
|
||||
total_online_hours: number
|
||||
first_message_time: string | null
|
||||
first_message_user: string | null
|
||||
first_message_content: string | null
|
||||
busiest_day: string | null
|
||||
busiest_day_count: number
|
||||
hourly_distribution: number[]
|
||||
midnight_chat_count: number
|
||||
is_night_owl: boolean
|
||||
}
|
||||
|
||||
export interface SocialNetworkData {
|
||||
total_groups: number
|
||||
top_groups: Array<{
|
||||
group_id: string
|
||||
group_name: string
|
||||
message_count: number
|
||||
is_webui?: boolean
|
||||
}>
|
||||
top_users: Array<{
|
||||
user_id: string
|
||||
user_nickname: string
|
||||
message_count: number
|
||||
is_webui?: boolean
|
||||
}>
|
||||
at_count: number
|
||||
mentioned_count: number
|
||||
longest_companion_user: string | null
|
||||
longest_companion_days: number
|
||||
}
|
||||
|
||||
export interface BrainPowerData {
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
favorite_model: string | null
|
||||
favorite_model_count: number
|
||||
model_distribution: Array<{
|
||||
model: string
|
||||
count: number
|
||||
tokens: number
|
||||
cost: number
|
||||
}>
|
||||
top_reply_models: Array<{
|
||||
model: string
|
||||
count: number
|
||||
}>
|
||||
most_expensive_cost: number
|
||||
most_expensive_time: string | null
|
||||
top_token_consumers: Array<{
|
||||
user_id: string
|
||||
cost: number
|
||||
tokens: number
|
||||
}>
|
||||
silence_rate: number
|
||||
total_actions: number
|
||||
no_reply_count: number
|
||||
avg_interest_value: number
|
||||
max_interest_value: number
|
||||
max_interest_time: string | null
|
||||
avg_reasoning_length: number
|
||||
max_reasoning_length: number
|
||||
max_reasoning_time: string | null
|
||||
}
|
||||
|
||||
export interface ExpressionVibeData {
|
||||
top_emoji: {
|
||||
id: number
|
||||
path: string
|
||||
description: string
|
||||
usage_count: number
|
||||
hash: string
|
||||
} | null
|
||||
top_emojis: Array<{
|
||||
id: number
|
||||
path: string
|
||||
description: string
|
||||
usage_count: number
|
||||
hash: string
|
||||
}>
|
||||
top_expressions: Array<{
|
||||
style: string
|
||||
count: number
|
||||
}>
|
||||
rejected_expression_count: number
|
||||
checked_expression_count: number
|
||||
total_expressions: number
|
||||
action_types: Array<{
|
||||
action: string
|
||||
count: number
|
||||
}>
|
||||
image_processed_count: number
|
||||
late_night_reply: {
|
||||
time: string
|
||||
content: string
|
||||
} | null
|
||||
favorite_reply: {
|
||||
content: string
|
||||
count: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface AchievementData {
|
||||
new_jargon_count: number
|
||||
sample_jargons: Array<{
|
||||
content: string
|
||||
meaning: string
|
||||
count: number
|
||||
}>
|
||||
total_messages: number
|
||||
total_replies: number
|
||||
}
|
||||
|
||||
export interface AnnualReportData {
|
||||
year: number
|
||||
bot_name: string
|
||||
generated_at: string
|
||||
time_footprint: TimeFootprintData
|
||||
social_network: SocialNetworkData
|
||||
brain_power: BrainPowerData
|
||||
expression_vibe: ExpressionVibeData
|
||||
achievements: AchievementData
|
||||
}
|
||||
|
||||
export async function getAnnualReport(year: number = 2025): Promise<AnnualReportData> {
|
||||
const response = await fetchWithAuth(`/api/webui/annual-report/full?year=${year}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '获取年度报告失败')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
93
dashboard/src/lib/keyboard.ts
Normal file
93
dashboard/src/lib/keyboard.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export type ShortcutKey =
|
||||
| 'mod'
|
||||
| 'shift'
|
||||
| 'alt'
|
||||
| 'enter'
|
||||
| 'esc'
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| string
|
||||
|
||||
const MAC_PLATFORMS = /(Mac|iPhone|iPod|iPad)/i
|
||||
|
||||
export function isMacLikePlatform(): boolean {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
return MAC_PLATFORMS.test(navigator.platform || navigator.userAgent)
|
||||
}
|
||||
|
||||
export function getShortcutKeyLabel(key: ShortcutKey): string {
|
||||
const isMacLike = isMacLikePlatform()
|
||||
const normalizedKey = key.toLowerCase()
|
||||
|
||||
switch (normalizedKey) {
|
||||
case 'mod':
|
||||
return isMacLike ? '⌘' : 'Ctrl'
|
||||
case 'shift':
|
||||
return isMacLike ? '⇧' : 'Shift'
|
||||
case 'alt':
|
||||
return isMacLike ? '⌥' : 'Alt'
|
||||
case 'enter':
|
||||
return isMacLike ? '↵' : 'Enter'
|
||||
case 'esc':
|
||||
case 'escape':
|
||||
return 'Esc'
|
||||
case 'up':
|
||||
return '↑'
|
||||
case 'down':
|
||||
return '↓'
|
||||
case 'left':
|
||||
return '←'
|
||||
case 'right':
|
||||
return '→'
|
||||
default:
|
||||
return key.length === 1 ? key.toUpperCase() : key
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlatformModifierAriaLabel(): string {
|
||||
return isMacLikePlatform() ? 'Command' : 'Control'
|
||||
}
|
||||
|
||||
export function matchesShortcut(event: KeyboardEvent | React.KeyboardEvent, keys: ShortcutKey[]): boolean {
|
||||
const normalizedKeys = keys.map((key) => key.toLowerCase())
|
||||
const eventKey = event.key.toLowerCase()
|
||||
|
||||
const modifierChecks = {
|
||||
mod: isMacLikePlatform() ? event.metaKey : event.ctrlKey,
|
||||
shift: event.shiftKey,
|
||||
alt: event.altKey,
|
||||
}
|
||||
|
||||
for (const key of normalizedKeys) {
|
||||
if (key in modifierChecks) {
|
||||
if (!modifierChecks[key as keyof typeof modifierChecks]) {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (eventKey !== key) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable ||
|
||||
target.getAttribute('role') === 'textbox'
|
||||
)
|
||||
}
|
||||
@@ -23,9 +23,6 @@ export const STORAGE_KEYS = {
|
||||
WS_MAX_RECONNECT_ATTEMPTS: 'maibot-ws-max-reconnect-attempts',
|
||||
|
||||
// 用户数据
|
||||
// 注意:ACCESS_TOKEN 已弃用,现在使用 HttpOnly Cookie 存储认证信息
|
||||
// 保留此常量仅用于向后兼容和清理旧数据
|
||||
ACCESS_TOKEN: 'access-token',
|
||||
COMPLETED_TOURS: 'maibot-completed-tours',
|
||||
CHAT_USER_ID: 'maibot_webui_user_id',
|
||||
CHAT_USER_NAME: 'maibot_webui_user_name',
|
||||
@@ -211,10 +208,8 @@ export function clearLocalCache(): { clearedKeys: string[]; preservedKeys: strin
|
||||
const keysToRemove: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key) {
|
||||
if (key.startsWith('maibot') || key.startsWith('accent-color') || key === 'access-token') {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
if (key && (key.startsWith('maibot') || key.startsWith('accent-color'))) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import { PluginMirrorsPage } from './routes/plugin-mirrors'
|
||||
import { PluginDetailPage } from './routes/plugin-detail'
|
||||
import { ChatPage } from './routes/chat/index'
|
||||
import { WebUIFeedbackSurveyPage, MaiBotFeedbackSurveyPage } from './routes/survey'
|
||||
import { AnnualReportPage } from './routes/annual-report'
|
||||
import PackMarketPage from './routes/config/pack-market'
|
||||
import PackDetailPage from './routes/config/pack-detail'
|
||||
import { Layout } from './components/layout'
|
||||
@@ -241,13 +240,6 @@ const maibotFeedbackSurveyRoute = createRoute({
|
||||
component: MaiBotFeedbackSurveyPage,
|
||||
})
|
||||
|
||||
// 年度报告路由
|
||||
const annualReportRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/annual-report',
|
||||
component: AnnualReportPage,
|
||||
})
|
||||
|
||||
// 404 路由
|
||||
const notFoundRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
@@ -284,11 +276,23 @@ const routeTree = rootRoute.addChildren([
|
||||
packDetailRoute,
|
||||
webuiFeedbackSurveyRoute,
|
||||
maibotFeedbackSurveyRoute,
|
||||
annualReportRoute,
|
||||
]),
|
||||
notFoundRoute,
|
||||
])
|
||||
|
||||
type RouteNode = {
|
||||
fullPath?: string
|
||||
children?: RouteNode[]
|
||||
}
|
||||
|
||||
function collectRoutePaths(node: RouteNode): string[] {
|
||||
const currentPath = node.fullPath ? [node.fullPath] : []
|
||||
const childPaths = node.children?.flatMap(collectRoutePaths) ?? []
|
||||
return [...currentPath, ...childPaths]
|
||||
}
|
||||
|
||||
export const registeredRoutePaths = new Set(collectRoutePaths(routeTree as RouteNode))
|
||||
|
||||
// 创建路由器
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
|
||||
@@ -1,883 +0,0 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { getAnnualReport, type AnnualReportData } from '@/lib/annual-report-api'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { toPng } from 'html-to-image'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import {
|
||||
Clock,
|
||||
Users,
|
||||
Brain,
|
||||
Smile,
|
||||
Trophy,
|
||||
Calendar,
|
||||
MessageSquare,
|
||||
Zap,
|
||||
Moon,
|
||||
Sun,
|
||||
AtSign,
|
||||
Heart,
|
||||
Image as ImageIcon,
|
||||
Bot,
|
||||
Download,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// 颜色常量
|
||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d']
|
||||
|
||||
// 动态比喻生成函数
|
||||
function getOnlineHoursMetaphor(hours: number): string {
|
||||
if (hours >= 8760) return "相当于全年无休,7x24小时在线!"
|
||||
if (hours >= 5000) return "相当于一位全职员工的年工作时长"
|
||||
if (hours >= 2000) return "相当于看完了 1000 部电影"
|
||||
if (hours >= 1000) return "相当于环球飞行 80 次"
|
||||
if (hours >= 500) return "相当于读完了 100 本书"
|
||||
if (hours >= 100) return "相当于马拉松跑了 25 次"
|
||||
return "虽然不多,但每一刻都很珍贵"
|
||||
}
|
||||
|
||||
function getMidnightMetaphor(count: number): string {
|
||||
if (count >= 1000) return "夜深人静时的知心好友"
|
||||
if (count >= 500) return "午夜场的常客"
|
||||
if (count >= 100) return "偶尔熬夜的小伙伴"
|
||||
if (count >= 50) return "深夜有时也会陪你聊聊"
|
||||
return "早睡早起,健康作息"
|
||||
}
|
||||
|
||||
function getTokenMetaphor(tokens: number): string {
|
||||
const millions = tokens / 1000000
|
||||
if (millions >= 100) return "思考量堪比一座图书馆"
|
||||
if (millions >= 50) return "相当于写了一部百科全书"
|
||||
if (millions >= 10) return "脑细胞估计消耗了不少"
|
||||
if (millions >= 1) return "也算是费了一番脑筋"
|
||||
return "轻轻松松,游刃有余"
|
||||
}
|
||||
|
||||
function getCostMetaphor(cost: number): string {
|
||||
if (cost >= 1000) return "这钱够吃一年的泡面了"
|
||||
if (cost >= 500) return "相当于买了一台游戏机"
|
||||
if (cost >= 100) return "够请大家喝几杯奶茶"
|
||||
if (cost >= 50) return "一顿火锅的钱"
|
||||
if (cost >= 10) return "几杯咖啡的价格"
|
||||
return "省钱小能手"
|
||||
}
|
||||
|
||||
function getSilenceMetaphor(rate: number): string {
|
||||
if (rate >= 80) return "沉默是金,惜字如金"
|
||||
if (rate >= 60) return "话不多但句句到位"
|
||||
if (rate >= 40) return "该说的时候才开口"
|
||||
if (rate >= 20) return "能聊的都聊了"
|
||||
return "话痨本痨,有问必答"
|
||||
}
|
||||
|
||||
function getImageMetaphor(count: number): string {
|
||||
if (count >= 10000) return "眼睛都快看花了"
|
||||
if (count >= 5000) return "堪比专业摄影师的阅片量"
|
||||
if (count >= 1000) return "看图小达人"
|
||||
if (count >= 500) return "图片鉴赏家"
|
||||
if (count >= 100) return "偶尔欣赏一下美图"
|
||||
return "图片?有空再看"
|
||||
}
|
||||
|
||||
function getRejectedMetaphor(count: number): string {
|
||||
if (count >= 500) return "在不断的纠正中成长"
|
||||
if (count >= 200) return "学习永无止境"
|
||||
if (count >= 100) return "虚心接受,积极改正"
|
||||
if (count >= 50) return "偶尔也会犯错"
|
||||
if (count >= 10) return "表现还算不错"
|
||||
return "完美表达,无需纠正"
|
||||
}
|
||||
|
||||
function getExpensiveThinkingMetaphor(cost: number): string {
|
||||
if (cost >= 1) return "这次思考的价值堪比一顿大餐!"
|
||||
if (cost >= 0.5) return "为了这个问题,我可是认真思考了!"
|
||||
if (cost >= 0.1) return "下了点功夫,值得的!"
|
||||
if (cost >= 0.01) return "花了点小钱,但很值得"
|
||||
return "小小思考,不足挂齿"
|
||||
}
|
||||
|
||||
function getFavoriteReplyMetaphor(count: number, botName: string): string {
|
||||
if (count >= 100) return "这句话简直是万能钥匙!"
|
||||
if (count >= 50) return "百试不爽的经典回复"
|
||||
if (count >= 20) return `${botName}的口头禅`
|
||||
if (count >= 10) return "常用语录之一"
|
||||
return "偶尔用用的小确幸"
|
||||
}
|
||||
|
||||
function getNightOwlMetaphor(isNightOwl: boolean, midnightCount: number): string {
|
||||
if (isNightOwl) {
|
||||
if (midnightCount >= 1000) return "深夜的守护者,黑暗中的光芒"
|
||||
if (midnightCount >= 500) return "月亮是我的好朋友"
|
||||
if (midnightCount >= 100) return "越夜越精神,夜晚才是主场"
|
||||
return "偶尔熬夜,享受宁静时光"
|
||||
} else {
|
||||
if (midnightCount <= 10) return "作息规律,健康生活的典范"
|
||||
if (midnightCount <= 50) return "早睡早起,偶尔也会熬个夜"
|
||||
return "虽然是早起鸟,但也会守候深夜"
|
||||
}
|
||||
}
|
||||
|
||||
function getBusiestDayMetaphor(count: number): string {
|
||||
if (count >= 1000) return "忙到飞起,键盘都要冒烟了"
|
||||
if (count >= 500) return "这天简直是话痨附体"
|
||||
if (count >= 200) return "社交达人上线"
|
||||
if (count >= 100) return "比平时活跃不少"
|
||||
if (count >= 50) return "小忙一下"
|
||||
return "还算轻松的一天"
|
||||
}
|
||||
|
||||
export function AnnualReportPage() {
|
||||
const [year] = useState(2025)
|
||||
const [data, setData] = useState<AnnualReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const reportRef = useRef<HTMLDivElement>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
const loadReport = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
const result = await getAnnualReport(year)
|
||||
setData(result)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('获取年度报告失败'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [year])
|
||||
|
||||
// 导出为图片
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!reportRef.current || !data) return
|
||||
|
||||
setIsExporting(true)
|
||||
toast({
|
||||
title: '正在生成图片',
|
||||
description: '请稍候...',
|
||||
})
|
||||
|
||||
try {
|
||||
const element = reportRef.current
|
||||
|
||||
// 获取当前主题的背景色
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const backgroundColor = computedStyle.getPropertyValue('--background').trim()
|
||||
? `hsl(${computedStyle.getPropertyValue('--background').trim()})`
|
||||
: (document.documentElement.classList.contains('dark') ? '#0a0a0a' : '#ffffff')
|
||||
|
||||
// 保存原始样式
|
||||
const originalWidth = element.style.width
|
||||
const originalMaxWidth = element.style.maxWidth
|
||||
|
||||
// 临时设置固定宽度以去除左右空白
|
||||
element.style.width = '1024px'
|
||||
element.style.maxWidth = '1024px'
|
||||
|
||||
const dataUrl = await toPng(element, {
|
||||
quality: 1,
|
||||
pixelRatio: 2,
|
||||
backgroundColor,
|
||||
cacheBust: true,
|
||||
filter: (node) => {
|
||||
// 过滤掉导出按钮
|
||||
if (node instanceof HTMLElement && node.hasAttribute('data-export-btn')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
// 恢复原始样式
|
||||
element.style.width = originalWidth
|
||||
element.style.maxWidth = originalMaxWidth
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a')
|
||||
link.download = `${data.bot_name}_${data.year}_年度总结.png`
|
||||
link.href = dataUrl
|
||||
link.click()
|
||||
|
||||
toast({
|
||||
title: '导出成功',
|
||||
description: '年度报告已保存为图片',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('导出图片失败:', err)
|
||||
toast({
|
||||
title: '导出失败',
|
||||
description: '请重试',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}, [data, toast])
|
||||
|
||||
useEffect(() => {
|
||||
loadReport()
|
||||
}, [loadReport])
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSkeleton />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center text-red-500">
|
||||
获取年度报告失败: {error.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100vh-4rem)]">
|
||||
<div className="min-h-screen bg-gradient-to-b from-background to-muted/50 p-4 md:p-8 print:p-0" ref={reportRef}>
|
||||
<div className="mx-auto max-w-5xl space-y-8 print:space-y-4">
|
||||
{/* 头部 Hero */}
|
||||
<header className="relative overflow-hidden rounded-3xl bg-primary p-8 text-primary-foreground shadow-2xl print:rounded-none print:shadow-none">
|
||||
{/* 导出按钮 */}
|
||||
<div className="absolute right-4 top-4 z-20 print:hidden" data-export-btn>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
className="gap-2 bg-white/20 hover:bg-white/30 text-white border-white/30"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
导出中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
保存图片
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-col items-center text-center">
|
||||
<Bot className="mb-4 h-16 w-16 animate-bounce" />
|
||||
<h1 className="text-4xl font-bold tracking-tighter sm:text-6xl">
|
||||
{data.bot_name} {data.year} 年度总结
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-lg opacity-90">
|
||||
连接与成长 · Connection & Growth
|
||||
</p>
|
||||
<div className="mt-6 flex items-center gap-2 text-sm opacity-75">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>生成时间: {data.generated_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 背景装饰 */}
|
||||
<div className="absolute -right-20 -top-20 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
|
||||
<div className="absolute -bottom-20 -left-20 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
|
||||
</header>
|
||||
|
||||
{/* 维度一:时光足迹 */}
|
||||
<section className="space-y-4 break-inside-avoid">
|
||||
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
|
||||
<Clock className="h-8 w-8" />
|
||||
<h2>时光足迹</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="年度在线时长"
|
||||
value={`${data.time_footprint.total_online_hours} 小时`}
|
||||
description={getOnlineHoursMetaphor(data.time_footprint.total_online_hours)}
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="最忙碌的一天"
|
||||
value={data.time_footprint.busiest_day || 'N/A'}
|
||||
description={getBusiestDayMetaphor(data.time_footprint.busiest_day_count)}
|
||||
icon={<Calendar className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="深夜互动 (0-4点)"
|
||||
value={`${data.time_footprint.midnight_chat_count} 次`}
|
||||
description={getMidnightMetaphor(data.time_footprint.midnight_chat_count)}
|
||||
icon={<Moon className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="作息属性"
|
||||
value={data.time_footprint.is_night_owl ? '夜猫子' : '早起鸟'}
|
||||
description={getNightOwlMetaphor(data.time_footprint.is_night_owl, data.time_footprint.midnight_chat_count)}
|
||||
icon={data.time_footprint.is_night_owl ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle>24小时活跃时钟</CardTitle>
|
||||
<CardDescription>{data.bot_name}在一天中各个时段的活跃程度</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.time_footprint.hourly_distribution.map((count: number, hour: number) => ({ hour: `${hour}点`, count }))}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="hour" />
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
|
||||
cursor={{ fill: 'transparent' }}
|
||||
/>
|
||||
<Bar dataKey="count" fill="hsl(var(--color-primary))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{data.time_footprint.first_message_time && (
|
||||
<Card className="bg-muted/30 border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center p-6 text-center">
|
||||
<p className="text-muted-foreground mb-2">2025年的故事开始于</p>
|
||||
<div className="text-xl font-bold text-primary mb-1">{data.time_footprint.first_message_time}</div>
|
||||
<p className="text-lg">
|
||||
<span className="font-semibold text-foreground">{data.time_footprint.first_message_user}</span> 说:
|
||||
<span className="italic text-muted-foreground">"{data.time_footprint.first_message_content}"</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 维度二:社交网络 */}
|
||||
<section className="space-y-4 break-inside-avoid">
|
||||
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
|
||||
<Users className="h-8 w-8" />
|
||||
<h2>社交网络</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<StatCard
|
||||
title="社交圈子"
|
||||
value={`${data.social_network.total_groups} 个群组`}
|
||||
description={`${data.bot_name}加入的群组总数`}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="被呼叫次数"
|
||||
value={`${data.social_network.at_count + data.social_network.mentioned_count} 次`}
|
||||
description="我的名字被大家频繁提起"
|
||||
icon={<AtSign className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="最长情陪伴"
|
||||
value={data.social_network.longest_companion_user || 'N/A'}
|
||||
description={`始终都在,已陪伴 ${data.social_network.longest_companion_days} 天`}
|
||||
icon={<Heart className="h-4 w-4 text-red-500" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>话痨群组 TOP5</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{data.social_network.top_groups.length > 0 ? (
|
||||
data.social_network.top_groups.map((group: { group_id: string; group_name: string; message_count: number; is_webui?: boolean }, index: number) => (
|
||||
<div key={group.group_id} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={index === 0 ? "default" : "secondary"} className="h-6 w-6 rounded-full p-0 flex items-center justify-center shrink-0">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<span className="font-medium truncate max-w-[120px]">{group.group_name}</span>
|
||||
{group.is_webui && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5 bg-blue-50 text-blue-600 border-blue-200">
|
||||
WebUI
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm shrink-0">{group.message_count} 条消息</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-4">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>年度最佳损友 TOP5</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{data.social_network.top_users.length > 0 ? (
|
||||
data.social_network.top_users.map((user: { user_id: string; user_nickname: string; message_count: number; is_webui?: boolean }, index: number) => (
|
||||
<div key={user.user_id} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={index === 0 ? "default" : "secondary"} className="h-6 w-6 rounded-full p-0 flex items-center justify-center shrink-0">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<span className="font-medium truncate max-w-[120px]">{user.user_nickname}</span>
|
||||
{user.is_webui && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5 bg-blue-50 text-blue-600 border-blue-200">
|
||||
WebUI
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm shrink-0">{user.message_count} 次互动</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-4">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 维度三:最强大脑 */}
|
||||
<section className="space-y-4 break-inside-avoid">
|
||||
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
|
||||
<Brain className="h-8 w-8" />
|
||||
<h2>最强大脑</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="年度 Token 消耗"
|
||||
value={(data.brain_power.total_tokens / 1000000).toFixed(2) + ' M'}
|
||||
description={getTokenMetaphor(data.brain_power.total_tokens)}
|
||||
icon={<Zap className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="年度总花费"
|
||||
value={`$${data.brain_power.total_cost.toFixed(2)}`}
|
||||
description={getCostMetaphor(data.brain_power.total_cost)}
|
||||
icon={<span className="font-bold">$</span>}
|
||||
/>
|
||||
<StatCard
|
||||
title="高冷指数"
|
||||
value={`${data.brain_power.silence_rate}%`}
|
||||
description={getSilenceMetaphor(data.brain_power.silence_rate)}
|
||||
icon={<Moon className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="最高兴趣值"
|
||||
value={data.brain_power.max_interest_value ?? 'N/A'}
|
||||
description={data.brain_power.max_interest_time ? `出现在 ${data.brain_power.max_interest_time}` : '暂无数据'}
|
||||
icon={<Heart className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>模型偏好分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{data.brain_power.model_distribution.slice(0, 5).map((item: { model: string; count: number }, index: number) => {
|
||||
const maxCount = data.brain_power.model_distribution[0]?.count || 1
|
||||
const percentage = Math.round((item.count / maxCount) * 100)
|
||||
return (
|
||||
<div key={item.model} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium truncate max-w-[200px]">{item.model}</span>
|
||||
<span className="text-muted-foreground">{item.count.toLocaleString()} 次</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor: COLORS[index % COLORS.length]
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 最喜欢的回复模型 TOP5 */}
|
||||
{data.brain_power.top_reply_models && data.brain_power.top_reply_models.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>最喜欢的回复模型 TOP5</CardTitle>
|
||||
<CardDescription>{data.bot_name}用来回复消息的模型偏好</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{data.brain_power.top_reply_models.map((item: { model: string; count: number }, index: number) => {
|
||||
const maxCount = data.brain_power.top_reply_models[0]?.count || 1
|
||||
const percentage = Math.round((item.count / maxCount) * 100)
|
||||
return (
|
||||
<div key={item.model} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium truncate max-w-[200px]">{item.model}</span>
|
||||
<span className="text-muted-foreground">{item.count.toLocaleString()} 次</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor: COLORS[index % COLORS.length]
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 烧钱大户 - 只有有有效用户数据时才显示 */}
|
||||
{data.brain_power.top_token_consumers && data.brain_power.top_token_consumers.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>烧钱大户 TOP3</CardTitle>
|
||||
<CardDescription>谁消耗了最多的 API 额度</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{data.brain_power.top_token_consumers.map((consumer: { user_id: string; cost: number; tokens: number }) => (
|
||||
<div key={consumer.user_id} className="space-y-2">
|
||||
<div className="flex justify-between text-sm font-medium">
|
||||
<span>用户 {consumer.user_id}</span>
|
||||
<span>${consumer.cost.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${(consumer.cost / (data.brain_power.top_token_consumers[0]?.cost || 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 最昂贵的思考 & 思考深度 */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/20 dark:to-orange-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">💰</span>
|
||||
最昂贵的一次思考
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<div className="text-4xl font-bold text-amber-600 dark:text-amber-400">
|
||||
${data.brain_power.most_expensive_cost.toFixed(4)}
|
||||
</div>
|
||||
{data.brain_power.most_expensive_time && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
发生在 {data.brain_power.most_expensive_time}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
{getExpensiveThinkingMetaphor(data.brain_power.most_expensive_cost)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-indigo-950/20 dark:to-blue-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">🧠</span>
|
||||
思考深度
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-indigo-600 dark:text-indigo-400">
|
||||
{data.brain_power.avg_reasoning_length?.toFixed(0) || 0}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">平均思考字数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{data.brain_power.max_reasoning_length?.toLocaleString() || 0}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">最长思考字数</div>
|
||||
</div>
|
||||
</div>
|
||||
{data.brain_power.max_reasoning_time && (
|
||||
<p className="mt-4 text-center text-xs text-muted-foreground">
|
||||
最深沉的思考发生在 {data.brain_power.max_reasoning_time}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 维度四:个性与表达 */}
|
||||
<section className="space-y-4 break-inside-avoid">
|
||||
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
|
||||
<Smile className="h-8 w-8" />
|
||||
<h2>个性与表达</h2>
|
||||
</div>
|
||||
|
||||
{/* 深夜回复 & 最喜欢的回复 */}
|
||||
{(data.expression_vibe.late_night_reply || data.expression_vibe.favorite_reply) && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{data.expression_vibe.late_night_reply && (
|
||||
<Card className="bg-gradient-to-br from-indigo-50 to-violet-50 dark:from-indigo-950/20 dark:to-violet-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">🌙</span>
|
||||
深夜还在回复
|
||||
</CardTitle>
|
||||
<CardDescription>凌晨 {data.expression_vibe.late_night_reply.time},{data.bot_name}还在回复...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<p className="text-lg italic text-muted-foreground">
|
||||
"{data.expression_vibe.late_night_reply.content}"
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
是有什么心事吗?
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data.expression_vibe.favorite_reply && (
|
||||
<Card className="bg-gradient-to-br from-rose-50 to-pink-50 dark:from-rose-950/20 dark:to-pink-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">💬</span>
|
||||
最喜欢的回复
|
||||
</CardTitle>
|
||||
<CardDescription>使用了 {data.expression_vibe.favorite_reply.count} 次</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<p className="text-lg font-medium text-primary">
|
||||
"{data.expression_vibe.favorite_reply.content}"
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
{getFavoriteReplyMetaphor(data.expression_vibe.favorite_reply.count, data.bot_name)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* 使用最多的表情包 TOP3 */}
|
||||
<Card className="bg-gradient-to-br from-pink-50 to-purple-50 dark:from-pink-950/20 dark:to-purple-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle>使用最多的表情包 TOP3</CardTitle>
|
||||
<CardDescription>年度最爱的表情包们</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.expression_vibe.top_emojis && data.expression_vibe.top_emojis.length > 0 ? (
|
||||
<div className="flex justify-center gap-4">
|
||||
{data.expression_vibe.top_emojis.slice(0, 3).map((emoji: { id: number; usage_count: number }, index: number) => (
|
||||
<div key={emoji.id} className="flex flex-col items-center">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={`/api/webui/emoji/${emoji.id}/thumbnail?original=true`}
|
||||
alt={`TOP ${index + 1}`}
|
||||
className="h-24 w-24 rounded-lg object-cover shadow-md transition-transform hover:scale-105"
|
||||
/>
|
||||
<Badge
|
||||
className={cn(
|
||||
"absolute -top-2 -right-2",
|
||||
index === 0 ? "bg-yellow-500" : index === 1 ? "bg-gray-400" : "bg-amber-700"
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{emoji.usage_count} 次</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground">暂无数据</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>印象最深刻的表达风格</CardTitle>
|
||||
<CardDescription>{data.bot_name}最常使用的表达方式</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.expression_vibe.top_expressions.map((exp: { style: string; count: number }, index: number) => (
|
||||
<Badge
|
||||
key={exp.style}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"px-3 py-1 text-sm",
|
||||
index === 0 && "border-primary bg-primary/10 text-primary text-base px-4 py-2"
|
||||
)}
|
||||
>
|
||||
{exp.style} ({exp.count})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatCard
|
||||
title="图片鉴赏"
|
||||
value={`${data.expression_vibe.image_processed_count} 张`}
|
||||
description={getImageMetaphor(data.expression_vibe.image_processed_count)}
|
||||
icon={<ImageIcon className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="成长的足迹"
|
||||
value={`${data.expression_vibe.rejected_expression_count} 次`}
|
||||
description={getRejectedMetaphor(data.expression_vibe.rejected_expression_count)}
|
||||
icon={<Zap className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 行动派 */}
|
||||
{data.expression_vibe.action_types.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">⚡</span>
|
||||
行动派
|
||||
</CardTitle>
|
||||
<CardDescription>除了聊天,我还帮大家做了这些事</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{data.expression_vibe.action_types.map((action: { action: string; count: number }) => (
|
||||
<div
|
||||
key={action.action}
|
||||
className="flex items-center gap-2 rounded-full bg-primary/10 px-4 py-2"
|
||||
>
|
||||
<span className="font-medium text-primary">{action.action}</span>
|
||||
<Badge variant="secondary">{action.count} 次</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 维度五:趣味成就 */}
|
||||
<section className="space-y-4 break-inside-avoid">
|
||||
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
|
||||
<Trophy className="h-8 w-8" />
|
||||
<h2>趣味成就</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="col-span-1 md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>新学到的"黑话"</CardTitle>
|
||||
<CardDescription>今年我学会了 {data.achievements.new_jargon_count} 个新词</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{data.achievements.sample_jargons.map((jargon: { content: string; meaning: string; count: number }) => (
|
||||
<div key={jargon.content} className="group relative rounded-lg border bg-card p-3 shadow-sm transition-all hover:shadow-md">
|
||||
<div className="font-bold text-primary">{jargon.content}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-2 max-w-[200px]">
|
||||
{jargon.meaning || '暂无解释'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex flex-col justify-center items-center bg-primary text-primary-foreground">
|
||||
<CardContent className="flex flex-col items-center justify-center p-6 text-center">
|
||||
<MessageSquare className="h-12 w-12 mb-4 opacity-80" />
|
||||
<div className="text-4xl font-bold mb-2">{data.achievements.total_messages.toLocaleString()}</div>
|
||||
<div className="text-sm opacity-80">年度总消息数</div>
|
||||
<div className="mt-4 text-xs opacity-60">
|
||||
其中回复了 {data.achievements.total_replies.toLocaleString()} 次
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 底部 */}
|
||||
<footer className="mt-12 text-center text-muted-foreground">
|
||||
<p>MaiBot 2025 Annual Report</p>
|
||||
<p className="text-sm">Generated with ❤️ by MaiBot Team</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
title: string
|
||||
value: string | number
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<div className="text-muted-foreground">{icon}</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="container mx-auto space-y-8 p-8">
|
||||
<Skeleton className="h-64 w-full rounded-3xl" />
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-32 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -54,7 +55,7 @@ export function VirtualIdentityDialog({
|
||||
}: VirtualIdentityDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogContent className="sm:max-w-125 max-h-[85vh] overflow-hidden flex flex-col" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserCircle2 className="h-5 w-5" />
|
||||
@@ -65,7 +66,7 @@ export function VirtualIdentityDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
|
||||
<DialogBody className="space-y-4 flex-1" viewportClassName="pr-0">
|
||||
{/* 平台选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
@@ -113,7 +114,7 @@ export function VirtualIdentityDialog({
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-[250px] border rounded-md">
|
||||
<ScrollArea className="h-62.5 border rounded-md">
|
||||
<div className="p-2">
|
||||
{isLoadingPersons ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
@@ -187,13 +188,14 @@ export function VirtualIdentityDialog({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
data-dialog-action="confirm"
|
||||
onClick={onCreateVirtualTab}
|
||||
disabled={!tempVirtualConfig.platform || !tempVirtualConfig.personId}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
@@ -249,7 +250,7 @@ function RegexEditor({
|
||||
正则编辑器
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh]">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-225">
|
||||
<DialogHeader>
|
||||
<DialogTitle>正则表达式编辑器</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
@@ -257,7 +258,7 @@ function RegexEditor({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[calc(90vh-120px)]">
|
||||
<DialogBody>
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'build' | 'test')} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="build">🔧 构建器</TabsTrigger>
|
||||
@@ -406,7 +407,7 @@ function RegexEditor({
|
||||
value={testText}
|
||||
onChange={(e) => setTestText(e.target.value)}
|
||||
placeholder="在此输入要测试的文本... 例如:打游戏是这样的"
|
||||
className="min-h-[100px] text-sm"
|
||||
className="min-h-25 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -444,7 +445,7 @@ function RegexEditor({
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">匹配高亮</Label>
|
||||
<ScrollArea className="h-40 rounded-md bg-muted p-3">
|
||||
<div className="text-sm break-words">
|
||||
<div className="text-sm wrap-break-word">
|
||||
{renderHighlightedText()}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -458,7 +459,7 @@ function RegexEditor({
|
||||
<div className="space-y-2">
|
||||
{Object.entries(captureGroups).map(([name, value]) => (
|
||||
<div key={name} className="flex items-start gap-2 text-sm">
|
||||
<span className="font-mono font-semibold text-primary min-w-[80px]">[{name}]</span>
|
||||
<span className="font-mono font-semibold text-primary min-w-20">[{name}]</span>
|
||||
<span className="text-muted-foreground">=</span>
|
||||
<span className="font-mono bg-muted px-2 py-0.5 rounded">{value}</span>
|
||||
</div>
|
||||
@@ -473,7 +474,7 @@ function RegexEditor({
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Reaction 替换预览</Label>
|
||||
<ScrollArea className="h-48 rounded-md bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3">
|
||||
<div className="text-sm break-words">
|
||||
<div className="text-sm wrap-break-word">
|
||||
{replacedReaction}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -497,7 +498,7 @@ function RegexEditor({
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -628,7 +629,7 @@ export const ProcessingSection = React.memo(function ProcessingSection({
|
||||
预览
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[95vw] sm:w-[500px]">
|
||||
<PopoverContent className="w-[95vw] sm:w-125">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">配置预览</h4>
|
||||
<ScrollArea className="h-60 rounded-md bg-muted p-3">
|
||||
@@ -656,7 +657,7 @@ export const ProcessingSection = React.memo(function ProcessingSection({
|
||||
预览
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[95vw] sm:w-[500px]">
|
||||
<PopoverContent className="w-[95vw] sm:w-125">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">配置预览</h4>
|
||||
<ScrollArea className="h-60 rounded-md bg-muted p-3">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -971,9 +972,10 @@ function ModelConfigPageContent() {
|
||||
{/* 编辑模型对话框 */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogClose}>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||
className="max-w-[95vw] sm:max-w-2xl"
|
||||
data-tour="model-dialog"
|
||||
preventOutsideClose={tourIsRunning}
|
||||
confirmOnEnter
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -982,6 +984,7 @@ function ModelConfigPageContent() {
|
||||
<DialogDescription>配置模型的基本信息和参数</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2" data-tour="model-name-input">
|
||||
<Label htmlFor="model_name" className={formErrors.name ? 'text-destructive' : ''}>模型名称 *</Label>
|
||||
@@ -1492,12 +1495,13 @@ function ModelConfigPageContent() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)} data-tour="model-cancel-button">
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSaveEdit} data-tour="model-save-button">保存</Button>
|
||||
<Button data-dialog-action="confirm" onClick={handleSaveEdit} data-tour="model-save-button">保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Check, ChevronsUpDown, Copy, Eye, EyeOff } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { HelpTooltip } from '@/components/ui/help-tooltip'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -116,9 +116,10 @@ export function ProviderForm({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||
className="max-w-[95vw] sm:max-w-2xl"
|
||||
data-tour="provider-dialog"
|
||||
preventOutsideClose={tourState.isRunning}
|
||||
confirmOnEnter
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -130,6 +131,7 @@ export function ProviderForm({
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveEdit(); }} autoComplete="off">
|
||||
<DialogBody>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2" data-tour="provider-template-select">
|
||||
<Label htmlFor="template">提供商模板</Label>
|
||||
@@ -450,12 +452,13 @@ export function ProviderForm({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} data-tour="provider-cancel-button">
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" data-tour="provider-save-button">保存</Button>
|
||||
<Button type="submit" data-dialog-action="confirm" data-tour="provider-save-button">保存</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -40,6 +40,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -575,7 +576,7 @@ function ApplyDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-2xl" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Package className="w-5 h-5" />
|
||||
@@ -589,6 +590,7 @@ function ApplyDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
{detectingConflicts ? (
|
||||
<div className="py-8 text-center">
|
||||
<Loader2 className="w-8 h-8 mx-auto animate-spin text-primary" />
|
||||
@@ -831,6 +833,7 @@ function ApplyDialog({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="flex justify-between">
|
||||
<div>
|
||||
@@ -845,11 +848,11 @@ function ApplyDialog({
|
||||
取消
|
||||
</Button>
|
||||
{step < totalSteps ? (
|
||||
<Button onClick={() => setStep(step + 1)} disabled={detectingConflicts}>
|
||||
<Button data-dialog-action="confirm" onClick={() => setStep(step + 1)} disabled={detectingConflicts}>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onApply} disabled={applying}>
|
||||
<Button data-dialog-action="confirm" onClick={onApply} disabled={applying}>
|
||||
{applying && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
应用模板
|
||||
</Button>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -573,7 +574,7 @@ export function PersonManagementPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewDetail(person)}
|
||||
className="text-xs px-2 py-1 h-auto flex-shrink-0"
|
||||
className="text-xs px-2 py-1 h-auto shrink-0"
|
||||
>
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
查看
|
||||
@@ -582,7 +583,7 @@ export function PersonManagementPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(person)}
|
||||
className="text-xs px-2 py-1 h-auto flex-shrink-0"
|
||||
className="text-xs px-2 py-1 h-auto shrink-0"
|
||||
>
|
||||
<Edit className="h-3 w-3 mr-1" />
|
||||
编辑
|
||||
@@ -591,7 +592,7 @@ export function PersonManagementPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmPerson(person)}
|
||||
className="text-xs px-2 py-1 h-auto flex-shrink-0 text-destructive hover:text-destructive"
|
||||
className="text-xs px-2 py-1 h-auto shrink-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
删除
|
||||
@@ -771,7 +772,7 @@ function PersonDetailDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>人物详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -779,6 +780,7 @@ function PersonDetailDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
{/* 基本信息 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -829,6 +831,7 @@ function PersonDetailDialog({
|
||||
<InfoItem icon={Clock} label="最后更新" value={formatTime(person.last_know)} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
@@ -919,7 +922,7 @@ function PersonEditDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-2xl" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑人物信息</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -927,6 +930,7 @@ function PersonEditDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -974,6 +978,7 @@ function PersonEditDialog({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -59,7 +60,7 @@ export function EmojiDetailDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>表情包详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[calc(90vh-8rem)] pr-4">
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
{/* 表情包预览图 - 使用原图 */}
|
||||
<div className="flex justify-center">
|
||||
@@ -177,7 +178,7 @@ export function EmojiDetailDialog({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -252,11 +253,12 @@ export function EmojiEditDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-w-2xl" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑表情包</DialogTitle>
|
||||
<DialogDescription>修改表情包的情绪和状态信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>情绪</Label>
|
||||
@@ -310,11 +312,12 @@ export function EmojiEditDialog({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Button data-dialog-action="confirm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -658,7 +661,7 @@ export function EmojiUploadDialog({
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* 预览图 */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="shrink-0">
|
||||
<div className="w-32 h-32 rounded-lg border overflow-hidden bg-muted flex items-center justify-center">
|
||||
<img
|
||||
src={file.previewUrl}
|
||||
@@ -764,7 +767,7 @@ export function EmojiUploadDialog({
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 左侧:文件卡片列表 */}
|
||||
<ScrollArea className="h-[350px] pr-2">
|
||||
<ScrollArea className="h-87.5 pr-2">
|
||||
<div className="space-y-2">
|
||||
{uploadedFiles.map((file) => {
|
||||
const complete = isFileComplete(file)
|
||||
@@ -782,7 +785,7 @@ export function EmojiUploadDialog({
|
||||
${complete ? 'border-green-500 bg-green-50 dark:bg-green-950/20' : 'border-border hover:border-muted-foreground/50'}
|
||||
`}
|
||||
>
|
||||
<div className="w-12 h-12 rounded border overflow-hidden bg-muted flex-shrink-0 flex items-center justify-center">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded border bg-muted">
|
||||
<img
|
||||
src={file.previewUrl}
|
||||
alt={file.name}
|
||||
@@ -796,9 +799,9 @@ export function EmojiUploadDialog({
|
||||
</p>
|
||||
</div>
|
||||
{complete ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" />
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<div className="h-5 w-5 rounded-full border-2 border-muted-foreground/30 flex-shrink-0" />
|
||||
<div className="h-5 w-5 shrink-0 rounded-full border-2 border-muted-foreground/30" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -908,7 +911,7 @@ export function EmojiUploadDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden">
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" />
|
||||
@@ -925,11 +928,11 @@ export function EmojiUploadDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto pr-1">
|
||||
<DialogBody viewportClassName="pr-1">
|
||||
{step === 'select' && renderSelectStep()}
|
||||
{step === 'edit-single' && renderEditSingleStep()}
|
||||
{step === 'edit-multiple' && renderEditMultipleStep()}
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -65,7 +66,7 @@ export function ExpressionDetailDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-2xl" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle>表达方式详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -73,6 +74,7 @@ export function ExpressionDetailDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="情境" value={expression.situation} />
|
||||
@@ -131,6 +133,7 @@ export function ExpressionDetailDialog({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
@@ -233,7 +236,7 @@ export function ExpressionCreateDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-2xl" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增表达方式</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -241,6 +244,7 @@ export function ExpressionCreateDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -291,12 +295,13 @@ export function ExpressionCreateDialog({
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={saving}>
|
||||
<Button data-dialog-action="confirm" onClick={handleCreate} disabled={saving}>
|
||||
{saving ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -371,7 +376,7 @@ export function ExpressionEditDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-2xl" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑表达方式</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -379,6 +384,7 @@ export function ExpressionEditDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -474,12 +480,13 @@ export function ExpressionEditDialog({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Button data-dialog-action="confirm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -24,7 +25,6 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { MarkdownRenderer } from '@/components/markdown-renderer'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -92,7 +92,7 @@ export function JargonDetailDialog({
|
||||
<DialogDescription>查看黑话的完整信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-full pr-4">
|
||||
<DialogBody className="h-full">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem icon={Hash} label="记录ID" value={jargon.id.toString()} mono />
|
||||
@@ -167,7 +167,7 @@ export function JargonDetailDialog({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<Button onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
@@ -234,12 +234,13 @@ export function JargonCreateDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-2xl" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增黑话</DialogTitle>
|
||||
<DialogDescription>创建新的黑话记录</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">
|
||||
@@ -294,10 +295,11 @@ export function JargonCreateDialog({
|
||||
<Label htmlFor="is_global">设为全局黑话</Label>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={saving}>
|
||||
<Button data-dialog-action="confirm" onClick={handleCreate} disabled={saving}>
|
||||
{saving ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -366,12 +368,13 @@ export function JargonEditDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-2xl" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑黑话</DialogTitle>
|
||||
<DialogDescription>修改黑话的信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit_content">内容</Label>
|
||||
@@ -439,10 +442,11 @@ export function JargonEditDialog({
|
||||
<Label htmlFor="edit_is_global">全局黑话</Label>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Button data-dialog-action="confirm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
import type { GraphNode, SelectedEdgeData } from './types'
|
||||
|
||||
@@ -24,7 +24,7 @@ export function NodeDetailDialog({ open, onOpenChange, selectedNodeData }: NodeD
|
||||
<DialogTitle>节点详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedNodeData && (
|
||||
<ScrollArea className="h-full pr-4">
|
||||
<DialogBody className="h-full">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -62,7 +62,7 @@ export function NodeDetailDialog({ open, onOpenChange, selectedNodeData }: NodeD
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogBody>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -83,7 +83,7 @@ export function EdgeDetailDialog({ open, onOpenChange, selectedEdgeData }: EdgeD
|
||||
<DialogTitle>边详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedEdgeData && (
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0 p-3 bg-blue-50 dark:bg-blue-950 rounded border-2 border-blue-200 dark:border-blue-800">
|
||||
@@ -114,7 +114,7 @@ export function EdgeDetailDialog({ open, onOpenChange, selectedEdgeData }: EdgeD
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogBody>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user