huhaa-myskills 0.2.13 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +9 -375
  2. package/bin/huhaa-myskills.mjs +194 -12
  3. package/{scripts → build}/verify.mjs +3 -1
  4. package/docs/Frontend-Spec.md +1149 -0
  5. package/docs/assets/layout-wireframe.png +0 -0
  6. package/docs/assets/theme-reference.png +0 -0
  7. package/docs/scan_rules.md +172 -0
  8. package/package.json +33 -9
  9. package/packages/scanner/src/adapters/directory-skill.mjs +239 -0
  10. package/packages/scanner/src/adapters/skill-adapter.mjs +345 -0
  11. package/packages/scanner/src/icon/brand-map.mjs +84 -0
  12. package/packages/scanner/src/icon/icon-extractor.mjs +246 -0
  13. package/packages/scanner/src/index.mjs +107 -82
  14. package/packages/scanner/src/types.d.ts +18 -0
  15. package/packages/scanner/test/icon.test.mjs +61 -0
  16. package/packages/scanner/test/scanner.test.mjs +37 -41
  17. package/packages/scanner/test/skill-adapter.test.mjs +70 -0
  18. package/packages/scanner/tests/directory-skill.test.mjs +93 -0
  19. package/packages/scanner/tests/scanner-integration.test.mjs +139 -0
  20. package/packages/server/src/index.mjs +164 -12
  21. package/packages/server/src/labels.mjs +62 -0
  22. package/packages/server/test/server.test.mjs +1 -1
  23. package/packages/web/README.md +33 -2
  24. package/packages/web/dist/assets/index-BCY2cRaO.js +175 -0
  25. package/packages/web/dist/assets/index-DALKjJPi.css +1 -0
  26. package/packages/web/dist/favicon-16x16.png +0 -0
  27. package/packages/web/dist/favicon-192x192.png +0 -0
  28. package/packages/web/dist/favicon-32x32.png +0 -0
  29. package/packages/web/dist/favicon-512x512.png +0 -0
  30. package/packages/web/dist/favicon-source.svg +5 -0
  31. package/packages/web/dist/index.html +23 -9
  32. package/packages/web/index.html +22 -8
  33. package/packages/web/package-lock.json +2989 -0
  34. package/packages/web/package.json +21 -8
  35. package/packages/web/postcss.config.js +6 -0
  36. package/packages/web/public/favicon-16x16.png +0 -0
  37. package/packages/web/public/favicon-192x192.png +0 -0
  38. package/packages/web/public/favicon-32x32.png +0 -0
  39. package/packages/web/public/favicon-512x512.png +0 -0
  40. package/packages/web/public/favicon-source.svg +5 -0
  41. package/packages/web/src/App.test.ts +40 -0
  42. package/packages/web/src/App.tsx +177 -0
  43. package/packages/web/src/components/ComingSoon.tsx +14 -0
  44. package/packages/web/src/components/layout/Sidebar.test.tsx +74 -0
  45. package/packages/web/src/components/layout/Sidebar.tsx +114 -0
  46. package/packages/web/src/components/layout/Topbar.tsx +74 -0
  47. package/packages/web/src/components/ui/ActionButton.tsx +48 -0
  48. package/packages/web/src/components/ui/button.tsx +48 -0
  49. package/packages/web/src/components/ui/card.tsx +45 -0
  50. package/packages/web/src/components/views/DashboardView.tsx +122 -0
  51. package/packages/web/src/components/views/OtherSkillsView.test.tsx +126 -0
  52. package/packages/web/src/components/views/OtherSkillsView.tsx +306 -0
  53. package/packages/web/src/components/views/SettingsView.tsx +95 -0
  54. package/packages/web/src/components/views/SkillDetail.test.tsx +57 -0
  55. package/packages/web/src/components/views/SkillDetail.tsx +81 -0
  56. package/packages/web/src/components/views/SkillsView.test.ts +28 -0
  57. package/packages/web/src/components/views/SkillsView.tsx +226 -0
  58. package/packages/web/src/hooks/QUICK_REFERENCE.md +165 -0
  59. package/packages/web/src/hooks/README.md +378 -0
  60. package/packages/web/src/hooks/__tests__/useOtherSkills.test.ts +422 -0
  61. package/packages/web/src/hooks/__tests__/useSkillIcons.test.ts +272 -0
  62. package/packages/web/src/hooks/useLiveReload.ts +20 -0
  63. package/packages/web/src/hooks/useOtherSkills.ts +387 -0
  64. package/packages/web/src/hooks/useSkillIcons.ts +248 -0
  65. package/packages/web/src/hooks/useTheme.ts +36 -0
  66. package/packages/web/src/index.css +161 -0
  67. package/packages/web/src/lib/api.test.ts +47 -0
  68. package/packages/web/src/lib/api.ts +77 -0
  69. package/packages/web/src/lib/cn.ts +7 -0
  70. package/packages/web/src/lib/editors.test.ts +30 -0
  71. package/packages/web/src/lib/editors.ts +70 -0
  72. package/packages/web/src/lib/markdown.test.ts +27 -0
  73. package/packages/web/src/lib/markdown.ts +14 -0
  74. package/packages/web/src/main.tsx +13 -0
  75. package/packages/web/src/types/index.ts +62 -0
  76. package/packages/web/src/types/other-skill.ts +88 -0
  77. package/packages/web/src/types/skill.ts +136 -0
  78. package/packages/web/src/types.ts +47 -0
  79. package/packages/web/src/vite-env.d.ts +1 -0
  80. package/packages/web/tailwind.config.ts +66 -0
  81. package/packages/web/tsconfig.json +28 -0
  82. package/packages/web/vite.config.ts +36 -0
  83. package/packages/web/vitest.config.ts +21 -0
  84. package/packages/web/vitest.setup.ts +1 -0
  85. package/scripts/activate-data-sources.sh +223 -0
  86. package/scripts/brand-completion.py +586 -0
  87. package/scripts/init-nextjs-project.sh +421 -0
  88. package/scripts/install-and-sync.sh +209 -0
  89. package/scripts/start-dev.sh +72 -0
  90. package/docs/GUIDE.md +0 -190
  91. package/docs/PLAN.md +0 -359
  92. package/docs/RULES.md +0 -329
  93. package/docs/RUNBOOK-myskills.md +0 -258
  94. package/docs/SYNC-SKILLS.md +0 -259
  95. package/docs/releases/README.md +0 -47
  96. package/docs/releases/v0.1.0.md +0 -256
  97. package/docs/releases/v0.1.1.md +0 -297
  98. package/docs/releases/v0.1.2.md +0 -207
  99. package/docs/releases/v0.1.3.md +0 -269
  100. package/docs/todo/v0.1.2.md +0 -98
  101. package/docs/todo/v0.1.3.md +0 -40
  102. package/docs/todo/v0.1.4.md +0 -36
  103. package/docs/todo/v0.1.5.md +0 -41
  104. package/docs/todo/v0.1.6.md +0 -43
  105. package/docs/todo/v0.1.7.md +0 -38
  106. package/docs/todo/v0.1.8.md +0 -36
  107. package/docs/todo/v0.1.9.md +0 -54
  108. package/packages/web/dist/assets/index-BPepSb3q.js +0 -36
  109. package/packages/web/dist/assets/index-CIGkgT5w.css +0 -1
  110. package/packages/web/src/App.vue +0 -483
  111. package/packages/web/src/components/AppTree.vue +0 -230
  112. package/packages/web/src/components/DirectoryTree.vue +0 -263
  113. package/packages/web/src/components/SkillTree.vue +0 -208
  114. package/packages/web/src/lib/api.js +0 -43
  115. package/packages/web/src/main.js +0 -6
  116. package/packages/web/src/stores/i18n.js +0 -161
  117. package/packages/web/src/stores/skills.js +0 -287
  118. package/packages/web/src/styles.css +0 -365
  119. package/packages/web/vite.config.js +0 -17
  120. /package/{scripts → build}/build-web.mjs +0 -0
  121. /package/{scripts → build}/prepare-publish.mjs +0 -0
@@ -1,20 +1,33 @@
1
1
  {
2
2
  "name": "@huhaa/web",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite --host 127.0.0.1 --port 11521",
8
8
  "build": "vite build",
9
- "preview": "vite preview --host 127.0.0.1 --port 11522"
9
+ "preview": "vite preview --host 127.0.0.1 --port 11522",
10
+ "type-check": "tsc --noEmit"
10
11
  },
11
12
  "dependencies": {
12
- "@vitejs/plugin-vue": "^5.2.1",
13
- "vite": "^6.0.0",
14
- "vue": "^3.5.13",
15
- "pinia": "^2.3.0",
13
+ "class-variance-authority": "^0.7.1",
14
+ "clsx": "^2.1.1",
16
15
  "fuse.js": "^7.0.0",
17
- "markdown-it": "^14.1.0"
16
+ "lucide-react": "^0.460.0",
17
+ "markdown-it": "^14.1.0",
18
+ "react": "^18.3.1",
19
+ "react-dom": "^18.3.1",
20
+ "tailwind-merge": "^2.5.5"
18
21
  },
19
- "devDependencies": {}
22
+ "devDependencies": {
23
+ "@types/markdown-it": "^14.1.2",
24
+ "@types/react": "^18.3.12",
25
+ "@types/react-dom": "^18.3.1",
26
+ "@vitejs/plugin-react": "^4.3.4",
27
+ "autoprefixer": "^10.4.20",
28
+ "postcss": "^8.4.49",
29
+ "tailwindcss": "^3.4.15",
30
+ "typescript": "^5.6.3",
31
+ "vite": "^6.0.0"
32
+ }
20
33
  }
@@ -0,0 +1,6 @@
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg width="192" height="192" viewBox="0 0 192 192" fill="none" xmlns="http://www.w3.org/2000/svg">
3
+ <rect width="192" height="192" rx="48" fill="#7c3aed"/>
4
+ <text x="96" y="120" font-size="100" font-weight="bold" fill="white" text-anchor="middle" font-family="system-ui">🛠</text>
5
+ </svg>
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { reducer, initialState, type UIState } from './App'
3
+
4
+ describe('App reducer 状态机(module × view)', () => {
5
+ it('editor 动作进入技能视图并重置 kind/选中(避免脏筛选)', () => {
6
+ const dirty: UIState = {
7
+ ...initialState,
8
+ view: 'dashboard',
9
+ kindFilter: 'skill',
10
+ selectedId: 'abc',
11
+ }
12
+ const next = reducer(dirty, { type: 'editor', key: 'Cursor' })
13
+ expect(next.view).toBe('skills')
14
+ expect(next.editorFilter).toBe('Cursor')
15
+ expect(next.kindFilter).toBeNull()
16
+ expect(next.selectedId).toBeNull()
17
+ })
18
+
19
+ it('editor=null 表示全部技能', () => {
20
+ expect(reducer(initialState, { type: 'editor', key: null }).editorFilter).toBeNull()
21
+ })
22
+
23
+ it('dashboard / settings 切换视图', () => {
24
+ expect(reducer(initialState, { type: 'settings' }).view).toBe('settings')
25
+ const back = reducer(reducer(initialState, { type: 'settings' }), { type: 'dashboard' })
26
+ expect(back.view).toBe('dashboard')
27
+ })
28
+
29
+ it('query / kind / select 只改对应字段', () => {
30
+ expect(reducer(initialState, { type: 'query', query: 'mcp' }).query).toBe('mcp')
31
+ expect(reducer(initialState, { type: 'kind', kind: 'skill' }).kindFilter).toBe('skill')
32
+ expect(reducer(initialState, { type: 'select', id: 'x1' }).selectedId).toBe('x1')
33
+ })
34
+
35
+ it('module 切换不丢其它状态', () => {
36
+ const s = reducer({ ...initialState, query: 'q' }, { type: 'module', module: 'commands' })
37
+ expect(s.module).toBe('commands')
38
+ expect(s.query).toBe('q')
39
+ })
40
+ })
@@ -0,0 +1,177 @@
1
+ import { useCallback, useEffect, useReducer, useState } from 'react'
2
+ import { Sidebar } from '@/components/layout/Sidebar'
3
+ import { Topbar, type ModuleKey } from '@/components/layout/Topbar'
4
+ import { DashboardView } from '@/components/views/DashboardView'
5
+ import { SkillsView } from '@/components/views/SkillsView'
6
+ import { SettingsView } from '@/components/views/SettingsView'
7
+ import { OtherSkillsView } from '@/components/views/OtherSkillsView'
8
+ import { ComingSoon } from '@/components/ComingSoon'
9
+ import { useLiveReload } from '@/hooks/useLiveReload'
10
+ import { fetchSkills, fetchStats, reload } from '@/lib/api'
11
+ import type { SkillItem, Stats } from '@/types'
12
+
13
+ export type View = 'dashboard' | 'skills' | 'otherSkills' | 'settings'
14
+
15
+ export interface UIState {
16
+ module: ModuleKey
17
+ view: View
18
+ editorFilter: string | null
19
+ kindFilter: string | null
20
+ query: string
21
+ selectedId: string | null
22
+ otherSkillsQuery: string
23
+ }
24
+
25
+ export type Action =
26
+ | { type: 'module'; module: ModuleKey }
27
+ | { type: 'dashboard' }
28
+ | { type: 'settings' }
29
+ | { type: 'otherSkills' }
30
+ | { type: 'otherSkillsQuery'; query: string }
31
+ | { type: 'editor'; key: string | null }
32
+ | { type: 'query'; query: string }
33
+ | { type: 'kind'; kind: string | null }
34
+ | { type: 'select'; id: string }
35
+
36
+ export const initialState: UIState = {
37
+ module: 'skills',
38
+ view: 'dashboard',
39
+ editorFilter: null,
40
+ kindFilter: null,
41
+ query: '',
42
+ selectedId: null,
43
+ otherSkillsQuery: '',
44
+ }
45
+
46
+ export function reducer(state: UIState, action: Action): UIState {
47
+ switch (action.type) {
48
+ case 'module':
49
+ return { ...state, module: action.module }
50
+ case 'dashboard':
51
+ return { ...state, view: 'dashboard' }
52
+ case 'settings':
53
+ return { ...state, view: 'settings' }
54
+ case 'otherSkills':
55
+ return { ...state, view: 'otherSkills' }
56
+ case 'otherSkillsQuery':
57
+ return { ...state, otherSkillsQuery: action.query }
58
+ case 'editor':
59
+ // 切换来源:进入技能视图,重置 kind/选中
60
+ return { ...state, view: 'skills', editorFilter: action.key, kindFilter: null, selectedId: null }
61
+ case 'query':
62
+ return { ...state, query: action.query }
63
+ case 'kind':
64
+ return { ...state, kindFilter: action.kind }
65
+ case 'select':
66
+ return { ...state, selectedId: action.id }
67
+ default:
68
+ return state
69
+ }
70
+ }
71
+
72
+ export default function App() {
73
+ const [items, setItems] = useState<SkillItem[]>([])
74
+ const [stats, setStats] = useState<Stats | null>(null)
75
+ const [loading, setLoading] = useState(true)
76
+ const [error, setError] = useState<string | null>(null)
77
+ const [reloading, setReloading] = useState(false)
78
+ const [ui, dispatch] = useReducer(reducer, initialState)
79
+
80
+ async function load() {
81
+ setLoading(true)
82
+ setError(null)
83
+ try {
84
+ const [skills, s] = await Promise.all([fetchSkills(), fetchStats()])
85
+ setItems(skills)
86
+ setStats(s)
87
+ } catch (e) {
88
+ setError(e instanceof Error ? e.message : '加载失败')
89
+ } finally {
90
+ setLoading(false)
91
+ }
92
+ }
93
+
94
+ useEffect(() => {
95
+ load()
96
+ }, [])
97
+
98
+ // SSE 静默刷新:文件变更时不闪「加载中」,只更新数据
99
+ const refresh = useCallback(async () => {
100
+ try {
101
+ const [skills, s] = await Promise.all([fetchSkills(), fetchStats()])
102
+ setItems(skills)
103
+ setStats(s)
104
+ } catch {
105
+ // 静默:实时刷新失败不打断当前界面
106
+ }
107
+ }, [])
108
+ useLiveReload(refresh)
109
+
110
+ async function handleReload() {
111
+ setReloading(true)
112
+ try {
113
+ await reload()
114
+ await load()
115
+ } finally {
116
+ setReloading(false)
117
+ }
118
+ }
119
+
120
+ function renderMain() {
121
+ if (ui.module !== 'skills') {
122
+ return <ComingSoon title={ui.module === 'commands' ? '命令' : '编辑器'} />
123
+ }
124
+ if (loading) return <p className="text-body-sm text-muted-foreground">加载中…</p>
125
+ if (error) {
126
+ return (
127
+ <div className="detail border-destructive">
128
+ <p className="text-body-sm text-destructive">加载失败:{error}</p>
129
+ </div>
130
+ )
131
+ }
132
+ if (ui.view === 'dashboard') return <DashboardView stats={stats} items={items} />
133
+ if (ui.view === 'otherSkills')
134
+ return (
135
+ <OtherSkillsView
136
+ query={ui.otherSkillsQuery}
137
+ onQuery={(q) => dispatch({ type: 'otherSkillsQuery', query: q })}
138
+ selectedId={ui.selectedId}
139
+ onSelect={(id) => dispatch({ type: 'select', id })}
140
+ />
141
+ )
142
+ if (ui.view === 'settings') return <SettingsView />
143
+ return (
144
+ <SkillsView
145
+ items={items}
146
+ editorFilter={ui.editorFilter}
147
+ query={ui.query}
148
+ onQuery={(q) => dispatch({ type: 'query', query: q })}
149
+ kindFilter={ui.kindFilter}
150
+ onKind={(k) => dispatch({ type: 'kind', kind: k })}
151
+ selectedId={ui.selectedId}
152
+ onSelect={(id) => dispatch({ type: 'select', id })}
153
+ />
154
+ )
155
+ }
156
+
157
+ return (
158
+ <div className="app-shell">
159
+ <Topbar
160
+ module={ui.module}
161
+ onModule={(m) => dispatch({ type: 'module', module: m })}
162
+ onReload={handleReload}
163
+ reloading={reloading}
164
+ />
165
+ <Sidebar
166
+ view={ui.view}
167
+ editorFilter={ui.editorFilter}
168
+ stats={stats}
169
+ onDashboard={() => dispatch({ type: 'dashboard' })}
170
+ onSettings={() => dispatch({ type: 'settings' })}
171
+ onOtherSkills={() => dispatch({ type: 'otherSkills' })}
172
+ onEditor={(key) => dispatch({ type: 'editor', key })}
173
+ />
174
+ <main className="main-pane">{renderMain()}</main>
175
+ </div>
176
+ )
177
+ }
@@ -0,0 +1,14 @@
1
+ interface ComingSoonProps {
2
+ title: string
3
+ }
4
+
5
+ export function ComingSoon({ title }: ComingSoonProps) {
6
+ return (
7
+ <div className="grid h-full place-items-center text-muted-foreground">
8
+ <div className="text-center">
9
+ <p className="text-h4 text-foreground">{title}</p>
10
+ <p className="mt-1 text-body-sm">待开发,敬请期待</p>
11
+ </div>
12
+ </div>
13
+ )
14
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import '@testing-library/jest-dom/vitest'
3
+ import { render, screen, fireEvent } from '@testing-library/react'
4
+ import { Sidebar } from './Sidebar'
5
+ import type { Stats } from '@/types'
6
+
7
+ function statsWith(byEditor: Record<string, number>): Stats {
8
+ const total = Object.values(byEditor).reduce((a, b) => a + b, 0)
9
+ return {
10
+ total,
11
+ bySource: {},
12
+ byEditor,
13
+ byKind: {},
14
+ byCategory: {},
15
+ byBrand: {},
16
+ }
17
+ }
18
+
19
+ const noop = () => {}
20
+
21
+ describe('Sidebar editor 导航(D2 + C6 临界缺口)', () => {
22
+ it('渲染真实 editor 项,(none) 不作首项且标注「未分类」', () => {
23
+ render(
24
+ <Sidebar
25
+ view="skills"
26
+ editorFilter={null}
27
+ stats={statsWith({ 'Claude Code': 3, Cursor: 1, '(none)': 2 })}
28
+ onDashboard={noop}
29
+ onSettings={noop}
30
+ onOtherSkills={noop}
31
+ onEditor={noop}
32
+ />
33
+ )
34
+ // 真实 editor 出现
35
+ expect(screen.getByText('Claude Code')).toBeInTheDocument()
36
+ expect(screen.getByText('Cursor')).toBeInTheDocument()
37
+ // (none) 桶渲染为「未分类」,绝不出现字面 "(none)"
38
+ expect(screen.getByText('未分类')).toBeInTheDocument()
39
+ expect(screen.queryByText('(none)')).toBeNull()
40
+ })
41
+
42
+ it('byEditor 全为 (none) 时不产出垃圾首项,只显示「未分类」', () => {
43
+ render(
44
+ <Sidebar
45
+ view="skills"
46
+ editorFilter={null}
47
+ stats={statsWith({ '(none)': 5 })}
48
+ onDashboard={noop}
49
+ onSettings={noop}
50
+ onOtherSkills={noop}
51
+ onEditor={noop}
52
+ />
53
+ )
54
+ expect(screen.queryByText('(none)')).toBeNull()
55
+ expect(screen.getByText('未分类')).toBeInTheDocument()
56
+ })
57
+
58
+ it('点击 editor 项回传其 key', () => {
59
+ const onEditor = vi.fn()
60
+ render(
61
+ <Sidebar
62
+ view="skills"
63
+ editorFilter={null}
64
+ stats={statsWith({ 'Claude Code': 3 })}
65
+ onDashboard={noop}
66
+ onSettings={noop}
67
+ onOtherSkills={noop}
68
+ onEditor={onEditor}
69
+ />
70
+ )
71
+ fireEvent.click(screen.getByText('Claude Code'))
72
+ expect(onEditor).toHaveBeenCalledWith('Claude Code')
73
+ })
74
+ })
@@ -0,0 +1,114 @@
1
+ import { LayoutDashboard, Settings, Layers, Sparkles } from 'lucide-react'
2
+ import { cn } from '@/lib/cn'
3
+ import { getEditorMeta, isNoneEditor } from '@/lib/editors'
4
+ import type { Stats } from '@/types'
5
+
6
+ interface SidebarProps {
7
+ view: 'dashboard' | 'skills' | 'otherSkills' | 'settings'
8
+ editorFilter: string | null
9
+ stats: Stats | null
10
+ onDashboard: () => void
11
+ onSettings: () => void
12
+ onOtherSkills: () => void
13
+ /** key=null 表示「全部技能」 */
14
+ onEditor: (key: string | null) => void
15
+ }
16
+
17
+ export function Sidebar({
18
+ view,
19
+ editorFilter,
20
+ stats,
21
+ onDashboard,
22
+ onSettings,
23
+ onOtherSkills,
24
+ onEditor,
25
+ }: SidebarProps) {
26
+ const byEditor = stats?.byEditor ?? {}
27
+
28
+ // 真实 editor 项(过滤 (none)),按数量降序
29
+ const editors = Object.entries(byEditor)
30
+ .filter(([k]) => !isNoneEditor(k))
31
+ .sort((a, b) => b[1] - a[1])
32
+ const noneCount = Object.entries(byEditor).find(([k]) => isNoneEditor(k))?.[1] ?? 0
33
+ const total = stats?.total ?? 0
34
+
35
+ const rowCls = (active: boolean) =>
36
+ cn(
37
+ 'flex items-center justify-between gap-2 rounded-md px-3 py-2 text-body-sm transition-colors',
38
+ active
39
+ ? 'bg-primary-soft text-primary'
40
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'
41
+ )
42
+
43
+ return (
44
+ <aside className="sidebar">
45
+ <button onClick={onDashboard} className={rowCls(view === 'dashboard')}>
46
+ <span className="flex items-center gap-2">
47
+ <LayoutDashboard size={16} />
48
+ 仪表盘
49
+ </span>
50
+ </button>
51
+
52
+ <button onClick={onOtherSkills} className={rowCls(view === 'otherSkills')}>
53
+ <span className="flex items-center gap-2">
54
+ <Sparkles size={16} />
55
+ 其它技能
56
+ </span>
57
+ </button>
58
+
59
+ {/* 技能来源 (Editor) */}
60
+ <p className="mt-3 px-3 text-caption text-muted-foreground/70">技能来源</p>
61
+
62
+ <button
63
+ onClick={() => onEditor(null)}
64
+ className={rowCls(view === 'skills' && editorFilter === null)}
65
+ >
66
+ <span className="flex items-center gap-2">
67
+ <Layers size={16} />
68
+ 全部来源
69
+ </span>
70
+ <span className="text-caption opacity-80">{total}</span>
71
+ </button>
72
+
73
+ {editors.map(([key, count]) => {
74
+ const meta = getEditorMeta(key)
75
+ const Icon = meta.icon
76
+ const active = view === 'skills' && editorFilter === key
77
+ return (
78
+ <button key={key} onClick={() => onEditor(key)} className={rowCls(active)}>
79
+ <span className="flex min-w-0 items-center gap-2">
80
+ <span
81
+ className="grid h-5 w-5 shrink-0 place-items-center rounded"
82
+ style={{ backgroundColor: meta.color + '1A', color: meta.color }}
83
+ >
84
+ <Icon size={13} />
85
+ </span>
86
+ <span className="truncate">{meta.label}</span>
87
+ </span>
88
+ <span className="text-caption opacity-80">{count}</span>
89
+ </button>
90
+ )
91
+ })}
92
+
93
+ {noneCount > 0 && (
94
+ <button
95
+ onClick={() => onEditor('(none)')}
96
+ className={rowCls(view === 'skills' && editorFilter === '(none)')}
97
+ >
98
+ <span className="flex items-center gap-2 text-muted-foreground">
99
+ <Layers size={16} />
100
+ 未分类
101
+ </span>
102
+ <span className="text-caption opacity-80">{noneCount}</span>
103
+ </button>
104
+ )}
105
+
106
+ <button onClick={onSettings} className={cn(rowCls(view === 'settings'), 'mt-auto')}>
107
+ <span className="flex items-center gap-2">
108
+ <Settings size={16} />
109
+ 设置
110
+ </span>
111
+ </button>
112
+ </aside>
113
+ )
114
+ }
@@ -0,0 +1,74 @@
1
+ import { Moon, Sun, RefreshCw, Boxes } from 'lucide-react'
2
+ import { Button } from '@/components/ui/button'
3
+ import { useTheme } from '@/hooks/useTheme'
4
+ import { cn } from '@/lib/cn'
5
+
6
+ export type ModuleKey = 'skills' | 'commands' | 'editor'
7
+
8
+ const MODULES: { key: ModuleKey; label: string; soon?: boolean }[] = [
9
+ { key: 'skills', label: '技能 Skills' },
10
+ { key: 'commands', label: '命令', soon: true },
11
+ { key: 'editor', label: '编辑器', soon: true },
12
+ ]
13
+
14
+ interface TopbarProps {
15
+ module: ModuleKey
16
+ onModule: (m: ModuleKey) => void
17
+ onReload: () => void
18
+ reloading: boolean
19
+ }
20
+
21
+ export function Topbar({ module, onModule, onReload, reloading }: TopbarProps) {
22
+ const { theme, toggle } = useTheme()
23
+
24
+ return (
25
+ <header className="topbar">
26
+ {/* 品牌 */}
27
+ <div className="flex items-center gap-2 pr-2">
28
+ <span className="grid h-8 w-8 place-items-center rounded-md bg-primary text-primary-foreground">
29
+ <Boxes size={18} />
30
+ </span>
31
+ <span className="text-body-sm font-bold text-foreground">HuHaa</span>
32
+ </div>
33
+
34
+ {/* 模块标签 */}
35
+ <nav className="flex items-center gap-1">
36
+ {MODULES.map((m) => {
37
+ const active = module === m.key
38
+ return (
39
+ <button
40
+ key={m.key}
41
+ disabled={m.soon}
42
+ onClick={() => !m.soon && onModule(m.key)}
43
+ className={cn(
44
+ 'rounded-md px-3 py-1.5 text-body-sm transition-colors',
45
+ active
46
+ ? 'bg-primary-soft text-primary'
47
+ : m.soon
48
+ ? 'cursor-not-allowed text-muted-foreground/60'
49
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'
50
+ )}
51
+ >
52
+ {m.label}
53
+ {m.soon && <span className="ml-1 text-caption opacity-70">待开发</span>}
54
+ </button>
55
+ )
56
+ })}
57
+ </nav>
58
+
59
+ <div className="ml-auto flex items-center gap-1">
60
+ <Button variant="ghost" size="icon" onClick={onReload} aria-label="重新扫描">
61
+ <RefreshCw size={18} className={reloading ? 'animate-spin' : undefined} />
62
+ </Button>
63
+ <Button
64
+ variant="ghost"
65
+ size="icon"
66
+ onClick={toggle}
67
+ aria-label={theme === 'dark' ? '切换到亮色' : '切换到暗色'}
68
+ >
69
+ {theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
70
+ </Button>
71
+ </div>
72
+ </header>
73
+ )
74
+ }
@@ -0,0 +1,48 @@
1
+ import { useState } from 'react'
2
+ import { cn } from '@/lib/cn'
3
+
4
+ interface ActionButtonProps {
5
+ label: string
6
+ /** 返回 {ok} 的异步动作(copy/open);ok=false 或抛错都显示失败 */
7
+ onAction: () => Promise<{ ok: boolean; error?: string }>
8
+ }
9
+
10
+ type Status = 'idle' | 'ok' | 'err'
11
+
12
+ /** 执行一次性动作并短暂反馈结果(用于复制/打开)。 */
13
+ export function ActionButton({ label, onAction }: ActionButtonProps) {
14
+ const [status, setStatus] = useState<Status>('idle')
15
+ const [busy, setBusy] = useState(false)
16
+
17
+ async function run() {
18
+ setBusy(true)
19
+ try {
20
+ const r = await onAction()
21
+ setStatus(r.ok ? 'ok' : 'err')
22
+ } catch {
23
+ setStatus('err')
24
+ } finally {
25
+ setBusy(false)
26
+ setTimeout(() => setStatus('idle'), 1400)
27
+ }
28
+ }
29
+
30
+ return (
31
+ <button
32
+ type="button"
33
+ onClick={run}
34
+ disabled={busy}
35
+ className={cn(
36
+ 'rounded-md border px-2.5 py-1 text-caption transition-colors disabled:opacity-60',
37
+ status === 'ok'
38
+ ? 'border-primary bg-primary-soft text-primary'
39
+ : status === 'err'
40
+ ? 'border-destructive text-destructive'
41
+ : 'border-border bg-card text-muted-foreground hover:text-foreground'
42
+ )}
43
+ >
44
+ {status === 'ok' ? '✓ ' : status === 'err' ? '✗ ' : ''}
45
+ {label}
46
+ </button>
47
+ )
48
+ }
@@ -0,0 +1,48 @@
1
+ import { forwardRef } from 'react'
2
+ import { cva, type VariantProps } from 'class-variance-authority'
3
+ import { cn } from '@/lib/cn'
4
+
5
+ // shadcn 风格按钮:用 cva 描述变体,token 全部来自 Tailwind 主题。
6
+ const buttonVariants = cva(
7
+ 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
12
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
13
+ destructive:
14
+ 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
15
+ outline:
16
+ 'border border-border bg-transparent hover:bg-muted hover:text-foreground',
17
+ ghost: 'hover:bg-muted hover:text-foreground',
18
+ },
19
+ size: {
20
+ default: 'h-9 px-4 py-2',
21
+ sm: 'h-8 px-3',
22
+ lg: 'h-10 px-6',
23
+ icon: 'h-9 w-9',
24
+ },
25
+ },
26
+ defaultVariants: {
27
+ variant: 'default',
28
+ size: 'default',
29
+ },
30
+ }
31
+ )
32
+
33
+ export interface ButtonProps
34
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
35
+ VariantProps<typeof buttonVariants> {}
36
+
37
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
38
+ ({ className, variant, size, ...props }, ref) => (
39
+ <button
40
+ ref={ref}
41
+ className={cn(buttonVariants({ variant, size }), className)}
42
+ {...props}
43
+ />
44
+ )
45
+ )
46
+ Button.displayName = 'Button'
47
+
48
+ export { buttonVariants }