mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-30 04:40:18 +08:00
fix(skills): stabilize loading, install list and scroll behavior
This commit is contained in:
299
src/pages/skills.js
Normal file
299
src/pages/skills.js
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Skills 页面
|
||||
* 默认展示 ClawHub 热门推荐 + 已安装 + 搜索结果 + 详情 + 安装
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
|
||||
const SKILLS_LOAD_TIMEOUT_MS = 10000
|
||||
const SKILLS_AUTO_RETRY_DELAY_MS = 1200
|
||||
const SKILLS_MAX_AUTO_RETRY = 1
|
||||
let skillsLoadSeq = 0
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return ''
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Skills</h1>
|
||||
<p class="page-desc">从 ClawHub 浏览热门推荐、搜索 Skill、查看详情并一键安装</p>
|
||||
</div>
|
||||
<div id="skills-content" class="config-section">
|
||||
<div class="stat-card loading-placeholder" style="height:96px"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
bindEvents(page)
|
||||
loadSkills(page)
|
||||
return page
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms, label = '请求') {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`${label}超时(>${Math.round(ms / 1000)}s)`)), ms)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
function setLoadingHint(page, text = '正在加载 Skills...') {
|
||||
const el = page.querySelector('#skills-content')
|
||||
if (!el) return
|
||||
el.innerHTML = `
|
||||
<div class="skills-loading-panel">
|
||||
<div class="stat-card loading-placeholder" style="height:96px"></div>
|
||||
<div class="form-hint" style="margin-top:8px">${escapeHtml(text)}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderLoadError(el, message, canAutoRetry) {
|
||||
el.innerHTML = `
|
||||
<div class="skills-load-error">
|
||||
<div style="color:var(--error);margin-bottom:8px">加载失败:${escapeHtml(message)}</div>
|
||||
<div class="form-hint" style="margin-bottom:10px">${canAutoRetry ? '正在自动重试...' : '你可以手动重试'}</div>
|
||||
<div class="clawhub-toolbar" style="margin-bottom:0">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-retry">立即重试</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async function loadSkills(page, query = '', options = {}) {
|
||||
const el = page.querySelector('#skills-content')
|
||||
if (!el) return
|
||||
|
||||
const silent = !!options.silent
|
||||
const retryCount = options.retryCount || 0
|
||||
const requestId = ++skillsLoadSeq
|
||||
|
||||
if (!silent) {
|
||||
setLoadingHint(page, retryCount > 0 ? `正在重试加载(第 ${retryCount + 1} 次)...` : '正在加载 Skills...')
|
||||
}
|
||||
|
||||
try {
|
||||
const [installed, trending, results] = await withTimeout(
|
||||
Promise.all([
|
||||
api.clawhubListInstalled(),
|
||||
api.clawhubTrending(),
|
||||
query ? api.clawhubSearch(query) : Promise.resolve([]),
|
||||
]),
|
||||
SKILLS_LOAD_TIMEOUT_MS,
|
||||
'Skills 数据加载'
|
||||
)
|
||||
|
||||
if (requestId !== skillsLoadSeq) return
|
||||
renderSkills(el, { installed, trending, results, query })
|
||||
} catch (e) {
|
||||
if (requestId !== skillsLoadSeq) return
|
||||
|
||||
const message = (e?.message || String(e || '')).trim() || '未知错误'
|
||||
const canAutoRetry = retryCount < SKILLS_MAX_AUTO_RETRY
|
||||
|
||||
renderLoadError(el, message, canAutoRetry)
|
||||
|
||||
if (canAutoRetry) {
|
||||
await wait(SKILLS_AUTO_RETRY_DELAY_MS)
|
||||
if (requestId !== skillsLoadSeq) return
|
||||
await loadSkills(page, query, { silent: false, retryCount: retryCount + 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkillItems(items, installedSet) {
|
||||
if (!items.length) return '<div class="clawhub-empty">暂无内容</div>'
|
||||
return items.map(item => `
|
||||
<div class="clawhub-item">
|
||||
<div class="clawhub-item-main">
|
||||
<div class="clawhub-item-title">${escapeHtml(item.displayName || item.slug)}</div>
|
||||
<div class="clawhub-item-meta">${escapeHtml(item.slug)}${item.author ? ` · @${escapeHtml(item.author)}` : ''}${item.downloadsText ? ` · ${escapeHtml(item.downloadsText)}` : ''}</div>
|
||||
<div class="clawhub-item-desc">${escapeHtml(item.summary || '暂无摘要,可点击查看详情')}</div>
|
||||
</div>
|
||||
<div class="clawhub-item-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-inspect" data-slug="${escapeHtml(item.slug)}">详情</button>
|
||||
${installedSet.has(item.slug)
|
||||
? '<span class="clawhub-badge installed">已安装</span>'
|
||||
: `<button class="btn btn-primary btn-sm" data-action="skill-install" data-slug="${escapeHtml(item.slug)}">安装</button>`}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
}
|
||||
|
||||
function renderTrendingCards(items, installedSet) {
|
||||
if (!items.length) return '<div class="clawhub-empty">暂无推荐内容</div>'
|
||||
return `
|
||||
<div class="skills-hero-grid">
|
||||
${items.map(item => `
|
||||
<div class="skill-hero-card">
|
||||
<div class="skill-hero-top">
|
||||
<div>
|
||||
<div class="skill-hero-title">${escapeHtml(item.displayName || item.slug)}</div>
|
||||
<div class="skill-hero-meta">${escapeHtml(item.slug)}${item.author ? ` · @${escapeHtml(item.author)}` : ''}</div>
|
||||
</div>
|
||||
<div class="skill-hero-badges">
|
||||
${item.downloadsText ? `<span class="clawhub-badge hot">${escapeHtml(item.downloadsText)}</span>` : ''}
|
||||
${installedSet.has(item.slug) ? '<span class="clawhub-badge installed">已安装</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="skill-hero-desc">${escapeHtml(item.summary || '暂无摘要')}</div>
|
||||
<div class="skill-hero-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-inspect" data-slug="${escapeHtml(item.slug)}">查看详情</button>
|
||||
${installedSet.has(item.slug)
|
||||
? '<span class="skill-hero-installed">已在本地可用</span>'
|
||||
: `<button class="btn btn-primary btn-sm" data-action="skill-install" data-slug="${escapeHtml(item.slug)}">一键安装</button>`}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderSkills(el, state) {
|
||||
const installed = state.installed || []
|
||||
const trending = state.trending || []
|
||||
const results = state.results || []
|
||||
const installedSet = new Set(installed.map(x => x.slug))
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="clawhub-toolbar">
|
||||
<input class="input clawhub-search-input" id="skill-search-input" placeholder="搜索 Skill,比如 weather / github / summarize" value="${escapeHtml(state.query || '')}">
|
||||
<button class="btn btn-primary btn-sm" data-action="skill-search">搜索</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-refresh">刷新</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://clawhub.ai/skills?sort=downloads" target="_blank" rel="noopener">打开 ClawHub</a>
|
||||
</div>
|
||||
|
||||
<div class="clawhub-panel skills-hero-panel">
|
||||
<div class="clawhub-panel-title">热门推荐</div>
|
||||
<div class="skills-scroll-area skills-trending-scroll">
|
||||
${renderTrendingCards(trending, installedSet)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clawhub-grid" style="margin-top:var(--space-lg)">
|
||||
<div class="clawhub-panel">
|
||||
<div class="clawhub-panel-title">已安装 Skills</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-installed-scroll">
|
||||
${installed.length ? installed.map(item => `
|
||||
<div class="clawhub-item">
|
||||
<div>
|
||||
<div class="clawhub-item-title">${escapeHtml(item.slug)}</div>
|
||||
<div class="clawhub-item-desc">已安装到本地 Skills 目录</div>
|
||||
</div>
|
||||
<span class="clawhub-badge installed">已安装</span>
|
||||
</div>
|
||||
`).join('') : '<div class="clawhub-empty">还没有已安装的 Skill</div>'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="clawhub-panel skills-tips-panel">
|
||||
<div class="clawhub-panel-title">使用提示</div>
|
||||
<div class="skills-tip-list">
|
||||
<div class="skills-tip-item"><strong>默认推荐</strong>:首屏展示 ClawHub 热门技能,方便直接浏览</div>
|
||||
<div class="skills-tip-item"><strong>搜索</strong>:输入关键词后会调用 ClawHub CLI 实时搜索</div>
|
||||
<div class="skills-tip-item"><strong>安装</strong>:安装受外部服务限流影响,失败时可稍后重试</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clawhub-panel" style="margin-top:var(--space-lg)">
|
||||
<div class="clawhub-panel-title">搜索结果</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-search-scroll">
|
||||
${state.query ? renderSkillItems(results, installedSet) : '<div class="clawhub-empty">输入关键词开始搜索</div>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="skill-detail-area"></div>
|
||||
`
|
||||
}
|
||||
|
||||
async function handleInspect(page, slug) {
|
||||
const detail = page.querySelector('#skill-detail-area')
|
||||
if (!detail) return
|
||||
detail.innerHTML = '<div class="form-hint" style="margin-top:var(--space-md)">正在加载 Skill 详情...</div>'
|
||||
try {
|
||||
const data = await api.clawhubInspect(slug)
|
||||
const skill = data?.skill || {}
|
||||
const owner = data?.owner || {}
|
||||
const version = data?.latestVersion || {}
|
||||
detail.innerHTML = `
|
||||
<div class="clawhub-detail-card">
|
||||
<div class="clawhub-detail-title">${escapeHtml(skill.displayName || slug)}</div>
|
||||
<div class="clawhub-detail-meta">slug: ${escapeHtml(skill.slug || slug)} · 作者: @${escapeHtml(owner.handle || 'unknown')} · 版本: ${escapeHtml(version.version || 'latest')}</div>
|
||||
<div class="clawhub-detail-desc">${escapeHtml(skill.summary || '暂无摘要')}</div>
|
||||
<div class="clawhub-detail-stats">
|
||||
<span>下载 ${escapeHtml(skill?.stats?.downloads ?? '-')}</span>
|
||||
<span>当前安装 ${escapeHtml(skill?.stats?.installsCurrent ?? '-')}</span>
|
||||
<span>Star ${escapeHtml(skill?.stats?.stars ?? '-')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
} catch (e) {
|
||||
detail.innerHTML = `<div style="color:var(--error);margin-top:var(--space-md)">加载详情失败: ${escapeHtml(e.message || e)}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstall(page, slug) {
|
||||
const btn = page.querySelector(`[data-action="skill-install"][data-slug="${slug}"]`)
|
||||
if (btn) {
|
||||
btn.disabled = true
|
||||
btn.textContent = '安装中...'
|
||||
}
|
||||
try {
|
||||
await api.clawhubInstall(slug)
|
||||
toast(`Skill ${slug} 安装成功`, 'success')
|
||||
} catch (e) {
|
||||
const message = (e?.message || String(e || '')).trim()
|
||||
const friendly = message.includes('Rate limit exceeded')
|
||||
? 'ClawHub 当前限流了,稍后再试'
|
||||
: `安装失败: ${message || '未知错误'}`
|
||||
toast(friendly, 'error')
|
||||
}
|
||||
const query = page.querySelector('#skill-search-input')?.value?.trim() || ''
|
||||
await loadSkills(page, query)
|
||||
}
|
||||
|
||||
|
||||
function bindEvents(page) {
|
||||
page.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-action]')
|
||||
if (!btn) return
|
||||
const action = btn.dataset.action
|
||||
switch (action) {
|
||||
case 'skill-search':
|
||||
await loadSkills(page, page.querySelector('#skill-search-input')?.value?.trim() || '')
|
||||
break
|
||||
case 'skill-refresh':
|
||||
await loadSkills(page, page.querySelector('#skill-search-input')?.value?.trim() || '')
|
||||
break
|
||||
case 'skill-retry':
|
||||
await loadSkills(page, page.querySelector('#skill-search-input')?.value?.trim() || '')
|
||||
break
|
||||
case 'skill-inspect':
|
||||
await handleInspect(page, btn.dataset.slug)
|
||||
break
|
||||
case 'skill-install':
|
||||
await handleInstall(page, btn.dataset.slug)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
page.addEventListener('keydown', async (e) => {
|
||||
if (e.key === 'Enter' && e.target?.id === 'skill-search-input') {
|
||||
e.preventDefault()
|
||||
await loadSkills(page, e.target.value.trim())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -214,6 +214,271 @@
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
|
||||
/* ClawHub Skills */
|
||||
.clawhub-toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.clawhub-search-input {
|
||||
flex: 1;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.clawhub-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.clawhub-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.clawhub-panel-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.clawhub-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.skills-scroll-area {
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.skills-trending-scroll {
|
||||
max-height: 560px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.skills-installed-scroll {
|
||||
max-height: 420px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.skills-search-scroll {
|
||||
max-height: 480px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.clawhub-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.clawhub-item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.clawhub-item-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.clawhub-item-meta {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.clawhub-item-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.clawhub-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clawhub-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clawhub-badge.installed {
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.clawhub-empty {
|
||||
color: var(--text-tertiary);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.clawhub-detail-card {
|
||||
margin-top: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.clawhub-detail-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.clawhub-detail-meta,
|
||||
.clawhub-detail-desc,
|
||||
.clawhub-detail-stats {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.clawhub-detail-stats {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.skills-hero-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skills-hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.skill-hero-card {
|
||||
position: relative;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(99, 102, 241, 0.12), transparent 32%),
|
||||
linear-gradient(180deg, rgba(255,255,255,0.02), transparent),
|
||||
var(--bg-card);
|
||||
box-shadow: 0 12px 30px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.skill-hero-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.skill-hero-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.skill-hero-meta {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.skill-hero-badges {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.clawhub-badge.hot {
|
||||
background: rgba(99, 102, 241, 0.14);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.skill-hero-desc {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.skill-hero-actions {
|
||||
margin-top: var(--space-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.skill-hero-installed {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.skills-tips-panel {
|
||||
background: linear-gradient(180deg, rgba(99, 102, 241, 0.06), transparent), var(--bg-secondary);
|
||||
}
|
||||
|
||||
.skills-tip-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.skills-tip-item {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
padding: var(--space-sm) 0;
|
||||
border-bottom: 1px dashed var(--border-secondary);
|
||||
}
|
||||
|
||||
.skills-tip-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.skills-loading-panel,
|
||||
.skills-load-error {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-secondary);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.clawhub-grid,
|
||||
.skills-hero-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.skills-trending-scroll,
|
||||
.skills-installed-scroll,
|
||||
.skills-search-scroll {
|
||||
max-height: none;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 记忆文件管理 */
|
||||
.memory-layout {
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user