Initial commit
This commit is contained in:
255
src/components/layout/Header.tsx
Normal file
255
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useAppStore } from '@/stores/useAppStore'
|
||||
import { Menu, Search, Sun, Moon, X } from 'lucide-react'
|
||||
import { useState, useMemo, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { processes, artifacts, tools, knowledgeAreaMap } from '@/data'
|
||||
|
||||
interface SearchResult {
|
||||
type: 'process' | 'artifact' | 'tool'
|
||||
id: string
|
||||
name: string
|
||||
nameEn: string
|
||||
extra?: string
|
||||
link: string
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
|
||||
const toggleSidebar = useAppStore((s) => s.toggleSidebar)
|
||||
const darkMode = useAppStore((s) => s.darkMode)
|
||||
const toggleDarkMode = useAppStore((s) => s.toggleDarkMode)
|
||||
const searchQuery = useAppStore((s) => s.searchQuery)
|
||||
const setSearchQuery = useAppStore((s) => s.setSearchQuery)
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||
const searchRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 搜索结果
|
||||
const searchResults = useMemo<SearchResult[]>(() => {
|
||||
if (!searchQuery.trim()) return []
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
const results: SearchResult[] = []
|
||||
|
||||
// 搜索过程
|
||||
processes.forEach((p) => {
|
||||
if (
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
p.nameEn.toLowerCase().includes(query) ||
|
||||
p.code.toLowerCase().includes(query)
|
||||
) {
|
||||
const ka = knowledgeAreaMap.get(p.knowledgeAreaId)
|
||||
results.push({
|
||||
type: 'process',
|
||||
id: p.id,
|
||||
name: `${p.code} ${p.name}`,
|
||||
nameEn: p.nameEn,
|
||||
extra: ka?.name,
|
||||
link: `/process/${p.id}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索工件/文档
|
||||
artifacts.forEach((a) => {
|
||||
if (
|
||||
a.name.toLowerCase().includes(query) ||
|
||||
a.nameEn.toLowerCase().includes(query)
|
||||
) {
|
||||
results.push({
|
||||
type: 'artifact',
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
nameEn: a.nameEn,
|
||||
extra: '工件/文档',
|
||||
link: `/artifact/${a.id}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索工具与技术
|
||||
tools.forEach((t) => {
|
||||
if (
|
||||
t.name.toLowerCase().includes(query) ||
|
||||
t.nameEn.toLowerCase().includes(query)
|
||||
) {
|
||||
results.push({
|
||||
type: 'tool',
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
nameEn: t.nameEn,
|
||||
extra: '工具与技术',
|
||||
link: `/tool/${t.id}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return results.slice(0, 10) // 限制结果数量
|
||||
}, [searchQuery])
|
||||
|
||||
// 点击外部关闭搜索
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
|
||||
setIsSearchOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// 键盘快捷键
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
||||
event.preventDefault()
|
||||
setIsSearchOpen(true)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setIsSearchOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
const handleResultClick = (result: SearchResult) => {
|
||||
navigate(result.link)
|
||||
setIsSearchOpen(false)
|
||||
}
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'process':
|
||||
return 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300'
|
||||
case 'artifact':
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
||||
case 'tool':
|
||||
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'process':
|
||||
return '过程'
|
||||
case 'artifact':
|
||||
return '工件'
|
||||
case 'tool':
|
||||
return '工具'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={`
|
||||
sticky top-0 z-10 flex h-16 items-center justify-between gap-4
|
||||
border-b border-gray-200 dark:border-gray-700
|
||||
bg-white dark:bg-gray-800 px-4
|
||||
transition-all duration-300
|
||||
${sidebarOpen ? 'lg:ml-64' : 'lg:ml-20'}
|
||||
`}>
|
||||
{/* 左侧:菜单按钮和搜索 */}
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="lg:hidden flex h-10 w-10 items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500"
|
||||
aria-label="切换菜单"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div ref={searchRef} className="relative flex-1 max-w-lg">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="搜索过程、输入、工具、输出..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setIsSearchOpen(true)
|
||||
}}
|
||||
onFocus={() => setIsSearchOpen(true)}
|
||||
onClick={() => setIsSearchOpen(true)}
|
||||
className="w-full h-10 pl-10 pr-20 rounded-lg border border-gray-200 dark:border-gray-600
|
||||
bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white
|
||||
placeholder-gray-400 dark:placeholder-gray-500
|
||||
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent
|
||||
transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
className="absolute right-12 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 hidden sm:inline-flex
|
||||
h-5 items-center gap-1 rounded border border-gray-200 dark:border-gray-600
|
||||
bg-gray-100 dark:bg-gray-600 px-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
⌘K
|
||||
</kbd>
|
||||
|
||||
{/* 搜索结果下拉 */}
|
||||
{isSearchOpen && searchQuery && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
|
||||
{searchResults.length > 0 ? (
|
||||
<ul className="max-h-96 overflow-y-auto">
|
||||
{searchResults.map((result) => (
|
||||
<li key={`${result.type}-${result.id}`}>
|
||||
<button
|
||||
onClick={() => handleResultClick(result)}
|
||||
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 text-left transition-colors"
|
||||
>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getTypeColor(result.type)}`}>
|
||||
{getTypeLabel(result.type)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{result.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{result.nameEn}
|
||||
{result.extra && ` · ${result.extra}`}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
未找到匹配的结果
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 主题切换 */}
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400"
|
||||
aria-label="切换主题"
|
||||
>
|
||||
{darkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
41
src/components/layout/Layout.tsx
Normal file
41
src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Header } from './Header'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { useAppStore } from '@/stores/useAppStore'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
|
||||
const darkMode = useAppStore((s) => s.darkMode)
|
||||
|
||||
return (
|
||||
<div className={clsx('min-h-screen', darkMode && 'dark')}>
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* 侧边栏 */}
|
||||
<Sidebar />
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* 顶部导航 */}
|
||||
<Header />
|
||||
|
||||
{/* 页面内容 */}
|
||||
<main
|
||||
className={clsx(
|
||||
'flex-1 overflow-y-auto p-6 transition-all duration-300',
|
||||
sidebarOpen ? 'lg:ml-64' : 'lg:ml-20'
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
111
src/components/layout/Sidebar.tsx
Normal file
111
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { clsx } from 'clsx'
|
||||
import { useAppStore } from '@/stores/useAppStore'
|
||||
import {
|
||||
Home,
|
||||
BookOpen,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Share2,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: '首页', icon: Home },
|
||||
{ path: '/knowledge-areas', label: '知识领域', icon: BookOpen },
|
||||
{ path: '/process-groups', label: '过程组', icon: Layers },
|
||||
{ path: '/process-matrix', label: '49过程矩阵', icon: LayoutGrid },
|
||||
{ path: '/process-graph', label: '过程关系图', icon: Share2 },
|
||||
{ path: '/settings', label: '设置', icon: Settings },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation()
|
||||
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
|
||||
const toggleSidebar = useAppStore((s) => s.toggleSidebar)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 移动端遮罩 */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-20 bg-black/50 lg:hidden"
|
||||
onClick={toggleSidebar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed left-0 top-0 z-30 h-full bg-white dark:bg-gray-800 shadow-lg transition-all duration-300',
|
||||
sidebarOpen ? 'w-64' : 'w-20',
|
||||
'lg:translate-x-0',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center justify-between px-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 text-white font-bold text-lg">
|
||||
IT
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
ITTOView
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="hidden lg:flex h-8 w-8 items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500"
|
||||
aria-label="切换侧边栏"
|
||||
>
|
||||
{sidebarOpen ? <ChevronLeft size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<nav className="mt-4 px-3">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path ||
|
||||
(item.path !== '/' && location.pathname.startsWith(item.path))
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<Link
|
||||
to={item.path}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
<Icon size={20} />
|
||||
{sidebarOpen && <span>{item.label}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* 底部信息 */}
|
||||
{sidebarOpen && (
|
||||
<div className="absolute bottom-4 left-4 right-4">
|
||||
<div className="rounded-lg bg-gray-50 dark:bg-gray-700/50 p-4">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">PMBOK 第6版</div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
49个过程 · 10大知识领域
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
3
src/components/layout/index.ts
Normal file
3
src/components/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Layout } from './Layout'
|
||||
export { Header } from './Header'
|
||||
export { Sidebar } from './Sidebar'
|
||||
Reference in New Issue
Block a user