mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui): per-skill secrets panel for env-var-style credentials
This commit is contained in:
parent
aded30bbb1
commit
ed788e9e42
@ -203,6 +203,24 @@ export const skillApi = {
|
||||
getLessons: (id: string | number) => http.get(`/skills/${id}/lessons`),
|
||||
clearLessons: (id: string | number) => http.post(`/skills/${id}/lessons/clear`),
|
||||
employees: (id: string | number) => http.get(`/skills/${id}/employees`),
|
||||
/**
|
||||
* Per-skill secrets — env-var-shaped key/value pairs that get injected
|
||||
* into the script subprocess at runtime. Plaintext values never leave
|
||||
* the server; list returns masked previews only.
|
||||
*/
|
||||
listSecrets: (id: string | number) => http.get(`/skills/${id}/secrets`),
|
||||
putSecret: (id: string | number, key: string, value: string) =>
|
||||
http.post(`/skills/${id}/secrets`, { key, value }),
|
||||
deleteSecret: (id: string | number, key: string) =>
|
||||
http.delete(`/skills/${id}/secrets/${encodeURIComponent(key)}`),
|
||||
}
|
||||
|
||||
/** Shape returned by GET /skills/{id}/secrets. */
|
||||
export interface SkillSecretSummary {
|
||||
key: string
|
||||
/** Masked preview, e.g. "abc...xyz". Plaintext is never shipped. */
|
||||
preview: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// ==================== Activity Feed (RFC-090 §4.5) ====================
|
||||
|
||||
433
mateclaw-ui/src/components/skill/SkillSecretsPanel.vue
Normal file
433
mateclaw-ui/src/components/skill/SkillSecretsPanel.vue
Normal file
@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<div class="skill-secrets">
|
||||
<p class="detail-hint">{{ t('skills.detail.secretsHint') }}</p>
|
||||
|
||||
<div v-if="loading" class="detail-empty">{{ t('common.loading') }}</div>
|
||||
|
||||
<table v-else-if="rows.length > 0" class="secrets-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="secrets-table__key">{{ t('skills.detail.secretKey') }}</th>
|
||||
<th class="secrets-table__preview">{{ t('skills.detail.secretValue') }}</th>
|
||||
<th class="secrets-table__updated">{{ t('skills.detail.secretUpdatedAt') }}</th>
|
||||
<th class="secrets-table__actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.key">
|
||||
<td class="secrets-table__key"><code>{{ row.key }}</code></td>
|
||||
<td class="secrets-table__preview">{{ row.preview }}</td>
|
||||
<td class="secrets-table__updated">{{ formatTime(row.updatedAt) }}</td>
|
||||
<td class="secrets-table__actions">
|
||||
<button class="secrets-row-btn" @click="openEdit(row.key)">
|
||||
{{ t('skills.detail.secretEdit') }}
|
||||
</button>
|
||||
<button class="secrets-row-btn secrets-row-btn--danger" @click="removeSecret(row.key)">
|
||||
{{ t('skills.detail.secretDelete') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p v-else class="detail-empty">{{ t('skills.detail.secretsEmpty') }}</p>
|
||||
|
||||
<div class="secrets-footer">
|
||||
<button class="secret-btn secret-btn-primary" @click="openAdd">
|
||||
{{ t('skills.detail.secretAdd') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add / edit dialog. Plaintext is required to overwrite — the backend's
|
||||
POST endpoint treats empty value as a delete, so we surface that
|
||||
clearly rather than silently swapping verbs. -->
|
||||
<Teleport to="body">
|
||||
<div v-if="dialogVisible" class="secret-modal-overlay" @click.self="closeDialog">
|
||||
<div class="secret-modal">
|
||||
<div class="secret-modal-header">
|
||||
<h2>
|
||||
{{ dialogMode === 'add'
|
||||
? t('skills.detail.secretAddTitle')
|
||||
: t('skills.detail.secretEditTitle') }}
|
||||
</h2>
|
||||
<button class="secret-modal-close" @click="closeDialog">
|
||||
<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="secret-modal-body">
|
||||
<p class="secret-modal-hint">{{ t('skills.detail.secretDialogHint') }}</p>
|
||||
<div class="secret-form-group">
|
||||
<label class="secret-form-label">{{ t('skills.detail.secretKey') }} *</label>
|
||||
<input
|
||||
v-model="form.key"
|
||||
class="secret-form-input"
|
||||
:disabled="dialogMode === 'edit'"
|
||||
:placeholder="t('skills.detail.secretKeyPlaceholder')"
|
||||
@keydown.enter="save"
|
||||
/>
|
||||
<p class="secret-form-hint">{{ t('skills.detail.secretKeyRule') }}</p>
|
||||
</div>
|
||||
<div class="secret-form-group">
|
||||
<label class="secret-form-label">{{ t('skills.detail.secretValue') }} *</label>
|
||||
<input
|
||||
v-model="form.value"
|
||||
class="secret-form-input"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:placeholder="t('skills.detail.secretValuePlaceholder')"
|
||||
@keydown.enter="save"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="secret-modal-footer">
|
||||
<button class="secret-btn secret-btn-secondary" @click="closeDialog">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="secret-btn secret-btn-primary"
|
||||
:disabled="!canSave || saving"
|
||||
@click="save"
|
||||
>
|
||||
{{ saving ? t('common.loading') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { skillApi, type SkillSecretSummary } from '@/api'
|
||||
|
||||
const props = defineProps<{
|
||||
skillId: number | string | null
|
||||
/** Bumped by the parent when the panel becomes visible so we re-fetch. */
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const rows = ref<SkillSecretSummary[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'add' | 'edit'>('add')
|
||||
const form = ref({ key: '', value: '' })
|
||||
const saving = ref(false)
|
||||
|
||||
// POSIX-ish env-var key shape — same regex the backend's KEY_PATTERN
|
||||
// enforces. We pre-validate client-side so the user sees the rule instead
|
||||
// of a generic 4xx from the server.
|
||||
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/
|
||||
|
||||
const canSave = computed(() =>
|
||||
form.value.value.length > 0 && KEY_RE.test(form.value.key.trim()))
|
||||
|
||||
async function load() {
|
||||
if (props.skillId == null) {
|
||||
rows.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await skillApi.listSecrets(props.skillId)
|
||||
rows.value = (res?.data ?? []) as SkillSecretSummary[]
|
||||
} catch (e: any) {
|
||||
rows.value = []
|
||||
ElMessage.error(typeof e === 'string' ? e : e?.message || t('skills.detail.secretLoadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.skillId, props.visible], () => {
|
||||
if (props.visible && props.skillId != null) load()
|
||||
}, { immediate: true })
|
||||
|
||||
function openAdd() {
|
||||
dialogMode.value = 'add'
|
||||
form.value = { key: '', value: '' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(key: string) {
|
||||
dialogMode.value = 'edit'
|
||||
// Plaintext is never returned from the server, so the value field starts
|
||||
// empty — saving overwrites whatever was there. Surface this in the hint.
|
||||
form.value = { key, value: '' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value || props.skillId == null) return
|
||||
saving.value = true
|
||||
try {
|
||||
await skillApi.putSecret(props.skillId, form.value.key.trim(), form.value.value)
|
||||
ElMessage.success(t('skills.detail.secretSaveSuccess'))
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(typeof e === 'string' ? e : e?.message || t('skills.detail.secretSaveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSecret(key: string) {
|
||||
if (props.skillId == null) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('skills.detail.secretDeleteConfirm', { key }),
|
||||
t('common.confirm'),
|
||||
{ type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return // user cancelled
|
||||
}
|
||||
try {
|
||||
await skillApi.deleteSecret(props.skillId, key)
|
||||
ElMessage.success(t('skills.detail.secretDeleteSuccess'))
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(typeof e === 'string' ? e : e?.message || t('skills.detail.secretDeleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(s?: string): string {
|
||||
if (!s) return '—'
|
||||
// Backend ships LocalDateTime as ISO-ish "2026-05-12T16:23:33". The Date
|
||||
// ctor handles that, and the user's locale formatting is good enough for
|
||||
// a "last updated" column.
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return s
|
||||
return d.toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Panel layout */
|
||||
.skill-secrets { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
/* Reuse the drawer's .detail-hint sizing locally so the panel doesn't
|
||||
depend on parent-scoped styles leaking in (Vue scoped CSS doesn't
|
||||
cross component boundaries). */
|
||||
.detail-hint {
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
.detail-empty {
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-tertiary);
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Secrets table */
|
||||
.secrets-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
background: var(--mc-bg);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.secrets-table th,
|
||||
.secrets-table td {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.secrets-table tr:last-child td { border-bottom: none; }
|
||||
.secrets-table th {
|
||||
font-weight: 600;
|
||||
color: var(--mc-text-secondary);
|
||||
background: var(--mc-bg-sunken);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.secrets-table__key { width: 36%; }
|
||||
.secrets-table__preview {
|
||||
width: 26%;
|
||||
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
.secrets-table__updated {
|
||||
width: 22%;
|
||||
color: var(--mc-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.secrets-table__actions {
|
||||
width: 16%;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
.secrets-table code {
|
||||
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: var(--mc-bg-sunken);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
|
||||
/* Per-row action buttons (compact, secondary) */
|
||||
.secrets-row-btn {
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border);
|
||||
color: var(--mc-text-primary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-left: 6px;
|
||||
transition: background 120ms, border-color 120ms;
|
||||
}
|
||||
.secrets-row-btn:hover { background: var(--mc-bg-sunken); }
|
||||
.secrets-row-btn--danger {
|
||||
color: var(--el-color-danger);
|
||||
border-color: var(--el-color-danger-light-5);
|
||||
}
|
||||
.secrets-row-btn--danger:hover {
|
||||
background: var(--el-color-danger-light-9);
|
||||
border-color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.secrets-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Modal — local styles so the panel stays self-contained.
|
||||
Mirrors the look of ImportHubDialog.vue / SkillMarket.vue's modal,
|
||||
but namespaced (.secret-*) so we don't compete with global styles. */
|
||||
.secret-modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 20px;
|
||||
}
|
||||
.secret-modal {
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 85vh;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.18);
|
||||
}
|
||||
.secret-modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.secret-modal-header h2 {
|
||||
font-size: 17px; font-weight: 600;
|
||||
color: var(--mc-text-primary); margin: 0;
|
||||
}
|
||||
.secret-modal-close {
|
||||
width: 32px; height: 32px;
|
||||
border: none; background: none; cursor: pointer;
|
||||
color: var(--mc-text-tertiary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.secret-modal-close:hover {
|
||||
background: var(--mc-bg-sunken);
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
.secret-modal-body {
|
||||
padding: 18px 22px;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.secret-modal-hint {
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.secret-modal-footer {
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
padding: 14px 22px;
|
||||
border-top: 1px solid var(--mc-border-light);
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.secret-form-group {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.secret-form-label {
|
||||
font-size: 13px; font-weight: 500;
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
.secret-form-input {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--mc-text-primary);
|
||||
background: var(--mc-bg-sunken);
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.secret-form-input:focus {
|
||||
border-color: var(--mc-primary);
|
||||
box-shadow: 0 0 0 2px rgba(217,119,87,0.15);
|
||||
}
|
||||
.secret-form-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
background: var(--mc-bg);
|
||||
}
|
||||
.secret-form-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.secret-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
}
|
||||
.secret-btn-primary {
|
||||
background: var(--mc-primary);
|
||||
color: white;
|
||||
}
|
||||
.secret-btn-primary:hover { background: var(--mc-primary-hover); }
|
||||
.secret-btn-primary:disabled {
|
||||
background: var(--mc-border);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.secret-btn-secondary {
|
||||
background: var(--mc-bg-elevated);
|
||||
color: var(--mc-text-primary);
|
||||
border-color: var(--mc-border);
|
||||
}
|
||||
.secret-btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||
</style>
|
||||
@ -2967,6 +2967,7 @@ export default {
|
||||
features: 'Features',
|
||||
security: 'Security',
|
||||
lessons: 'Lessons',
|
||||
secrets: 'Secrets',
|
||||
memory: 'Memory',
|
||||
scanStatus: 'Scan status',
|
||||
missingDeps: 'Missing dependencies',
|
||||
@ -3000,6 +3001,26 @@ export default {
|
||||
bindingImplicit: 'Implicit',
|
||||
clearLessons: 'Clear all',
|
||||
clearLessonsConfirm: 'Delete all recorded lessons for this skill? This cannot be undone.',
|
||||
secretsHint: 'Key/value pairs injected as environment variables into this skill\'s script subprocess. Stored AES-encrypted; plaintext is never returned over the API. Example: tencent-meeting needs TENCENT_MEETING_TOKEN.',
|
||||
secretsEmpty: 'No secrets configured for this skill.',
|
||||
secretKey: 'Key',
|
||||
secretValue: 'Value',
|
||||
secretUpdatedAt: 'Last updated',
|
||||
secretAdd: '+ Add secret',
|
||||
secretAddTitle: 'Add secret',
|
||||
secretEditTitle: 'Edit secret',
|
||||
secretEdit: 'Edit',
|
||||
secretDelete: 'Delete',
|
||||
secretDialogHint: 'Saved immediately and injected into the script subprocess on next skill execution.',
|
||||
secretKeyPlaceholder: 'e.g. TENCENT_MEETING_TOKEN',
|
||||
secretKeyRule: 'Letters, digits and underscore only; first char must be a letter or underscore; ≤128 chars.',
|
||||
secretValuePlaceholder: 'Paste the value (AES-encrypted server-side on submit)',
|
||||
secretSaveSuccess: 'Secret saved',
|
||||
secretSaveFailed: 'Failed to save',
|
||||
secretDeleteConfirm: 'Delete secret "{key}"? This skill\'s scripts will no longer receive this environment variable.',
|
||||
secretDeleteSuccess: 'Secret deleted',
|
||||
secretDeleteFailed: 'Failed to delete',
|
||||
secretLoadFailed: 'Failed to load secrets',
|
||||
},
|
||||
runtime: {
|
||||
disabled: 'Disabled',
|
||||
|
||||
@ -3059,6 +3059,7 @@ export default {
|
||||
features: '特性',
|
||||
security: '安全',
|
||||
lessons: '经验',
|
||||
secrets: '密钥',
|
||||
memory: '记忆',
|
||||
scanStatus: '扫描状态',
|
||||
missingDeps: '缺失依赖',
|
||||
@ -3092,6 +3093,26 @@ export default {
|
||||
bindingImplicit: '隐式可见',
|
||||
clearLessons: '全部清空',
|
||||
clearLessonsConfirm: '删除该 skill 的全部经验?操作不可撤销。',
|
||||
secretsHint: '这些键值会作为环境变量注入 skill 脚本子进程。明文加密落盘,从不通过接口回传。例如腾讯会议技能需要 TENCENT_MEETING_TOKEN。',
|
||||
secretsEmpty: '该 skill 还没有配置任何密钥。',
|
||||
secretKey: '键名',
|
||||
secretValue: '值',
|
||||
secretUpdatedAt: '最近更新',
|
||||
secretAdd: '+ 新增密钥',
|
||||
secretAddTitle: '新增密钥',
|
||||
secretEditTitle: '修改密钥',
|
||||
secretEdit: '修改',
|
||||
secretDelete: '删除',
|
||||
secretDialogHint: '保存后立即生效,下次执行 skill 脚本时注入到子进程环境。',
|
||||
secretKeyPlaceholder: '例如 TENCENT_MEETING_TOKEN',
|
||||
secretKeyRule: '允许字母、数字与下划线;首字符不能是数字;长度 ≤ 128。',
|
||||
secretValuePlaceholder: '粘贴值(提交后服务端 AES 加密保存)',
|
||||
secretSaveSuccess: '密钥已保存',
|
||||
secretSaveFailed: '保存失败',
|
||||
secretDeleteConfirm: '删除密钥 "{key}"?此 skill 后续脚本将不再收到此环境变量。',
|
||||
secretDeleteSuccess: '密钥已删除',
|
||||
secretDeleteFailed: '删除失败',
|
||||
secretLoadFailed: '加载密钥列表失败',
|
||||
},
|
||||
runtime: {
|
||||
disabled: '已停用',
|
||||
|
||||
@ -291,6 +291,9 @@
|
||||
<button class="detail-tab" :class="{ active: detailTab === 'lessons' }" @click="detailTab = 'lessons'">
|
||||
{{ t('skills.detail.lessons') }}
|
||||
</button>
|
||||
<button class="detail-tab" :class="{ active: detailTab === 'secrets' }" @click="detailTab = 'secrets'">
|
||||
{{ t('skills.detail.secrets') }}
|
||||
</button>
|
||||
<button class="detail-tab" :class="{ active: detailTab === 'memory' }" @click="detailTab = 'memory'">
|
||||
{{ t('skills.detail.memory') }}
|
||||
<span v-if="detailEmployees.length > 0" class="tab-count">{{ detailEmployees.length }}</span>
|
||||
@ -581,6 +584,16 @@
|
||||
<p v-else-if="!detailLessonsRaw" class="detail-empty">{{ t('skills.detail.noLessons') }}</p>
|
||||
<pre v-else class="detail-pre">{{ detailLessonsRaw }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Per-skill secrets — env-var key/value table.
|
||||
Plaintext never leaves the server; the panel shows masked
|
||||
previews and routes write/delete through the SkillSecretController. -->
|
||||
<div v-if="detailTab === 'secrets'" class="detail-section">
|
||||
<SkillSecretsPanel
|
||||
:skill-id="detailSkill?.id ?? null"
|
||||
:visible="detailTab === 'secrets'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -668,6 +681,7 @@ 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 SkillSecretsPanel from '@/components/skill/SkillSecretsPanel.vue'
|
||||
import McPagination from '@/components/common/McPagination.vue'
|
||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
|
||||
@ -705,7 +719,7 @@ const rescanning = ref<Record<string, boolean>>({})
|
||||
* tab for back-compat with any deep links that may pass it. */
|
||||
const detailDrawerVisible = ref(false)
|
||||
const detailSkill = ref<Skill | null>(null)
|
||||
const detailTab = ref<'overview' | 'body' | 'manifest' | 'tools' | 'features' | 'security' | 'lessons' | 'memory'>('overview')
|
||||
const detailTab = ref<'overview' | 'body' | 'manifest' | 'tools' | 'features' | 'security' | 'lessons' | 'secrets' | 'memory'>('overview')
|
||||
const detailLessonsRaw = ref<string>('')
|
||||
const detailLessonsLoading = ref(false)
|
||||
const detailEmployees = ref<Array<{ id: number; name: string; icon?: string; binding?: 'explicit' | 'implicit' }>>([])
|
||||
@ -821,7 +835,7 @@ const detailFeaturesCount = computed(() => detailFeatures.value.length)
|
||||
|
||||
function openDetailDrawer(
|
||||
skill: Skill,
|
||||
tab: 'overview' | 'body' | 'tools' | 'features' | 'security' | 'lessons' | 'memory' = 'overview',
|
||||
tab: 'overview' | 'body' | 'tools' | 'features' | 'security' | 'lessons' | 'secrets' | 'memory' = 'overview',
|
||||
opts: { editIdentity?: boolean; editBody?: boolean } = {},
|
||||
) {
|
||||
detailSkill.value = skill
|
||||
|
||||
Loading…
Reference in New Issue
Block a user