Skip to content

Commit 0f6fb80

Browse files
committed
feat: 完善 UI 一致性 - 添加皮肤预设、组件、多标签、命令面板、多账号、缓存
- 新增 11 个皮肤预设 CSS - 新增 4 个仪表盘组件 (BarChart, PieChart, Tasks, Calendar) - 实现多标签导航系统 (TabBar) - 添加命令面板 (⌘K) - 实现多账号认证与切换 - 添加页面状态缓存 (KeepAlive)
1 parent d37653a commit 0f6fb80

11 files changed

Lines changed: 1993 additions & 3 deletions

File tree

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
import { h } from 'preact'
2+
import { useState, useEffect, useCallback } from 'preact/hooks'
3+
import { route } from 'preact-router'
4+
import {
5+
Search,
6+
Home,
7+
Users,
8+
Settings,
9+
FileText,
10+
FolderOpen,
11+
Mail,
12+
Calendar,
13+
BarChart3,
14+
Shield,
15+
Sun,
16+
Moon,
17+
LogOut,
18+
UserCheck
19+
} from 'lucide-preact'
20+
import { user as userSignal, accounts, activeAccountId, switchAccount, logout } from '../../stores/auth'
21+
import { Button } from '../ui/Button'
22+
23+
interface CommandPaletteProps {
24+
open: boolean
25+
onOpenChange: (open: boolean) => void
26+
}
27+
28+
interface Command {
29+
id: string
30+
label: string
31+
icon: any
32+
action: () => void | Promise<void>
33+
keywords?: string[]
34+
category: string
35+
}
36+
37+
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
38+
const [search, setSearch] = useState('')
39+
const [selectedIndex, setSelectedIndex] = useState(0)
40+
41+
// 导航命令
42+
const navigationCommands: Command[] = [
43+
{
44+
id: 'nav-home',
45+
label: '仪表盘',
46+
icon: Home,
47+
action: () => route('/'),
48+
keywords: ['dashboard', 'home'],
49+
category: '导航'
50+
},
51+
{
52+
id: 'nav-users',
53+
label: '用户管理',
54+
icon: Users,
55+
action: () => route('/users'),
56+
keywords: ['users', 'user management'],
57+
category: '导航'
58+
},
59+
{
60+
id: 'nav-analytics',
61+
label: '数据分析',
62+
icon: BarChart3,
63+
action: () => route('/analytics'),
64+
keywords: ['analytics', 'data'],
65+
category: '导航'
66+
},
67+
{
68+
id: 'nav-documents',
69+
label: '文档管理',
70+
icon: FileText,
71+
action: () => route('/documents'),
72+
keywords: ['documents', 'docs'],
73+
category: '导航'
74+
},
75+
{
76+
id: 'nav-files',
77+
label: '文件存储',
78+
icon: FolderOpen,
79+
action: () => route('/files'),
80+
keywords: ['files', 'storage'],
81+
category: '导航'
82+
},
83+
{
84+
id: 'nav-messages',
85+
label: '消息中心',
86+
icon: Mail,
87+
action: () => route('/messages'),
88+
keywords: ['messages', 'mail'],
89+
category: '导航'
90+
},
91+
{
92+
id: 'nav-calendar',
93+
label: '日程安排',
94+
icon: Calendar,
95+
action: () => route('/calendar'),
96+
keywords: ['calendar', 'schedule'],
97+
category: '导航'
98+
},
99+
{
100+
id: 'nav-accounts',
101+
label: '账号与权限',
102+
icon: Shield,
103+
action: () => route('/accounts'),
104+
keywords: ['accounts', 'permissions'],
105+
category: '导航'
106+
},
107+
{
108+
id: 'nav-settings',
109+
label: '系统设置',
110+
icon: Settings,
111+
action: () => route('/settings'),
112+
keywords: ['settings'],
113+
category: '导航'
114+
}
115+
]
116+
117+
// 主题命令
118+
const themeCommands: Command[] = [
119+
{
120+
id: 'theme-light',
121+
label: '浅色模式',
122+
icon: Sun,
123+
action: () => {
124+
document.documentElement.classList.remove('dark')
125+
localStorage.setItem('theme', 'light')
126+
},
127+
category: '主题'
128+
},
129+
{
130+
id: 'theme-dark',
131+
label: '深色模式',
132+
icon: Moon,
133+
action: () => {
134+
document.documentElement.classList.add('dark')
135+
localStorage.setItem('theme', 'dark')
136+
},
137+
category: '主题'
138+
}
139+
]
140+
141+
// 账号命令
142+
const accountCommands: Command[] = accounts.value.map((account) => ({
143+
id: `account-${account.id}`,
144+
label: `切换为 ${account.name}`,
145+
icon: UserCheck,
146+
action: async () => {
147+
if (account.id !== activeAccountId.value) {
148+
await switchAccount(account.id)
149+
// 刷新页面以应用新账号
150+
window.location.reload()
151+
}
152+
},
153+
keywords: [account.name, account.email],
154+
category: '账号'
155+
}))
156+
157+
// 操作命令
158+
const actionCommands: Command[] = [
159+
{
160+
id: 'action-search',
161+
label: '全局搜索',
162+
icon: Search,
163+
action: () => console.log('全局搜索'),
164+
keywords: ['search', 'find'],
165+
category: '操作'
166+
},
167+
{
168+
id: 'action-logout',
169+
label: '退出登录',
170+
icon: LogOut,
171+
action: async () => {
172+
await logout()
173+
route('/login')
174+
},
175+
keywords: ['logout', 'signout'],
176+
category: '操作'
177+
}
178+
]
179+
180+
// 合并所有命令
181+
const allCommands = [
182+
...navigationCommands,
183+
...themeCommands,
184+
...accountCommands,
185+
...actionCommands
186+
]
187+
188+
// 过滤命令
189+
const filteredCommands = search
190+
? allCommands.filter((cmd) => {
191+
const searchLower = search.toLowerCase()
192+
const matchLabel = cmd.label.toLowerCase().includes(searchLower)
193+
const matchKeywords = cmd.keywords?.some((k) => k.toLowerCase().includes(searchLower))
194+
return matchLabel || matchKeywords
195+
})
196+
: allCommands
197+
198+
// 按类别分组
199+
const groupedCommands = filteredCommands.reduce((groups, cmd) => {
200+
if (!groups[cmd.category]) {
201+
groups[cmd.category] = []
202+
}
203+
groups[cmd.category].push(cmd)
204+
return groups
205+
}, {} as Record<string, Command[]>)
206+
207+
// 键盘快捷键
208+
useEffect(() => {
209+
const handleKeyDown = (e: KeyboardEvent) => {
210+
// ⌘K 或 Ctrl+K 打开命令面板
211+
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
212+
e.preventDefault()
213+
onOpenChange(!open)
214+
return
215+
}
216+
217+
if (!open) return
218+
219+
// ESC 关闭
220+
if (e.key === 'Escape') {
221+
onOpenChange(false)
222+
setSearch('')
223+
setSelectedIndex(0)
224+
return
225+
}
226+
227+
// 上下键导航
228+
if (e.key === 'ArrowDown') {
229+
e.preventDefault()
230+
setSelectedIndex((prev) => Math.min(prev + 1, filteredCommands.length - 1))
231+
} else if (e.key === 'ArrowUp') {
232+
e.preventDefault()
233+
setSelectedIndex((prev) => Math.max(prev - 1, 0))
234+
}
235+
236+
// 回车执行命令
237+
if (e.key === 'Enter' && filteredCommands[selectedIndex]) {
238+
e.preventDefault()
239+
executeCommand(filteredCommands[selectedIndex])
240+
}
241+
}
242+
243+
document.addEventListener('keydown', handleKeyDown)
244+
return () => document.removeEventListener('keydown', handleKeyDown)
245+
}, [open, filteredCommands, selectedIndex])
246+
247+
// 重置选中索引当搜索改变时
248+
useEffect(() => {
249+
setSelectedIndex(0)
250+
}, [search])
251+
252+
const executeCommand = useCallback(async (cmd: Command) => {
253+
onOpenChange(false)
254+
setSearch('')
255+
setSelectedIndex(0)
256+
await cmd.action()
257+
}, [onOpenChange])
258+
259+
if (!open) return null
260+
261+
return (
262+
<div
263+
class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
264+
onClick={() => onOpenChange(false)}
265+
>
266+
<div class="fixed left-1/2 top-[20%] -translate-x-1/2 w-full max-w-2xl">
267+
<div
268+
class="bg-popover text-popover-foreground rounded-lg shadow-lg border border-border overflow-hidden"
269+
onClick={(e) => e.stopPropagation()}
270+
>
271+
{/* 搜索输入 */}
272+
<div class="flex items-center border-b border-border px-4">
273+
<Search class="h-4 w-4 mr-2 text-muted-foreground shrink-0" />
274+
<input
275+
type="text"
276+
value={search}
277+
onInput={(e) => setSearch((e.target as HTMLInputElement).value)}
278+
placeholder="输入命令或搜索..."
279+
class="flex-1 bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
280+
autoFocus
281+
/>
282+
<kbd class="hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border border-border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
283+
ESC
284+
</kbd>
285+
</div>
286+
287+
{/* 命令列表 */}
288+
<div class="max-h-[400px] overflow-y-auto p-2">
289+
{filteredCommands.length === 0 ? (
290+
<div class="py-6 text-center text-sm text-muted-foreground">
291+
未找到结果
292+
</div>
293+
) : (
294+
Object.entries(groupedCommands).map(([category, commands]) => (
295+
<div key={category} class="mb-2 last:mb-0">
296+
<div class="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
297+
{category}
298+
</div>
299+
{commands.map((cmd, idx) => {
300+
const globalIndex = filteredCommands.indexOf(cmd)
301+
const isSelected = globalIndex === selectedIndex
302+
const Icon = cmd.icon
303+
304+
return (
305+
<button
306+
key={cmd.id}
307+
class={`w-full flex items-center gap-2 px-2 py-2 text-sm rounded-md transition-colors ${
308+
isSelected
309+
? 'bg-accent text-accent-foreground'
310+
: 'hover:bg-accent hover:text-accent-foreground'
311+
}`}
312+
onClick={() => executeCommand(cmd)}
313+
onMouseEnter={() => setSelectedIndex(globalIndex)}
314+
>
315+
<Icon class="h-4 w-4" />
316+
<span class="flex-1 text-left">{cmd.label}</span>
317+
{cmd.id.startsWith('account-') &&
318+
cmd.id === `account-${activeAccountId.value}` && (
319+
<span class="text-xs text-muted-foreground">当前</span>
320+
)}
321+
</button>
322+
)
323+
})}
324+
</div>
325+
))
326+
)}
327+
</div>
328+
329+
{/* 底部提示 */}
330+
<div class="border-t border-border px-4 py-2 text-xs text-muted-foreground flex items-center justify-between">
331+
<div class="flex items-center gap-2">
332+
<kbd class="inline-flex h-5 select-none items-center gap-1 rounded border border-border bg-muted px-1.5 font-mono text-[10px] font-medium">
333+
↑↓
334+
</kbd>
335+
<span>导航</span>
336+
<kbd class="inline-flex h-5 select-none items-center gap-1 rounded border border-border bg-muted px-1.5 font-mono text-[10px] font-medium">
337+
Enter
338+
</kbd>
339+
<span>选择</span>
340+
</div>
341+
<div>
342+
<kbd class="inline-flex h-5 select-none items-center gap-1 rounded border border-border bg-muted px-1.5 font-mono text-[10px] font-medium">
343+
⌘K
344+
</kbd>
345+
</div>
346+
</div>
347+
</div>
348+
</div>
349+
</div>
350+
)
351+
}

0 commit comments

Comments
 (0)