diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css
index bde62e93c0..91f7512990 100644
--- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css
+++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css
@@ -1,4 +1,4 @@
-/* Auto-generated MDI subset – 272 icons */
+/* Auto-generated MDI subset – 273 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -904,6 +904,10 @@
content: "\F046A";
}
+.mdi-select-multiple::before {
+ content: "\F1281";
+}
+
.mdi-send-outline::before {
content: "\F1165";
}
diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff
index 7a31dd05c8..c375b29714 100644
Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff differ
diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2
index 5602b50fa3..f7d2280fb7 100644
Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 differ
diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue
index 706e3ec2b9..47c39f7667 100644
--- a/dashboard/src/components/extension/SkillsSection.vue
+++ b/dashboard/src/components/extension/SkillsSection.vue
@@ -48,9 +48,59 @@
-
- {{ tm("status.installed") }}
-
+
+
+
+
+
-
+
-
+
+
+
+
+ {{ tm("skills.batchDeleteTitle") }}
+
+
+
+ {{
+ tm("skills.batchDeleteMessage", {
+ count: batchDeleteTargets.length,
+ })
+ }}
+
+
+
+
+
+
+
+ {{ tm("skills.cancel") }}
+
+
+ {{
+ tm("skills.batchDeleteConfirm", {
+ count: batchDeleteTargets.length,
+ })
+ }}
+
+
+
+
+
isSandboxPresetSkill(skill) || isPluginProvidedSkill(skill);
+ const deletableSkills = computed(() =>
+ skills.value.filter((skill) => !isReadOnlySourceSkill(skill)),
+ );
+ const allDeletableSelected = computed(
+ () =>
+ deletableSkills.value.length > 0 &&
+ deletableSkills.value.every((skill) =>
+ selectedSkillNames.value.includes(skill.name),
+ ),
+ );
const normalizeNeoItemsPayload = (res) => {
const payload = res?.data?.data || [];
@@ -1200,8 +1337,21 @@ export default {
try {
const res = await skillApi.list();
skills.value = normalizeSkillsPayload(res);
+ const deletableNames = new Set(
+ skills.value
+ .filter((skill) => !isReadOnlySourceSkill(skill))
+ .map((skill) => skill.name),
+ );
+ selectedSkillNames.value = selectedSkillNames.value.filter((name) =>
+ deletableNames.has(name),
+ );
+ if (batchSelectionEnabled.value && deletableNames.size === 0) {
+ batchSelectionEnabled.value = false;
+ }
+ return true;
} catch (_err) {
showMessage(tm("skills.loadFailed"), "error");
+ return false;
} finally {
loading.value = false;
}
@@ -1275,6 +1425,104 @@ export default {
}
};
+ const startBatchSelection = () => {
+ selectedSkillNames.value = [];
+ batchDeleteTargets.value = [];
+ batchSelectionEnabled.value = true;
+ };
+
+ const cancelBatchSelection = () => {
+ if (batchDeleting.value) return;
+ batchSelectionEnabled.value = false;
+ selectedSkillNames.value = [];
+ batchDeleteTargets.value = [];
+ batchDeleteDialog.value = false;
+ };
+
+ const toggleSelectAll = () => {
+ if (allDeletableSelected.value) {
+ selectedSkillNames.value = [];
+ return;
+ }
+ selectedSkillNames.value = deletableSkills.value.map(
+ (skill) => skill.name,
+ );
+ };
+
+ const confirmBatchDelete = () => {
+ const selectedNames = new Set(selectedSkillNames.value);
+ batchDeleteTargets.value = [
+ ...new Set(
+ deletableSkills.value
+ .filter((skill) => selectedNames.has(skill.name))
+ .map((skill) => skill.name),
+ ),
+ ];
+ if (batchDeleteTargets.value.length === 0) return;
+ batchDeleteDialog.value = true;
+ };
+
+ const deleteSelectedSkills = async () => {
+ if (batchDeleting.value || batchDeleteTargets.value.length === 0) return;
+
+ const targets = [...batchDeleteTargets.value];
+ const failed = [];
+ let succeeded = 0;
+ batchDeleting.value = true;
+
+ try {
+ for (const name of targets) {
+ try {
+ const res = await skillApi.delete(name);
+ if (res?.data?.status === "ok") {
+ succeeded += 1;
+ } else {
+ failed.push(name);
+ }
+ } catch (_err) {
+ failed.push(name);
+ }
+ }
+
+ const refreshed = await fetchSkills();
+ if (refreshed) {
+ const currentDeletableNames = new Set(
+ skills.value
+ .filter((skill) => !isReadOnlySourceSkill(skill))
+ .map((skill) => skill.name),
+ );
+ selectedSkillNames.value = failed.filter((name) =>
+ currentDeletableNames.has(name),
+ );
+ } else {
+ selectedSkillNames.value = failed;
+ batchSelectionEnabled.value = failed.length > 0;
+ }
+ batchDeleteDialog.value = false;
+ batchDeleteTargets.value = [];
+
+ if (!refreshed) return;
+
+ if (failed.length === 0) {
+ batchSelectionEnabled.value = false;
+ showMessage(
+ tm("skills.batchDeleteSuccess", { count: succeeded }),
+ "success",
+ );
+ } else {
+ showMessage(
+ tm("skills.batchDeletePartial", {
+ succeeded,
+ failed: failed.length,
+ }),
+ "warning",
+ );
+ }
+ } finally {
+ batchDeleting.value = false;
+ }
+ };
+
const toggleSkill = async (skill) => {
if (isInactivePluginSkill(skill)) {
showMessage(tm("skills.pluginDisabled"), "warning");
@@ -1775,6 +2023,7 @@ export default {
watch(mode, async (nextMode) => {
if (nextMode === "neo") {
+ cancelBatchSelection();
await loadNeoAvailability();
if (neoEnabled.value) {
await fetchNeoData();
@@ -1815,6 +2064,11 @@ export default {
itemLoading,
deleteDialog,
deleting,
+ batchSelectionEnabled,
+ selectedSkillNames,
+ batchDeleteTargets,
+ batchDeleteDialog,
+ batchDeleting,
snackbar,
neoEnabled,
neoUnavailableMessage,
@@ -1825,6 +2079,8 @@ export default {
candidateStatusItems,
releaseStageItems,
activeReleaseCount,
+ deletableSkills,
+ allDeletableSelected,
candidateHeaders,
releaseHeaders,
payloadDialog,
@@ -1853,6 +2109,11 @@ export default {
toggleSkill,
confirmDelete,
deleteSkill,
+ startBatchSelection,
+ cancelBatchSelection,
+ toggleSelectAll,
+ confirmBatchDelete,
+ deleteSelectedSkills,
evaluateCandidate,
promoteCandidate,
isCandidatePromoteLoading,
@@ -1880,10 +2141,27 @@ export default {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
-.skills-list-title {
+.skills-list-header {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ justify-content: space-between;
margin-bottom: 16px;
}
+.skills-list-title {
+ margin: 0;
+}
+
+.skills-list-actions {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-left: auto;
+}
+
.skill-list-item :deep(.outlined-action-list-item__main) {
gap: 0;
}
@@ -1892,6 +2170,11 @@ export default {
opacity: 0.58;
}
+.skill-list-item--selected {
+ background: rgba(var(--v-theme-primary), 0.06);
+ border-color: rgba(var(--v-theme-primary), 0.5);
+}
+
.skill-list-item :deep(.outlined-action-list-item__content) {
flex: 1 1 auto;
}
@@ -1932,6 +2215,22 @@ export default {
color: rgb(var(--v-theme-on-surface));
}
+.batch-delete-targets {
+ background: transparent;
+ max-height: 240px;
+ overflow-y: auto;
+ padding: 0;
+}
+
+.batch-delete-target {
+ background: rgba(var(--v-theme-on-surface), 0.05);
+ border-radius: 8px;
+}
+
+.batch-delete-target + .batch-delete-target {
+ margin-top: 6px;
+}
+
.skills-fab-stack {
align-items: center;
bottom: 52px;
@@ -2411,7 +2710,7 @@ export default {
.skills-list {
grid-template-columns: minmax(0, 1fr);
}
-
+
.skill-editor {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
@@ -2467,6 +2766,15 @@ export default {
}
@media (max-width: 640px) {
+ .skills-list-header {
+ align-items: stretch;
+ }
+
+ .skills-list-actions {
+ margin-left: 0;
+ width: 100%;
+ }
+
.skills-upload-dialog {
max-height: 92vh;
}
diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json
index 49ce481c60..4b556e78ef 100644
--- a/dashboard/src/i18n/locales/en-US/features/extension.json
+++ b/dashboard/src/i18n/locales/en-US/features/extension.json
@@ -382,6 +382,16 @@
"deleteMessage": "Are you sure you want to delete this Skill?",
"deleteSuccess": "Deleted successfully",
"deleteFailed": "Delete failed",
+ "select": "Select",
+ "selectSkill": "Select Skill {name}",
+ "selectAll": "Select all",
+ "clearSelection": "Clear selection",
+ "deleteSelected": "Delete selected ({count})",
+ "batchDeleteTitle": "Delete selected Skills",
+ "batchDeleteMessage": "Are you sure you want to delete these {count} Skills? This action cannot be undone.",
+ "batchDeleteConfirm": "Delete {count}",
+ "batchDeleteSuccess": "All {count} skill(s) deleted successfully.",
+ "batchDeletePartial": "Deleted {succeeded}; failed to delete {failed}",
"neoSkillKey": "Filter by skill_key",
"neoStatus": "Candidate Status",
"neoStage": "Release Stage",
diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json
index 6eb112b0fd..52f25cff31 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/extension.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json
@@ -377,6 +377,16 @@
"deleteMessage": "Вы уверены, что хотите удалить этот навык?",
"deleteSuccess": "Удалено",
"deleteFailed": "Ошибка удаления",
+ "select": "Выбрать",
+ "selectSkill": "Выбрать навык {name}",
+ "selectAll": "Выбрать все",
+ "clearSelection": "Снять выделение",
+ "deleteSelected": "Удалить выбранные ({count})",
+ "batchDeleteTitle": "Массовое удаление навыков",
+ "batchDeleteMessage": "Удалить выбранные навыки ({count})? Это действие нельзя отменить.",
+ "batchDeleteConfirm": "Удалить ({count})",
+ "batchDeleteSuccess": "Удалено навыков: {count}",
+ "batchDeletePartial": "Удалено: {succeeded}; ошибок: {failed}",
"neoSkillKey": "Фильтр по ключу",
"neoStatus": "Статус кандидата",
"neoStage": "Этап публикации",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json
index 476bacd051..3525246988 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/extension.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json
@@ -382,6 +382,16 @@
"deleteMessage": "确定要删除该技能吗?",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
+ "select": "选择",
+ "selectSkill": "选择技能 {name}",
+ "selectAll": "全选",
+ "clearSelection": "清除选择",
+ "deleteSelected": "删除所选({count})",
+ "batchDeleteTitle": "批量删除技能",
+ "batchDeleteMessage": "确定要删除以下 {count} 个技能吗?此操作无法撤销。",
+ "batchDeleteConfirm": "删除 {count} 个技能",
+ "batchDeleteSuccess": "已删除 {count} 个技能",
+ "batchDeletePartial": "已删除 {succeeded} 个,{failed} 个删除失败",
"neoSkillKey": "skill_key 过滤",
"neoStatus": "候选状态",
"neoStage": "发布阶段",