812834bc9f
* feat(skills): add batch upload functionality for multiple skill ZIP files - Implemented a new endpoint for batch uploading skills. - Enhanced the SkillsSection component to support multiple file selection and drag-and-drop functionality. - Updated localization files for new upload features and messages. - Added tests to validate batch upload behavior and error handling. * feat(skills): improve batch upload handling and enhance accessibility for dropzone * feat(skills): enhance batch upload process and improve UI for better user experience * feat(skills): enhance skills upload dialog layout and styling for improved usability * feat(skills): update upload dialog description styling for better visibility and usability * feat(skills): improve upload dialog button styling and layout for enhanced usability * feat(skills): refine upload dialog text for clarity and consistency * feat(skills): enhance batch upload functionality by ignoring __MACOSX entries and improving upload dialog styling * feat(skills): refactor upload dialog and button styles for improved consistency and usability --------- Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
1806 lines
51 KiB
Vue
1806 lines
51 KiB
Vue
<template>
|
|
<div class="skills-page">
|
|
<v-container fluid class="pa-0" elevation="0">
|
|
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-4">
|
|
<div>
|
|
<v-btn
|
|
v-if="mode === 'local'"
|
|
color="primary"
|
|
prepend-icon="mdi-upload"
|
|
class="me-2"
|
|
variant="tonal"
|
|
@click="openUploadDialog"
|
|
>
|
|
{{ tm("skills.upload") }}
|
|
</v-btn>
|
|
<v-btn
|
|
color="primary"
|
|
prepend-icon="mdi-refresh"
|
|
variant="tonal"
|
|
@click="refreshCurrentMode"
|
|
>
|
|
{{ tm("skills.refresh") }}
|
|
</v-btn>
|
|
</div>
|
|
<v-btn-toggle v-model="mode" mandatory divided density="comfortable">
|
|
<v-btn value="local">{{ tm("skills.modeLocal") }}</v-btn>
|
|
<v-btn value="neo" :disabled="!neoEnabled">{{
|
|
tm("skills.modeNeo")
|
|
}}</v-btn>
|
|
</v-btn-toggle>
|
|
</v-row>
|
|
|
|
<div v-if="mode === 'local'" class="px-2 pb-2 d-flex flex-column ga-2">
|
|
<small style="color: grey">{{ tm("skills.runtimeHint") }}</small>
|
|
<v-alert
|
|
v-if="runtime === 'sandbox' && !sandboxCache.ready"
|
|
type="info"
|
|
variant="tonal"
|
|
density="comfortable"
|
|
border="start"
|
|
>
|
|
{{ tm("skills.sandboxDiscoveryPending") }}
|
|
</v-alert>
|
|
</div>
|
|
|
|
<div v-if="mode === 'neo' && !neoEnabled" class="px-3 pb-3">
|
|
<v-alert
|
|
type="warning"
|
|
variant="tonal"
|
|
density="comfortable"
|
|
border="start"
|
|
>
|
|
{{ neoUnavailableMessage }}
|
|
</v-alert>
|
|
</div>
|
|
|
|
<template v-if="mode === 'local'">
|
|
<v-progress-linear
|
|
v-if="loading"
|
|
indeterminate
|
|
color="primary"
|
|
></v-progress-linear>
|
|
|
|
<div v-else-if="skills.length === 0" class="text-center pa-8">
|
|
<v-icon size="64" color="grey-lighten-1">mdi-folder-open</v-icon>
|
|
<p class="text-grey mt-4">{{ tm("skills.empty") }}</p>
|
|
<small class="text-grey">{{ tm("skills.emptyHint") }}</small>
|
|
</div>
|
|
|
|
<v-row v-else align="stretch">
|
|
<v-col
|
|
v-for="skill in skills"
|
|
:key="skill.name"
|
|
cols="12"
|
|
md="6"
|
|
lg="4"
|
|
xl="3"
|
|
class="d-flex"
|
|
>
|
|
<item-card
|
|
:item="skill"
|
|
title-field="name"
|
|
enabled-field="active"
|
|
:loading="itemLoading[skill.name] || false"
|
|
:show-edit-button="false"
|
|
:disable-toggle="isSandboxPresetSkill(skill)"
|
|
:disable-delete="isSandboxPresetSkill(skill)"
|
|
@toggle-enabled="toggleSkill"
|
|
@delete="confirmDelete"
|
|
>
|
|
<template #item-details="{ item }">
|
|
<div class="d-flex align-center mb-2 ga-2 flex-wrap">
|
|
<v-chip
|
|
size="x-small"
|
|
variant="tonal"
|
|
:color="sourceTypeColor(item.source_type)"
|
|
>
|
|
{{ sourceTypeLabel(item.source_type) }}
|
|
</v-chip>
|
|
<div
|
|
class="text-caption text-medium-emphasis skill-description"
|
|
>
|
|
<v-icon size="small" class="me-1">mdi-text</v-icon>
|
|
{{ item.description || tm("skills.noDescription") }}
|
|
</div>
|
|
</div>
|
|
<div class="text-caption text-medium-emphasis skill-path">
|
|
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
|
|
{{ tm("skills.path") }}: {{ item.path }}
|
|
</div>
|
|
</template>
|
|
<template #actions="{ item }">
|
|
<v-btn
|
|
variant="tonal"
|
|
color="primary"
|
|
size="small"
|
|
rounded="xl"
|
|
:disabled="
|
|
itemLoading[item.name] ||
|
|
false ||
|
|
isSandboxPresetSkill(item)
|
|
"
|
|
@click="downloadSkill(item)"
|
|
>
|
|
{{ tm("skills.download") }}
|
|
</v-btn>
|
|
</template>
|
|
</item-card>
|
|
</v-col>
|
|
</v-row>
|
|
</template>
|
|
|
|
<template v-else-if="mode === 'neo' && neoEnabled">
|
|
<v-card class="mx-3 mb-4 pa-4 neo-filter-card" variant="outlined">
|
|
<div
|
|
class="d-flex flex-wrap justify-space-between align-center ga-2 mb-3"
|
|
>
|
|
<div>
|
|
<div class="text-subtitle-1 font-weight-bold">Neo Skills</div>
|
|
<div class="text-caption text-medium-emphasis">
|
|
{{ tm("skills.neoFilterHint") }}
|
|
</div>
|
|
</div>
|
|
<v-btn
|
|
color="primary"
|
|
prepend-icon="mdi-refresh"
|
|
variant="flat"
|
|
@click="fetchNeoData"
|
|
>
|
|
{{ tm("skills.refresh") }}
|
|
</v-btn>
|
|
</div>
|
|
|
|
<v-row class="ga-md-0 ga-2">
|
|
<v-col cols="12" md="4">
|
|
<v-text-field
|
|
v-model="neoFilters.skill_key"
|
|
:label="tm('skills.neoSkillKey')"
|
|
prepend-inner-icon="mdi-key-outline"
|
|
density="comfortable"
|
|
hide-details
|
|
variant="outlined"
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="4">
|
|
<v-select
|
|
v-model="neoFilters.status"
|
|
:label="tm('skills.neoStatus')"
|
|
:items="candidateStatusItems"
|
|
item-title="title"
|
|
item-value="value"
|
|
prepend-inner-icon="mdi-progress-check"
|
|
density="comfortable"
|
|
hide-details
|
|
variant="outlined"
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="4">
|
|
<v-select
|
|
v-model="neoFilters.stage"
|
|
:label="tm('skills.neoStage')"
|
|
:items="releaseStageItems"
|
|
item-title="title"
|
|
item-value="value"
|
|
prepend-inner-icon="mdi-layers-outline"
|
|
density="comfortable"
|
|
hide-details
|
|
variant="outlined"
|
|
/>
|
|
</v-col>
|
|
</v-row>
|
|
</v-card>
|
|
|
|
<v-progress-linear
|
|
v-if="neoLoading"
|
|
indeterminate
|
|
color="primary"
|
|
></v-progress-linear>
|
|
|
|
<div class="mx-3 mb-3 d-flex flex-wrap ga-2">
|
|
<v-chip size="small" color="primary" variant="tonal"
|
|
>Candidates: {{ neoCandidates.length }}</v-chip
|
|
>
|
|
<v-chip size="small" color="indigo" variant="tonal"
|
|
>Releases: {{ neoReleases.length }}</v-chip
|
|
>
|
|
<v-chip size="small" color="success" variant="tonal"
|
|
>Active: {{ activeReleaseCount }}</v-chip
|
|
>
|
|
</div>
|
|
|
|
<v-card class="mx-3 mb-4 neo-table-card" variant="outlined">
|
|
<v-card-title class="text-subtitle-1 font-weight-bold">{{
|
|
tm("skills.neoCandidates")
|
|
}}</v-card-title>
|
|
<v-data-table
|
|
:headers="candidateHeaders"
|
|
:items="neoCandidates"
|
|
density="compact"
|
|
:items-per-page="10"
|
|
class="neo-data-table"
|
|
>
|
|
<template #item.latest_score="{ item }">
|
|
{{ item.latest_score ?? "-" }}
|
|
</template>
|
|
<template #item.actions="{ item }">
|
|
<div class="d-flex ga-1 flex-wrap">
|
|
<v-btn
|
|
size="x-small"
|
|
color="success"
|
|
variant="tonal"
|
|
@click="evaluateCandidate(item, true)"
|
|
>
|
|
{{ tm("skills.neoPass") }}
|
|
</v-btn>
|
|
<v-btn
|
|
size="x-small"
|
|
color="warning"
|
|
variant="tonal"
|
|
@click="evaluateCandidate(item, false)"
|
|
>
|
|
{{ tm("skills.neoReject") }}
|
|
</v-btn>
|
|
<v-btn
|
|
size="x-small"
|
|
color="primary"
|
|
variant="tonal"
|
|
:loading="isCandidatePromoteLoading(item.id, 'canary')"
|
|
:disabled="isCandidatePromoting(item.id)"
|
|
@click="promoteCandidate(item, 'canary')"
|
|
>
|
|
Canary
|
|
</v-btn>
|
|
<v-btn
|
|
size="x-small"
|
|
color="primary"
|
|
variant="tonal"
|
|
:loading="isCandidatePromoteLoading(item.id, 'stable')"
|
|
:disabled="isCandidatePromoting(item.id)"
|
|
@click="promoteCandidate(item, 'stable')"
|
|
>
|
|
Stable
|
|
</v-btn>
|
|
<v-btn
|
|
size="x-small"
|
|
variant="tonal"
|
|
:disabled="!item.payload_ref"
|
|
@click="viewPayload(item.payload_ref)"
|
|
>
|
|
Payload
|
|
</v-btn>
|
|
<v-btn
|
|
size="x-small"
|
|
color="error"
|
|
variant="tonal"
|
|
@click="deleteCandidate(item)"
|
|
>
|
|
{{ tm("skills.neoDelete") }}
|
|
</v-btn>
|
|
</div>
|
|
</template>
|
|
</v-data-table>
|
|
</v-card>
|
|
|
|
<v-card class="mx-3 mb-4 neo-table-card" variant="outlined">
|
|
<v-card-title class="text-subtitle-1 font-weight-bold">{{
|
|
tm("skills.neoReleases")
|
|
}}</v-card-title>
|
|
<v-data-table
|
|
:headers="releaseHeaders"
|
|
:items="neoReleases"
|
|
density="compact"
|
|
:items-per-page="10"
|
|
class="neo-data-table"
|
|
>
|
|
<template #item.is_active="{ item }">
|
|
<v-chip
|
|
size="small"
|
|
:color="item.is_active ? 'success' : 'default'"
|
|
variant="tonal"
|
|
>
|
|
{{ item.is_active ? "active" : "inactive" }}
|
|
</v-chip>
|
|
</template>
|
|
<template #item.actions="{ item }">
|
|
<div class="d-flex ga-1 flex-wrap">
|
|
<v-btn
|
|
size="x-small"
|
|
color="warning"
|
|
variant="tonal"
|
|
@click="handleReleaseLifecycleAction(item)"
|
|
>
|
|
{{
|
|
item.is_active
|
|
? tm("skills.neoDeactivate")
|
|
: tm("skills.neoRollback")
|
|
}}
|
|
</v-btn>
|
|
<v-btn
|
|
size="x-small"
|
|
color="primary"
|
|
variant="tonal"
|
|
@click="syncRelease(item)"
|
|
>
|
|
{{ tm("skills.neoSync") }}
|
|
</v-btn>
|
|
<v-btn
|
|
size="x-small"
|
|
color="error"
|
|
variant="tonal"
|
|
@click="deleteRelease(item)"
|
|
>
|
|
{{ tm("skills.neoDelete") }}
|
|
</v-btn>
|
|
</div>
|
|
</template>
|
|
</v-data-table>
|
|
</v-card>
|
|
</template>
|
|
</v-container>
|
|
|
|
<v-dialog v-model="uploadDialog" max-width="880px" :persistent="uploading">
|
|
<v-card class="skills-upload-dialog">
|
|
<v-card-title class="skills-upload-dialog__header px-6 pt-6 pb-2">
|
|
<div class="skills-upload-dialog__heading">
|
|
<div class="text-h4 font-weight-medium">
|
|
{{ tm("skills.uploadDialogTitle") }}
|
|
</div>
|
|
</div>
|
|
<v-btn
|
|
class="skills-upload-dialog__close"
|
|
icon="mdi-close"
|
|
variant="text"
|
|
:disabled="uploading"
|
|
@click="closeUploadDialog"
|
|
/>
|
|
</v-card-title>
|
|
|
|
<v-card-text class="skills-upload-dialog__body px-6 pb-5 pt-2">
|
|
<p
|
|
class="skills-upload-dialog__description skills-upload-dialog__description--body"
|
|
>
|
|
{{ tm("skills.uploadHint") }}
|
|
</p>
|
|
|
|
<div class="skills-upload-structure-note">
|
|
<v-icon size="18">mdi-information-outline</v-icon>
|
|
<span>{{ tm("skills.structureRequirement") }}</span>
|
|
</div>
|
|
|
|
<div class="skills-upload-capabilities">
|
|
<div class="skills-upload-capability">
|
|
<div class="skills-upload-capability__icon">
|
|
<v-icon size="18">mdi-layers-outline</v-icon>
|
|
</div>
|
|
<span>{{ tm("skills.abilityMultiple") }}</span>
|
|
</div>
|
|
<div class="skills-upload-capability">
|
|
<div class="skills-upload-capability__icon">
|
|
<v-icon size="18">mdi-shield-check-outline</v-icon>
|
|
</div>
|
|
<span>{{ tm("skills.abilityValidate") }}</span>
|
|
</div>
|
|
<div class="skills-upload-capability">
|
|
<div class="skills-upload-capability__icon">
|
|
<v-icon size="18">mdi-skip-next-circle-outline</v-icon>
|
|
</div>
|
|
<span>{{ tm("skills.abilitySkip") }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="skills-dropzone"
|
|
:class="{ 'skills-dropzone--dragover': isUploadDragging }"
|
|
role="button"
|
|
tabindex="0"
|
|
:aria-label="tm('skills.dropzoneTitle')"
|
|
@click="openUploadPicker"
|
|
@keydown.enter="openUploadPicker"
|
|
@keydown.space.prevent="openUploadPicker"
|
|
@dragover.prevent="isUploadDragging = true"
|
|
@dragleave.prevent="isUploadDragging = false"
|
|
@drop.prevent="handleUploadDrop"
|
|
>
|
|
<div class="skills-dropzone__icon">
|
|
<v-icon size="34">mdi-folder-zip-outline</v-icon>
|
|
</div>
|
|
<div class="text-h6 font-weight-medium">
|
|
{{ tm("skills.dropzoneTitle") }}
|
|
</div>
|
|
<div class="skills-dropzone__subtitle">
|
|
{{ tm("skills.dropzoneAction") }}
|
|
</div>
|
|
<div class="skills-dropzone__hint">
|
|
{{ tm("skills.dropzoneHint") }}
|
|
</div>
|
|
<input
|
|
ref="uploadInput"
|
|
type="file"
|
|
multiple
|
|
hidden
|
|
accept=".zip"
|
|
@change="handleUploadSelection"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="uploadItems.length > 0" class="skills-upload-summary">
|
|
<v-chip
|
|
size="small"
|
|
variant="flat"
|
|
class="skills-upload-summary__chip"
|
|
>
|
|
{{
|
|
tm("skills.summaryTotal", { count: uploadStateCounts.total })
|
|
}}
|
|
</v-chip>
|
|
<v-chip
|
|
size="small"
|
|
variant="flat"
|
|
class="skills-upload-summary__chip"
|
|
>
|
|
{{
|
|
tm("skills.summaryReady", {
|
|
count:
|
|
uploadStateCounts.waiting + uploadStateCounts.uploading,
|
|
})
|
|
}}
|
|
</v-chip>
|
|
<v-chip
|
|
size="small"
|
|
variant="flat"
|
|
class="skills-upload-summary__chip skills-upload-summary__chip--success"
|
|
>
|
|
{{
|
|
tm("skills.summarySuccess", {
|
|
count: uploadStateCounts.success,
|
|
})
|
|
}}
|
|
</v-chip>
|
|
<v-chip
|
|
size="small"
|
|
variant="flat"
|
|
class="skills-upload-summary__chip skills-upload-summary__chip--error"
|
|
>
|
|
{{
|
|
tm("skills.summaryFailed", { count: uploadStateCounts.error })
|
|
}}
|
|
</v-chip>
|
|
<v-chip
|
|
size="small"
|
|
variant="flat"
|
|
class="skills-upload-summary__chip"
|
|
>
|
|
{{
|
|
tm("skills.summarySkipped", {
|
|
count: uploadStateCounts.skipped,
|
|
})
|
|
}}
|
|
</v-chip>
|
|
</div>
|
|
|
|
<div v-if="uploadItems.length > 0" class="skills-upload-list">
|
|
<div class="skills-upload-list__header">
|
|
<span>{{ tm("skills.fileListTitle") }}</span>
|
|
</div>
|
|
<div
|
|
v-for="item in uploadItems"
|
|
:key="item.id"
|
|
class="skills-upload-row"
|
|
>
|
|
<div class="skills-upload-row__meta">
|
|
<div class="skills-upload-row__name">{{ item.name }}</div>
|
|
<div class="skills-upload-row__size">
|
|
{{ formatFileSize(item.size) }}
|
|
</div>
|
|
<div class="skills-upload-row__message">
|
|
{{ item.validationMessage }}
|
|
</div>
|
|
</div>
|
|
<div class="skills-upload-row__actions">
|
|
<v-chip
|
|
size="small"
|
|
variant="flat"
|
|
:class="statusChipClass(item.status)"
|
|
>
|
|
{{ uploadStatusLabel(item.status) }}
|
|
</v-chip>
|
|
<v-btn
|
|
icon="mdi-close"
|
|
size="small"
|
|
variant="text"
|
|
:disabled="uploading || item.status === 'uploading'"
|
|
@click="removeUploadItem(item.id)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="skills-upload-empty">
|
|
{{ tm("skills.fileListEmpty") }}
|
|
</div>
|
|
</v-card-text>
|
|
|
|
<v-card-actions
|
|
class="skills-upload-dialog__actions justify-end px-6 pb-3 pt-2"
|
|
>
|
|
<v-btn
|
|
class="skills-upload-dialog__action-btn"
|
|
variant="tonal"
|
|
color="secondary"
|
|
:disabled="uploading"
|
|
@click="closeUploadDialog"
|
|
>
|
|
{{ tm("skills.cancel") }}
|
|
</v-btn>
|
|
<v-btn
|
|
class="skills-upload-dialog__action-btn"
|
|
variant="flat"
|
|
color="primary"
|
|
:loading="uploading"
|
|
:disabled="!hasUploadableItems"
|
|
@click="uploadSkillBatch"
|
|
>
|
|
{{ tm("skills.confirmUpload") }}
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<v-dialog v-model="deleteDialog" max-width="400px">
|
|
<v-card>
|
|
<v-card-title>{{ tm("skills.deleteTitle") }}</v-card-title>
|
|
<v-card-text>{{ tm("skills.deleteMessage") }}</v-card-text>
|
|
<v-card-actions class="d-flex justify-end">
|
|
<v-btn variant="text" @click="deleteDialog = false">{{
|
|
tm("skills.cancel")
|
|
}}</v-btn>
|
|
<v-btn color="error" :loading="deleting" @click="deleteSkill">
|
|
{{ t("core.common.itemCard.delete") }}
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<v-dialog v-model="payloadDialog.show" max-width="820px">
|
|
<v-card>
|
|
<v-card-title>{{ tm("skills.neoPayloadTitle") }}</v-card-title>
|
|
<v-card-text>
|
|
<pre class="payload-preview">{{ payloadDialog.content }}</pre>
|
|
</v-card-text>
|
|
<v-card-actions class="d-flex justify-end">
|
|
<v-btn variant="text" @click="payloadDialog.show = false">{{
|
|
tm("skills.cancel")
|
|
}}</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<v-snackbar
|
|
v-model="snackbar.show"
|
|
:timeout="3500"
|
|
:color="snackbar.color"
|
|
elevation="24"
|
|
>
|
|
{{ snackbar.message }}
|
|
</v-snackbar>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import axios from "axios";
|
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
|
import ItemCard from "@/components/shared/ItemCard.vue";
|
|
import { useI18n, useModuleI18n } from "@/i18n/composables";
|
|
|
|
const STATUS_WAITING = "waiting";
|
|
const STATUS_UPLOADING = "uploading";
|
|
const STATUS_SUCCESS = "success";
|
|
const STATUS_ERROR = "error";
|
|
const STATUS_SKIPPED = "skipped";
|
|
|
|
export default {
|
|
name: "SkillsSection",
|
|
components: { ItemCard },
|
|
setup() {
|
|
const { t } = useI18n();
|
|
const { tm } = useModuleI18n("features/extension");
|
|
|
|
const mode = ref("local");
|
|
const skills = ref([]);
|
|
const loading = ref(false);
|
|
const runtime = ref("local");
|
|
const sandboxCache = reactive({ ready: false, count: 0, updated_at: null });
|
|
const uploading = ref(false);
|
|
const uploadDialog = ref(false);
|
|
const uploadInput = ref(null);
|
|
const uploadItems = ref([]);
|
|
const isUploadDragging = ref(false);
|
|
const itemLoading = reactive({});
|
|
const deleteDialog = ref(false);
|
|
const deleting = ref(false);
|
|
const skillToDelete = ref(null);
|
|
const snackbar = reactive({ show: false, message: "", color: "success" });
|
|
|
|
const neoLoading = ref(false);
|
|
const neoCandidates = ref([]);
|
|
const neoReleases = ref([]);
|
|
const neoFilters = reactive({
|
|
skill_key: "",
|
|
status: "",
|
|
stage: "",
|
|
});
|
|
const candidatePromoteLoading = reactive({});
|
|
const payloadDialog = reactive({
|
|
show: false,
|
|
content: "",
|
|
});
|
|
|
|
const neoEnabled = ref(false);
|
|
const neoUnavailableMessage = ref("");
|
|
let nextUploadItemId = 0;
|
|
|
|
const candidateStatusItems = computed(() => [
|
|
{ title: tm("skills.neoAll"), value: "" },
|
|
{ title: "draft", value: "draft" },
|
|
{ title: "evaluating", value: "evaluating" },
|
|
{ title: "promoted", value: "promoted" },
|
|
{ title: "promoted_canary", value: "promoted_canary" },
|
|
{ title: "promoted_stable", value: "promoted_stable" },
|
|
{ title: "rejected", value: "rejected" },
|
|
{ title: "rolled_back", value: "rolled_back" },
|
|
]);
|
|
|
|
const releaseStageItems = computed(() => [
|
|
{ title: tm("skills.neoAll"), value: "" },
|
|
{ title: "canary", value: "canary" },
|
|
{ title: "stable", value: "stable" },
|
|
]);
|
|
|
|
const activeReleaseCount = computed(
|
|
() => neoReleases.value.filter((item) => item?.is_active).length,
|
|
);
|
|
const uploadStateCounts = computed(() =>
|
|
uploadItems.value.reduce(
|
|
(counts, item) => {
|
|
counts.total += 1;
|
|
counts[item.status] += 1;
|
|
return counts;
|
|
},
|
|
{
|
|
total: 0,
|
|
[STATUS_WAITING]: 0,
|
|
[STATUS_UPLOADING]: 0,
|
|
[STATUS_SUCCESS]: 0,
|
|
[STATUS_ERROR]: 0,
|
|
[STATUS_SKIPPED]: 0,
|
|
},
|
|
),
|
|
);
|
|
const hasUploadableItems = computed(() =>
|
|
uploadItems.value.some(
|
|
(item) =>
|
|
item.status === STATUS_WAITING || item.status === STATUS_ERROR,
|
|
),
|
|
);
|
|
|
|
const candidateHeaders = computed(() => [
|
|
{ title: "ID", key: "id", width: "180px" },
|
|
{ title: "skill_key", key: "skill_key" },
|
|
{ title: "status", key: "status", width: "130px" },
|
|
{ title: "score", key: "latest_score", width: "90px" },
|
|
{
|
|
title: tm("skills.actions"),
|
|
key: "actions",
|
|
sortable: false,
|
|
width: "420px",
|
|
},
|
|
]);
|
|
|
|
const releaseHeaders = computed(() => [
|
|
{ title: "ID", key: "id", width: "180px" },
|
|
{ title: "skill_key", key: "skill_key" },
|
|
{ title: "stage", key: "stage", width: "100px" },
|
|
{ title: "version", key: "version", width: "90px" },
|
|
{ title: "active", key: "is_active", width: "110px" },
|
|
{
|
|
title: tm("skills.actions"),
|
|
key: "actions",
|
|
sortable: false,
|
|
width: "220px",
|
|
},
|
|
]);
|
|
|
|
const showMessage = (message, color = "success") => {
|
|
snackbar.message = message;
|
|
snackbar.color = color;
|
|
snackbar.show = true;
|
|
};
|
|
|
|
const normalizeSkillsPayload = (res) => {
|
|
const payload = res?.data?.data || [];
|
|
if (Array.isArray(payload)) {
|
|
runtime.value = "local";
|
|
sandboxCache.ready = false;
|
|
sandboxCache.count = 0;
|
|
sandboxCache.updated_at = null;
|
|
return payload;
|
|
}
|
|
runtime.value = payload.runtime || "local";
|
|
const cache = payload.sandbox_cache || {};
|
|
sandboxCache.ready = !!cache.ready;
|
|
sandboxCache.count = Number(cache.count || 0);
|
|
sandboxCache.updated_at = cache.updated_at || null;
|
|
return payload.skills || [];
|
|
};
|
|
|
|
const sourceTypeLabel = (sourceType) => {
|
|
if (sourceType === "sandbox_only") return tm("skills.sourceSandboxOnly");
|
|
if (sourceType === "both") return tm("skills.sourceBoth");
|
|
return tm("skills.sourceLocalOnly");
|
|
};
|
|
|
|
const sourceTypeColor = (sourceType) => {
|
|
if (sourceType === "sandbox_only") return "indigo";
|
|
if (sourceType === "both") return "success";
|
|
return "primary";
|
|
};
|
|
|
|
const isSandboxPresetSkill = (skill) =>
|
|
skill?.source_type === "sandbox_only";
|
|
|
|
const normalizeNeoItemsPayload = (res) => {
|
|
const payload = res?.data?.data || [];
|
|
if (Array.isArray(payload)) return payload;
|
|
if (Array.isArray(payload.items)) return payload.items;
|
|
return [];
|
|
};
|
|
|
|
const formatFileSize = (size) => {
|
|
if (!Number.isFinite(size) || size <= 0) return "0 B";
|
|
if (size < 1024) return `${size} B`;
|
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
};
|
|
|
|
const normalizeUploadName = (name) =>
|
|
String(name || "")
|
|
.trim()
|
|
.toLowerCase();
|
|
|
|
const buildUploadItem = (file, status, validationMessage) => ({
|
|
id: `upload-${nextUploadItemId++}`,
|
|
file,
|
|
name: file.name,
|
|
size: file.size,
|
|
status,
|
|
validationMessage,
|
|
filenameKey: normalizeUploadName(file.name),
|
|
});
|
|
|
|
const uploadStatusLabel = (status) => {
|
|
if (status === STATUS_UPLOADING) return tm("skills.statusUploading");
|
|
if (status === STATUS_SUCCESS) return tm("skills.statusSuccess");
|
|
if (status === STATUS_ERROR) return tm("skills.statusError");
|
|
if (status === STATUS_SKIPPED) return tm("skills.statusSkipped");
|
|
return tm("skills.statusWaiting");
|
|
};
|
|
|
|
const statusChipClass = (status) =>
|
|
`skills-status-chip skills-status-chip--${status}`;
|
|
|
|
const resetUploadState = () => {
|
|
uploadItems.value = [];
|
|
isUploadDragging.value = false;
|
|
if (uploadInput.value) {
|
|
uploadInput.value.value = "";
|
|
}
|
|
};
|
|
|
|
const openUploadDialog = () => {
|
|
uploadDialog.value = true;
|
|
};
|
|
|
|
const closeUploadDialog = () => {
|
|
if (uploading.value) return;
|
|
uploadDialog.value = false;
|
|
};
|
|
|
|
const openUploadPicker = () => {
|
|
if (uploading.value) return;
|
|
uploadInput.value?.click();
|
|
};
|
|
|
|
const addUploadFiles = (filesToAdd) => {
|
|
const existingNames = new Set(
|
|
uploadItems.value.map((item) => item.filenameKey),
|
|
);
|
|
const nextItems = [];
|
|
|
|
for (const file of filesToAdd) {
|
|
if (!file?.name) continue;
|
|
const filenameKey = normalizeUploadName(file.name);
|
|
|
|
if (existingNames.has(filenameKey)) {
|
|
nextItems.push(
|
|
buildUploadItem(
|
|
file,
|
|
STATUS_SKIPPED,
|
|
tm("skills.validationDuplicate"),
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
existingNames.add(filenameKey);
|
|
if (!/\.zip$/i.test(file.name)) {
|
|
nextItems.push(
|
|
buildUploadItem(
|
|
file,
|
|
STATUS_SKIPPED,
|
|
tm("skills.validationZipOnly"),
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
nextItems.push(
|
|
buildUploadItem(file, STATUS_WAITING, tm("skills.validationReady")),
|
|
);
|
|
}
|
|
|
|
if (nextItems.length > 0) {
|
|
uploadItems.value = [...uploadItems.value, ...nextItems];
|
|
}
|
|
};
|
|
|
|
const handleUploadSelection = (event) => {
|
|
const selected = Array.from(event?.target?.files || []);
|
|
addUploadFiles(selected);
|
|
if (uploadInput.value) {
|
|
uploadInput.value.value = "";
|
|
}
|
|
};
|
|
|
|
const handleUploadDrop = (event) => {
|
|
isUploadDragging.value = false;
|
|
if (uploading.value) {
|
|
return;
|
|
}
|
|
addUploadFiles(Array.from(event?.dataTransfer?.files || []));
|
|
};
|
|
|
|
const removeUploadItem = (itemId) => {
|
|
uploadItems.value = uploadItems.value.filter(
|
|
(item) => item.id !== itemId,
|
|
);
|
|
};
|
|
|
|
const takeFirstMatch = (matchMap, filenameKey) => {
|
|
const matches = matchMap.get(filenameKey) || [];
|
|
const entry = matches.shift() || null;
|
|
if (matches.length === 0) {
|
|
matchMap.delete(filenameKey);
|
|
}
|
|
return entry;
|
|
};
|
|
|
|
const buildResultMap = (items = []) => {
|
|
const resultMap = new Map();
|
|
for (const item of items) {
|
|
const filenameKey = normalizeUploadName(item?.filename);
|
|
if (!filenameKey) continue;
|
|
if (!resultMap.has(filenameKey)) {
|
|
resultMap.set(filenameKey, []);
|
|
}
|
|
resultMap.get(filenameKey).push(item);
|
|
}
|
|
return resultMap;
|
|
};
|
|
|
|
const applyUploadResults = (attemptedItems, payload) => {
|
|
const succeededMap = buildResultMap(payload?.succeeded);
|
|
const failedMap = buildResultMap(payload?.failed);
|
|
|
|
for (const item of attemptedItems) {
|
|
const successEntry = takeFirstMatch(succeededMap, item.filenameKey);
|
|
if (successEntry) {
|
|
item.status = STATUS_SUCCESS;
|
|
item.validationMessage = tm("skills.validationUploadedAs", {
|
|
name: successEntry.name || item.name,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const failedEntry = takeFirstMatch(failedMap, item.filenameKey);
|
|
if (failedEntry) {
|
|
item.status = STATUS_ERROR;
|
|
item.validationMessage =
|
|
failedEntry.error || tm("skills.validationUploadFailed");
|
|
continue;
|
|
}
|
|
|
|
item.status = STATUS_ERROR;
|
|
item.validationMessage = tm("skills.validationNoResult");
|
|
}
|
|
};
|
|
|
|
const fetchSkills = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const res = await axios.get("/api/skills");
|
|
skills.value = normalizeSkillsPayload(res);
|
|
} catch (_err) {
|
|
showMessage(tm("skills.loadFailed"), "error");
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const handleApiResponse = (
|
|
res,
|
|
successMessage,
|
|
failureMessageDefault,
|
|
onSuccess,
|
|
) => {
|
|
if (res && res.data && res.data.status === "ok") {
|
|
showMessage(successMessage, "success");
|
|
if (onSuccess) onSuccess();
|
|
} else {
|
|
const msg =
|
|
(res && res.data && res.data.message) || failureMessageDefault;
|
|
showMessage(msg, "error");
|
|
}
|
|
};
|
|
|
|
const uploadSkillBatch = async () => {
|
|
const attemptedItems = uploadItems.value.filter(
|
|
(item) =>
|
|
item.status === STATUS_WAITING || item.status === STATUS_ERROR,
|
|
);
|
|
if (attemptedItems.length === 0) return;
|
|
|
|
uploading.value = true;
|
|
for (const item of attemptedItems) {
|
|
item.status = STATUS_UPLOADING;
|
|
item.validationMessage = tm("skills.validationUploading");
|
|
}
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
for (const item of attemptedItems) {
|
|
formData.append("files", item.file);
|
|
}
|
|
|
|
const res = await axios.post("/api/skills/batch-upload", formData, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
|
|
const payload = res?.data?.data || {};
|
|
applyUploadResults(attemptedItems, payload);
|
|
|
|
const succeededCount = Array.isArray(payload.succeeded)
|
|
? payload.succeeded.length
|
|
: 0;
|
|
const failedCount = Array.isArray(payload.failed)
|
|
? payload.failed.length
|
|
: 0;
|
|
const responseColor =
|
|
res?.data?.status === "error"
|
|
? "error"
|
|
: failedCount > 0
|
|
? "warning"
|
|
: "success";
|
|
showMessage(
|
|
res?.data?.message || tm("skills.uploadSuccess"),
|
|
responseColor,
|
|
);
|
|
|
|
if (succeededCount > 0) {
|
|
await fetchSkills();
|
|
}
|
|
} catch (_err) {
|
|
for (const item of attemptedItems) {
|
|
item.status = STATUS_ERROR;
|
|
item.validationMessage = tm("skills.validationUploadFailed");
|
|
}
|
|
showMessage(tm("skills.uploadFailed"), "error");
|
|
} finally {
|
|
uploading.value = false;
|
|
}
|
|
};
|
|
|
|
const toggleSkill = async (skill) => {
|
|
if (isSandboxPresetSkill(skill)) {
|
|
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
|
|
return;
|
|
}
|
|
const nextActive = !skill.active;
|
|
itemLoading[skill.name] = true;
|
|
try {
|
|
const res = await axios.post("/api/skills/update", {
|
|
name: skill.name,
|
|
active: nextActive,
|
|
});
|
|
handleApiResponse(
|
|
res,
|
|
tm("skills.updateSuccess"),
|
|
tm("skills.updateFailed"),
|
|
() => {
|
|
skill.active = nextActive;
|
|
},
|
|
);
|
|
} catch (_err) {
|
|
showMessage(tm("skills.updateFailed"), "error");
|
|
} finally {
|
|
itemLoading[skill.name] = false;
|
|
}
|
|
};
|
|
|
|
const confirmDelete = (skill) => {
|
|
if (isSandboxPresetSkill(skill)) {
|
|
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
|
|
return;
|
|
}
|
|
skillToDelete.value = skill;
|
|
deleteDialog.value = true;
|
|
};
|
|
|
|
const deleteSkill = async () => {
|
|
if (!skillToDelete.value) return;
|
|
deleting.value = true;
|
|
try {
|
|
const res = await axios.post("/api/skills/delete", {
|
|
name: skillToDelete.value.name,
|
|
});
|
|
handleApiResponse(
|
|
res,
|
|
tm("skills.deleteSuccess"),
|
|
tm("skills.deleteFailed"),
|
|
async () => {
|
|
deleteDialog.value = false;
|
|
await fetchSkills();
|
|
},
|
|
);
|
|
} catch (_err) {
|
|
showMessage(tm("skills.deleteFailed"), "error");
|
|
} finally {
|
|
deleting.value = false;
|
|
}
|
|
};
|
|
|
|
const downloadSkill = async (skill) => {
|
|
if (isSandboxPresetSkill(skill)) {
|
|
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
|
|
return;
|
|
}
|
|
itemLoading[skill.name] = true;
|
|
try {
|
|
const res = await axios.get("/api/skills/download", {
|
|
params: { name: skill.name },
|
|
responseType: "blob",
|
|
});
|
|
const blob = new Blob([res.data], { type: "application/zip" });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `${skill.name}.zip`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
window.URL.revokeObjectURL(url);
|
|
showMessage(tm("skills.downloadSuccess"), "success");
|
|
} catch (_err) {
|
|
showMessage(tm("skills.downloadFailed"), "error");
|
|
} finally {
|
|
itemLoading[skill.name] = false;
|
|
}
|
|
};
|
|
|
|
const fetchNeoCandidates = async () => {
|
|
const params = {
|
|
skill_key: neoFilters.skill_key || undefined,
|
|
status: neoFilters.status || undefined,
|
|
};
|
|
const res = await axios.get("/api/skills/neo/candidates", { params });
|
|
neoCandidates.value = normalizeNeoItemsPayload(res);
|
|
};
|
|
|
|
const fetchNeoReleases = async () => {
|
|
const params = {
|
|
skill_key: neoFilters.skill_key || undefined,
|
|
stage: neoFilters.stage || undefined,
|
|
};
|
|
const res = await axios.get("/api/skills/neo/releases", { params });
|
|
neoReleases.value = normalizeNeoItemsPayload(res).map((item) => {
|
|
if (!item || typeof item !== "object") {
|
|
return item;
|
|
}
|
|
return {
|
|
...item,
|
|
is_active: item.is_active ?? item.active ?? false,
|
|
};
|
|
});
|
|
};
|
|
|
|
const loadNeoAvailability = async () => {
|
|
try {
|
|
const res = await axios.get("/api/config/get");
|
|
const config = res?.data?.data?.config || {};
|
|
const providerSettings = config?.provider_settings || {};
|
|
const currentRuntime =
|
|
providerSettings?.computer_use_runtime || "local";
|
|
const booter = providerSettings?.sandbox?.booter || "";
|
|
neoEnabled.value =
|
|
currentRuntime === "sandbox" && booter === "shipyard_neo";
|
|
} catch (_err) {
|
|
neoEnabled.value = false;
|
|
}
|
|
|
|
neoUnavailableMessage.value = tm("skills.neoRuntimeRequired");
|
|
if (!neoEnabled.value && mode.value === "neo") {
|
|
mode.value = "local";
|
|
}
|
|
};
|
|
|
|
const fetchNeoData = async () => {
|
|
neoLoading.value = true;
|
|
try {
|
|
await Promise.all([fetchNeoCandidates(), fetchNeoReleases()]);
|
|
} catch (_err) {
|
|
showMessage(tm("skills.neoLoadFailed"), "error");
|
|
} finally {
|
|
neoLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const evaluateCandidate = async (candidate, passed) => {
|
|
try {
|
|
const res = await axios.post("/api/skills/neo/evaluate", {
|
|
candidate_id: candidate.id,
|
|
passed,
|
|
score: passed ? 1.0 : 0.0,
|
|
report: passed ? "approved_from_webui" : "rejected_from_webui",
|
|
});
|
|
handleApiResponse(
|
|
res,
|
|
tm("skills.neoEvaluateSuccess"),
|
|
tm("skills.neoEvaluateFailed"),
|
|
async () => {
|
|
await fetchNeoCandidates();
|
|
},
|
|
);
|
|
} catch (_err) {
|
|
showMessage(tm("skills.neoEvaluateFailed"), "error");
|
|
}
|
|
};
|
|
|
|
const candidatePromoteLoadingKey = (candidateId, stage) =>
|
|
`${candidateId}:${stage}`;
|
|
const isCandidatePromoteLoading = (candidateId, stage) =>
|
|
!!candidatePromoteLoading[candidatePromoteLoadingKey(candidateId, stage)];
|
|
const isCandidatePromoting = (candidateId) =>
|
|
isCandidatePromoteLoading(candidateId, "canary") ||
|
|
isCandidatePromoteLoading(candidateId, "stable");
|
|
|
|
const promoteCandidate = async (candidate, stage) => {
|
|
const candidateId = candidate?.id;
|
|
if (!candidateId) return;
|
|
const loadingKey = candidatePromoteLoadingKey(candidateId, stage);
|
|
if (candidatePromoteLoading[loadingKey]) return;
|
|
candidatePromoteLoading[loadingKey] = true;
|
|
try {
|
|
const res = await axios.post("/api/skills/neo/promote", {
|
|
candidate_id: candidateId,
|
|
stage,
|
|
sync_to_local: true,
|
|
});
|
|
const ok = res?.data?.status === "ok";
|
|
if (!ok) {
|
|
showMessage(
|
|
res?.data?.message || tm("skills.neoPromoteFailed"),
|
|
"error",
|
|
);
|
|
} else {
|
|
showMessage(tm("skills.neoPromoteSuccess"), "success");
|
|
}
|
|
await fetchNeoData();
|
|
if (stage === "stable") {
|
|
await fetchSkills();
|
|
}
|
|
} catch (_err) {
|
|
showMessage(tm("skills.neoPromoteFailed"), "error");
|
|
} finally {
|
|
candidatePromoteLoading[loadingKey] = false;
|
|
}
|
|
};
|
|
|
|
const rollbackRelease = async (release) => {
|
|
try {
|
|
const res = await axios.post("/api/skills/neo/rollback", {
|
|
release_id: release.id,
|
|
});
|
|
handleApiResponse(
|
|
res,
|
|
tm("skills.neoRollbackSuccess"),
|
|
tm("skills.neoRollbackFailed"),
|
|
async () => {
|
|
await fetchNeoData();
|
|
},
|
|
);
|
|
} catch (_err) {
|
|
showMessage(tm("skills.neoRollbackFailed"), "error");
|
|
}
|
|
};
|
|
|
|
const deactivateRelease = async (release) => {
|
|
try {
|
|
const res = await axios.post("/api/skills/neo/rollback", {
|
|
release_id: release.id,
|
|
});
|
|
handleApiResponse(
|
|
res,
|
|
tm("skills.neoDeactivateSuccess"),
|
|
tm("skills.neoDeactivateFailed"),
|
|
async () => {
|
|
await fetchNeoData();
|
|
},
|
|
);
|
|
} catch (_err) {
|
|
showMessage(tm("skills.neoDeactivateFailed"), "error");
|
|
}
|
|
};
|
|
|
|
const handleReleaseLifecycleAction = async (release) => {
|
|
if (release?.is_active) {
|
|
await deactivateRelease(release);
|
|
return;
|
|
}
|
|
await rollbackRelease(release);
|
|
};
|
|
|
|
const syncRelease = async (release) => {
|
|
try {
|
|
const res = await axios.post("/api/skills/neo/sync", {
|
|
release_id: release.id,
|
|
});
|
|
handleApiResponse(
|
|
res,
|
|
tm("skills.neoSyncSuccess"),
|
|
tm("skills.neoSyncFailed"),
|
|
async () => {
|
|
await fetchSkills();
|
|
},
|
|
);
|
|
} catch (_err) {
|
|
showMessage(tm("skills.neoSyncFailed"), "error");
|
|
}
|
|
};
|
|
|
|
const viewPayload = async (payloadRef) => {
|
|
if (!payloadRef) return;
|
|
try {
|
|
const res = await axios.get("/api/skills/neo/payload", {
|
|
params: { payload_ref: payloadRef },
|
|
});
|
|
if (res?.data?.status !== "ok") {
|
|
showMessage(
|
|
res?.data?.message || tm("skills.neoPayloadFailed"),
|
|
"error",
|
|
);
|
|
return;
|
|
}
|
|
const payload = res?.data?.data || {};
|
|
payloadDialog.content = JSON.stringify(payload, null, 2);
|
|
payloadDialog.show = true;
|
|
} catch (_err) {
|
|
showMessage(tm("skills.neoPayloadFailed"), "error");
|
|
}
|
|
};
|
|
|
|
const deleteCandidate = async (candidate) => {
|
|
try {
|
|
const res = await axios.post("/api/skills/neo/delete-candidate", {
|
|
candidate_id: candidate.id,
|
|
reason: "deleted_from_webui",
|
|
});
|
|
handleApiResponse(
|
|
res,
|
|
tm("skills.neoDeleteSuccess"),
|
|
tm("skills.neoDeleteFailed"),
|
|
async () => {
|
|
await fetchNeoData();
|
|
},
|
|
);
|
|
} catch (_err) {
|
|
showMessage(tm("skills.neoDeleteFailed"), "error");
|
|
}
|
|
};
|
|
|
|
const deleteRelease = async (release) => {
|
|
try {
|
|
const res = await axios.post("/api/skills/neo/delete-release", {
|
|
release_id: release.id,
|
|
reason: "deleted_from_webui",
|
|
});
|
|
handleApiResponse(
|
|
res,
|
|
tm("skills.neoDeleteSuccess"),
|
|
tm("skills.neoDeleteFailed"),
|
|
async () => {
|
|
await fetchNeoData();
|
|
},
|
|
);
|
|
} catch (_err) {
|
|
showMessage(tm("skills.neoDeleteFailed"), "error");
|
|
}
|
|
};
|
|
|
|
const refreshCurrentMode = async () => {
|
|
if (mode.value === "neo") {
|
|
await loadNeoAvailability();
|
|
if (neoEnabled.value) {
|
|
await fetchNeoData();
|
|
} else {
|
|
showMessage(tm("skills.neoRuntimeRequired"), "warning");
|
|
}
|
|
} else {
|
|
await fetchSkills();
|
|
}
|
|
};
|
|
|
|
watch(mode, async (nextMode) => {
|
|
if (nextMode === "neo") {
|
|
await loadNeoAvailability();
|
|
if (neoEnabled.value) {
|
|
await fetchNeoData();
|
|
}
|
|
} else {
|
|
await fetchSkills();
|
|
}
|
|
});
|
|
|
|
watch(uploadDialog, (isOpen) => {
|
|
if (!isOpen && !uploading.value) {
|
|
resetUploadState();
|
|
}
|
|
});
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([fetchSkills(), loadNeoAvailability()]);
|
|
if (neoEnabled.value) {
|
|
await fetchNeoData();
|
|
}
|
|
});
|
|
|
|
return {
|
|
t,
|
|
tm,
|
|
mode,
|
|
skills,
|
|
loading,
|
|
runtime,
|
|
sandboxCache,
|
|
uploadDialog,
|
|
uploadInput,
|
|
uploadItems,
|
|
uploadStateCounts,
|
|
hasUploadableItems,
|
|
isUploadDragging,
|
|
uploading,
|
|
itemLoading,
|
|
deleteDialog,
|
|
deleting,
|
|
snackbar,
|
|
neoEnabled,
|
|
neoUnavailableMessage,
|
|
neoLoading,
|
|
neoCandidates,
|
|
neoReleases,
|
|
neoFilters,
|
|
candidateStatusItems,
|
|
releaseStageItems,
|
|
activeReleaseCount,
|
|
candidateHeaders,
|
|
releaseHeaders,
|
|
payloadDialog,
|
|
formatFileSize,
|
|
uploadStatusLabel,
|
|
statusChipClass,
|
|
openUploadDialog,
|
|
closeUploadDialog,
|
|
openUploadPicker,
|
|
handleUploadSelection,
|
|
handleUploadDrop,
|
|
removeUploadItem,
|
|
refreshCurrentMode,
|
|
fetchNeoData,
|
|
uploadSkillBatch,
|
|
downloadSkill,
|
|
toggleSkill,
|
|
confirmDelete,
|
|
deleteSkill,
|
|
evaluateCandidate,
|
|
promoteCandidate,
|
|
isCandidatePromoteLoading,
|
|
isCandidatePromoting,
|
|
rollbackRelease,
|
|
deactivateRelease,
|
|
handleReleaseLifecycleAction,
|
|
syncRelease,
|
|
viewPayload,
|
|
deleteCandidate,
|
|
deleteRelease,
|
|
sourceTypeLabel,
|
|
sourceTypeColor,
|
|
isSandboxPresetSkill,
|
|
};
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.skill-description {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 1;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
min-height: 20px;
|
|
}
|
|
|
|
.skill-path {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
min-height: 40px;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.skills-upload-dialog {
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-height: min(88vh, 960px);
|
|
border-radius: 24px;
|
|
background: rgb(var(--v-theme-surface));
|
|
border: 1px solid var(--v-theme-border);
|
|
outline: 1px solid rgba(var(--v-theme-primary), 0.1);
|
|
outline-offset: -1px;
|
|
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.skills-upload-dialog__header {
|
|
position: relative;
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
align-items: flex-start;
|
|
gap: 16px;
|
|
white-space: normal;
|
|
overflow: visible;
|
|
}
|
|
|
|
.skills-upload-dialog__heading {
|
|
min-width: 0;
|
|
padding-right: 0;
|
|
white-space: normal;
|
|
}
|
|
|
|
.skills-upload-dialog__description {
|
|
max-width: 100%;
|
|
color: var(--v-theme-secondaryText);
|
|
line-height: 1.7;
|
|
word-break: break-word;
|
|
white-space: normal;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.skills-upload-dialog__description--body {
|
|
margin: 0 0 14px;
|
|
font-size: 15px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.skills-upload-dialog__close {
|
|
align-self: flex-start;
|
|
}
|
|
|
|
.skills-upload-dialog__body {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.skills-upload-dialog__actions {
|
|
flex: 0 0 auto;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
border-top: 1px solid var(--v-theme-border);
|
|
background: rgba(var(--v-theme-surface), 0.98);
|
|
}
|
|
|
|
.skills-upload-dialog__action-btn {
|
|
min-width: 96px;
|
|
height: 38px;
|
|
border-radius: 10px;
|
|
font-weight: 600;
|
|
letter-spacing: 0;
|
|
text-transform: none;
|
|
}
|
|
|
|
.skills-upload-structure-note {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 18px;
|
|
padding: 12px 14px;
|
|
border-radius: 16px;
|
|
border: 1px solid rgba(var(--v-theme-primary), 0.18);
|
|
background: rgba(var(--v-theme-surface), 0.96);
|
|
color: var(--v-theme-secondaryText);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.skills-upload-capabilities {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 12px;
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.skills-upload-capability {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-height: 52px;
|
|
padding: 0 14px;
|
|
border-radius: 16px;
|
|
border: 1px solid rgba(var(--v-theme-primary), 0.16);
|
|
background: rgba(var(--v-theme-surface), 0.96);
|
|
color: var(--v-theme-secondaryText);
|
|
}
|
|
|
|
.skills-upload-capability__icon {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 999px;
|
|
background: rgba(var(--v-theme-primary), 0.16);
|
|
color: rgba(var(--v-theme-primary), 0.95);
|
|
}
|
|
|
|
.skills-dropzone {
|
|
padding: 36px 24px;
|
|
border-radius: 22px;
|
|
border: 1.5px dashed rgba(var(--v-theme-primary), 0.24);
|
|
background: rgba(var(--v-theme-surface), 0.94);
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition:
|
|
border-color 0.2s ease,
|
|
transform 0.2s ease,
|
|
background-color 0.2s ease;
|
|
}
|
|
|
|
.skills-dropzone:hover,
|
|
.skills-dropzone--dragover {
|
|
border-color: rgba(var(--v-theme-primary), 0.52);
|
|
background: rgba(var(--v-theme-primary), 0.05);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.skills-dropzone__icon {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 66px;
|
|
height: 66px;
|
|
margin: 0 auto 18px;
|
|
border-radius: 20px;
|
|
background: rgba(var(--v-theme-primary), 0.15);
|
|
color: rgba(var(--v-theme-primary), 0.96);
|
|
}
|
|
|
|
.skills-dropzone__subtitle {
|
|
margin-top: 10px;
|
|
color: var(--v-theme-secondaryText);
|
|
}
|
|
|
|
.skills-dropzone__hint {
|
|
margin-top: 8px;
|
|
font-size: 13px;
|
|
color: var(--v-theme-secondaryText);
|
|
opacity: 0.82;
|
|
}
|
|
|
|
.skills-upload-summary {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-top: 18px;
|
|
}
|
|
|
|
.skills-upload-summary__chip {
|
|
background: rgba(var(--v-theme-surface), 0.96);
|
|
border: 1px solid rgba(var(--v-theme-primary), 0.16);
|
|
color: var(--v-theme-secondaryText);
|
|
}
|
|
|
|
.skills-upload-summary__chip--success {
|
|
background: rgba(var(--v-theme-primary), 0.18);
|
|
color: var(--v-theme-primaryText);
|
|
}
|
|
|
|
.skills-upload-summary__chip--error {
|
|
background: #f2e6e2;
|
|
color: #8b5d54;
|
|
}
|
|
|
|
.skills-upload-list {
|
|
margin-top: 16px;
|
|
border-radius: 20px;
|
|
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
|
background: rgba(var(--v-theme-surface), 0.94);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.skills-upload-list__header {
|
|
padding: 14px 18px;
|
|
border-bottom: 1px solid rgba(var(--v-theme-primary), 0.14);
|
|
color: var(--v-theme-primaryText);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.skills-upload-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
padding: 16px 18px;
|
|
}
|
|
|
|
.skills-upload-row + .skills-upload-row {
|
|
border-top: 1px solid rgba(var(--v-theme-primary), 0.12);
|
|
}
|
|
|
|
.skills-upload-row__meta {
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.skills-upload-row__name {
|
|
font-weight: 600;
|
|
color: var(--v-theme-primaryText);
|
|
word-break: break-all;
|
|
}
|
|
|
|
.skills-upload-row__size {
|
|
margin-top: 4px;
|
|
font-size: 12px;
|
|
color: var(--v-theme-secondaryText);
|
|
opacity: 0.82;
|
|
}
|
|
|
|
.skills-upload-row__message {
|
|
margin-top: 8px;
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
color: var(--v-theme-secondaryText);
|
|
}
|
|
|
|
.skills-upload-row__actions {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
}
|
|
|
|
.skills-status-chip {
|
|
min-width: 74px;
|
|
justify-content: center;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.skills-status-chip--waiting {
|
|
background: rgba(var(--v-theme-surface), 0.96);
|
|
border: 1px solid rgba(var(--v-theme-primary), 0.16);
|
|
color: var(--v-theme-secondaryText);
|
|
}
|
|
|
|
.skills-status-chip--uploading {
|
|
background: rgba(var(--v-theme-primary), 0.14);
|
|
color: var(--v-theme-primaryText);
|
|
}
|
|
|
|
.skills-status-chip--success {
|
|
background: rgba(var(--v-theme-primary), 0.2);
|
|
color: var(--v-theme-primaryText);
|
|
}
|
|
|
|
.skills-status-chip--error {
|
|
background: #f2e6e2;
|
|
color: #8a5a50;
|
|
}
|
|
|
|
.skills-status-chip--skipped {
|
|
background: rgba(var(--v-theme-surface), 0.96);
|
|
border: 1px solid rgba(var(--v-theme-primary), 0.16);
|
|
color: var(--v-theme-secondaryText);
|
|
}
|
|
|
|
.skills-upload-empty {
|
|
margin-top: 16px;
|
|
padding: 20px 18px;
|
|
border-radius: 20px;
|
|
border: 1px dashed rgba(var(--v-theme-primary), 0.24);
|
|
background: rgba(var(--v-theme-surface), 0.94);
|
|
text-align: center;
|
|
color: var(--v-theme-secondaryText);
|
|
}
|
|
|
|
.payload-preview {
|
|
max-height: 480px;
|
|
overflow: auto;
|
|
background: #111;
|
|
color: #ececec;
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.neo-filter-card {
|
|
border-radius: 14px;
|
|
border-color: rgba(var(--v-theme-primary), 0.25);
|
|
background: linear-gradient(
|
|
180deg,
|
|
rgba(var(--v-theme-primary), 0.03),
|
|
rgba(var(--v-theme-surface), 1)
|
|
);
|
|
}
|
|
|
|
.neo-table-card {
|
|
border-radius: 14px;
|
|
}
|
|
|
|
.neo-data-table :deep(.v-data-table-header__content) {
|
|
font-weight: 700;
|
|
}
|
|
|
|
.neo-data-table :deep(tbody tr:hover) {
|
|
background: rgba(var(--v-theme-primary), 0.04);
|
|
}
|
|
|
|
@media (max-width: 860px) {
|
|
.skills-upload-capabilities {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.skills-upload-dialog {
|
|
max-height: 92vh;
|
|
}
|
|
|
|
.skills-upload-dialog__header {
|
|
gap: 12px;
|
|
}
|
|
|
|
.skills-upload-dialog__heading {
|
|
padding-right: 0;
|
|
}
|
|
|
|
.skills-upload-row {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.skills-upload-row__actions {
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.skills-upload-dialog__description--body {
|
|
font-size: 14px;
|
|
}
|
|
}
|
|
</style>
|