mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): SkillMarket lifecycle UI + curator control panel
This commit is contained in:
parent
4cd21056a0
commit
2c1e673fba
@ -185,6 +185,8 @@ export const skillApi = {
|
||||
enabled?: boolean
|
||||
/** 'PASSED' / 'FAILED' — filters by security_scan_status. */
|
||||
scanStatus?: string
|
||||
/** 'active' / 'stale' / 'archived' — filters by lifecycle_state. */
|
||||
lifecycleState?: string
|
||||
} = {}) => http.get('/skills', { params }),
|
||||
/** Tab count aggregate — returns { all, builtin, mcp, dynamic } */
|
||||
counts: () => http.get('/skills/counts'),
|
||||
@ -217,6 +219,35 @@ export const skillApi = {
|
||||
http.post(`/skills/${id}/secrets`, { key, value }),
|
||||
deleteSecret: (id: string | number, key: string) =>
|
||||
http.delete(`/skills/${id}/secrets/${encodeURIComponent(key)}`),
|
||||
|
||||
// ---- Lifecycle curator ----
|
||||
/** Pin / unpin a skill — pinned skills are never auto-archived. */
|
||||
pin: (id: string | number, pinned: boolean) =>
|
||||
http.post(`/skills/${id}/pin`, { pinned }),
|
||||
/**
|
||||
* Manually archive a skill. When the skill is bound to an enabled agent
|
||||
* and {@code force} is false, the backend replies HTTP 409 with
|
||||
* {@code code: 'BOUND_SKILL_CONFIRM_REQUIRED'} and a {@code boundAgents}
|
||||
* list; retry with {@code force: true} to confirm.
|
||||
*/
|
||||
archive: (id: string | number, opts: { force?: boolean; reason?: string } = {}) =>
|
||||
http.post(`/skills/${id}/archive`, { reason: opts.reason ?? null },
|
||||
{ params: { force: opts.force ?? false } }),
|
||||
/** Restore an archived skill back to active. */
|
||||
restore: (id: string | number) => http.post(`/skills/${id}/restore`),
|
||||
/** Curator control-panel status (config / control / counts / lastReport). */
|
||||
curatorStatus: () => http.get('/skills/curator/status'),
|
||||
/** Run a curator dry-run preview immediately. */
|
||||
curatorDryRun: () => http.post('/skills/curator/dry-run'),
|
||||
/** Activate (apply transitions) or deactivate (preview-only) the curator. */
|
||||
curatorActivate: (activate: boolean) =>
|
||||
http.post('/skills/curator/activate', null, { params: { activate } }),
|
||||
curatorPause: () => http.post('/skills/curator/pause'),
|
||||
curatorResume: () => http.post('/skills/curator/resume'),
|
||||
/** List recent curator run report ids. */
|
||||
curatorReports: () => http.get('/skills/curator/reports'),
|
||||
/** Read one curator run report (parsed run.json). */
|
||||
curatorReport: (runId: string) => http.get(`/skills/curator/reports/${runId}`),
|
||||
}
|
||||
|
||||
/** Shape returned by GET /skills/{id}/secrets. */
|
||||
|
||||
28
mateclaw-ui/src/components/skill/SkillLineIcon.vue
Normal file
28
mateclaw-ui/src/components/skill/SkillLineIcon.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<svg
|
||||
:width="size" :height="size" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
aria-hidden="true" v-html="path"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { skillLineIcons, type SkillLineIconName } from './lineIcons'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Icon key from the skill icon registry. */
|
||||
name: string
|
||||
/** Rendered width/height in pixels. */
|
||||
size?: number | string
|
||||
}>(), {
|
||||
size: 16,
|
||||
})
|
||||
|
||||
const path = computed(() => skillLineIcons[props.name as SkillLineIconName] ?? '')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
svg { display: block; flex-shrink: 0; }
|
||||
</style>
|
||||
20
mateclaw-ui/src/components/skill/lineIcons.ts
Normal file
20
mateclaw-ui/src/components/skill/lineIcons.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Feather-style line-art icons for the skill catalog and lifecycle curator.
|
||||
* Each glyph is drawn on a 24x24 canvas and stroked with `currentColor`.
|
||||
* Consumed by SkillLineIcon.vue so the skill surfaces share one icon source
|
||||
* instead of inlining raw SVG or emoji — mirrors the memory module's registry.
|
||||
*/
|
||||
export const skillLineIcons = {
|
||||
// Catalog source tabs
|
||||
all: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
|
||||
builtin: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
|
||||
mcp: '<path d="M9 2v6"/><path d="M15 2v6"/><path d="M6 8h12v3a6 6 0 0 1-12 0z"/><path d="M12 17v5"/>',
|
||||
acp: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
dynamic: '<line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
|
||||
// Lifecycle
|
||||
clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
archive: '<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>',
|
||||
pin: '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>',
|
||||
} as const
|
||||
|
||||
export type SkillLineIconName = keyof typeof skillLineIcons
|
||||
@ -573,6 +573,7 @@ export default {
|
||||
featureFlags: 'Feature Flags',
|
||||
about: 'About',
|
||||
advanced: 'Advanced',
|
||||
skillCurator: 'Skill Curator',
|
||||
},
|
||||
models: {
|
||||
sidecar: {
|
||||
@ -2980,6 +2981,34 @@ export default {
|
||||
mcp: 'MCP',
|
||||
acp: 'ACP',
|
||||
dynamic: 'Dynamic',
|
||||
stale: 'Stale',
|
||||
archived: 'Archived',
|
||||
},
|
||||
lifecycle: {
|
||||
section: 'Lifecycle',
|
||||
state: 'State',
|
||||
lastUsed: 'Last used',
|
||||
neverUsed: 'Never used',
|
||||
today: 'Today',
|
||||
daysAgo: '{n}d ago',
|
||||
states: {
|
||||
active: 'Active',
|
||||
stale: 'Stale',
|
||||
archived: 'Archived',
|
||||
},
|
||||
pin: 'Pin this skill',
|
||||
pinHint: 'Pinned skills are never auto-archived',
|
||||
archive: 'Archive',
|
||||
restore: 'Restore',
|
||||
pinFailed: 'Failed to update pin',
|
||||
archiveTitle: 'Archive skill',
|
||||
archiveConfirm: 'Archive "{name}"? The skill will be disabled and can be restored anytime.',
|
||||
archiveSuccess: 'Skill archived',
|
||||
archiveFailed: 'Failed to archive',
|
||||
restoreSuccess: 'Skill restored',
|
||||
restoreFailed: 'Failed to restore',
|
||||
boundConfirmTitle: 'Skill is bound to agents',
|
||||
boundConfirmBody: '"{name}" is explicitly bound to {count} enabled agent(s) ({agents}). Archiving removes that capability from them. Continue?',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Search by name, description, or tags...',
|
||||
@ -3209,6 +3238,52 @@ export default {
|
||||
tooLarge: 'File too large (max 50MB)',
|
||||
},
|
||||
},
|
||||
skillCurator: {
|
||||
title: 'Skill Curator',
|
||||
desc: 'Automatically archives long-idle skills on a time window to keep the skill catalog tidy.',
|
||||
stateEnabled: 'Deployed',
|
||||
stateActivated: 'Activated',
|
||||
statePreview: 'Preview only',
|
||||
statePaused: 'Paused',
|
||||
config: 'Configuration',
|
||||
scope: 'Scope',
|
||||
staleAfter: 'Stale after (days)',
|
||||
archiveAfter: 'Archive after (days)',
|
||||
cron: 'Schedule',
|
||||
counts: 'Current counts',
|
||||
active: 'Active',
|
||||
stale: 'Stale',
|
||||
archived: 'Archived',
|
||||
pinned: 'Pinned',
|
||||
blockedByBindings: 'Binding-protected',
|
||||
control: 'Runtime control',
|
||||
lastObservedAt: 'First observed',
|
||||
lastDryRunAt: 'Last preview',
|
||||
lastRunAt: 'Last run',
|
||||
nextScheduledRun: 'Next run',
|
||||
activate: 'Activate curator',
|
||||
deactivate: 'Back to preview',
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
runDryRun: 'Preview now',
|
||||
activatedHint: 'The curator is activated and archives idle skills on schedule.',
|
||||
previewHint: 'The curator is in preview-only mode and never archives. Review a preview report, then activate.',
|
||||
pausedHint: 'The curator is paused; the scheduled sweep will not run.',
|
||||
reports: 'Run reports',
|
||||
noReports: 'No run reports yet',
|
||||
reportPlanned: 'Planned',
|
||||
reportApplied: 'Applied',
|
||||
reportScanned: 'Scanned',
|
||||
reportDryRun: 'Preview',
|
||||
reportTransitions: 'Transitions',
|
||||
activateSuccess: 'Skill curator activated',
|
||||
deactivateSuccess: 'Back to preview-only mode',
|
||||
pauseSuccess: 'Skill curator paused',
|
||||
resumeSuccess: 'Skill curator resumed',
|
||||
dryRunSuccess: 'Preview report generated',
|
||||
actionFailed: 'Action failed',
|
||||
loadFailed: 'Failed to load skill curator status',
|
||||
},
|
||||
onboarding: {
|
||||
title: 'Welcome to MateClaw',
|
||||
subtitle: 'Set up your first AI model',
|
||||
|
||||
@ -453,6 +453,7 @@ export default {
|
||||
featureFlags: '功能开关',
|
||||
about: '关于',
|
||||
advanced: '高级',
|
||||
skillCurator: '技能管家',
|
||||
},
|
||||
models: {
|
||||
sidecar: {
|
||||
@ -3072,6 +3073,34 @@ export default {
|
||||
mcp: 'MCP',
|
||||
acp: 'ACP',
|
||||
dynamic: '动态',
|
||||
stale: '待归档',
|
||||
archived: '已归档',
|
||||
},
|
||||
lifecycle: {
|
||||
section: '生命周期',
|
||||
state: '状态',
|
||||
lastUsed: '上次使用',
|
||||
neverUsed: '从未使用',
|
||||
today: '今天',
|
||||
daysAgo: '{n} 天前',
|
||||
states: {
|
||||
active: '活跃',
|
||||
stale: '待归档',
|
||||
archived: '已归档',
|
||||
},
|
||||
pin: '钉住此技能',
|
||||
pinHint: '钉住后永不会被自动归档',
|
||||
archive: '归档',
|
||||
restore: '恢复',
|
||||
pinFailed: '钉住操作失败',
|
||||
archiveTitle: '归档技能',
|
||||
archiveConfirm: '确定归档「{name}」吗?归档后该技能将停用,可随时恢复。',
|
||||
archiveSuccess: '技能已归档',
|
||||
archiveFailed: '归档失败',
|
||||
restoreSuccess: '技能已恢复',
|
||||
restoreFailed: '恢复失败',
|
||||
boundConfirmTitle: '该技能已被 Agent 绑定',
|
||||
boundConfirmBody: '「{name}」被 {count} 个启用中的 Agent 显式绑定({agents})。归档后这些 Agent 将失去该能力,确定继续?',
|
||||
},
|
||||
search: {
|
||||
placeholder: '搜索技能名称、描述或标签...',
|
||||
@ -3301,6 +3330,52 @@ export default {
|
||||
tooLarge: '文件过大(最大 50MB)',
|
||||
},
|
||||
},
|
||||
skillCurator: {
|
||||
title: '技能管家',
|
||||
desc: '按时间窗口自动归档长期闲置的技能,保持技能目录整洁。',
|
||||
stateEnabled: '已部署',
|
||||
stateActivated: '已激活',
|
||||
statePreview: '仅预览',
|
||||
statePaused: '已暂停',
|
||||
config: '配置',
|
||||
scope: '清理范围',
|
||||
staleAfter: 'stale 阈值(天)',
|
||||
archiveAfter: '归档阈值(天)',
|
||||
cron: '调度表达式',
|
||||
counts: '当前统计',
|
||||
active: '活跃',
|
||||
stale: '待归档',
|
||||
archived: '已归档',
|
||||
pinned: '已钉住',
|
||||
blockedByBindings: '被绑定保护',
|
||||
control: '运行控制',
|
||||
lastObservedAt: '首次观测',
|
||||
lastDryRunAt: '上次预览',
|
||||
lastRunAt: '上次运行',
|
||||
nextScheduledRun: '下次调度',
|
||||
activate: '激活管家',
|
||||
deactivate: '退回仅预览',
|
||||
pause: '暂停',
|
||||
resume: '恢复',
|
||||
runDryRun: '立即预览',
|
||||
activatedHint: '管家已激活,每日按调度真正归档闲置技能。',
|
||||
previewHint: '管家处于仅预览模式,不会真正归档。查看预览报告满意后再激活。',
|
||||
pausedHint: '管家已暂停,定时扫描不会运行。',
|
||||
reports: '运行报告',
|
||||
noReports: '暂无运行报告',
|
||||
reportPlanned: '计划',
|
||||
reportApplied: '执行',
|
||||
reportScanned: '扫描',
|
||||
reportDryRun: '预览',
|
||||
reportTransitions: '状态变更',
|
||||
activateSuccess: '技能管家已激活',
|
||||
deactivateSuccess: '已退回仅预览模式',
|
||||
pauseSuccess: '技能管家已暂停',
|
||||
resumeSuccess: '技能管家已恢复',
|
||||
dryRunSuccess: '预览报告已生成',
|
||||
actionFailed: '操作失败',
|
||||
loadFailed: '加载技能管家状态失败',
|
||||
},
|
||||
onboarding: {
|
||||
title: '欢迎使用 MateClaw',
|
||||
subtitle: '配置你的第一个 AI 模型',
|
||||
|
||||
@ -182,6 +182,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/CronJobs.vue'),
|
||||
meta: { title: 'Settings - Cron Jobs', requiredCapability: 'manage:agents' },
|
||||
},
|
||||
{
|
||||
path: 'skill-curator',
|
||||
name: 'SettingsSkillCurator',
|
||||
component: () => import('@/views/Settings/SkillCurator/index.vue'),
|
||||
meta: { title: 'Settings - Skill Curator', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
{
|
||||
path: 'workflows',
|
||||
name: 'SettingsWorkflows',
|
||||
|
||||
@ -285,6 +285,14 @@ export interface Skill {
|
||||
securityScanResult?: string
|
||||
/** RFC-042 §2.3 — wall-clock time of the last scan */
|
||||
securityScanTime?: string
|
||||
/** Lifecycle curator state: 'active' / 'stale' / 'archived' */
|
||||
lifecycleState?: string
|
||||
/** User-pinned skill — exempt from automatic archival */
|
||||
pinned?: boolean
|
||||
/** Last activity timestamp — drives the curator's idle window */
|
||||
lastActivityAt?: string
|
||||
/** When the curator moved this skill to archived state */
|
||||
archivedAt?: string
|
||||
}
|
||||
|
||||
/** 运行时解析状态(来自 /runtime/status) */
|
||||
|
||||
@ -167,6 +167,12 @@ const sections = computed(() => [
|
||||
label: t('nav.triggers', 'Triggers'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>',
|
||||
},
|
||||
{
|
||||
id: 'skill-curator',
|
||||
path: '/settings/skill-curator',
|
||||
label: t('settings.sections.skillCurator', 'Skill Curator'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 8v13H3V8"/><rect x="1" y="3" width="22" height="5" rx="1"/><line x1="10" y1="12" x2="14" y2="12"/></svg>',
|
||||
},
|
||||
{
|
||||
id: 'datasources',
|
||||
path: '/settings/datasources',
|
||||
|
||||
308
mateclaw-ui/src/views/Settings/SkillCurator/index.vue
Normal file
308
mateclaw-ui/src/views/Settings/SkillCurator/index.vue
Normal file
@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">{{ t('skillCurator.title') }}</h2>
|
||||
<p class="section-desc">{{ t('skillCurator.desc') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="settings-card state-card">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="settings-card state-card state-card--error">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<span>{{ error }}</span>
|
||||
<button class="btn-secondary" @click="load">{{ t('common.retry', 'Retry') }}</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="status">
|
||||
<!-- Runtime state + control -->
|
||||
<div class="settings-card">
|
||||
<div class="state-pills">
|
||||
<span class="curator-pill" :class="status.config.enabled ? 'pill-on' : 'pill-off'">
|
||||
{{ t('skillCurator.stateEnabled') }}
|
||||
</span>
|
||||
<span class="curator-pill" :class="status.control.activated ? 'pill-on' : 'pill-muted'">
|
||||
{{ status.control.activated ? t('skillCurator.stateActivated') : t('skillCurator.statePreview') }}
|
||||
</span>
|
||||
<span v-if="status.control.paused" class="curator-pill pill-warn">
|
||||
{{ t('skillCurator.statePaused') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="curator-hint">
|
||||
{{ status.control.paused ? t('skillCurator.pausedHint')
|
||||
: status.control.activated ? t('skillCurator.activatedHint')
|
||||
: t('skillCurator.previewHint') }}
|
||||
</p>
|
||||
<div class="curator-actions">
|
||||
<button class="btn-secondary" :disabled="busy" @click="runDryRun">
|
||||
{{ t('skillCurator.runDryRun') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!status.control.activated"
|
||||
class="btn-primary" :disabled="busy"
|
||||
@click="setActivated(true)"
|
||||
>{{ t('skillCurator.activate') }}</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-secondary" :disabled="busy"
|
||||
@click="setActivated(false)"
|
||||
>{{ t('skillCurator.deactivate') }}</button>
|
||||
<button
|
||||
v-if="!status.control.paused"
|
||||
class="btn-secondary" :disabled="busy"
|
||||
@click="setPaused(true)"
|
||||
>{{ t('skillCurator.pause') }}</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-secondary" :disabled="busy"
|
||||
@click="setPaused(false)"
|
||||
>{{ t('skillCurator.resume') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Counts -->
|
||||
<div class="settings-card">
|
||||
<h3 class="card-title">{{ t('skillCurator.counts') }}</h3>
|
||||
<div class="count-grid">
|
||||
<div class="count-cell"><span class="count-num">{{ status.counts.active }}</span><span class="count-label">{{ t('skillCurator.active') }}</span></div>
|
||||
<div class="count-cell"><span class="count-num count-stale">{{ status.counts.stale }}</span><span class="count-label">{{ t('skillCurator.stale') }}</span></div>
|
||||
<div class="count-cell"><span class="count-num">{{ status.counts.archived }}</span><span class="count-label">{{ t('skillCurator.archived') }}</span></div>
|
||||
<div class="count-cell"><span class="count-num">{{ status.counts.pinned }}</span><span class="count-label">{{ t('skillCurator.pinned') }}</span></div>
|
||||
<div class="count-cell"><span class="count-num">{{ status.counts.blockedByBindings }}</span><span class="count-label">{{ t('skillCurator.blockedByBindings') }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config + control timestamps -->
|
||||
<div class="settings-card">
|
||||
<h3 class="card-title">{{ t('skillCurator.config') }}</h3>
|
||||
<dl class="kv-list">
|
||||
<div class="kv-row"><dt>{{ t('skillCurator.scope') }}</dt><dd><code>{{ status.config.scope }}</code></dd></div>
|
||||
<div class="kv-row"><dt>{{ t('skillCurator.staleAfter') }}</dt><dd>{{ status.config.staleAfterDays }}</dd></div>
|
||||
<div class="kv-row"><dt>{{ t('skillCurator.archiveAfter') }}</dt><dd>{{ status.config.archiveAfterDays }}</dd></div>
|
||||
<div class="kv-row"><dt>{{ t('skillCurator.cron') }}</dt><dd><code>{{ status.config.cron }}</code></dd></div>
|
||||
<div class="kv-row"><dt>{{ t('skillCurator.lastObservedAt') }}</dt><dd>{{ fmt(status.control.lastObservedAt) }}</dd></div>
|
||||
<div class="kv-row"><dt>{{ t('skillCurator.lastDryRunAt') }}</dt><dd>{{ fmt(status.control.lastDryRunAt) }}</dd></div>
|
||||
<div class="kv-row"><dt>{{ t('skillCurator.lastRunAt') }}</dt><dd>{{ fmt(status.control.lastRunAt) }}</dd></div>
|
||||
<div class="kv-row"><dt>{{ t('skillCurator.nextScheduledRun') }}</dt><dd>{{ fmt(status.control.nextScheduledRun) }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Run reports -->
|
||||
<div class="settings-card">
|
||||
<h3 class="card-title">{{ t('skillCurator.reports') }}</h3>
|
||||
<p v-if="reports.length === 0" class="empty-note">{{ t('skillCurator.noReports') }}</p>
|
||||
<div v-else class="report-list">
|
||||
<button
|
||||
v-for="rid in reports"
|
||||
:key="rid"
|
||||
class="report-item"
|
||||
:class="{ active: selectedReportId === rid }"
|
||||
@click="openReport(rid)"
|
||||
>{{ rid }}</button>
|
||||
</div>
|
||||
<div v-if="selectedReport" class="report-detail">
|
||||
<div class="report-detail-row">
|
||||
<span class="curator-pill" :class="selectedReport.dryRun ? 'pill-muted' : 'pill-on'">
|
||||
{{ selectedReport.dryRun ? t('skillCurator.reportDryRun') : t('skillCurator.reportApplied') }}
|
||||
</span>
|
||||
<span class="report-meta">{{ t('skillCurator.reportScanned') }}: {{ selectedReport.scanned }}</span>
|
||||
</div>
|
||||
<div class="report-counts">
|
||||
<span>{{ t('skillCurator.reportPlanned') }}: stale {{ selectedReport.planned?.stale ?? 0 }} · archived {{ selectedReport.planned?.archived ?? 0 }} · reactivated {{ selectedReport.planned?.reactivated ?? 0 }}</span>
|
||||
<span>{{ t('skillCurator.reportApplied') }}: stale {{ selectedReport.applied?.stale ?? 0 }} · archived {{ selectedReport.applied?.archived ?? 0 }} · reactivated {{ selectedReport.applied?.reactivated ?? 0 }}</span>
|
||||
</div>
|
||||
<div v-if="(selectedReport.transitions || []).length > 0" class="report-transitions">
|
||||
<div class="report-transitions-head">{{ t('skillCurator.reportTransitions') }}</div>
|
||||
<div v-for="(tr, i) in selectedReport.transitions" :key="i" class="report-transition">
|
||||
<code>{{ tr.name }}</code>
|
||||
<span>{{ tr.from }} → {{ tr.to }}</span>
|
||||
<span class="report-meta">{{ tr.daysIdle }}d</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElIcon } from 'element-plus'
|
||||
import { Loading, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { mcToast } from '@/composables/useMcToast'
|
||||
import { skillApi } from '@/api/index'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface CuratorStatus {
|
||||
config: { enabled: boolean; scope: string; staleAfterDays: number; archiveAfterDays: number; cron: string }
|
||||
control: {
|
||||
activated: boolean; paused: boolean
|
||||
lastObservedAt: string | null; lastDryRunAt: string | null
|
||||
lastRunAt: string | null; nextScheduledRun: string | null
|
||||
}
|
||||
counts: Record<string, number | string>
|
||||
lastReport: { id: string; url: string } | null
|
||||
}
|
||||
|
||||
interface CuratorReport {
|
||||
dryRun: boolean
|
||||
scanned: number
|
||||
planned?: { stale: number; archived: number; reactivated: number }
|
||||
applied?: { stale: number; archived: number; reactivated: number }
|
||||
transitions?: Array<{ name: string; from: string; to: string; daysIdle: number }>
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
const status = ref<CuratorStatus | null>(null)
|
||||
const reports = ref<string[]>([])
|
||||
const selectedReportId = ref<string>('')
|
||||
const selectedReport = ref<CuratorReport | null>(null)
|
||||
|
||||
function fmt(ts: string | null | undefined): string {
|
||||
if (!ts) return '—'
|
||||
const d = new Date(ts)
|
||||
if (Number.isNaN(d.getTime())) return ts
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res: any = await skillApi.curatorStatus()
|
||||
status.value = res.data as CuratorStatus
|
||||
await loadReports()
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || t('skillCurator.loadFailed')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
try {
|
||||
const res: any = await skillApi.curatorReports()
|
||||
reports.value = Array.isArray(res.data) ? res.data : []
|
||||
} catch {
|
||||
reports.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function openReport(runId: string) {
|
||||
selectedReportId.value = runId
|
||||
try {
|
||||
const res: any = await skillApi.curatorReport(runId)
|
||||
selectedReport.value = res.data as CuratorReport
|
||||
} catch (e: any) {
|
||||
selectedReport.value = null
|
||||
mcToast.error(e?.message || t('skillCurator.actionFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function runDryRun() {
|
||||
busy.value = true
|
||||
try {
|
||||
await skillApi.curatorDryRun()
|
||||
mcToast.success(t('skillCurator.dryRunSuccess'))
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
mcToast.error(e?.message || t('skillCurator.actionFailed'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setActivated(activate: boolean) {
|
||||
busy.value = true
|
||||
try {
|
||||
const res: any = await skillApi.curatorActivate(activate)
|
||||
status.value = res.data as CuratorStatus
|
||||
mcToast.success(t(activate ? 'skillCurator.activateSuccess' : 'skillCurator.deactivateSuccess'))
|
||||
} catch (e: any) {
|
||||
mcToast.error(e?.message || t('skillCurator.actionFailed'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setPaused(paused: boolean) {
|
||||
busy.value = true
|
||||
try {
|
||||
const res: any = paused ? await skillApi.curatorPause() : await skillApi.curatorResume()
|
||||
status.value = res.data as CuratorStatus
|
||||
mcToast.success(t(paused ? 'skillCurator.pauseSuccess' : 'skillCurator.resumeSuccess'))
|
||||
} catch (e: any) {
|
||||
mcToast.error(e?.message || t('skillCurator.actionFailed'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-section { width: 100%; }
|
||||
.section-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; }
|
||||
.section-title { margin: 0; font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
|
||||
.section-desc { margin: 0; font-size: 14px; color: var(--mc-text-secondary); }
|
||||
|
||||
.settings-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; padding: 18px; box-shadow: 0 8px 24px rgba(124, 63, 30, 0.04); width: 100%; margin-bottom: 16px; }
|
||||
.card-title { margin: 0 0 14px; font-size: 15px; font-weight: 700; color: var(--mc-text-primary); }
|
||||
|
||||
.state-card { display: flex; align-items: center; gap: 10px; color: var(--mc-text-secondary); }
|
||||
.state-card--error { color: var(--el-color-danger); }
|
||||
|
||||
.state-pills { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.curator-pill { padding: 3px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
||||
.pill-on { color: #1e8e3e; background: rgba(46, 160, 67, 0.14); }
|
||||
.pill-off { color: var(--mc-text-tertiary); background: var(--mc-bg-sunken); }
|
||||
.pill-muted { color: var(--mc-text-secondary); background: var(--mc-bg-sunken); }
|
||||
.pill-warn { color: #b9770e; background: rgba(243, 156, 18, 0.16); }
|
||||
|
||||
.curator-hint { margin: 12px 0 14px; font-size: 13px; color: var(--mc-text-secondary); line-height: 1.5; }
|
||||
.curator-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.btn-secondary { border: 1px solid var(--mc-border); border-radius: 10px; padding: 7px 14px; font-size: 13px; font-weight: 600; cursor: pointer; background: var(--mc-bg-elevated); color: var(--mc-text-primary); transition: all 0.15s; }
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--mc-bg-sunken); }
|
||||
.btn-primary { border: 1px solid var(--mc-primary); border-radius: 10px; padding: 7px 14px; font-size: 13px; font-weight: 600; cursor: pointer; background: var(--mc-primary); color: #fff; transition: all 0.15s; }
|
||||
.btn-primary:hover:not(:disabled) { background: var(--mc-primary-hover); }
|
||||
.btn-secondary:disabled, .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.count-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 12px; }
|
||||
.count-cell { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 14px 8px; background: var(--mc-bg-sunken); border-radius: 12px; }
|
||||
.count-num { font-size: 24px; font-weight: 700; color: var(--mc-text-primary); }
|
||||
.count-stale { color: #b9770e; }
|
||||
.count-label { font-size: 12px; color: var(--mc-text-secondary); }
|
||||
|
||||
.kv-list { margin: 0; display: flex; flex-direction: column; }
|
||||
.kv-row { display: flex; justify-content: space-between; gap: 16px; padding: 9px 0; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.kv-row:last-child { border-bottom: none; }
|
||||
.kv-row dt { font-size: 13px; color: var(--mc-text-secondary); }
|
||||
.kv-row dd { margin: 0; font-size: 13px; color: var(--mc-text-primary); font-weight: 600; }
|
||||
.kv-row code { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; }
|
||||
|
||||
.empty-note { margin: 0; font-size: 13px; color: var(--mc-text-tertiary); }
|
||||
.report-list { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.report-item { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; padding: 5px 10px; border-radius: 8px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; }
|
||||
.report-item:hover { border-color: var(--mc-text-tertiary); }
|
||||
.report-item.active { border-color: var(--mc-primary); color: var(--mc-primary); }
|
||||
|
||||
.report-detail { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--mc-border-light); display: flex; flex-direction: column; gap: 10px; }
|
||||
.report-detail-row { display: flex; align-items: center; gap: 12px; }
|
||||
.report-meta { font-size: 12px; color: var(--mc-text-tertiary); }
|
||||
.report-counts { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--mc-text-secondary); }
|
||||
.report-transitions-head { font-size: 12px; font-weight: 700; color: var(--mc-text-secondary); margin-bottom: 6px; }
|
||||
.report-transition { display: flex; gap: 12px; align-items: center; font-size: 13px; color: var(--mc-text-primary); padding: 4px 0; }
|
||||
.report-transition code { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.kv-row { flex-direction: column; gap: 2px; }
|
||||
}
|
||||
</style>
|
||||
@ -41,10 +41,11 @@
|
||||
<!-- 分类 Tab -->
|
||||
<div class="category-tabs mc-surface-card">
|
||||
<button v-for="tab in categoryTabs" :key="tab.value" class="cat-tab"
|
||||
:class="{ active: query.skillType === tab.value }" @click="onTabChange(tab.value)">
|
||||
<span class="cat-icon">{{ tab.icon }}</span>
|
||||
:class="{ active: isTabActive(tab), 'cat-tab--lifecycle': tab.kind === 'lifecycle' }"
|
||||
@click="onTabChange(tab)">
|
||||
<SkillLineIcon :name="tab.icon" :size="15" class="cat-icon" />
|
||||
{{ tab.label }}
|
||||
<span class="cat-count">{{ getCategoryCount(tab.value) }}</span>
|
||||
<span class="cat-count">{{ getCategoryCount(tab) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -132,6 +133,12 @@
|
||||
</span>
|
||||
<span class="source-label" :class="getSourceClass(skill)">{{ getSourceLabel(skill) }}</span>
|
||||
<span v-if="skill.version" class="skill-version">v{{ skill.version }}</span>
|
||||
<span
|
||||
v-if="!isSkillRowVirtual(skill) && skill.lastActivityAt"
|
||||
class="lifecycle-badge"
|
||||
:class="{ 'lifecycle-badge--stale': skill.lifecycleState === 'stale',
|
||||
'lifecycle-badge--archived': skill.lifecycleState === 'archived' }"
|
||||
><SkillLineIcon name="clock" :size="11" />{{ lastUsedLabel(skill) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="skill-footer" @click.stop>
|
||||
@ -287,6 +294,42 @@
|
||||
<!-- Overview tab — manifest-projected chips (read-only) +
|
||||
DB-only display overrides (editable) + collapsed manifest. -->
|
||||
<div v-if="detailTab === 'overview'" class="detail-section">
|
||||
<!-- Lifecycle: state + last-used + pin / archive / restore. -->
|
||||
<div v-if="!isVirtualSkill" class="detail-block">
|
||||
<div class="detail-block-head">
|
||||
<h4 class="detail-block-title">{{ t('skills.lifecycle.section') }}</h4>
|
||||
</div>
|
||||
<div class="meta-chips">
|
||||
<span class="meta-chip">
|
||||
<span class="meta-chip-label">{{ t('skills.lifecycle.state') }}</span>
|
||||
<span class="lc-pill" :class="'lc-' + (detailSkill.lifecycleState || 'active')">
|
||||
{{ t('skills.lifecycle.states.' + (detailSkill.lifecycleState || 'active')) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="meta-chip">
|
||||
<span class="meta-chip-label">{{ t('skills.lifecycle.lastUsed') }}</span>
|
||||
{{ lastUsedLabel(detailSkill) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="lifecycle-actions">
|
||||
<label class="lifecycle-pin" :title="t('skills.lifecycle.pinHint')">
|
||||
<input type="checkbox" :checked="!!detailSkill.pinned" @change="togglePin(detailSkill)" />
|
||||
<SkillLineIcon name="pin" :size="14" />
|
||||
<span>{{ t('skills.lifecycle.pin') }}</span>
|
||||
</label>
|
||||
<button
|
||||
v-if="detailSkill.lifecycleState === 'archived'"
|
||||
class="lc-btn lc-btn--restore"
|
||||
@click="restoreSkill(detailSkill)"
|
||||
>{{ t('skills.lifecycle.restore') }}</button>
|
||||
<button
|
||||
v-else-if="detailSkill.skillType !== 'builtin'"
|
||||
class="lc-btn lc-btn--archive"
|
||||
@click="archiveSkill(detailSkill)"
|
||||
>{{ t('skills.lifecycle.archive') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manifest-projected fields (chips, read-only).
|
||||
These columns are overwritten by SkillPackageResolver from
|
||||
manifest_json on every resolve, so editing them on the row
|
||||
@ -668,6 +711,7 @@ import McPagination from '@/components/common/McPagination.vue'
|
||||
import MateDrawer from '@/components/common/MateDrawer.vue'
|
||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
|
||||
import SkillLineIcon from '@/components/skill/SkillLineIcon.vue'
|
||||
import { mcConfirm } from '@/components/common/useConfirm'
|
||||
import { useSkillName } from '@/composables/useSkillName'
|
||||
|
||||
@ -689,6 +733,8 @@ const query = reactive({
|
||||
skillType: 'all' as string,
|
||||
statusFilter: '' as string,
|
||||
sort: 'recommended' as string,
|
||||
/** '' = active+stale catalog; 'stale' / 'archived' = lifecycle tabs. */
|
||||
lifecycleState: '' as string,
|
||||
})
|
||||
|
||||
/** Per-skill UI state for the RFC-042 §2.3 findings panel. */
|
||||
@ -892,17 +938,30 @@ watch(detailTab, (tab) => {
|
||||
})
|
||||
|
||||
const categoryTabs = computed(() => [
|
||||
{ label: t('skills.tabs.all'), value: 'all', icon: '🗂️' },
|
||||
{ label: t('skills.tabs.builtin'), value: 'builtin', icon: '🔧' },
|
||||
{ label: t('skills.tabs.mcp'), value: 'mcp', icon: '🔌' },
|
||||
{ label: t('skills.tabs.all'), value: 'all', icon: 'all', kind: 'source' },
|
||||
{ label: t('skills.tabs.builtin'), value: 'builtin', icon: 'builtin', kind: 'source' },
|
||||
{ label: t('skills.tabs.mcp'), value: 'mcp', icon: 'mcp', kind: 'source' },
|
||||
// ACP (Agent Communication Protocol) — auto-bridged from
|
||||
// Settings ▸ ACP Endpoints; one card per enabled endpoint.
|
||||
{ label: t('skills.tabs.acp'), value: 'acp', icon: '🤝' },
|
||||
{ label: t('skills.tabs.dynamic'), value: 'dynamic', icon: '📦' },
|
||||
{ label: t('skills.tabs.acp'), value: 'acp', icon: 'acp', kind: 'source' },
|
||||
{ label: t('skills.tabs.dynamic'), value: 'dynamic', icon: 'dynamic', kind: 'source' },
|
||||
// Lifecycle tabs — orthogonal to skillType; they filter by lifecycle_state.
|
||||
{ label: t('skills.tabs.stale'), value: 'stale', icon: 'clock', kind: 'lifecycle' },
|
||||
{ label: t('skills.tabs.archived'), value: 'archived', icon: 'archive', kind: 'lifecycle' },
|
||||
])
|
||||
|
||||
function getCategoryCount(category: string) {
|
||||
return counts.value[category] ?? 0
|
||||
/** Stale / archived counts, sourced from the curator status endpoint. */
|
||||
const lifecycleCounts = ref<Record<string, number>>({})
|
||||
|
||||
function getCategoryCount(tab: { value: string; kind: string }) {
|
||||
if (tab.kind === 'lifecycle') return lifecycleCounts.value[tab.value] ?? 0
|
||||
return counts.value[tab.value] ?? 0
|
||||
}
|
||||
|
||||
function isTabActive(tab: { value: string; kind: string }) {
|
||||
return tab.kind === 'lifecycle'
|
||||
? query.lifecycleState === tab.value
|
||||
: !query.lifecycleState && query.skillType === tab.value
|
||||
}
|
||||
|
||||
function parseTags(tags: string): string[] {
|
||||
@ -918,9 +977,24 @@ async function loadAll() {
|
||||
// transient "checking" state until runtimeStatusMap populates.
|
||||
loadCounts()
|
||||
loadRuntimeStatus()
|
||||
loadLifecycleCounts()
|
||||
await loadSkills()
|
||||
}
|
||||
|
||||
/** Stale / archived tab counts — best-effort, from the curator status endpoint. */
|
||||
async function loadLifecycleCounts() {
|
||||
try {
|
||||
const res: any = await skillApi.curatorStatus()
|
||||
const c = res.data?.counts || {}
|
||||
lifecycleCounts.value = {
|
||||
stale: Number(c.stale) || 0,
|
||||
archived: Number(c.archived) || 0,
|
||||
}
|
||||
} catch {
|
||||
lifecycleCounts.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Coalesce keyword edits into one server call per 300ms so typing doesn't thrash. */
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
watch(() => query.keyword, () => {
|
||||
@ -931,8 +1005,14 @@ watch(() => query.keyword, () => {
|
||||
}, 300)
|
||||
})
|
||||
|
||||
function onTabChange(tab: string) {
|
||||
query.skillType = tab
|
||||
function onTabChange(tab: { value: string; kind: string }) {
|
||||
if (tab.kind === 'lifecycle') {
|
||||
query.lifecycleState = tab.value
|
||||
query.skillType = 'all'
|
||||
} else {
|
||||
query.lifecycleState = ''
|
||||
query.skillType = tab.value
|
||||
}
|
||||
query.page = 1
|
||||
loadSkills()
|
||||
}
|
||||
@ -959,6 +1039,7 @@ async function loadSkills(allowPageClamp = true) {
|
||||
if (query.statusFilter === 'enabled') params.enabled = true
|
||||
else if (query.statusFilter === 'disabled') params.enabled = false
|
||||
else if (query.statusFilter === 'scan_failed') params.scanStatus = 'FAILED'
|
||||
if (query.lifecycleState) params.lifecycleState = query.lifecycleState
|
||||
|
||||
const res: any = await skillApi.page(params)
|
||||
const data = res.data || {}
|
||||
@ -1237,6 +1318,80 @@ async function toggleSkill(skill: Skill) {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Lifecycle: pin / archive / restore ====================
|
||||
|
||||
/** Human-readable "last used" label from {@code lastActivityAt}. */
|
||||
function lastUsedLabel(skill: Skill): string {
|
||||
const ts = skill.lastActivityAt
|
||||
if (!ts) return t('skills.lifecycle.neverUsed')
|
||||
const then = new Date(ts).getTime()
|
||||
if (Number.isNaN(then)) return t('skills.lifecycle.neverUsed')
|
||||
const days = Math.floor((Date.now() - then) / 86400000)
|
||||
if (days <= 0) return t('skills.lifecycle.today')
|
||||
return t('skills.lifecycle.daysAgo', { n: days })
|
||||
}
|
||||
|
||||
async function togglePin(skill: Skill) {
|
||||
try {
|
||||
const res: any = await skillApi.pin(skill.id, !skill.pinned)
|
||||
if (detailSkill.value && detailSkill.value.id === skill.id) {
|
||||
detailSkill.value = { ...detailSkill.value, ...(res.data || {}) }
|
||||
}
|
||||
await loadSkills()
|
||||
loadLifecycleCounts()
|
||||
} catch (e: any) {
|
||||
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.lifecycle.pinFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveSkill(skill: Skill) {
|
||||
const ok = await mcConfirm({
|
||||
title: t('skills.lifecycle.archiveTitle'),
|
||||
message: t('skills.lifecycle.archiveConfirm', { name: resolveSkillName(skill) }),
|
||||
tone: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
await doArchive(skill, false)
|
||||
}
|
||||
|
||||
/** Archive call with the bound-skill 409 confirm handshake. */
|
||||
async function doArchive(skill: Skill, force: boolean) {
|
||||
try {
|
||||
await skillApi.archive(skill.id, { force })
|
||||
mcToast.success(t('skills.lifecycle.archiveSuccess'))
|
||||
closeDetailDrawer()
|
||||
await loadAll()
|
||||
} catch (e: any) {
|
||||
const resp = e?.response
|
||||
if (!force && resp?.status === 409 && resp?.data?.code === 'BOUND_SKILL_CONFIRM_REQUIRED') {
|
||||
const bound = (resp.data.boundAgents || []) as Array<{ name?: string }>
|
||||
const confirmForce = await mcConfirm({
|
||||
title: t('skills.lifecycle.boundConfirmTitle'),
|
||||
message: t('skills.lifecycle.boundConfirmBody', {
|
||||
name: resolveSkillName(skill),
|
||||
count: bound.length,
|
||||
agents: bound.map(a => a.name).filter(Boolean).join('、'),
|
||||
}),
|
||||
tone: 'danger',
|
||||
})
|
||||
if (confirmForce) await doArchive(skill, true)
|
||||
return
|
||||
}
|
||||
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.lifecycle.archiveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreSkill(skill: Skill) {
|
||||
try {
|
||||
await skillApi.restore(skill.id)
|
||||
mcToast.success(t('skills.lifecycle.restoreSuccess'))
|
||||
closeDetailDrawer()
|
||||
await loadAll()
|
||||
} catch (e: any) {
|
||||
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.lifecycle.restoreFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshRuntime() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
@ -2357,4 +2512,39 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ==================== Lifecycle / curator ==================== */
|
||||
.cat-tab--lifecycle { margin-left: 4px; }
|
||||
.lifecycle-badge {
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
font-size: 11px; color: var(--mc-text-tertiary);
|
||||
padding: 1px 7px; border-radius: 999px; background: var(--mc-bg-sunken);
|
||||
}
|
||||
.lifecycle-badge--stale { color: #b9770e; background: rgba(243, 156, 18, 0.14); }
|
||||
.lifecycle-badge--archived { color: var(--mc-text-tertiary); background: var(--mc-bg-muted); }
|
||||
|
||||
.lc-pill {
|
||||
display: inline-block; padding: 1px 8px; border-radius: 999px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.lc-active { color: #1e8e3e; background: rgba(46, 160, 67, 0.14); }
|
||||
.lc-stale { color: #b9770e; background: rgba(243, 156, 18, 0.16); }
|
||||
.lc-archived { color: var(--mc-text-secondary); background: var(--mc-bg-sunken); }
|
||||
|
||||
.lifecycle-actions {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 10px;
|
||||
}
|
||||
.lifecycle-pin {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; color: var(--mc-text-secondary); cursor: pointer; user-select: none;
|
||||
}
|
||||
.lifecycle-pin input { cursor: pointer; }
|
||||
.lc-btn {
|
||||
padding: 5px 14px; border-radius: 8px; font-size: 13px; font-weight: 600;
|
||||
cursor: pointer; border: 1px solid var(--mc-border); background: var(--mc-bg-muted);
|
||||
color: var(--mc-text-secondary); transition: all 0.15s;
|
||||
}
|
||||
.lc-btn:hover { border-color: var(--mc-text-tertiary); }
|
||||
.lc-btn--archive:hover { color: #d14343; border-color: #d14343; }
|
||||
.lc-btn--restore { color: var(--mc-primary); border-color: var(--mc-primary); }
|
||||
.lc-btn--restore:hover { background: var(--mc-primary); color: #fff; }
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user