Initial commit

This commit is contained in:
史悦
2026-02-02 18:30:58 +08:00
commit ae1ca8bfaa
40 changed files with 10900 additions and 0 deletions

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View File

@@ -0,0 +1,3 @@
export { Layout } from './Layout'
export { Header } from './Header'
export { Sidebar } from './Sidebar'