fix(B02): show installed recommendation actions and remove duplicate navigation
This commit is contained in:
1 parent
9f1e583e23
commit
14a1354c95
7 files changed
+196
-25
No files matched your search
@@ -11,7 +11,6 @@ export interface RecommendedSkillsProps {
|
||||
installed: SkillRecord[]
|
||||
onUseSkill?(skill: SkillRecord): void
|
||||
onInstalledChange(): void
|
||||
onViewInstalled(): void
|
||||
}
|
||||
|
||||
type InstallPhase = SkillInstallProgress['phase'] | 'idle'
|
||||
@@ -34,6 +33,7 @@ const Card = CardFrame as ComponentType<{
|
||||
sourceLabel?: string
|
||||
iconUrl?: string
|
||||
disabled?: boolean
|
||||
installed?: boolean
|
||||
onOpen?(): void
|
||||
actions?: React.ReactNode
|
||||
className?: string
|
||||
@@ -110,9 +110,10 @@ function InstalledOverview({ skill, cwd }: { skill?: SkillRecord; cwd?: string }
|
||||
</details>
|
||||
}
|
||||
|
||||
export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChange, onViewInstalled }: RecommendedSkillsProps): React.JSX.Element {
|
||||
export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChange }: RecommendedSkillsProps): React.JSX.Element {
|
||||
const [recommendations, setRecommendations] = useState<RecommendedSkill[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [catalogRevision, setCatalogRevision] = useState(0)
|
||||
const [unavailable, setUnavailable] = useState('')
|
||||
const [view, setView] = useState<InstallView | null>(null)
|
||||
const [detail, setDetail] = useState<RecommendedSkill | null>(null)
|
||||
@@ -130,6 +131,7 @@ export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChang
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setLoading(true); setUnavailable('')
|
||||
if (typeof window.ycode.skills.recommendations !== 'function') { setUnavailable('当前版本未提供推荐技能目录'); setLoading(false); return }
|
||||
void window.ycode.skills.recommendations().then(values => {
|
||||
if (!alive) return
|
||||
@@ -138,7 +140,7 @@ export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChang
|
||||
}).catch(reason => { if (alive) setUnavailable(String(reason.message ?? reason)) })
|
||||
.finally(() => { if (alive) setLoading(false) })
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
}, [catalogRevision])
|
||||
useEffect(() => {
|
||||
if (!notice) return
|
||||
const timer = window.setTimeout(() => setNotice(null), 6000)
|
||||
@@ -181,7 +183,7 @@ export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChang
|
||||
|
||||
const begin = (recommendation: RecommendedSkill): void => {
|
||||
setDetail(recommendation)
|
||||
if (running || submitting.current) return
|
||||
if (running || submitting.current || matchInstalled(recommendation, installed).state === 'current') return
|
||||
setError('')
|
||||
const next: InstallView = { recommendation, operationId: operationId(), phase: 'idle', receivedBytes: 0, totalBytes: recommendation.expectedBytes, overwrite: false }
|
||||
active.current = next; setView(next)
|
||||
@@ -189,6 +191,8 @@ export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChang
|
||||
const run = async (overwrite: boolean): Promise<void> => {
|
||||
const current = active.current
|
||||
if (!current || current.phase !== 'idle' || submitting.current || foreign && isRunning(foreign.phase)) return
|
||||
// An external install can finish while this confirmation is open.
|
||||
if (matchInstalled(current.recommendation, installed).state === 'current') { active.current = null; setView(null); return }
|
||||
const operation = current.operationId
|
||||
submitting.current = true
|
||||
setError('')
|
||||
@@ -244,7 +248,7 @@ export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChang
|
||||
onClick={event => { event.stopPropagation(); void useSkill(skill) }}><UseSkillIcon />{enabling ? '正在启用…' : skill.enabled ? '去使用' : '启用后使用'}</button>
|
||||
|
||||
if (loading) return <div className="sm-empty" role="status">加载中…</div>
|
||||
if (unavailable) return <div className="sm-empty yc-skill-recommendation-unavailable"><p>推荐技能目录暂不可用</p><p>{unavailable}</p><button type="button" className="wb-button wb-button--secondary wb-button--medium" onClick={onViewInstalled}>查看已安装技能</button></div>
|
||||
if (unavailable) return <div className="sm-empty yc-skill-recommendation-unavailable"><p>推荐技能目录暂不可用</p><p>{unavailable}</p><button type="button" className="wb-button wb-button--secondary wb-button--medium" onClick={() => setCatalogRevision(value => value + 1)}>重试加载推荐技能</button></div>
|
||||
|
||||
return <>
|
||||
{error && !detail && <p className="yc-skill-error" role="alert">{error}</p>}
|
||||
@@ -253,20 +257,36 @@ export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChang
|
||||
<div className="sm-grid yc-skill-recommendation-grid">{matches.map(({ recommendation, match }) => {
|
||||
const operation = operationFor(recommendation)
|
||||
const pending = !!operation && isRunning(operation.phase)
|
||||
const status = pending ? PHASE_LABEL[operation.phase] : match.state === 'current' ? '已安装' : match.state === 'modified' ? '已安装(本地已修改)' : match.state === 'outdated' ? '已安装(版本不同)' : match.state === 'conflict' ? '已存在同名本地技能' : '未安装'
|
||||
const isInstalled = !!match.skill && match.state !== 'conflict'
|
||||
const status = pending ? PHASE_LABEL[operation.phase] : isInstalled && match.skill?.error ? '已安装(损坏)' : match.state === 'current'
|
||||
? !match.skill?.enabled ? '已安装(已禁用)' : !match.skill.userInvocable ? '已安装(仅模型调用)' : '已安装'
|
||||
: match.state === 'modified' ? '已安装(本地已修改)' : match.state === 'outdated' ? '已安装(版本不同)' : match.state === 'conflict' ? '已存在同名本地技能' : '未安装'
|
||||
const installLabel = match.state === 'conflict' ? '替换' : match.skill?.error ? '修复' : match.state === 'outdated' ? '更新' : match.skill ? '替换' : '安装'
|
||||
const useTitle = !match.skill?.userInvocable ? '此技能仅供模型调用' : !onUseSkill ? '当前无法使用技能' : enabling ? '正在启用…' : running ? '当前有推荐技能正在安装' : match.skill.enabled ? '去使用' : '启用后使用'
|
||||
return <div key={recommendation.id} className="yc-skill-card-keyboard yc-skill-recommendation-card" data-recommendation-id={recommendation.id} data-recommendation-state={match.state} data-install-phase={operation?.phase} role="button" tabIndex={0} aria-label={`查看 ${recommendation.name}`}
|
||||
onKeyDown={event => { if (event.target === event.currentTarget && ['Enter', ' '].includes(event.key)) { event.preventDefault(); setDetail(recommendation) } }}>
|
||||
<Card title={recommendation.name} description={recommendation.description} iconUrl={skillAvatarUrl(recommendation.id)} sourceLabel={status} onOpen={() => setDetail(recommendation)} actions={<div className="sm-status" onClick={event => event.stopPropagation()}><button type="button" className={'sm-add-btn' + (pending ? ' sm-add-btn--loading' : '')} aria-label={`安装 ${recommendation.name}`} disabled={running || match.state === 'current'} title={running ? '当前有推荐技能正在安装' : undefined} onClick={() => begin(recommendation)}>{pending ? <span className="yc-skill-recommendation-spinner" aria-hidden /> : <PlusIcon />}</button></div>} />
|
||||
<p className="yc-skill-recommendation-meta"><span>{recommendation.author}</span><span>v{recommendation.version}</span><span>{recommendation.license}</span>{foreign && <span className="yc-skill-recommendation-busy">其他窗口正在安装</span>}{useButton(recommendation, match.skill)}</p>
|
||||
<Card title={recommendation.name} description={recommendation.description} iconUrl={skillAvatarUrl(recommendation.id)} sourceLabel={status}
|
||||
installed={isInstalled} disabled={isInstalled && !match.skill?.enabled} onOpen={() => setDetail(recommendation)}
|
||||
actions={<div className="sm-status" onClick={event => event.stopPropagation()}>
|
||||
{match.state === 'current' && !pending ? <button type="button" className="sm-use-btn"
|
||||
aria-label={`使用推荐技能 ${recommendation.name}`} title={useTitle}
|
||||
disabled={enabling || running || !match.skill?.userInvocable || !onUseSkill}
|
||||
onClick={() => { if (match.skill) void useSkill(match.skill) }}><UseSkillIcon /></button>
|
||||
: <button type="button" className={(match.skill ? 'yc-skill-recommendation-replace-btn' : 'sm-add-btn') + (pending ? ' sm-add-btn--loading' : '')}
|
||||
aria-label={`${installLabel} ${recommendation.name}`} disabled={running}
|
||||
title={running ? '当前有推荐技能正在安装' : `${installLabel}推荐版本`}
|
||||
onClick={() => begin(recommendation)}>{pending ? <span className="yc-skill-recommendation-spinner" aria-hidden /> : match.skill ? installLabel : <PlusIcon />}</button>}
|
||||
</div>} />
|
||||
<p className="yc-skill-recommendation-meta"><span>{recommendation.author}</span><span>v{recommendation.version}</span><span>{recommendation.license}</span>{foreign && <span className="yc-skill-recommendation-busy">其他窗口正在安装</span>}</p>
|
||||
</div>
|
||||
})}</div>
|
||||
<div className="yc-skill-recommendation-footer"><button type="button" className="wb-button wb-button--secondary wb-button--medium" onClick={onViewInstalled}>查看已安装技能</button></div>
|
||||
{detail && (() => {
|
||||
const match = matchInstalled(detail, installed)
|
||||
const operation = operationFor(detail)
|
||||
const pending = !!operation && isRunning(operation.phase)
|
||||
const confirming = operation?.phase === 'idle'
|
||||
const canInstall = match.state !== 'current'
|
||||
const installLabel = match.state === 'conflict' ? '替换' : match.skill?.error ? '修复' : match.state === 'outdated' ? '更新' : match.skill ? '替换' : '安装'
|
||||
return <SkillDetailShell title={detail.name} description={detail.description} ariaLabel={`推荐技能详情 ${detail.name}`} recommendationId={detail.id}
|
||||
avatar={<SkillAvatar name={detail.name} recommendationId={detail.id} size={68} />}
|
||||
metadata={<><span>v{detail.version}</span><span>{detail.license}</span><span>{detail.author}</span>{detail.containsScripts && <span>包含脚本</span>}</>}
|
||||
@@ -274,7 +294,7 @@ export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChang
|
||||
actions={<>
|
||||
{pending ? <><button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" disabled><span className="yc-skill-recommendation-spinner" />{PHASE_LABEL[operation.phase]}</button><button type="button" className="sm-skill-detail-btn" disabled={operation.phase === 'installing' || cancelPending} onClick={() => void cancel()}>{cancelPending ? '正在请求取消…' : '取消安装'}</button></>
|
||||
: confirming ? <><button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label="确认安装推荐技能" onClick={() => void run(!!match.skill)}>{match.skill ? '替换并安装' : '确认安装'}</button><button type="button" className="sm-skill-detail-btn" onClick={() => { active.current = null; setView(null) }}>返回详情</button></>
|
||||
: <>{useButton(detail, match.skill, true)}{canInstall && <button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label={`安装推荐版本 ${detail.name}`} disabled={running} onClick={() => begin(detail)}>{operation?.phase === 'failed' || operation?.phase === 'cancelled' ? '重试安装' : match.skill ? '安装推荐版本' : '安装'}</button>}</>}
|
||||
: <>{useButton(detail, match.skill, true)}{canInstall && <button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label={`${installLabel}推荐版本 ${detail.name}`} disabled={running} onClick={() => begin(detail)}>{operation?.phase === 'failed' || operation?.phase === 'cancelled' ? `重试${installLabel}` : match.skill ? `${installLabel}推荐版本` : '安装'}</button>}</>}
|
||||
</>}>
|
||||
{confirming && <InstallDisclosure recommendation={detail} installed={match.skill} />}
|
||||
{operation && operation.phase !== 'idle' && <div className="yc-skill-inline-install" role="status" aria-live="polite" data-install-phase={operation.phase} data-operation-id={operation.operationId}>
|
||||
|
||||
@@ -100,9 +100,9 @@ export default function SkillsPage({ cwd, onUseSkill, onCreateSkill, onFindSkill
|
||||
{view === 'market' ? <><div className="sm-search-panel"><div className="sm-segment-bar"><div className="sm-segment-group" role="tablist" aria-label="技能来源">{['推荐', 'SkillHub', '套件'].map(value => <button key={value} role="tab" aria-selected={category === value} className={'sm-segment-item' + (category === value ? ' sm-segment-item--active' : '')} onClick={() => setCategory(value)}>{value}</button>)}</div></div>
|
||||
{category === '推荐'
|
||||
? <section className="sm-installed-section yc-skill-recommendation-section">
|
||||
<RecommendedSkills cwd={cwd} installed={items} onUseSkill={onUseSkill} onInstalledChange={() => { void reload().catch(reason => { if (mounted.current) setError(String(reason.message ?? reason)) }) }} onViewInstalled={() => { setView('installed'); setQuery('') }} />
|
||||
<RecommendedSkills cwd={cwd} installed={items} onUseSkill={onUseSkill} onInstalledChange={() => { void reload().catch(reason => { if (mounted.current) setError(String(reason.message ?? reason)) }) }} />
|
||||
</section>
|
||||
: <div className="sm-empty yc-skill-market-unavailable"><SkillsIcon width={32} height={32} /><p>当前未连接{category}目录服务</p><p>你可以导入本地技能,或查看已安装技能。</p><Button onClick={() => { setView('installed'); setQuery('') }}>查看已安装技能</Button></div>}
|
||||
: <div className="sm-empty yc-skill-market-unavailable"><SkillsIcon width={32} height={32} /><p>当前未连接{category}目录服务</p><p>你可以导入本地技能,或通过顶部“已安装”查看现有技能。</p></div>}
|
||||
</div></> : <section className="sm-installed-section">
|
||||
{batch && <div className="sm-batch-toolbar"><div className="sm-batch-toolbar-left"><input type="checkbox" aria-label="全选技能" checked={matches.length > 0 && matches.every(item => selected.includes(item.id))} onChange={event => setSelected(event.target.checked ? matches.map(item => item.id) : [])} />已选择 {selected.length} 项</div><div className="sm-batch-toolbar-right"><Button disabled={busy || !selected.length} onClick={() => { void run(async () => { for (const item of items.filter(value => selected.includes(value.id) && !value.error)) await window.ycode.skills.setEnabled(item.id, true, cwd) }) }}>启用</Button><Button disabled={busy || !selected.length} onClick={() => { void run(async () => { for (const item of items.filter(value => selected.includes(value.id) && !value.error)) await window.ycode.skills.setEnabled(item.id, false, cwd) }) }}>禁用</Button><Button disabled={busy || !selected.some(id => items.some(item => item.id === id && item.scope === 'user'))} onClick={() => setPendingRemove(items.filter(item => selected.includes(item.id) && item.scope === 'user'))}>卸载</Button></div></div>}
|
||||
{loading ? <div className="sm-empty" role="status">加载中…</div> : !matches.length ? <div className="sm-empty">{query ? '没有找到相关技能' : '暂无已安装技能'}</div> : <div className="sm-installed-list">{(['workspace', 'user'] as const).map(scope => { const group = matches.filter(item => item.scope === scope); return !!group.length && <section key={scope} className="sm-installed-group"><div className="sm-installed-group-header"><div className="sm-installed-group-title"><span>{scope === 'workspace' ? '空间技能' : '自定义技能'}</span><span className="sm-installed-group-count">({group.length})</span></div></div><div className="sm-grid">{group.map(skill => <div className="yc-skill-card-keyboard" key={skill.id} data-skill-id={skill.id} role={batch ? undefined : 'button'} tabIndex={0} aria-label={`查看 ${skill.name}`} onKeyDown={event => { if (event.target === event.currentTarget && ['Enter', ' '].includes(event.key)) { event.preventDefault(); if (batch) toggleSelected(skill.id); else setSelectedId(skill.id) } }}>
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
.yc-skill-installed-overview>.markdown-body{margin-top:12px}
|
||||
.yc-skill-notice.yc-skill-install-success{display:flex;align-items:center;gap:12px;pointer-events:auto}
|
||||
.yc-skill-install-success .yc-skill-recommendation-link{display:inline-flex;align-items:center;gap:4px}
|
||||
.yc-skill-recommendation-meta .yc-skill-recommendation-link{display:inline-flex;align-items:center;gap:4px}
|
||||
.yc-skill-recommendation-link svg{width:14px;height:14px;flex:none}
|
||||
.yc-skill-recommendation-link:disabled{cursor:default;opacity:.5}
|
||||
/* Keep the recovered use icon/slot geometry; host theme tokens also cover dark mode. */
|
||||
.yc-skill-recommendation-card .sm-use-btn{display:inline-flex;align-items:center;justify-content:center;flex:none;border-radius:var(--yc-radius-md);cursor:pointer;color:var(--wb-color-text-primary)}
|
||||
.yc-skill-recommendation-card .sm-use-btn:hover:not(:disabled){background:var(--wb-palette-gray-5);color:var(--wb-color-text-primary)}
|
||||
.yc-skill-recommendation-card .sm-use-btn:disabled{cursor:default;opacity:.5;background:transparent}
|
||||
.yc-skill-recommendation-replace-btn{display:inline-flex;align-items:center;justify-content:center;min-width:var(--yc-size-10);height:var(--yc-size-6);padding:0 calc(var(--yc-space-4) / 2);border:0;border-radius:var(--yc-radius-md);background:var(--wb-bg-hover);color:var(--wb-color-text-primary);font:inherit;font-size:var(--yc-font-size-3);white-space:nowrap;cursor:pointer}
|
||||
.yc-skill-recommendation-replace-btn:hover:not(:disabled){background:var(--wb-bg-hover-light)}
|
||||
.yc-skill-recommendation-replace-btn:disabled{cursor:default;opacity:.5}
|
||||
.yc-skill-recommendation-card .sm-use-btn:focus-visible,.yc-skill-recommendation-replace-btn:focus-visible{outline:var(--yc-space-1) solid var(--wb-border-focus);outline-offset:var(--yc-space-1)}
|
||||
html[data-theme="dark"] .yc-skill-recommendation-card .sm-use-btn:hover:not(:disabled){background:var(--wb-bg-hover-light)}
|
||||
@@ -14,10 +14,8 @@
|
||||
.yc-skill-recommendation-card{position:relative}
|
||||
.yc-skill-recommendation-grid .yc-skill-recommendation-card>.sm-card{height:126px;box-sizing:border-box;padding-bottom:40px}
|
||||
.yc-skill-recommendation-meta{position:absolute;bottom:14px;left:20px;right:20px;display:flex;align-items:center;gap:8px;margin:0;color:var(--wb-color-text-tertiary);font-size:12px;line-height:20px}
|
||||
.yc-skill-recommendation-meta>.yc-skill-recommendation-link{margin-left:auto;white-space:nowrap}
|
||||
.yc-skill-recommendation-meta>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.yc-skill-recommendation-busy{color:var(--wb-color-text-secondary)}
|
||||
.yc-skill-recommendation-footer{display:flex;justify-content:flex-start;margin-top:4px}
|
||||
.yc-skill-recommendation-progress{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding:12px 16px;border:1px solid var(--wb-border-default);border-radius:12px;background:var(--wb-bg-card);font-size:13px}
|
||||
.yc-skill-recommendation-progress-text{color:var(--wb-color-text-primary);font-weight:500}
|
||||
.yc-skill-recommendation-progress .yc-skill-error{flex:1;min-width:0}
|
||||
|
||||
Reference in new issue
Block a user