Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions dashboard/src/components/shared/ExtensionCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ const props = defineProps({
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
selected: {
type: Boolean,
default: false,
},
selectionMode: {
type: Boolean,
default: false,
},
});
Comment on lines +28 to 40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Add the selectionMode prop to support showing checkboxes on all cards when selection mode is active.

  selectable: {
    type: Boolean,
    default: false,
  },
  selected: {
    type: Boolean,
    default: false,
  },
  selectionMode: {
    type: Boolean,
    default: false,
  },
});


// 定义要发送到父组件的事件
Expand All @@ -39,6 +51,7 @@ const emit = defineEmits([
"view-changelog",
"toggle-pin",
"open-webui",
"toggle-select",
]);

const hasPages = computed(() => {
Expand Down Expand Up @@ -165,6 +178,23 @@ const openWebui = () => {
: '#ffffffdd',
}"
>
<!-- 选择模式下的复选框覆盖层:悬停时或选中/选择模式下可见 -->
<div
v-if="selectable && !extension.reserved"
class="extension-select-overlay"
:class="{ 'show-overlay': selected || selectionMode }"
@click.stop="$emit('toggle-select')"
>
<v-checkbox
:model-value="selected"
density="compact"
color="primary"
hide-details
class="extension-select-checkbox"
style="pointer-events: none"
/>
</div>

<v-card-text class="extension-card-text">
<div class="extension-content-row">
<div class="extension-image-container">
Expand Down Expand Up @@ -416,6 +446,27 @@ const openWebui = () => {
</template>

<style scoped>
.extension-select-overlay {
position: absolute;
top: 0;
left: 0;
z-index: 10;
padding: 4px;
opacity: 0;
transition: opacity 0.2s ease;
}

.extension-card:hover .extension-select-overlay,
.extension-select-overlay.show-overlay {
opacity: 1;
}

.extension-select-checkbox {
background: rgba(var(--v-theme-surface), 0.92);
border-radius: 6px;
padding: 2px;
}
Comment on lines +449 to +468

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Add hover and active selection state styles to the overlay. This keeps the checkbox hidden by default to maintain a clean UI, but reveals it gracefully on hover or when selection mode is active.

.extension-select-overlay {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  padding: 4px;
  opacity: 0;
  transition: opacity 0.2s;
}

.extension-card:hover .extension-select-overlay,
.extension-select-overlay.show-overlay {
  opacity: 1;
}

.extension-select-checkbox {
  background: rgba(var(--v-theme-surface), 0.92);
  border-radius: 6px;
  padding: 2px;
}


.extension-card-text {
padding: 12px 14px 8px;
width: 100%;
Expand Down
21 changes: 21 additions & 0 deletions dashboard/src/i18n/locales/en-US/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,27 @@
"goToManage": "Go to Manage",
"later": "Later"
},
"batch": {
"selected": "{count} plugin(s) selected",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"clearSelection": "Clear Selection",
"enable": "Batch Enable",
"disable": "Batch Disable",
"uninstall": "Batch Uninstall",
"noEnabledPlugins": "No enabled plugins to disable",
"noDisabledPlugins": "No disabled plugins to enable",
"enableSuccess": "Successfully enabled {count} plugin(s)",
"enablePartial": "{success} enabled, {failed} failed",
"disableSuccess": "Successfully disabled {count} plugin(s)",
"disablePartial": "{success} disabled, {failed} failed",
"uninstallSuccess": "Successfully uninstalled {count} plugin(s)",
"uninstallPartial": "{success} uninstalled, {failed} failed",
"noPluginsToUninstall": "No plugins selected to uninstall",
"uninstallConfirmTitle": "Confirm Batch Uninstall",
"uninstallConfirmMessage": "Are you sure you want to uninstall {count} selected plugin(s)? This action cannot be undone.",
"uninstallConfirm": "Confirm Uninstall"
},
"pluginChangelog": {
"menuTitle": "View Changelog"
}
Expand Down
21 changes: 21 additions & 0 deletions dashboard/src/i18n/locales/zh-CN/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,27 @@
"goToManage": "前往处理",
"later": "稍后处理"
},
"batch": {
"selected": "已选中 {count} 个插件",
"selectAll": "全选",
"deselectAll": "取消全选",
"clearSelection": "取消选择",
"enable": "批量启用",
"disable": "批量禁用",
"uninstall": "批量卸载",
"noEnabledPlugins": "没有已启用的插件需要禁用",
"noDisabledPlugins": "没有已禁用的插件需要启用",
"enableSuccess": "成功启用 {count} 个插件",
"enablePartial": "{success} 个启用成功,{failed} 个失败",
"disableSuccess": "成功禁用 {count} 个插件",
"disablePartial": "{success} 个禁用成功,{failed} 个失败",
"uninstallSuccess": "成功卸载 {count} 个插件",
"uninstallPartial": "{success} 个卸载成功,{failed} 个失败",
"noPluginsToUninstall": "没有选中的插件可以卸载",
"uninstallConfirmTitle": "批量卸载确认",
"uninstallConfirmMessage": "确定要卸载选中的 {count} 个插件吗?此操作不可撤销。",
"uninstallConfirm": "确认卸载"
},
"pluginChangelog": {
"menuTitle": "查看更新日志"
}
Expand Down
162 changes: 160 additions & 2 deletions dashboard/src/views/extension/InstalledPluginsTab.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup>
import PluginSortControl from "@/components/extension/PluginSortControl.vue";
import ExtensionCard from "@/components/shared/ExtensionCard.vue";
import { normalizeTextInput } from "@/utils/inputValue";
import {
Expand Down Expand Up @@ -73,6 +74,8 @@ const {
refreshingMarket,
sortBy,
sortOrder,
sortInstalledBy,
sortInstalledOrder,
randomPluginNames,
normalizeStr,
toPinyinText,
Expand Down Expand Up @@ -141,6 +144,20 @@ const {
refreshPluginMarket,
handleLocaleChange,
searchDebounceTimer,
selectedPluginNames,
selectModeActive,
selectedCount,
selectableInstalledPlugins,
isAllSelected,
toggleSelectPlugin,
toggleSelectAll,
clearSelection,
batchEnable,
batchDisable,
requestBatchUninstall,
executeBatchUninstall,
cancelBatchUninstall,
batchUninstallConfirmDialog,
} = props.state;

const openPluginDetail = (extension) => {
Expand All @@ -166,6 +183,14 @@ const openPluginWebui = (extension) => {

const pinnedExtensionNames = ref(readPinnedExtensions());

const installedSortItems = computed(() => [
{ title: tm("sort.default"), value: "default" },
{ title: tm("sort.name"), value: "name" },
{ title: tm("sort.author"), value: "author" },
{ title: tm("sort.updated"), value: "updated" },
{ title: tm("sort.updateStatus"), value: "updateStatus" },
]);

const pinnedExtensionOrder = computed(() => {
const order = new Map();
pinnedExtensionNames.value.forEach((name, index) => {
Expand All @@ -176,7 +201,40 @@ const pinnedExtensionOrder = computed(() => {

const sortedInstalledPlugins = computed(() => {
const order = pinnedExtensionOrder.value;
return [...filteredPlugins.value].sort((a, b) => {
let plugins = [...filteredPlugins.value];

// Apply user-selected sort first, then pin-sort as a stable override.
// Pinned items always stay on top; the user sort determines order within
// non-pinned items (all of which have pin-order Infinity, so sort() treats
// them as equal and preserves the previous user-sort ordering).
if (sortInstalledBy.value === "name") {
plugins.sort((a, b) => {
const r = (a?.name || "").localeCompare(b?.name || "");
return sortInstalledOrder.value === "desc" ? -r : r;
});
} else if (sortInstalledBy.value === "author") {
plugins.sort((a, b) => {
const r = ((a?.author || "").toLowerCase()).localeCompare(
(b?.author || "").toLowerCase(),
);
return sortInstalledOrder.value === "desc" ? -r : r;
});
} else if (sortInstalledBy.value === "updated") {
plugins.sort((a, b) => {
const da = a?.updated_at ? new Date(a.updated_at).getTime() : 0;
const db = b?.updated_at ? new Date(b.updated_at).getTime() : 0;
return sortInstalledOrder.value === "desc" ? db - da : da - db;
});
} else if (sortInstalledBy.value === "updateStatus") {
plugins.sort((a, b) => {
const ua = a?.has_update ? 1 : 0;
const ub = b?.has_update ? 1 : 0;
return sortInstalledOrder.value === "desc" ? ub - ua : ua - ub;
});
}

// Pinned items always stay on top, respecting their pinned order
return plugins.sort((a, b) => {
const aIndex = order.has(a?.name)
? order.get(a.name)
: Number.POSITIVE_INFINITY;
Expand Down Expand Up @@ -237,6 +295,16 @@ const togglePinnedExtension = (extension) => {
style="min-width: 220px; max-width: 340px"
>
</v-text-field>
<PluginSortControl
v-model="sortInstalledBy"
:items="installedSortItems"
:label="tm('sort.by')"
:order="sortInstalledOrder"
:ascending-label="tm('sort.ascending')"
:descending-label="tm('sort.descending')"
:show-order="sortInstalledBy !== 'default'"
@update:order="sortInstalledOrder = $event"
/>
</div>
</div>
</div>
Expand Down Expand Up @@ -315,6 +383,88 @@ const togglePinnedExtension = (extension) => {
</v-card-text>
</v-card>

<!-- 批量操作工具栏 -->
<v-slide-y-transition>
<v-card
v-if="selectedCount > 0"
class="mb-4 rounded-lg"
color="primary"
variant="tonal"
>
<v-card-text class="d-flex align-center flex-wrap py-3" style="gap: 8px">
<span class="text-body-1 font-weight-medium mr-2">
{{ tm("batch.selected", { count: selectedCount }) }}
</span>
<v-btn
size="small"
variant="text"
@click="toggleSelectAll"
>
{{ isAllSelected ? tm("batch.deselectAll") : tm("batch.selectAll") }}
</v-btn>
<v-divider vertical class="mx-1" />
<v-btn
size="small"
color="success"
variant="tonal"
prepend-icon="mdi-check-circle"
@click="batchEnable"
>
{{ tm("batch.enable") }}
</v-btn>
<v-btn
size="small"
color="warning"
variant="tonal"
prepend-icon="mdi-cancel"
@click="batchDisable"
>
{{ tm("batch.disable") }}
</v-btn>
<v-btn
size="small"
color="error"
variant="tonal"
prepend-icon="mdi-delete"
@click="requestBatchUninstall"
>
{{ tm("batch.uninstall") }}
</v-btn>
<v-spacer />
<v-btn
size="small"
variant="text"
@click="clearSelection"
>
{{ tm("batch.clearSelection") }}
</v-btn>
</v-card-text>
</v-card>
</v-slide-y-transition>

<!-- 批量卸载确认对话框 -->
<v-dialog v-model="batchUninstallConfirmDialog" max-width="500px">
<v-card>
<v-card-title class="bg-error text-white py-3">
<v-icon color="white" class="me-2">mdi-alert</v-icon>
<span>{{ tm("batch.uninstallConfirmTitle") }}</span>
</v-card-title>
<v-card-text class="py-4">
<p>{{ tm("batch.uninstallConfirmMessage", { count: selectedCount }) }}</p>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="cancelBatchUninstall">
{{ tm("buttons.cancel") }}
</v-btn>
<v-btn color="error" @click="executeBatchUninstall">
{{ tm("batch.uninstallConfirm") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>

<v-fade-transition hide-on-leave>
<div>
<v-row v-if="sortedInstalledPlugins.length === 0" class="text-center">
Expand All @@ -340,10 +490,18 @@ const togglePinnedExtension = (extension) => {
<ExtensionCard
:extension="extension"
:is-pinned="isPinnedExtension(extension)"
selectable
:selection-mode="selectModeActive"
:selected="selectedPluginNames.has(extension.name)"
Comment on lines 490 to +495

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

There is a chicken-and-egg usability bug here: selectable is bound to selectModeActive, which is initially false. Because it is false, the checkbox overlay on the plugin cards is never rendered, meaning the user has no way to click a checkbox to enter selection mode in the first place.

To fix this, we should pass selectable as true (or simply selectable) so that the checkboxes are always available for selection, and pass selectModeActive as a separate selection-mode prop to control the active selection state.

            <ExtensionCard
              :extension="extension"
              :is-pinned="isPinnedExtension(extension)"
              selectable
              :selection-mode="selectModeActive"
              :selected="selectedPluginNames.has(extension.name)"

class="rounded-lg"
style="background-color: rgb(var(--v-theme-mcpCardBg))"
@click="openPluginDetail(extension)"
@click="
selectModeActive
? toggleSelectPlugin(extension.name)
: openPluginDetail(extension)
"
@toggle-pin="togglePinnedExtension(extension)"
@toggle-select="toggleSelectPlugin(extension.name)"
@configure="openExtensionConfig(extension.name)"
@uninstall="
(ext, options) => uninstallExtension(ext.name, options)
Expand Down
Loading