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}
|
||||
|
||||
Reference in New Issue
Block a user