From dd905cec4279c96c8b7d65a758299d69f147c880 Mon Sep 17 00:00:00 2001
From: Ars1027 <1062642212@qq.com>
Date: Tue, 1 Sep 2026 17:58:36 +0800
Subject: [PATCH 1/5] feat: implement batch deletion functionality for skills
with confirmation dialog
---
.../components/extension/SkillsSection.vue | 309 +++++++++++++++++-
.../locales/en-US/features/extension.json | 10 +
.../locales/ru-RU/features/extension.json | 10 +
.../locales/zh-CN/features/extension.json | 10 +
dashboard/tests/skillsBatchDelete.test.mjs | 194 +++++++++++
5 files changed, 525 insertions(+), 8 deletions(-)
create mode 100644 dashboard/tests/skillsBatchDelete.test.mjs
diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue
index 706e3ec2b9..e894cb35fb 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,6 +1340,17 @@ 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;
+ }
} catch (_err) {
showMessage(tm("skills.loadFailed"), "error");
} finally {
@@ -1275,6 +1426,97 @@ 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);
+ }
+ }
+
+ await fetchSkills();
+ const currentDeletableNames = new Set(
+ skills.value
+ .filter((skill) => !isReadOnlySourceSkill(skill))
+ .map((skill) => skill.name),
+ );
+ selectedSkillNames.value = failed.filter((name) =>
+ currentDeletableNames.has(name),
+ );
+ batchDeleteDialog.value = false;
+ batchDeleteTargets.value = [];
+
+ 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 +2017,7 @@ export default {
watch(mode, async (nextMode) => {
if (nextMode === "neo") {
+ cancelBatchSelection();
await loadNeoAvailability();
if (neoEnabled.value) {
await fetchNeoData();
@@ -1815,6 +2058,11 @@ export default {
itemLoading,
deleteDialog,
deleting,
+ batchSelectionEnabled,
+ selectedSkillNames,
+ batchDeleteTargets,
+ batchDeleteDialog,
+ batchDeleting,
snackbar,
neoEnabled,
neoUnavailableMessage,
@@ -1825,6 +2073,8 @@ export default {
candidateStatusItems,
releaseStageItems,
activeReleaseCount,
+ deletableSkills,
+ allDeletableSelected,
candidateHeaders,
releaseHeaders,
payloadDialog,
@@ -1853,6 +2103,11 @@ export default {
toggleSkill,
confirmDelete,
deleteSkill,
+ startBatchSelection,
+ cancelBatchSelection,
+ toggleSelectAll,
+ confirmBatchDelete,
+ deleteSelectedSkills,
evaluateCandidate,
promoteCandidate,
isCandidatePromoteLoading,
@@ -1880,10 +2135,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 +2164,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 +2209,13 @@ export default {
color: rgb(var(--v-theme-on-surface));
}
+.batch-delete-targets {
+ border: 1px solid rgba(var(--v-theme-on-surface), 0.16);
+ border-radius: 10px;
+ max-height: 240px;
+ overflow-y: auto;
+}
+
.skills-fab-stack {
align-items: center;
bottom: 52px;
@@ -2411,7 +2695,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 +2751,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..d0a2ab504c 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": "Deleted {count} Skill(s)",
+ "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": "发布阶段",
diff --git a/dashboard/tests/skillsBatchDelete.test.mjs b/dashboard/tests/skillsBatchDelete.test.mjs
new file mode 100644
index 0000000000..4c0ae00ca1
--- /dev/null
+++ b/dashboard/tests/skillsBatchDelete.test.mjs
@@ -0,0 +1,194 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const skillsSectionSource = readFileSync(
+ new URL("../src/components/extension/SkillsSection.vue", import.meta.url),
+ "utf8",
+);
+
+function instantiateHandler(name, scope) {
+ const startMarker = ` const ${name} =`;
+ const start = skillsSectionSource.indexOf(startMarker);
+ assert.notEqual(start, -1, `Missing ${name} handler`);
+
+ const endMarker = "\n };";
+ const end = skillsSectionSource.indexOf(endMarker, start);
+ assert.notEqual(end, -1, `Missing end of ${name} handler`);
+
+ const declaration = skillsSectionSource.slice(start, end + endMarker.length);
+ const scopeNames = Object.keys(scope);
+ const factory = new Function(
+ ...scopeNames,
+ `"use strict";\n${declaration}\nreturn ${name};`,
+ );
+ return factory(...scopeNames.map((scopeName) => scope[scopeName]));
+}
+
+test("batch deletion runs serially and resets selection after success", async () => {
+ const calls = [];
+ const messages = [];
+ const skills = { value: [{ name: "alpha" }, { name: "beta" }] };
+ let activeRequests = 0;
+ let maxActiveRequests = 0;
+ let fetchCount = 0;
+
+ const scope = {
+ batchDeleting: { value: false },
+ batchDeleteTargets: { value: ["alpha", "beta"] },
+ skillApi: {
+ delete: async (name) => {
+ calls.push(name);
+ activeRequests += 1;
+ maxActiveRequests = Math.max(maxActiveRequests, activeRequests);
+ await new Promise((resolve) => setImmediate(resolve));
+ activeRequests -= 1;
+ return { data: { status: "ok" } };
+ },
+ },
+ fetchSkills: async () => {
+ fetchCount += 1;
+ skills.value = [];
+ },
+ skills,
+ isReadOnlySourceSkill: () => false,
+ selectedSkillNames: { value: ["alpha", "beta"] },
+ batchDeleteDialog: { value: true },
+ batchSelectionEnabled: { value: true },
+ showMessage: (message, color) => messages.push({ message, color }),
+ tm: (key, params) => `${key}:${JSON.stringify(params)}`,
+ };
+ const deleteSelectedSkills = instantiateHandler(
+ "deleteSelectedSkills",
+ scope,
+ );
+
+ await deleteSelectedSkills();
+
+ assert.deepEqual(calls, ["alpha", "beta"]);
+ assert.equal(maxActiveRequests, 1);
+ assert.equal(fetchCount, 1);
+ assert.deepEqual(scope.selectedSkillNames.value, []);
+ assert.deepEqual(scope.batchDeleteTargets.value, []);
+ assert.equal(scope.batchDeleteDialog.value, false);
+ assert.equal(scope.batchSelectionEnabled.value, false);
+ assert.equal(scope.batchDeleting.value, false);
+ assert.deepEqual(messages, [
+ {
+ message: 'skills.batchDeleteSuccess:{"count":2}',
+ color: "success",
+ },
+ ]);
+});
+
+test("batch deletion continues after failures and retains retryable names", async () => {
+ const calls = [];
+ const messages = [];
+ const skills = {
+ value: [{ name: "alpha" }, { name: "beta" }, { name: "gamma" }],
+ };
+
+ const scope = {
+ batchDeleting: { value: false },
+ batchDeleteTargets: { value: ["alpha", "beta", "gamma"] },
+ skillApi: {
+ delete: async (name) => {
+ calls.push(name);
+ if (name === "beta") return { data: { status: "error" } };
+ if (name === "gamma") throw new Error("network unavailable");
+ return { data: { status: "ok" } };
+ },
+ },
+ fetchSkills: async () => {
+ skills.value = [{ name: "beta" }, { name: "gamma" }];
+ },
+ skills,
+ isReadOnlySourceSkill: () => false,
+ selectedSkillNames: { value: ["alpha", "beta", "gamma"] },
+ batchDeleteDialog: { value: true },
+ batchSelectionEnabled: { value: true },
+ showMessage: (message, color) => messages.push({ message, color }),
+ tm: (key, params) => `${key}:${JSON.stringify(params)}`,
+ };
+ const deleteSelectedSkills = instantiateHandler(
+ "deleteSelectedSkills",
+ scope,
+ );
+
+ await deleteSelectedSkills();
+
+ assert.deepEqual(calls, ["alpha", "beta", "gamma"]);
+ assert.deepEqual(scope.selectedSkillNames.value, ["beta", "gamma"]);
+ assert.equal(scope.batchSelectionEnabled.value, true);
+ assert.equal(scope.batchDeleteDialog.value, false);
+ assert.equal(scope.batchDeleting.value, false);
+ assert.deepEqual(messages, [
+ {
+ message: 'skills.batchDeletePartial:{"succeeded":1,"failed":2}',
+ color: "warning",
+ },
+ ]);
+});
+
+test("batch confirmation excludes names outside the deletable inventory", () => {
+ const scope = {
+ selectedSkillNames: {
+ value: ["local", "synced-local", "plugin", "sandbox-preset"],
+ },
+ batchDeleteTargets: { value: [] },
+ deletableSkills: {
+ value: [{ name: "local" }, { name: "synced-local" }],
+ },
+ batchDeleteDialog: { value: false },
+ };
+ const confirmBatchDelete = instantiateHandler("confirmBatchDelete", scope);
+
+ confirmBatchDelete();
+
+ assert.deepEqual(scope.batchDeleteTargets.value, ["local", "synced-local"]);
+ assert.equal(scope.batchDeleteDialog.value, true);
+ assert.match(
+ skillsSectionSource,
+ /batchSelectionEnabled\s*&&\s*!isReadOnlySourceSkill\(skill\)/,
+ );
+
+ scope.selectedSkillNames.value = [];
+ scope.batchDeleteDialog.value = false;
+ confirmBatchDelete();
+ assert.deepEqual(scope.batchDeleteTargets.value, []);
+ assert.equal(scope.batchDeleteDialog.value, false);
+});
+
+test("existing single-skill deletion remains compatible", async () => {
+ const calls = [];
+ let fetchCount = 0;
+ const scope = {
+ skillToDelete: { value: { name: "legacy-skill" } },
+ deleting: { value: false },
+ skillApi: {
+ delete: async (name) => {
+ calls.push(name);
+ return { data: { status: "ok" } };
+ },
+ },
+ handleApiResponse: (res, _success, _failure, onSuccess) => {
+ assert.equal(res.data.status, "ok");
+ onSuccess();
+ },
+ tm: (key) => key,
+ deleteDialog: { value: true },
+ fetchSkills: async () => {
+ fetchCount += 1;
+ },
+ showMessage: () => assert.fail("Unexpected single-delete failure"),
+ };
+ const deleteSkill = instantiateHandler("deleteSkill", scope);
+
+ await deleteSkill();
+
+ assert.deepEqual(calls, ["legacy-skill"]);
+ assert.equal(fetchCount, 1);
+ assert.equal(scope.deleteDialog.value, false);
+ assert.equal(scope.deleting.value, false);
+ assert.match(skillsSectionSource, /@click\.stop="confirmDelete\(skill\)"/);
+});
From 5a38a2665e3a9eabf763f02d2d0cead4b59ee599 Mon Sep 17 00:00:00 2001
From: Ars1027 <1062642212@qq.com>
Date: Tue, 1 Sep 2026 20:07:02 +0800
Subject: [PATCH 2/5] fix(dashboard): refine skill batch deletion feedback
---
.../components/extension/SkillsSection.vue | 20 ++++++++++++-------
.../locales/en-US/features/extension.json | 2 +-
.../locales/ru-RU/features/extension.json | 2 +-
.../locales/zh-CN/features/extension.json | 2 +-
4 files changed, 16 insertions(+), 10 deletions(-)
diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue
index e894cb35fb..948216cb40 100644
--- a/dashboard/src/components/extension/SkillsSection.vue
+++ b/dashboard/src/components/extension/SkillsSection.vue
@@ -727,14 +727,11 @@
})
}}
-
+
@@ -2210,10 +2207,19 @@ export default {
}
.batch-delete-targets {
- border: 1px solid rgba(var(--v-theme-on-surface), 0.16);
- border-radius: 10px;
+ 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 {
diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json
index d0a2ab504c..4b556e78ef 100644
--- a/dashboard/src/i18n/locales/en-US/features/extension.json
+++ b/dashboard/src/i18n/locales/en-US/features/extension.json
@@ -390,7 +390,7 @@
"batchDeleteTitle": "Delete selected Skills",
"batchDeleteMessage": "Are you sure you want to delete these {count} Skills? This action cannot be undone.",
"batchDeleteConfirm": "Delete {count}",
- "batchDeleteSuccess": "Deleted {count} Skill(s)",
+ "batchDeleteSuccess": "All {count} skill(s) deleted successfully.",
"batchDeletePartial": "Deleted {succeeded}; failed to delete {failed}",
"neoSkillKey": "Filter by skill_key",
"neoStatus": "Candidate Status",
diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json
index 52f25cff31..e519b29558 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/extension.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json
@@ -385,7 +385,7 @@
"batchDeleteTitle": "Массовое удаление навыков",
"batchDeleteMessage": "Удалить выбранные навыки ({count})? Это действие нельзя отменить.",
"batchDeleteConfirm": "Удалить ({count})",
- "batchDeleteSuccess": "Удалено навыков: {count}",
+ "batchDeleteSuccess": "All {count} skill(s) deleted successfully.",
"batchDeletePartial": "Удалено: {succeeded}; ошибок: {failed}",
"neoSkillKey": "Фильтр по ключу",
"neoStatus": "Статус кандидата",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json
index 3525246988..67f8e8dd1b 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/extension.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json
@@ -390,7 +390,7 @@
"batchDeleteTitle": "批量删除技能",
"batchDeleteMessage": "确定要删除以下 {count} 个技能吗?此操作无法撤销。",
"batchDeleteConfirm": "删除 {count} 个技能",
- "batchDeleteSuccess": "已删除 {count} 个技能",
+ "batchDeleteSuccess": "All {count} skill(s) deleted successfully.",
"batchDeletePartial": "已删除 {succeeded} 个,{failed} 个删除失败",
"neoSkillKey": "skill_key 过滤",
"neoStatus": "候选状态",
From 7e8288e1e4da954bf9bc439b5e4a5ff0742a0fa7 Mon Sep 17 00:00:00 2001
From: Ars1027 <1062642212@qq.com>
Date: Tue, 1 Sep 2026 20:37:32 +0800
Subject: [PATCH 3/5] fix(dashboard): preserve batch deletion feedback
---
.../components/extension/SkillsSection.vue | 27 +++++++----
.../locales/ru-RU/features/extension.json | 2 +-
.../locales/zh-CN/features/extension.json | 2 +-
dashboard/tests/skillsBatchDelete.test.mjs | 45 +++++++++++++++++++
4 files changed, 65 insertions(+), 11 deletions(-)
diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue
index 948216cb40..826000c6fe 100644
--- a/dashboard/src/components/extension/SkillsSection.vue
+++ b/dashboard/src/components/extension/SkillsSection.vue
@@ -1348,8 +1348,10 @@ export default {
if (batchSelectionEnabled.value && deletableNames.size === 0) {
batchSelectionEnabled.value = false;
}
+ return true;
} catch (_err) {
showMessage(tm("skills.loadFailed"), "error");
+ return false;
} finally {
loading.value = false;
}
@@ -1482,18 +1484,25 @@ export default {
}
}
- await fetchSkills();
- const currentDeletableNames = new Set(
- skills.value
- .filter((skill) => !isReadOnlySourceSkill(skill))
- .map((skill) => skill.name),
- );
- selectedSkillNames.value = failed.filter((name) =>
- currentDeletableNames.has(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(
diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json
index e519b29558..52f25cff31 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/extension.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json
@@ -385,7 +385,7 @@
"batchDeleteTitle": "Массовое удаление навыков",
"batchDeleteMessage": "Удалить выбранные навыки ({count})? Это действие нельзя отменить.",
"batchDeleteConfirm": "Удалить ({count})",
- "batchDeleteSuccess": "All {count} skill(s) deleted successfully.",
+ "batchDeleteSuccess": "Удалено навыков: {count}",
"batchDeletePartial": "Удалено: {succeeded}; ошибок: {failed}",
"neoSkillKey": "Фильтр по ключу",
"neoStatus": "Статус кандидата",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json
index 67f8e8dd1b..3525246988 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/extension.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json
@@ -390,7 +390,7 @@
"batchDeleteTitle": "批量删除技能",
"batchDeleteMessage": "确定要删除以下 {count} 个技能吗?此操作无法撤销。",
"batchDeleteConfirm": "删除 {count} 个技能",
- "batchDeleteSuccess": "All {count} skill(s) deleted successfully.",
+ "batchDeleteSuccess": "已删除 {count} 个技能",
"batchDeletePartial": "已删除 {succeeded} 个,{failed} 个删除失败",
"neoSkillKey": "skill_key 过滤",
"neoStatus": "候选状态",
diff --git a/dashboard/tests/skillsBatchDelete.test.mjs b/dashboard/tests/skillsBatchDelete.test.mjs
index 4c0ae00ca1..198f7a9814 100644
--- a/dashboard/tests/skillsBatchDelete.test.mjs
+++ b/dashboard/tests/skillsBatchDelete.test.mjs
@@ -49,6 +49,7 @@ test("batch deletion runs serially and resets selection after success", async ()
fetchSkills: async () => {
fetchCount += 1;
skills.value = [];
+ return true;
},
skills,
isReadOnlySourceSkill: () => false,
@@ -101,6 +102,7 @@ test("batch deletion continues after failures and retains retryable names", asyn
},
fetchSkills: async () => {
skills.value = [{ name: "beta" }, { name: "gamma" }];
+ return true;
},
skills,
isReadOnlySourceSkill: () => false,
@@ -130,6 +132,49 @@ test("batch deletion continues after failures and retains retryable names", asyn
]);
});
+test("batch deletion preserves refresh errors and retryable targets", async () => {
+ const calls = [];
+ const messages = [];
+ const scope = {
+ batchDeleting: { value: false },
+ batchDeleteTargets: { value: ["alpha", "beta"] },
+ skillApi: {
+ delete: async (name) => {
+ calls.push(name);
+ if (name === "beta") return { data: { status: "error" } };
+ return { data: { status: "ok" } };
+ },
+ },
+ fetchSkills: async () => {
+ messages.push({ message: "skills.loadFailed", color: "error" });
+ return false;
+ },
+ skills: { value: [{ name: "alpha" }, { name: "beta" }] },
+ isReadOnlySourceSkill: () => false,
+ selectedSkillNames: { value: ["alpha", "beta"] },
+ batchDeleteDialog: { value: true },
+ batchSelectionEnabled: { value: true },
+ showMessage: (message, color) => messages.push({ message, color }),
+ tm: (key, params) => `${key}:${JSON.stringify(params)}`,
+ };
+ const deleteSelectedSkills = instantiateHandler(
+ "deleteSelectedSkills",
+ scope,
+ );
+
+ await deleteSelectedSkills();
+
+ assert.deepEqual(calls, ["alpha", "beta"]);
+ assert.deepEqual(scope.selectedSkillNames.value, ["beta"]);
+ assert.equal(scope.batchSelectionEnabled.value, true);
+ assert.equal(scope.batchDeleteDialog.value, false);
+ assert.deepEqual(scope.batchDeleteTargets.value, []);
+ assert.equal(scope.batchDeleting.value, false);
+ assert.deepEqual(messages, [
+ { message: "skills.loadFailed", color: "error" },
+ ]);
+});
+
test("batch confirmation excludes names outside the deletable inventory", () => {
const scope = {
selectedSkillNames: {
From 91b69dd7a21f517d591a5a006081e454c058e876 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Tue, 1 Sep 2026 23:35:57 +0800
Subject: [PATCH 4/5] fix(dashboard): improve skill selection icon
---
.../mdi-subset/materialdesignicons-subset.css | 6 +++++-
.../materialdesignicons-webfont-subset.woff | Bin 19028 -> 19160 bytes
.../materialdesignicons-webfont-subset.woff2 | Bin 15300 -> 15408 bytes
.../components/extension/SkillsSection.vue | 2 +-
4 files changed, 6 insertions(+), 2 deletions(-)
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 7a31dd05c843442b8576434dc136714a04864d6c..c375b29714991759ee7cf3a9b37f57b0c26764ac 100644
GIT binary patch
delta 18181
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