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.
- package/README.md +9 -375
- package/bin/huhaa-myskills.mjs +194 -12
- package/{scripts → build}/verify.mjs +3 -1
- package/docs/Frontend-Spec.md +1149 -0
- package/docs/assets/layout-wireframe.png +0 -0
- package/docs/assets/theme-reference.png +0 -0
- package/docs/scan_rules.md +172 -0
- package/package.json +33 -9
- package/packages/scanner/src/adapters/directory-skill.mjs +239 -0
- package/packages/scanner/src/adapters/skill-adapter.mjs +345 -0
- package/packages/scanner/src/icon/brand-map.mjs +84 -0
- package/packages/scanner/src/icon/icon-extractor.mjs +246 -0
- package/packages/scanner/src/index.mjs +107 -82
- package/packages/scanner/src/types.d.ts +18 -0
- package/packages/scanner/test/icon.test.mjs +61 -0
- package/packages/scanner/test/scanner.test.mjs +37 -41
- package/packages/scanner/test/skill-adapter.test.mjs +70 -0
- package/packages/scanner/tests/directory-skill.test.mjs +93 -0
- package/packages/scanner/tests/scanner-integration.test.mjs +139 -0
- package/packages/server/src/index.mjs +164 -12
- package/packages/server/src/labels.mjs +62 -0
- package/packages/server/test/server.test.mjs +1 -1
- package/packages/web/README.md +33 -2
- package/packages/web/dist/assets/index-BCY2cRaO.js +175 -0
- package/packages/web/dist/assets/index-DALKjJPi.css +1 -0
- package/packages/web/dist/favicon-16x16.png +0 -0
- package/packages/web/dist/favicon-192x192.png +0 -0
- package/packages/web/dist/favicon-32x32.png +0 -0
- package/packages/web/dist/favicon-512x512.png +0 -0
- package/packages/web/dist/favicon-source.svg +5 -0
- package/packages/web/dist/index.html +23 -9
- package/packages/web/index.html +22 -8
- package/packages/web/package-lock.json +2989 -0
- package/packages/web/package.json +21 -8
- package/packages/web/postcss.config.js +6 -0
- package/packages/web/public/favicon-16x16.png +0 -0
- package/packages/web/public/favicon-192x192.png +0 -0
- package/packages/web/public/favicon-32x32.png +0 -0
- package/packages/web/public/favicon-512x512.png +0 -0
- package/packages/web/public/favicon-source.svg +5 -0
- package/packages/web/src/App.test.ts +40 -0
- package/packages/web/src/App.tsx +177 -0
- package/packages/web/src/components/ComingSoon.tsx +14 -0
- package/packages/web/src/components/layout/Sidebar.test.tsx +74 -0
- package/packages/web/src/components/layout/Sidebar.tsx +114 -0
- package/packages/web/src/components/layout/Topbar.tsx +74 -0
- package/packages/web/src/components/ui/ActionButton.tsx +48 -0
- package/packages/web/src/components/ui/button.tsx +48 -0
- package/packages/web/src/components/ui/card.tsx +45 -0
- package/packages/web/src/components/views/DashboardView.tsx +122 -0
- package/packages/web/src/components/views/OtherSkillsView.test.tsx +126 -0
- package/packages/web/src/components/views/OtherSkillsView.tsx +306 -0
- package/packages/web/src/components/views/SettingsView.tsx +95 -0
- package/packages/web/src/components/views/SkillDetail.test.tsx +57 -0
- package/packages/web/src/components/views/SkillDetail.tsx +81 -0
- package/packages/web/src/components/views/SkillsView.test.ts +28 -0
- package/packages/web/src/components/views/SkillsView.tsx +226 -0
- package/packages/web/src/hooks/QUICK_REFERENCE.md +165 -0
- package/packages/web/src/hooks/README.md +378 -0
- package/packages/web/src/hooks/__tests__/useOtherSkills.test.ts +422 -0
- package/packages/web/src/hooks/__tests__/useSkillIcons.test.ts +272 -0
- package/packages/web/src/hooks/useLiveReload.ts +20 -0
- package/packages/web/src/hooks/useOtherSkills.ts +387 -0
- package/packages/web/src/hooks/useSkillIcons.ts +248 -0
- package/packages/web/src/hooks/useTheme.ts +36 -0
- package/packages/web/src/index.css +161 -0
- package/packages/web/src/lib/api.test.ts +47 -0
- package/packages/web/src/lib/api.ts +77 -0
- package/packages/web/src/lib/cn.ts +7 -0
- package/packages/web/src/lib/editors.test.ts +30 -0
- package/packages/web/src/lib/editors.ts +70 -0
- package/packages/web/src/lib/markdown.test.ts +27 -0
- package/packages/web/src/lib/markdown.ts +14 -0
- package/packages/web/src/main.tsx +13 -0
- package/packages/web/src/types/index.ts +62 -0
- package/packages/web/src/types/other-skill.ts +88 -0
- package/packages/web/src/types/skill.ts +136 -0
- package/packages/web/src/types.ts +47 -0
- package/packages/web/src/vite-env.d.ts +1 -0
- package/packages/web/tailwind.config.ts +66 -0
- package/packages/web/tsconfig.json +28 -0
- package/packages/web/vite.config.ts +36 -0
- package/packages/web/vitest.config.ts +21 -0
- package/packages/web/vitest.setup.ts +1 -0
- package/scripts/activate-data-sources.sh +223 -0
- package/scripts/brand-completion.py +586 -0
- package/scripts/init-nextjs-project.sh +421 -0
- package/scripts/install-and-sync.sh +209 -0
- package/scripts/start-dev.sh +72 -0
- package/docs/GUIDE.md +0 -190
- package/docs/PLAN.md +0 -359
- package/docs/RULES.md +0 -329
- package/docs/RUNBOOK-myskills.md +0 -258
- package/docs/SYNC-SKILLS.md +0 -259
- package/docs/releases/README.md +0 -47
- package/docs/releases/v0.1.0.md +0 -256
- package/docs/releases/v0.1.1.md +0 -297
- package/docs/releases/v0.1.2.md +0 -207
- package/docs/releases/v0.1.3.md +0 -269
- package/docs/todo/v0.1.2.md +0 -98
- package/docs/todo/v0.1.3.md +0 -40
- package/docs/todo/v0.1.4.md +0 -36
- package/docs/todo/v0.1.5.md +0 -41
- package/docs/todo/v0.1.6.md +0 -43
- package/docs/todo/v0.1.7.md +0 -38
- package/docs/todo/v0.1.8.md +0 -36
- package/docs/todo/v0.1.9.md +0 -54
- package/packages/web/dist/assets/index-BPepSb3q.js +0 -36
- package/packages/web/dist/assets/index-CIGkgT5w.css +0 -1
- package/packages/web/src/App.vue +0 -483
- package/packages/web/src/components/AppTree.vue +0 -230
- package/packages/web/src/components/DirectoryTree.vue +0 -263
- package/packages/web/src/components/SkillTree.vue +0 -208
- package/packages/web/src/lib/api.js +0 -43
- package/packages/web/src/main.js +0 -6
- package/packages/web/src/stores/i18n.js +0 -161
- package/packages/web/src/stores/skills.js +0 -287
- package/packages/web/src/styles.css +0 -365
- package/packages/web/vite.config.js +0 -17
- /package/{scripts → build}/build-web.mjs +0 -0
- /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`
|