mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): pre-flight install dialog + [Set Up] action
This commit is contained in:
parent
29a8b841d4
commit
170cb1f2f2
245
mateclaw-ui/src/components/skill/PreflightInstallDialog.vue
Normal file
245
mateclaw-ui/src/components/skill/PreflightInstallDialog.vue
Normal file
@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div v-if="visible" class="modal-overlay" @click.self="handleClose">
|
||||
<div class="preflight-modal">
|
||||
<div class="modal-header">
|
||||
<h2>{{ t('skills.preflight.title') }}: {{ skillName }}</h2>
|
||||
<button class="modal-close" @click="handleClose">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p v-if="loading" class="loading">{{ t('common.loading') }}</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- Status summary -->
|
||||
<div class="status-banner" :class="statusBannerClass">
|
||||
<span class="status-icon">{{ allMet ? '✅' : '⚙️' }}</span>
|
||||
<div class="status-text">
|
||||
<strong>
|
||||
{{ allMet ? t('skills.preflight.allMet') : t('skills.preflight.setupNeeded', { count: missingCount }) }}
|
||||
</strong>
|
||||
<p>{{ allMet ? t('skills.preflight.allMetDesc') : t('skills.preflight.setupNeededDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requirement list -->
|
||||
<div v-if="statuses.length > 0" class="req-list">
|
||||
<h3>{{ t('skills.preflight.requirements') }}</h3>
|
||||
<div v-for="req in statuses" :key="req.key" class="req-item" :class="{ 'req-ok': req.satisfied, 'req-missing': !req.satisfied && !req.optional, 'req-optional': req.optional }">
|
||||
<div class="req-head">
|
||||
<span class="req-icon">{{ req.satisfied ? '✓' : (req.optional ? '○' : '✗') }}</span>
|
||||
<span class="req-name">{{ req.key }}</span>
|
||||
<span v-if="req.type" class="req-type">{{ req.type }}</span>
|
||||
<span v-if="req.optional" class="req-optional-tag">{{ t('skills.preflight.optional') }}</span>
|
||||
</div>
|
||||
<p v-if="req.description" class="req-desc">{{ req.description }}</p>
|
||||
<!-- Install commands per platform -->
|
||||
<div v-if="!req.satisfied && req.installCommands && Object.keys(req.installCommands).length > 0" class="install-cmds">
|
||||
<div class="install-cmds-head">{{ t('skills.preflight.installCommands') }}</div>
|
||||
<div v-for="(cmd, platform) in req.installCommands" :key="platform" class="install-cmd-row">
|
||||
<span class="platform-tag" :class="`platform-${platformClass(platform)}`">{{ formatPlatform(platform) }}</span>
|
||||
<code class="install-cmd">{{ cmd }}</code>
|
||||
<button class="copy-btn" :title="t('common.copy')" @click="copy(String(cmd))">📋</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature matrix -->
|
||||
<div v-if="featureRows.length > 0" class="feat-section">
|
||||
<h3>{{ t('skills.preflight.features') }}</h3>
|
||||
<div v-for="row in featureRows" :key="row.id" class="feat-item">
|
||||
<span class="feat-id">{{ row.id }}</span>
|
||||
<span class="feat-status" :class="`feat-${row.status.toLowerCase()}`">{{ row.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty manifest fallback -->
|
||||
<p v-if="statuses.length === 0 && featureRows.length === 0" class="empty-msg">
|
||||
{{ summary || t('skills.preflight.noManifest') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="handleClose">{{ t('common.close') }}</button>
|
||||
<button v-if="!allMet" class="btn-secondary" @click="reload" :disabled="loading">
|
||||
{{ loading ? t('common.loading') : t('skills.preflight.recheck') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { skillApi } from '@/api/index'
|
||||
|
||||
interface RequirementStatus {
|
||||
key: string
|
||||
type?: string
|
||||
description?: string
|
||||
optional?: boolean
|
||||
status?: string
|
||||
satisfied?: boolean
|
||||
installCommands?: Record<string, string>
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
skillId: number | string | null
|
||||
skillName: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
const allMet = ref(false)
|
||||
const statuses = ref<RequirementStatus[]>([])
|
||||
const summary = ref('')
|
||||
const featureStatuses = ref<Record<string, string>>({})
|
||||
const activeFeatures = ref<string[]>([])
|
||||
|
||||
const missingCount = computed(() =>
|
||||
statuses.value.filter(s => !s.satisfied && !s.optional).length,
|
||||
)
|
||||
const statusBannerClass = computed(() => allMet.value ? 'banner-ok' : 'banner-needs-setup')
|
||||
|
||||
const featureRows = computed(() =>
|
||||
Object.entries(featureStatuses.value).map(([id, status]) => ({ id, status })),
|
||||
)
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v && props.skillId != null) reload()
|
||||
})
|
||||
|
||||
async function reload() {
|
||||
if (props.skillId == null) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await skillApi.requirements(props.skillId)
|
||||
const data = res?.data || {}
|
||||
allMet.value = !!data.allMet
|
||||
statuses.value = Array.isArray(data.statuses) ? data.statuses : []
|
||||
summary.value = data.summary || ''
|
||||
featureStatuses.value = data.featureStatuses || {}
|
||||
activeFeatures.value = Array.isArray(data.activeFeatures) ? data.activeFeatures : []
|
||||
} catch (e: any) {
|
||||
ElMessage.error(typeof e === 'string' ? e : e?.message || t('skills.preflight.loadFailed'))
|
||||
statuses.value = []
|
||||
allMet.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
async function copy(cmd: string) {
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(cmd)
|
||||
} else {
|
||||
// Fallback for clipboard-API-disabled contexts (eg http://). The
|
||||
// textarea trick is broadly supported and avoids a noisy permission
|
||||
// failure on the production-ish workflow.
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = cmd
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
ElMessage.success(t('common.copied'))
|
||||
} catch {
|
||||
ElMessage.warning(t('common.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function platformClass(platform: string): string {
|
||||
const p = String(platform).toLowerCase()
|
||||
if (p.includes('mac')) return 'mac'
|
||||
if (p.includes('linux') || p.includes('apt') || p.includes('dnf') || p.includes('yum')) return 'linux'
|
||||
if (p.includes('win')) return 'win'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function formatPlatform(platform: string): string {
|
||||
const p = String(platform).toLowerCase()
|
||||
if (p === 'macos') return 'macOS'
|
||||
if (p === 'linux_apt') return 'Linux (apt)'
|
||||
if (p === 'linux_dnf') return 'Linux (dnf)'
|
||||
if (p === 'windows') return 'Windows'
|
||||
if (p === 'manual_url') return 'Manual'
|
||||
return String(platform)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; z-index: 1100; padding: 20px; }
|
||||
.preflight-modal { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; width: 100%; max-width: 720px; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 24px 64px rgba(0, 0, 0, 0.18); }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.modal-header h2 { font-size: 17px; font-weight: 600; margin: 0; color: var(--mc-text-primary); }
|
||||
.modal-close { width: 30px; height: 30px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); border-radius: 6px; display: flex; align-items: center; justify-content: center; }
|
||||
.modal-close:hover { background: var(--mc-bg-sunken); }
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 18px 22px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 22px; border-top: 1px solid var(--mc-border-light); }
|
||||
.btn-secondary { padding: 8px 14px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 13px; cursor: pointer; }
|
||||
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||
.btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.loading { color: var(--mc-text-tertiary); }
|
||||
.empty-msg { color: var(--mc-text-tertiary); font-size: 13px; }
|
||||
|
||||
.status-banner { display: flex; align-items: flex-start; gap: 12px; padding: 14px; border-radius: 12px; }
|
||||
.banner-ok { background: rgba(34, 197, 94, 0.08); border: 1px solid rgba(34, 197, 94, 0.2); }
|
||||
.banner-needs-setup { background: var(--mc-primary-bg); border: 1px solid rgba(217, 109, 70, 0.18); }
|
||||
.status-icon { font-size: 22px; line-height: 1; }
|
||||
.status-text strong { display: block; font-size: 14px; font-weight: 600; color: var(--mc-text-primary); margin-bottom: 2px; }
|
||||
.status-text p { margin: 0; font-size: 12px; color: var(--mc-text-secondary); line-height: 1.5; }
|
||||
|
||||
.req-list h3, .feat-section h3 { font-size: 12px; font-weight: 700; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 6px; }
|
||||
.req-item { padding: 10px 12px; border-radius: 10px; border: 1px solid var(--mc-border-light); margin-bottom: 6px; background: var(--mc-bg-muted); }
|
||||
.req-item.req-ok { border-color: rgba(34, 197, 94, 0.2); }
|
||||
.req-item.req-missing { border-color: rgba(217, 109, 70, 0.25); }
|
||||
.req-head { display: flex; align-items: center; gap: 8px; }
|
||||
.req-icon { font-weight: 700; }
|
||||
.req-ok .req-icon { color: #16a34a; }
|
||||
.req-missing .req-icon { color: var(--mc-danger); }
|
||||
.req-optional .req-icon { color: var(--mc-text-tertiary); }
|
||||
.req-name { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
|
||||
.req-type { font-size: 10px; padding: 1px 6px; background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); border-radius: 999px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.req-optional-tag { font-size: 10px; padding: 1px 6px; background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); border-radius: 999px; font-weight: 600; }
|
||||
.req-desc { margin: 6px 0 0; font-size: 12px; color: var(--mc-text-secondary); line-height: 1.5; }
|
||||
.install-cmds { margin-top: 8px; }
|
||||
.install-cmds-head { font-size: 11px; font-weight: 700; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; }
|
||||
.install-cmd-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
|
||||
.platform-tag { font-size: 10px; padding: 1px 6px; border-radius: 4px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; min-width: 64px; text-align: center; }
|
||||
.platform-mac { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
|
||||
.platform-linux { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
|
||||
.platform-win { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
|
||||
.platform-other { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
||||
.install-cmd { flex: 1; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 12px; padding: 4px 8px; background: var(--mc-bg-sunken); border-radius: 4px; color: var(--mc-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.copy-btn { width: 26px; height: 26px; border: none; background: none; cursor: pointer; border-radius: 4px; font-size: 14px; }
|
||||
.copy-btn:hover { background: var(--mc-bg-sunken); }
|
||||
|
||||
.feat-section { display: flex; flex-direction: column; gap: 4px; }
|
||||
.feat-item { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; background: var(--mc-bg-muted); border-radius: 8px; font-size: 12px; }
|
||||
.feat-id { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; color: var(--mc-text-primary); }
|
||||
.feat-status { font-size: 10px; padding: 1px 6px; border-radius: 999px; font-weight: 700; letter-spacing: 0.04em; }
|
||||
.feat-ready { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
|
||||
.feat-setup_needed { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
||||
.feat-unsupported { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
||||
.feat-unknown { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
||||
</style>
|
||||
@ -17,6 +17,7 @@ export default {
|
||||
view: 'View',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
copyFailed: 'Copy failed',
|
||||
confirm: 'Confirm',
|
||||
close: 'Close',
|
||||
add: 'Add',
|
||||
@ -2006,10 +2007,25 @@ export default {
|
||||
actions: {
|
||||
configure: 'Configure',
|
||||
view: 'View',
|
||||
setUp: 'Set Up',
|
||||
delete: 'Delete',
|
||||
saveChanges: 'Save Changes',
|
||||
createSkill: 'Create Skill',
|
||||
},
|
||||
preflight: {
|
||||
title: 'Pre-flight check',
|
||||
allMet: 'All requirements satisfied',
|
||||
allMetDesc: 'This skill is ready to use as soon as you bind it to an agent.',
|
||||
setupNeeded: '{count} requirement(s) need attention',
|
||||
setupNeededDesc: 'Install the missing pieces below, then re-check or re-resolve the skill.',
|
||||
requirements: 'Requirements',
|
||||
features: 'Features',
|
||||
installCommands: 'Install commands',
|
||||
optional: 'Optional',
|
||||
noManifest: 'This skill has no v3 manifest. Status falls back to legacy dependency reporting.',
|
||||
recheck: 'Re-check',
|
||||
loadFailed: 'Failed to load preflight data',
|
||||
},
|
||||
detail: {
|
||||
title: 'Skill detail',
|
||||
manifest: 'Manifest',
|
||||
|
||||
@ -17,6 +17,7 @@ export default {
|
||||
view: '查看',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
copyFailed: '复制失败',
|
||||
confirm: '确认',
|
||||
close: '关闭',
|
||||
add: '添加',
|
||||
@ -2008,10 +2009,25 @@ export default {
|
||||
actions: {
|
||||
configure: '配置',
|
||||
view: '查看',
|
||||
setUp: '配置就绪',
|
||||
delete: '删除',
|
||||
saveChanges: '保存更改',
|
||||
createSkill: '创建技能',
|
||||
},
|
||||
preflight: {
|
||||
title: '装前检查',
|
||||
allMet: '所有依赖已满足',
|
||||
allMetDesc: '该 skill 已就绪,绑定到 agent 即可使用。',
|
||||
setupNeeded: '{count} 项依赖待处理',
|
||||
setupNeededDesc: '按下方提示安装所需依赖,安装完成后点重新检查。',
|
||||
requirements: '依赖项',
|
||||
features: '特性',
|
||||
installCommands: '安装命令',
|
||||
optional: '可选',
|
||||
noManifest: '该 skill 未提供 v3 manifest,状态回退到旧依赖检查模式。',
|
||||
recheck: '重新检查',
|
||||
loadFailed: '加载预检数据失败',
|
||||
},
|
||||
detail: {
|
||||
title: '技能详情',
|
||||
manifest: 'Manifest',
|
||||
|
||||
@ -198,6 +198,14 @@
|
||||
<div class="skill-footer">
|
||||
<span v-if="skill.author" class="skill-author">by {{ skill.author }}</span>
|
||||
<div class="skill-actions">
|
||||
<!-- RFC-090 §4.2 [Set Up] — surfaced when card is Setup
|
||||
Needed; opens the preflight dialog with install hints -->
|
||||
<button v-if="needsSetup(skill)" class="skill-btn skill-btn-setup" @click="openPreflight(skill)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.09a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c0 .66.26 1.3.73 1.77.47.47 1.11.73 1.77.73H21a2 2 0 1 1 0 4h-.09c-.66 0-1.3.26-1.77.73-.47.47-.73 1.11-.73 1.77z"/>
|
||||
</svg>
|
||||
{{ t('skills.actions.setUp') }}
|
||||
</button>
|
||||
<button class="skill-btn" @click="openDetailDrawer(skill)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/>
|
||||
@ -249,6 +257,13 @@
|
||||
<!-- Import Hub Dialog -->
|
||||
<ImportHubDialog v-model:visible="showImportDialog" @installed="loadAll" />
|
||||
|
||||
<!-- RFC-090 §4.4 Pre-flight install dialog -->
|
||||
<PreflightInstallDialog
|
||||
v-model:visible="preflightVisible"
|
||||
:skill-id="preflightSkillId"
|
||||
:skill-name="preflightSkillName"
|
||||
/>
|
||||
|
||||
<!-- RFC-090 Phase 3 — Skill detail drawer (Manifest / Tools / Features) -->
|
||||
<el-drawer
|
||||
v-model="detailDrawerVisible"
|
||||
@ -429,6 +444,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { skillApi, skillInstallApi } from '@/api/index'
|
||||
import type { Skill, SkillRuntimeStatus, SkillSecurityFinding } from '@/types/index'
|
||||
import ImportHubDialog from '@/components/skill/ImportHubDialog.vue'
|
||||
import PreflightInstallDialog from '@/components/skill/PreflightInstallDialog.vue'
|
||||
import { useSkillName } from '@/composables/useSkillName'
|
||||
|
||||
const { t } = useI18n()
|
||||
@ -467,6 +483,17 @@ const detailLessonsLoading = ref(false)
|
||||
const lessonsCountBySkill = ref<Record<string, number>>({})
|
||||
const usedByBySkill = ref<Record<string, number>>({})
|
||||
|
||||
/** RFC-090 §4.4 — pre-flight dialog state. */
|
||||
const preflightVisible = ref(false)
|
||||
const preflightSkillId = ref<number | string | null>(null)
|
||||
const preflightSkillName = ref('')
|
||||
|
||||
function openPreflight(skill: Skill) {
|
||||
preflightSkillId.value = skill.id
|
||||
preflightSkillName.value = resolveSkillName(skill)
|
||||
preflightVisible.value = true
|
||||
}
|
||||
|
||||
const detailRuntime = computed(() =>
|
||||
detailSkill.value ? runtimeStatusMap.value[detailSkill.value.name] || null : null,
|
||||
)
|
||||
@ -979,6 +1006,25 @@ function getLessonsCount(skill: Skill): number {
|
||||
return lessonsCountBySkill.value[String(skill.id)] || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-090 §4.2 — does this skill need the [Set Up] button surfaced?
|
||||
* Yes iff: enabled, not security-blocked, AND either:
|
||||
* - manifest features exist with at least one not-READY, OR
|
||||
* - legacy dependencyReady is false.
|
||||
*/
|
||||
function needsSetup(skill: Skill): boolean {
|
||||
if (!skill.enabled) return false
|
||||
const rt = getRuntimeStatus(skill)
|
||||
if (!rt) return false
|
||||
if (rt.securityBlocked) return false
|
||||
if (rt.manifest && Array.isArray(rt.manifest.features) && rt.manifest.features.length > 0) {
|
||||
const total = rt.manifest.features.length
|
||||
const ready = (rt.activeFeatures || []).length
|
||||
return ready < total
|
||||
}
|
||||
return rt.dependencyReady === false
|
||||
}
|
||||
|
||||
function getUsedByCount(skill: Skill): number {
|
||||
return usedByBySkill.value[String(skill.id)] || 0
|
||||
}
|
||||
@ -1292,6 +1338,10 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
|
||||
.usedby-badge, .lessons-badge { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
|
||||
.lessons-badge { cursor: pointer; }
|
||||
.lessons-badge:hover { background: var(--mc-primary-bg); color: var(--mc-primary); }
|
||||
|
||||
/* RFC-090 §4.2 — [Set Up] button highlight */
|
||||
.skill-btn-setup { background: var(--mc-primary-bg); color: var(--mc-primary); border-color: rgba(217, 109, 70, 0.18); font-weight: 600; }
|
||||
.skill-btn-setup:hover { background: var(--mc-primary); color: white; }
|
||||
:root.dark .rt-features-ready { background: rgba(34, 197, 94, 0.2); color: #4ade80; }
|
||||
:root.dark .rt-features-mixed { background: rgba(129, 140, 248, 0.18); color: #a5b4fc; }
|
||||
.rt-disabled { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user