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
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import '@testing-library/jest-dom/vitest'
3
+ import { render, screen } from '@testing-library/react'
4
+ import { SkillDetail } from './SkillDetail'
5
+ import type { SkillItem } from '@/types'
6
+
7
+ vi.mock('@/lib/api', () => ({
8
+ fetchSkillDetail: vi.fn(),
9
+ copy: vi.fn(async () => ({ ok: true })),
10
+ open: vi.fn(async () => ({ ok: true })),
11
+ }))
12
+ import { fetchSkillDetail } from '@/lib/api'
13
+
14
+ const item: SkillItem = {
15
+ id: 'demo-1',
16
+ kind: 'skill',
17
+ source: 'claude-code',
18
+ editor: 'Claude Code',
19
+ name: 'autoplan',
20
+ title: 'autoplan',
21
+ description: 'Auto-review pipeline',
22
+ paths: { abs: '/Users/x/.claude/skills/autoplan/SKILL.md', rootKind: 'home' },
23
+ preview: '预览内容',
24
+ updatedAt: '2026-01-01',
25
+ }
26
+
27
+ beforeEach(() => vi.clearAllMocks())
28
+
29
+ describe('SkillDetail(P7 markdown 正文)', () => {
30
+ it('拉取 raw 并渲染 markdown 正文', async () => {
31
+ vi.mocked(fetchSkillDetail).mockResolvedValue({
32
+ ...item,
33
+ raw: '## 正文标题\n\n正文段落内容',
34
+ })
35
+ render(<SkillDetail item={item} />)
36
+ // 元数据
37
+ expect(screen.getByText('Claude Code')).toBeInTheDocument()
38
+ expect(screen.getByText((item.paths as NonNullable<typeof item.paths>).abs)).toBeInTheDocument()
39
+ expect(screen.getByText('复制正文')).toBeInTheDocument()
40
+ // markdown 渲染(异步加载后出现)
41
+ expect(await screen.findByText('正文标题')).toBeInTheDocument()
42
+ expect(screen.getByText('正文段落内容')).toBeInTheDocument()
43
+ expect(fetchSkillDetail).toHaveBeenCalledWith('demo-1')
44
+ })
45
+
46
+ it('raw 为空显示占位', async () => {
47
+ vi.mocked(fetchSkillDetail).mockResolvedValue({ ...item, raw: '' })
48
+ render(<SkillDetail item={item} />)
49
+ expect(await screen.findByText('(无正文内容)')).toBeInTheDocument()
50
+ })
51
+
52
+ it('拉取失败显示错误', async () => {
53
+ vi.mocked(fetchSkillDetail).mockRejectedValue(new Error('boom'))
54
+ render(<SkillDetail item={item} />)
55
+ expect(await screen.findByText('正文加载失败')).toBeInTheDocument()
56
+ })
57
+ })
@@ -0,0 +1,81 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { ActionButton } from '@/components/ui/ActionButton'
3
+ import { copy, open, fetchSkillDetail } from '@/lib/api'
4
+ import { renderMarkdown } from '@/lib/markdown'
5
+ import { itemEditorKey } from '@/lib/editors'
6
+ import type { SkillItem } from '@/types'
7
+
8
+ interface SkillDetailProps {
9
+ item: SkillItem
10
+ }
11
+
12
+ type RawStatus = 'loading' | 'ready' | 'error'
13
+
14
+ export function SkillDetail({ item }: SkillDetailProps) {
15
+ const [raw, setRaw] = useState<string>('')
16
+ const [status, setStatus] = useState<RawStatus>('loading')
17
+
18
+ useEffect(() => {
19
+ let alive = true
20
+ setStatus('loading')
21
+ setRaw('')
22
+ fetchSkillDetail(item.id)
23
+ .then((detail) => {
24
+ if (!alive) return
25
+ setRaw(detail.raw ?? '')
26
+ setStatus('ready')
27
+ })
28
+ .catch(() => {
29
+ if (alive) setStatus('error')
30
+ })
31
+ return () => {
32
+ alive = false
33
+ }
34
+ }, [item.id])
35
+
36
+ return (
37
+ <div className="detail">
38
+ <h2 className="text-h3 text-foreground">{item.title || item.name}</h2>
39
+ <p className="mt-2 text-body-sm text-muted-foreground">
40
+ {item.description || '(无描述)'}
41
+ </p>
42
+
43
+ <dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-body-sm">
44
+ <dt className="text-muted-foreground">类型</dt>
45
+ <dd>{item.kind}</dd>
46
+ <dt className="text-muted-foreground">来源</dt>
47
+ <dd>{itemEditorKey(item)}</dd>
48
+ <dt className="text-muted-foreground">路径</dt>
49
+ <dd className="break-all font-mono text-caption">{item.paths?.abs}</dd>
50
+ </dl>
51
+
52
+ <div className="mt-5 flex flex-wrap gap-2">
53
+ <ActionButton label="复制路径" onAction={() => copy(item.id, 'path')} />
54
+ <ActionButton label="复制名称" onAction={() => copy(item.id, 'name')} />
55
+ <ActionButton label="复制正文" onAction={() => copy(item.id, 'raw')} />
56
+ <ActionButton label="复制调用提示" onAction={() => copy(item.id, 'prompt')} />
57
+ <ActionButton label="打开" onAction={() => open(item.id, 'default')} />
58
+ <ActionButton label="在访达显示" onAction={() => open(item.id, 'finder')} />
59
+ </div>
60
+
61
+ {/* 正文(markdown 渲染) */}
62
+ <div className="mt-6 border-t border-border pt-5">
63
+ {status === 'loading' && (
64
+ <p className="text-body-sm text-muted-foreground">加载正文…</p>
65
+ )}
66
+ {status === 'error' && (
67
+ <p className="text-body-sm text-destructive">正文加载失败</p>
68
+ )}
69
+ {status === 'ready' &&
70
+ (raw.trim() ? (
71
+ <div
72
+ className="markdown-body"
73
+ dangerouslySetInnerHTML={{ __html: renderMarkdown(raw) }}
74
+ />
75
+ ) : (
76
+ <p className="text-body-sm text-muted-foreground">(无正文内容)</p>
77
+ ))}
78
+ </div>
79
+ </div>
80
+ )
81
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { itemEditorKey } from './SkillsView'
3
+ import type { SkillItem } from '@/types'
4
+
5
+ function mk(partial: Partial<SkillItem>): SkillItem {
6
+ return {
7
+ id: 'id',
8
+ kind: 'skill',
9
+ source: 'hermes',
10
+ name: 'n',
11
+ paths: { abs: '/x', rootKind: 'home' },
12
+ preview: '',
13
+ updatedAt: '2026-01-01',
14
+ ...partial,
15
+ }
16
+ }
17
+
18
+ describe('itemEditorKey(与 server buildStats 口径一致)', () => {
19
+ it('优先 editor', () => {
20
+ expect(itemEditorKey(mk({ editor: 'Claude Code', source: 'claude-code' }))).toBe('Claude Code')
21
+ })
22
+ it('无 editor 落到 source', () => {
23
+ expect(itemEditorKey(mk({ editor: undefined, source: 'hermes' }))).toBe('hermes')
24
+ })
25
+ it('editor 与 source 皆空 → (none)', () => {
26
+ expect(itemEditorKey(mk({ editor: undefined, source: '' }))).toBe('(none)')
27
+ })
28
+ })
@@ -0,0 +1,226 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import Fuse from 'fuse.js'
5
+ import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
6
+ import { SkillDetail } from './SkillDetail'
7
+ import { cn } from '@/lib/cn'
8
+ import { isNoneEditor, itemEditorKey } from '@/lib/editors'
9
+ import { useSkillIcons } from '@/hooks/useSkillIcons'
10
+ import type { SkillItem } from '@/types'
11
+
12
+ /**
13
+ * 技能图标组件(R6 真实应用图标)
14
+ * 优先展示真实应用图标,加载失败自动回退到 tier/brand emoji。
15
+ * 对标 Pearcleaner 的真实应用图标展示逻辑。
16
+ */
17
+ function SkillIcon({ item, size = 20 }: { item: SkillItem; size?: number }) {
18
+ const [hasError, setHasError] = useState(false)
19
+ const icons = useSkillIcons(item)
20
+ // Tier 1 工具优先使用真实应用图标;其余 Tier 使用 tier emoji
21
+ const iconUrl = item.brand && icons.isTier1 ? `/api/icons/${encodeURIComponent(item.brand)}?size=${size}` : null
22
+
23
+ if (iconUrl && !hasError) {
24
+ return (
25
+ <img
26
+ src={iconUrl}
27
+ alt=""
28
+ width={size}
29
+ height={size}
30
+ loading="lazy"
31
+ onError={() => setHasError(true)}
32
+ className="shrink-0 rounded-sm object-contain"
33
+ style={{ width: size, height: size }}
34
+ />
35
+ )
36
+ }
37
+
38
+ return (
39
+ <span
40
+ className="inline-flex shrink-0 items-center justify-center"
41
+ style={{ width: size, height: size, fontSize: size * 0.85 }}
42
+ >
43
+ {icons.brandIcon}
44
+ </span>
45
+ )
46
+ }
47
+
48
+ interface SkillsViewProps {
49
+ items: SkillItem[]
50
+ editorFilter: string | null
51
+ query: string
52
+ onQuery: (q: string) => void
53
+ kindFilter: string | null
54
+ onKind: (k: string | null) => void
55
+ selectedId: string | null
56
+ onSelect: (id: string) => void
57
+ }
58
+
59
+ /** 单条目的 editor 归属 key,与 server buildStats 口径一致。 */
60
+ export { itemEditorKey }
61
+
62
+ export function SkillsView({
63
+ items,
64
+ editorFilter,
65
+ query,
66
+ onQuery,
67
+ kindFilter,
68
+ onKind,
69
+ selectedId,
70
+ onSelect,
71
+ }: SkillsViewProps) {
72
+ // 1) editor 过滤
73
+ const byEditor = useMemo(() => {
74
+ if (editorFilter === null) return items
75
+ if (isNoneEditor(editorFilter)) return items.filter((it) => isNoneEditor(itemEditorKey(it)))
76
+ return items.filter((it) => itemEditorKey(it) === editorFilter)
77
+ }, [items, editorFilter])
78
+
79
+ // 2) kind chips 选项(来自当前 editor 子集)
80
+ const kinds = useMemo(() => {
81
+ const m = new Map<string, number>()
82
+ for (const it of byEditor) m.set(it.kind, (m.get(it.kind) ?? 0) + 1)
83
+ return [...m.entries()].sort((a, b) => b[1] - a[1])
84
+ }, [byEditor])
85
+
86
+ // 3) Fuse 搜索(在 editor 子集上)
87
+ const fuse = useMemo(
88
+ () =>
89
+ new Fuse(byEditor, {
90
+ keys: ['name', 'title', 'description', 'category', 'brand', 'dirName', 'tags'],
91
+ threshold: 0.4,
92
+ ignoreLocation: true,
93
+ }),
94
+ [byEditor],
95
+ )
96
+
97
+ const filtered = useMemo(() => {
98
+ let list = query.trim() ? fuse.search(query).map((r) => r.item) : byEditor
99
+ if (kindFilter) list = list.filter((it) => it.kind === kindFilter)
100
+ return list
101
+ }, [byEditor, fuse, query, kindFilter])
102
+
103
+ const selected = useMemo(
104
+ () => filtered.find((it) => it.id === selectedId) ?? null,
105
+ [filtered, selectedId],
106
+ )
107
+
108
+ return (
109
+ <div className="flex h-full flex-col gap-4">
110
+ {/* 搜索栏 */}
111
+ <input
112
+ type="search"
113
+ value={query}
114
+ onChange={(e) => onQuery(e.target.value)}
115
+ placeholder="搜索技能、插件、自定义…"
116
+ className="h-10 w-full rounded-md border border-border bg-input px-3 text-body-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
117
+ />
118
+
119
+ {/* kind chips (secondary filter) */}
120
+ <div className="flex flex-wrap gap-1.5">
121
+ <button
122
+ onClick={() => onKind(null)}
123
+ className={cn(
124
+ 'rounded-full px-2.5 py-0.5 text-caption transition-colors',
125
+ kindFilter === null
126
+ ? 'bg-primary text-primary-foreground'
127
+ : 'bg-muted text-muted-foreground hover:text-foreground',
128
+ )}
129
+ >
130
+ 全部类型
131
+ </button>
132
+ {kinds.map(([k, c]) => (
133
+ <button
134
+ key={k}
135
+ onClick={() => onKind(k)}
136
+ className={cn(
137
+ 'rounded-full px-2.5 py-0.5 text-caption transition-colors',
138
+ kindFilter === k
139
+ ? 'bg-primary text-primary-foreground'
140
+ : 'bg-muted text-muted-foreground hover:text-foreground',
141
+ )}
142
+ >
143
+ {k} ({c})
144
+ </button>
145
+ ))}
146
+ </div>
147
+
148
+ {/* 列表 + 详情 */}
149
+ <div className="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-[1fr_1.2fr]">
150
+ <section className="flex min-h-0 flex-col gap-2 overflow-y-auto pr-1">
151
+ <p className="text-caption text-muted-foreground">共 {filtered.length} 项</p>
152
+ {filtered.length === 0 && (
153
+ <p className="text-body-sm text-muted-foreground">没有匹配的条目</p>
154
+ )}
155
+ {filtered.map((it) => {
156
+ const icons = useSkillIcons(it)
157
+ return (
158
+ <button key={it.id} onClick={() => onSelect(it.id)} className="text-left">
159
+ <Card
160
+ className={cn(
161
+ 'cursor-pointer transition-colors hover:border-primary',
162
+ it.id === selectedId ? 'border-primary bg-primary-soft' : '',
163
+ )}
164
+ >
165
+ <CardHeader>
166
+ {/* R6:真实应用图标(对标 Pearcleaner),加载失败自动回退 emoji */}
167
+ <CardTitle className="flex items-center gap-2">
168
+ <SkillIcon item={it} size={24} />
169
+ <span>{it.title || it.name}</span>
170
+ </CardTitle>
171
+
172
+ {/* Tier 2 specific: show dirName as secondary label */}
173
+ {icons.isTier2 && it.dirName && (
174
+ <p className="text-caption text-muted-foreground">
175
+ 目录: <code className="rounded bg-muted px-1.5 py-0.5">{it.dirName}</code>
176
+ </p>
177
+ )}
178
+
179
+ <CardDescription>
180
+ {it.description || it.preview || '(无描述)'}
181
+ </CardDescription>
182
+
183
+ {/* Tags and metadata */}
184
+ <div className="mt-1 flex flex-wrap gap-1.5">
185
+ {/* Kind tag */}
186
+ <span className="rounded-sm bg-muted px-1.5 py-0.5 text-caption text-muted-foreground">
187
+ {it.kind}
188
+ </span>
189
+
190
+ {/* Tier label (NEW) */}
191
+ <span
192
+ className={cn(
193
+ 'rounded-sm px-1.5 py-0.5 text-caption font-medium',
194
+ icons.isTier1 && 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
195
+ icons.isTier2 && 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-200',
196
+ icons.isTier3 && 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
197
+ )}
198
+ >
199
+ {icons.tierLabel}
200
+ </span>
201
+
202
+ {/* Editor/Source */}
203
+ <span className="rounded-sm bg-muted px-1.5 py-0.5 text-caption text-muted-foreground">
204
+ {itemEditorKey(it)}
205
+ </span>
206
+ </div>
207
+ </CardHeader>
208
+ </Card>
209
+ </button>
210
+ )
211
+ })}
212
+ </section>
213
+
214
+ <section className="min-h-0 overflow-y-auto">
215
+ {selected ? (
216
+ <SkillDetail item={selected} />
217
+ ) : (
218
+ <div className="detail text-muted-foreground">
219
+ <p className="text-body-sm">从左侧选择一项查看详情</p>
220
+ </div>
221
+ )}
222
+ </section>
223
+ </div>
224
+ </div>
225
+ )
226
+ }
@@ -0,0 +1,165 @@
1
+ # useOtherSkills - Quick Reference Card
2
+
3
+ ## 最常用的 3 个用法
4
+
5
+ ### 1️⃣ 基础:获取和分组
6
+
7
+ ```typescript
8
+ import { useOtherSkills, GroupBy } from '@/hooks/useOtherSkills'
9
+
10
+ const { groups } = useOtherSkills({ groupBy: GroupBy.CATEGORY })
11
+
12
+ groups.map(g => `${g.icon} ${g.label} (${g.count})`)
13
+ // => "🤖 Ai (5) | 📝 Editor (3) | ..."
14
+ ```
15
+
16
+ ### 2️⃣ 搜索
17
+
18
+ ```typescript
19
+ const [query, setQuery] = useState('')
20
+ const { items } = useOtherSkills({ query })
21
+
22
+ // 自动搜索:名字、描述、标签、品牌、来源
23
+ ```
24
+
25
+ ### 3️⃣ 排序和过滤
26
+
27
+ ```typescript
28
+ const { items } = useOtherSkills({
29
+ sortBy: SortBy.UPDATED,
30
+ sortOrder: SortOrder.DESC,
31
+ filterBrand: 'hermes',
32
+ })
33
+ ```
34
+
35
+ ## 完整 API
36
+
37
+ ```typescript
38
+ // 导入
39
+ import { useOtherSkills, GroupBy, SortBy, SortOrder } from '@/hooks/useOtherSkills'
40
+
41
+ // 调用
42
+ const result = useOtherSkills(options)
43
+
44
+ // options
45
+ {
46
+ query?: string // 搜索词
47
+ sortBy?: SortBy // NAME | UPDATED | CATEGORY
48
+ sortOrder?: SortOrder // ASC | DESC
49
+ groupBy?: GroupBy // CATEGORY | BRAND | SOURCE | NONE
50
+ filterBrand?: string // 过滤品牌
51
+ filterCategory?: string // 过滤分类
52
+ filterSource?: string // 过滤来源
53
+ limit?: number // 分页大小
54
+ offset?: number // 分页偏移
55
+ }
56
+
57
+ // 返回值
58
+ {
59
+ items: OtherSkill[], // 技能列表
60
+ groups: OtherSkillGroup[], // 分组结果
61
+ isLoading: boolean, // 加载中
62
+ error: OtherSkillsError | null, // 错误
63
+ total: number, // 总数
64
+ filtered: number, // 过滤后数
65
+ refetch: () => Promise<void>, // 刷新
66
+ }
67
+ ```
68
+
69
+ ## 分组结果结构
70
+
71
+ ```typescript
72
+ groups[0] = {
73
+ groupKey: 'ai', // 分组键
74
+ label: 'Ai', // 显示标签
75
+ icon: '🤖', // icon
76
+ items: [...], // 该组内的技能
77
+ count: 5, // 该组内的数量
78
+ }
79
+ ```
80
+
81
+ ## 技能项结构
82
+
83
+ ```typescript
84
+ OtherSkill {
85
+ id: string // 唯一 id
86
+ name: string // 技能名
87
+ title?: string // 显示标题
88
+ description?: string // 描述
89
+ category?: string | string[] // 分类
90
+ brand?: string // 品牌
91
+ source?: string // 来源
92
+ tags?: string[] // 标签
93
+ icon?: string // 自定义 icon
94
+ docs?: string // 文档链接
95
+ links?: { label, url }[]
96
+ examples?: string[]
97
+ updatedAt?: string // ISO 时间戳
98
+ parseError?: string // 错误
99
+ }
100
+ ```
101
+
102
+ ## 常见错误和解决方案
103
+
104
+ | 错误 | 原因 | 解决 |
105
+ |------|------|------|
106
+ | `Cannot read properties of undefined` | options 不存在 | 提供默认值或检查 |
107
+ | `isLoading 一直为 true` | API 未响应 | 检查 `/api/other-skills` 端点 |
108
+ | `error.code === 'INVALID_FORMAT'` | 返回格式不对 | 确保返回 `OtherSkill[]` |
109
+ | 搜索无结果 | 搜索词未匹配 | 检查搜索词大小写(不敏感) |
110
+ | 分组为空 | 都被过滤掉了 | 检查 filter 条件 |
111
+
112
+ ## 性能提示
113
+
114
+ ```typescript
115
+ // ❌ 不好:每次都创建新对象
116
+ const { items } = useOtherSkills({
117
+ sortBy: SortBy.NAME,
118
+ groupBy: GroupBy.CATEGORY,
119
+ })
120
+
121
+ // ✅ 好:使用 useMemo
122
+ const options = useMemo(() => ({
123
+ sortBy: SortBy.NAME,
124
+ groupBy: GroupBy.CATEGORY,
125
+ }), [])
126
+
127
+ const { items } = useOtherSkills(options)
128
+ ```
129
+
130
+ ## 测试示例
131
+
132
+ ```typescript
133
+ import { renderHook } from '@testing-library/react'
134
+
135
+ // 纯函数测试(推荐)
136
+ const skills = [{ id: '1', name: 'Docker', ... }]
137
+ const filtered = skills.filter(s =>
138
+ s.name.toLowerCase().includes('docker')
139
+ )
140
+ expect(filtered).toHaveLength(1)
141
+
142
+ // Hook 测试
143
+ const { result } = renderHook(() =>
144
+ useOtherSkills({ query: 'docker' })
145
+ )
146
+ ```
147
+
148
+ ## 核心特性对比
149
+
150
+ | 功能 | 实现 |
151
+ |------|------|
152
+ | 搜索 | 6 个字段(OR 逻辑) |
153
+ | 排序 | 3 个字段 × 升降序 |
154
+ | 分组 | 4 种分组方式 |
155
+ | 过滤 | 3 个条件(可组合) |
156
+ | 分页 | limit + offset |
157
+ | 缓存 | useMemo 自动优化 |
158
+ | 错误 | 自定义错误类 |
159
+
160
+ ## 快速导航
161
+
162
+ - 📖 完整文档: `packages/web/src/hooks/README.md`
163
+ - 🧪 测试文件: `packages/web/src/hooks/__tests__/useOtherSkills.test.ts`
164
+ - 📝 实现代码: `packages/web/src/hooks/useOtherSkills.ts`
165
+ - 🔷 类型定义: `packages/web/src/types/other-skill.ts`