feat(ui): per-skill secrets panel for env-var-style credentials

This commit is contained in:
matevip 2026-05-12 17:20:30 +08:00
parent aded30bbb1
commit ed788e9e42
5 changed files with 509 additions and 2 deletions

View File

@ -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) ====================

View 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>

View File

@ -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',

View File

@ -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: '已停用',

View File

@ -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