mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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`),
|
getLessons: (id: string | number) => http.get(`/skills/${id}/lessons`),
|
||||||
clearLessons: (id: string | number) => http.post(`/skills/${id}/lessons/clear`),
|
clearLessons: (id: string | number) => http.post(`/skills/${id}/lessons/clear`),
|
||||||
employees: (id: string | number) => http.get(`/skills/${id}/employees`),
|
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) ====================
|
// ==================== 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',
|
features: 'Features',
|
||||||
security: 'Security',
|
security: 'Security',
|
||||||
lessons: 'Lessons',
|
lessons: 'Lessons',
|
||||||
|
secrets: 'Secrets',
|
||||||
memory: 'Memory',
|
memory: 'Memory',
|
||||||
scanStatus: 'Scan status',
|
scanStatus: 'Scan status',
|
||||||
missingDeps: 'Missing dependencies',
|
missingDeps: 'Missing dependencies',
|
||||||
@ -3000,6 +3001,26 @@ export default {
|
|||||||
bindingImplicit: 'Implicit',
|
bindingImplicit: 'Implicit',
|
||||||
clearLessons: 'Clear all',
|
clearLessons: 'Clear all',
|
||||||
clearLessonsConfirm: 'Delete all recorded lessons for this skill? This cannot be undone.',
|
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: {
|
runtime: {
|
||||||
disabled: 'Disabled',
|
disabled: 'Disabled',
|
||||||
|
|||||||
@ -3059,6 +3059,7 @@ export default {
|
|||||||
features: '特性',
|
features: '特性',
|
||||||
security: '安全',
|
security: '安全',
|
||||||
lessons: '经验',
|
lessons: '经验',
|
||||||
|
secrets: '密钥',
|
||||||
memory: '记忆',
|
memory: '记忆',
|
||||||
scanStatus: '扫描状态',
|
scanStatus: '扫描状态',
|
||||||
missingDeps: '缺失依赖',
|
missingDeps: '缺失依赖',
|
||||||
@ -3092,6 +3093,26 @@ export default {
|
|||||||
bindingImplicit: '隐式可见',
|
bindingImplicit: '隐式可见',
|
||||||
clearLessons: '全部清空',
|
clearLessons: '全部清空',
|
||||||
clearLessonsConfirm: '删除该 skill 的全部经验?操作不可撤销。',
|
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: {
|
runtime: {
|
||||||
disabled: '已停用',
|
disabled: '已停用',
|
||||||
|
|||||||
@ -291,6 +291,9 @@
|
|||||||
<button class="detail-tab" :class="{ active: detailTab === 'lessons' }" @click="detailTab = 'lessons'">
|
<button class="detail-tab" :class="{ active: detailTab === 'lessons' }" @click="detailTab = 'lessons'">
|
||||||
{{ t('skills.detail.lessons') }}
|
{{ t('skills.detail.lessons') }}
|
||||||
</button>
|
</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'">
|
<button class="detail-tab" :class="{ active: detailTab === 'memory' }" @click="detailTab = 'memory'">
|
||||||
{{ t('skills.detail.memory') }}
|
{{ t('skills.detail.memory') }}
|
||||||
<span v-if="detailEmployees.length > 0" class="tab-count">{{ detailEmployees.length }}</span>
|
<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>
|
<p v-else-if="!detailLessonsRaw" class="detail-empty">{{ t('skills.detail.noLessons') }}</p>
|
||||||
<pre v-else class="detail-pre">{{ detailLessonsRaw }}</pre>
|
<pre v-else class="detail-pre">{{ detailLessonsRaw }}</pre>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -668,6 +681,7 @@ import { skillApi, skillInstallApi } from '@/api/index'
|
|||||||
import type { Skill, SkillRuntimeStatus, SkillSecurityFinding } from '@/types/index'
|
import type { Skill, SkillRuntimeStatus, SkillSecurityFinding } from '@/types/index'
|
||||||
import ImportHubDialog from '@/components/skill/ImportHubDialog.vue'
|
import ImportHubDialog from '@/components/skill/ImportHubDialog.vue'
|
||||||
import PreflightInstallDialog from '@/components/skill/PreflightInstallDialog.vue'
|
import PreflightInstallDialog from '@/components/skill/PreflightInstallDialog.vue'
|
||||||
|
import SkillSecretsPanel from '@/components/skill/SkillSecretsPanel.vue'
|
||||||
import McPagination from '@/components/common/McPagination.vue'
|
import McPagination from '@/components/common/McPagination.vue'
|
||||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||||
import SkillIconPicker from '@/components/common/SkillIconPicker.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. */
|
* tab for back-compat with any deep links that may pass it. */
|
||||||
const detailDrawerVisible = ref(false)
|
const detailDrawerVisible = ref(false)
|
||||||
const detailSkill = ref<Skill | null>(null)
|
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 detailLessonsRaw = ref<string>('')
|
||||||
const detailLessonsLoading = ref(false)
|
const detailLessonsLoading = ref(false)
|
||||||
const detailEmployees = ref<Array<{ id: number; name: string; icon?: string; binding?: 'explicit' | 'implicit' }>>([])
|
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(
|
function openDetailDrawer(
|
||||||
skill: Skill,
|
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 } = {},
|
opts: { editIdentity?: boolean; editBody?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
detailSkill.value = skill
|
detailSkill.value = skill
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user