Skip to content

Commit 795aec9

Browse files
authored
feat(extension): add filtering and sorting for installed plugins in WebUI (AstrBotDevs#5923)
* feat(extension): add PluginSortControl reusable component for sorting * i18n: add i18n keys for plugin sorting and filtering features * feat(extension): add sorting and status filtering for installed plugins Backend changes (plugin.py): - Add _resolve_plugin_dir method to resolve plugin directory path - Add _get_plugin_installed_at method to get installation time from file mtime - Add installed_at field to plugin API response Frontend changes (InstalledPluginsTab.vue): - Import PluginSortControl component - Add status filter toggle (all/enabled/disabled) using v-btn-toggle - Integrate PluginSortControl for sorting options - Add toolbar layout with actions and controls sections Frontend changes (MarketPluginsTab.vue): - Import PluginSortControl component - Replace v-select + v-btn combination with unified PluginSortControl Frontend changes (useExtensionPage.js): - Add installedStatusFilter, installedSortBy, installedSortOrder refs - Add installedSortItems and installedSortUsesOrder computed properties - Add sortInstalledPlugins function with multi-criteria support - Support sorting by install time, name, author, and update status - Add status filtering in filteredPlugins computed property - Disable default table sorting by setting sortable: false * test: add tests for installed_at field in plugin API - Assert all plugins have installed_at field in get_plugins response - Assert installed_at is not null after plugin installation * fix(extension): add explicit fallbacks for installed plugin sort comparisons * i18n(extension): rename install time label to last modified * fix(extension): cache installed_at parsing and validate timestamp format in tests * test(dashboard): strengthen installed_at coverage for plugin API
1 parent 7d31140 commit 795aec9

8 files changed

Lines changed: 425 additions & 76 deletions

File tree

astrbot/dashboard/routes/plugin.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import ssl
66
import traceback
77
from dataclasses import dataclass
8-
from datetime import datetime
8+
from datetime import datetime, timezone
9+
from pathlib import Path
910

1011
import aiohttp
1112
import certifi
@@ -352,6 +353,34 @@ async def get_plugin_logo_token(self, logo_path: str):
352353
logger.warning(f"获取插件 Logo 失败: {e}")
353354
return None
354355

356+
def _resolve_plugin_dir(self, plugin) -> Path | None:
357+
if not plugin.root_dir_name:
358+
return None
359+
360+
base_dir = Path(
361+
self.plugin_manager.reserved_plugin_path
362+
if plugin.reserved
363+
else self.plugin_manager.plugin_store_path
364+
)
365+
plugin_dir = base_dir / plugin.root_dir_name
366+
if not plugin_dir.is_dir():
367+
return None
368+
return plugin_dir
369+
370+
def _get_plugin_installed_at(self, plugin) -> str | None:
371+
plugin_dir = self._resolve_plugin_dir(plugin)
372+
if plugin_dir is None:
373+
return None
374+
375+
try:
376+
return datetime.fromtimestamp(
377+
plugin_dir.stat().st_mtime,
378+
timezone.utc,
379+
).isoformat()
380+
except OSError as exc:
381+
logger.warning(f"获取插件安装时间失败 {plugin.name}: {exc!s}")
382+
return None
383+
355384
async def get_plugins(self):
356385
_plugin_resp = []
357386
plugin_name = request.args.get("name")
@@ -377,6 +406,7 @@ async def get_plugins(self):
377406
"logo": f"/api/file/{logo_url}" if logo_url else None,
378407
"support_platforms": plugin.support_platforms,
379408
"astrbot_version": plugin.astrbot_version,
409+
"installed_at": self._get_plugin_installed_at(plugin),
380410
}
381411
# 检查是否为全空的幽灵插件
382412
if not any(
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<script setup>
2+
const props = defineProps({
3+
modelValue: {
4+
type: String,
5+
required: true,
6+
},
7+
items: {
8+
type: Array,
9+
required: true,
10+
},
11+
label: {
12+
type: String,
13+
required: true,
14+
},
15+
order: {
16+
type: String,
17+
default: "desc",
18+
},
19+
ascendingLabel: {
20+
type: String,
21+
default: "Ascending",
22+
},
23+
descendingLabel: {
24+
type: String,
25+
default: "Descending",
26+
},
27+
showOrder: {
28+
type: Boolean,
29+
default: false,
30+
},
31+
});
32+
33+
const emit = defineEmits(["update:modelValue", "update:order"]);
34+
35+
const updateSortBy = (value) => {
36+
emit("update:modelValue", value);
37+
};
38+
39+
const toggleOrder = () => {
40+
emit("update:order", props.order === "desc" ? "asc" : "desc");
41+
};
42+
</script>
43+
44+
<template>
45+
<div class="plugin-sort-control">
46+
<v-select
47+
:model-value="modelValue"
48+
:items="items"
49+
density="compact"
50+
variant="outlined"
51+
hide-details
52+
:label="label"
53+
class="plugin-sort-control__select"
54+
@update:model-value="updateSortBy"
55+
>
56+
<template #prepend-inner>
57+
<v-icon size="small">mdi-sort</v-icon>
58+
</template>
59+
</v-select>
60+
61+
<v-btn
62+
v-if="showOrder"
63+
icon
64+
variant="text"
65+
density="compact"
66+
@click="toggleOrder"
67+
>
68+
<v-icon>{{
69+
order === "desc" ? "mdi-arrow-down-thin" : "mdi-arrow-up-thin"
70+
}}</v-icon>
71+
<v-tooltip activator="parent" location="top">
72+
{{ order === "desc" ? descendingLabel : ascendingLabel }}
73+
</v-tooltip>
74+
</v-btn>
75+
</div>
76+
</template>
77+
78+
<style scoped>
79+
.plugin-sort-control {
80+
display: flex;
81+
align-items: center;
82+
gap: 8px;
83+
flex-wrap: wrap;
84+
}
85+
86+
.plugin-sort-control__select {
87+
min-width: 180px;
88+
max-width: 220px;
89+
}
90+
91+
.plugin-sort-control__select :deep(.v-field__input),
92+
.plugin-sort-control__select :deep(.v-field-label),
93+
.plugin-sort-control__select :deep(.v-select__selection-text),
94+
.plugin-sort-control__select :deep(.v-field__prepend-inner) {
95+
font-size: 0.875rem;
96+
}
97+
</style>

dashboard/src/i18n/locales/en-US/features/extension.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
"placeholder": "Search extensions...",
2424
"marketPlaceholder": "Search market extensions..."
2525
},
26+
"filters": {
27+
"all": "All"
28+
},
2629
"views": {
2730
"card": "Card View",
2831
"list": "List View"
@@ -122,10 +125,14 @@
122125
"sourceSafetyWarning": "Even with the default source, plugin stability and security cannot be fully guaranteed. Please verify carefully before use."
123126
},
124127
"sort": {
128+
"by": "Sort by",
125129
"default": "Default",
130+
"installTime": "Last Modified",
131+
"name": "Name",
126132
"stars": "Stars",
127133
"author": "Author",
128134
"updated": "Last Updated",
135+
"updateStatus": "Update Status",
129136
"ascending": "Ascending",
130137
"descending": "Descending"
131138
},

dashboard/src/i18n/locales/zh-CN/features/extension.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
"placeholder": "搜索插件...",
2424
"marketPlaceholder": "搜索市场插件..."
2525
},
26+
"filters": {
27+
"all": "全部"
28+
},
2629
"views": {
2730
"card": "卡片视图",
2831
"list": "列表视图"
@@ -122,10 +125,14 @@
122125
"sourceSafetyWarning": "即使是默认插件源,我们也不能完全保证插件的稳定性和安全性,使用前请谨慎核查。"
123126
},
124127
"sort": {
128+
"by": "排序方式",
125129
"default": "默认排序",
130+
"installTime": "最后修改时间",
131+
"name": "名称",
126132
"stars": "Star数",
127133
"author": "作者名",
128134
"updated": "更新时间",
135+
"updateStatus": "更新状态",
129136
"ascending": "升序",
130137
"descending": "降序"
131138
},

dashboard/src/views/extension/InstalledPluginsTab.vue

Lines changed: 98 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script setup>
2+
import PluginSortControl from "@/components/extension/PluginSortControl.vue";
23
import ExtensionCard from "@/components/shared/ExtensionCard.vue";
34
import StyledMenu from "@/components/shared/StyledMenu.vue";
45
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
@@ -48,6 +49,9 @@ const {
4849
getInitialListViewMode,
4950
isListView,
5051
pluginSearch,
52+
installedStatusFilter,
53+
installedSortBy,
54+
installedSortOrder,
5155
loading_,
5256
currentPage,
5357
dangerConfirmDialog,
@@ -82,6 +86,8 @@ const {
8286
toPinyinText,
8387
toInitials,
8488
plugin_handler_info_headers,
89+
installedSortItems,
90+
installedSortUsesOrder,
8591
pluginHeaders,
8692
filteredExtensions,
8793
filteredPlugins,
@@ -185,30 +191,64 @@ const {
185191
</div>
186192

187193
<v-row class="mb-4">
188-
<v-col cols="12" class="d-flex align-center flex-wrap ga-2">
189-
<v-btn variant="tonal" @click="toggleShowReserved">
190-
<v-icon>{{
191-
showReserved ? "mdi-eye-off" : "mdi-eye"
192-
}}</v-icon>
193-
{{
194-
showReserved
195-
? tm("buttons.hideSystemPlugins")
196-
: tm("buttons.showSystemPlugins")
197-
}}
198-
</v-btn>
199-
200-
<v-btn
201-
class="ml-2"
202-
color="warning"
203-
variant="tonal"
204-
:disabled="updatableExtensions.length === 0"
205-
:loading="updatingAll"
206-
@click="showUpdateAllConfirm"
207-
>
208-
<v-icon>mdi-update</v-icon>
209-
{{ tm("buttons.updateAll") }}
210-
</v-btn>
211-
194+
<v-col cols="12">
195+
<div class="installed-toolbar">
196+
<div class="installed-toolbar__actions">
197+
<v-btn variant="tonal" @click="toggleShowReserved">
198+
<v-icon>{{
199+
showReserved ? "mdi-eye-off" : "mdi-eye"
200+
}}</v-icon>
201+
{{
202+
showReserved
203+
? tm("buttons.hideSystemPlugins")
204+
: tm("buttons.showSystemPlugins")
205+
}}
206+
</v-btn>
207+
208+
<v-btn
209+
color="warning"
210+
variant="tonal"
211+
:disabled="updatableExtensions.length === 0"
212+
:loading="updatingAll"
213+
@click="showUpdateAllConfirm"
214+
>
215+
<v-icon>mdi-update</v-icon>
216+
{{ tm("buttons.updateAll") }}
217+
</v-btn>
218+
</div>
219+
220+
<div class="installed-toolbar__controls">
221+
<v-btn-toggle
222+
v-model="installedStatusFilter"
223+
mandatory
224+
divided
225+
density="compact"
226+
color="primary"
227+
class="installed-status-toggle"
228+
>
229+
<v-btn value="all" prepend-icon="mdi-filter-variant">
230+
{{ tm("filters.all") }}
231+
</v-btn>
232+
<v-btn value="enabled" prepend-icon="mdi-play-circle-outline">
233+
{{ tm("status.enabled") }}
234+
</v-btn>
235+
<v-btn value="disabled" prepend-icon="mdi-pause-circle-outline">
236+
{{ tm("status.disabled") }}
237+
</v-btn>
238+
</v-btn-toggle>
239+
240+
<PluginSortControl
241+
v-model="installedSortBy"
242+
:items="installedSortItems"
243+
:label="tm('sort.by')"
244+
:order="installedSortOrder"
245+
:ascending-label="tm('sort.ascending')"
246+
:descending-label="tm('sort.descending')"
247+
:show-order="installedSortUsesOrder"
248+
@update:order="installedSortOrder = $event"
249+
/>
250+
</div>
251+
</div>
212252
</v-col>
213253
</v-row>
214254

@@ -654,6 +694,32 @@ const {
654694
</template>
655695

656696
<style scoped>
697+
.installed-toolbar {
698+
display: flex;
699+
align-items: center;
700+
justify-content: space-between;
701+
gap: 12px;
702+
flex-wrap: wrap;
703+
}
704+
705+
.installed-toolbar__actions,
706+
.installed-toolbar__controls {
707+
display: flex;
708+
align-items: center;
709+
gap: 8px;
710+
flex-wrap: wrap;
711+
}
712+
713+
.installed-toolbar__controls {
714+
margin-left: auto;
715+
justify-content: flex-end;
716+
}
717+
718+
.installed-status-toggle :deep(.v-btn) {
719+
min-height: 34px;
720+
text-transform: none;
721+
}
722+
657723
.view-mode-toggle :deep(.v-btn) {
658724
min-width: 30px;
659725
height: 28px;
@@ -684,6 +750,14 @@ const {
684750
}
685751
}
686752
753+
@media (max-width: 960px) {
754+
.installed-toolbar__controls {
755+
margin-left: 0;
756+
width: 100%;
757+
justify-content: flex-start;
758+
}
759+
}
760+
687761
.fab-button {
688762
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
689763
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);

0 commit comments

Comments
 (0)