-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
feat: add sort and batch operations to installed plugins page #8856
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
| }, | ||
| }); | ||
|
|
||
| // 定义要发送到父组件的事件 | ||
|
|
@@ -39,6 +51,7 @@ const emit = defineEmits([ | |
| "view-changelog", | ||
| "toggle-pin", | ||
| "open-webui", | ||
| "toggle-select", | ||
| ]); | ||
|
|
||
| const hasPages = computed(() => { | ||
|
|
@@ -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"> | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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-card-text { | ||
| padding: 12px 14px 8px; | ||
| width: 100%; | ||
|
|
||
| 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 { | ||
|
|
@@ -73,6 +74,8 @@ const { | |
| refreshingMarket, | ||
| sortBy, | ||
| sortOrder, | ||
| sortInstalledBy, | ||
| sortInstalledOrder, | ||
| randomPluginNames, | ||
| normalizeStr, | ||
| toPinyinText, | ||
|
|
@@ -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) => { | ||
|
|
@@ -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) => { | ||
|
|
@@ -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; | ||
|
|
@@ -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> | ||
|
|
@@ -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"> | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a chicken-and-egg usability bug here: To fix this, we should pass |
||
| 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) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add the
selectionModeprop to support showing checkboxes on all cards when selection mode is active.