feat(extension): add filtering and sorting for installed plugins in WebUI (#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
This commit is contained in:
@@ -5,7 +5,8 @@ import os
|
||||
import ssl
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
import certifi
|
||||
@@ -352,6 +353,34 @@ class PluginRoute(Route):
|
||||
logger.warning(f"获取插件 Logo 失败: {e}")
|
||||
return None
|
||||
|
||||
def _resolve_plugin_dir(self, plugin) -> Path | None:
|
||||
if not plugin.root_dir_name:
|
||||
return None
|
||||
|
||||
base_dir = Path(
|
||||
self.plugin_manager.reserved_plugin_path
|
||||
if plugin.reserved
|
||||
else self.plugin_manager.plugin_store_path
|
||||
)
|
||||
plugin_dir = base_dir / plugin.root_dir_name
|
||||
if not plugin_dir.is_dir():
|
||||
return None
|
||||
return plugin_dir
|
||||
|
||||
def _get_plugin_installed_at(self, plugin) -> str | None:
|
||||
plugin_dir = self._resolve_plugin_dir(plugin)
|
||||
if plugin_dir is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.fromtimestamp(
|
||||
plugin_dir.stat().st_mtime,
|
||||
timezone.utc,
|
||||
).isoformat()
|
||||
except OSError as exc:
|
||||
logger.warning(f"获取插件安装时间失败 {plugin.name}: {exc!s}")
|
||||
return None
|
||||
|
||||
async def get_plugins(self):
|
||||
_plugin_resp = []
|
||||
plugin_name = request.args.get("name")
|
||||
@@ -377,6 +406,7 @@ class PluginRoute(Route):
|
||||
"logo": f"/api/file/{logo_url}" if logo_url else None,
|
||||
"support_platforms": plugin.support_platforms,
|
||||
"astrbot_version": plugin.astrbot_version,
|
||||
"installed_at": self._get_plugin_installed_at(plugin),
|
||||
}
|
||||
# 检查是否为全空的幽灵插件
|
||||
if not any(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
order: {
|
||||
type: String,
|
||||
default: "desc",
|
||||
},
|
||||
ascendingLabel: {
|
||||
type: String,
|
||||
default: "Ascending",
|
||||
},
|
||||
descendingLabel: {
|
||||
type: String,
|
||||
default: "Descending",
|
||||
},
|
||||
showOrder: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "update:order"]);
|
||||
|
||||
const updateSortBy = (value) => {
|
||||
emit("update:modelValue", value);
|
||||
};
|
||||
|
||||
const toggleOrder = () => {
|
||||
emit("update:order", props.order === "desc" ? "asc" : "desc");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugin-sort-control">
|
||||
<v-select
|
||||
:model-value="modelValue"
|
||||
:items="items"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
:label="label"
|
||||
class="plugin-sort-control__select"
|
||||
@update:model-value="updateSortBy"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<v-icon size="small">mdi-sort</v-icon>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-btn
|
||||
v-if="showOrder"
|
||||
icon
|
||||
variant="text"
|
||||
density="compact"
|
||||
@click="toggleOrder"
|
||||
>
|
||||
<v-icon>{{
|
||||
order === "desc" ? "mdi-arrow-down-thin" : "mdi-arrow-up-thin"
|
||||
}}</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ order === "desc" ? descendingLabel : ascendingLabel }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-sort-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plugin-sort-control__select {
|
||||
min-width: 180px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.plugin-sort-control__select :deep(.v-field__input),
|
||||
.plugin-sort-control__select :deep(.v-field-label),
|
||||
.plugin-sort-control__select :deep(.v-select__selection-text),
|
||||
.plugin-sort-control__select :deep(.v-field__prepend-inner) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
@@ -23,6 +23,9 @@
|
||||
"placeholder": "Search extensions...",
|
||||
"marketPlaceholder": "Search market extensions..."
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"views": {
|
||||
"card": "Card View",
|
||||
"list": "List View"
|
||||
@@ -122,10 +125,14 @@
|
||||
"sourceSafetyWarning": "Even with the default source, plugin stability and security cannot be fully guaranteed. Please verify carefully before use."
|
||||
},
|
||||
"sort": {
|
||||
"by": "Sort by",
|
||||
"default": "Default",
|
||||
"installTime": "Last Modified",
|
||||
"name": "Name",
|
||||
"stars": "Stars",
|
||||
"author": "Author",
|
||||
"updated": "Last Updated",
|
||||
"updateStatus": "Update Status",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending"
|
||||
},
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"placeholder": "搜索插件...",
|
||||
"marketPlaceholder": "搜索市场插件..."
|
||||
},
|
||||
"filters": {
|
||||
"all": "全部"
|
||||
},
|
||||
"views": {
|
||||
"card": "卡片视图",
|
||||
"list": "列表视图"
|
||||
@@ -122,10 +125,14 @@
|
||||
"sourceSafetyWarning": "即使是默认插件源,我们也不能完全保证插件的稳定性和安全性,使用前请谨慎核查。"
|
||||
},
|
||||
"sort": {
|
||||
"by": "排序方式",
|
||||
"default": "默认排序",
|
||||
"installTime": "最后修改时间",
|
||||
"name": "名称",
|
||||
"stars": "Star数",
|
||||
"author": "作者名",
|
||||
"updated": "更新时间",
|
||||
"updateStatus": "更新状态",
|
||||
"ascending": "升序",
|
||||
"descending": "降序"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import PluginSortControl from "@/components/extension/PluginSortControl.vue";
|
||||
import ExtensionCard from "@/components/shared/ExtensionCard.vue";
|
||||
import StyledMenu from "@/components/shared/StyledMenu.vue";
|
||||
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
|
||||
@@ -48,6 +49,9 @@ const {
|
||||
getInitialListViewMode,
|
||||
isListView,
|
||||
pluginSearch,
|
||||
installedStatusFilter,
|
||||
installedSortBy,
|
||||
installedSortOrder,
|
||||
loading_,
|
||||
currentPage,
|
||||
dangerConfirmDialog,
|
||||
@@ -82,6 +86,8 @@ const {
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
plugin_handler_info_headers,
|
||||
installedSortItems,
|
||||
installedSortUsesOrder,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
filteredPlugins,
|
||||
@@ -185,30 +191,64 @@ const {
|
||||
</div>
|
||||
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" class="d-flex align-center flex-wrap ga-2">
|
||||
<v-btn variant="tonal" @click="toggleShowReserved">
|
||||
<v-icon>{{
|
||||
showReserved ? "mdi-eye-off" : "mdi-eye"
|
||||
}}</v-icon>
|
||||
{{
|
||||
showReserved
|
||||
? tm("buttons.hideSystemPlugins")
|
||||
: tm("buttons.showSystemPlugins")
|
||||
}}
|
||||
</v-btn>
|
||||
<v-col cols="12">
|
||||
<div class="installed-toolbar">
|
||||
<div class="installed-toolbar__actions">
|
||||
<v-btn variant="tonal" @click="toggleShowReserved">
|
||||
<v-icon>{{
|
||||
showReserved ? "mdi-eye-off" : "mdi-eye"
|
||||
}}</v-icon>
|
||||
{{
|
||||
showReserved
|
||||
? tm("buttons.hideSystemPlugins")
|
||||
: tm("buttons.showSystemPlugins")
|
||||
}}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
class="ml-2"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
:disabled="updatableExtensions.length === 0"
|
||||
:loading="updatingAll"
|
||||
@click="showUpdateAllConfirm"
|
||||
>
|
||||
<v-icon>mdi-update</v-icon>
|
||||
{{ tm("buttons.updateAll") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
:disabled="updatableExtensions.length === 0"
|
||||
:loading="updatingAll"
|
||||
@click="showUpdateAllConfirm"
|
||||
>
|
||||
<v-icon>mdi-update</v-icon>
|
||||
{{ tm("buttons.updateAll") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="installed-toolbar__controls">
|
||||
<v-btn-toggle
|
||||
v-model="installedStatusFilter"
|
||||
mandatory
|
||||
divided
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="installed-status-toggle"
|
||||
>
|
||||
<v-btn value="all" prepend-icon="mdi-filter-variant">
|
||||
{{ tm("filters.all") }}
|
||||
</v-btn>
|
||||
<v-btn value="enabled" prepend-icon="mdi-play-circle-outline">
|
||||
{{ tm("status.enabled") }}
|
||||
</v-btn>
|
||||
<v-btn value="disabled" prepend-icon="mdi-pause-circle-outline">
|
||||
{{ tm("status.disabled") }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<PluginSortControl
|
||||
v-model="installedSortBy"
|
||||
:items="installedSortItems"
|
||||
:label="tm('sort.by')"
|
||||
:order="installedSortOrder"
|
||||
:ascending-label="tm('sort.ascending')"
|
||||
:descending-label="tm('sort.descending')"
|
||||
:show-order="installedSortUsesOrder"
|
||||
@update:order="installedSortOrder = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -654,6 +694,32 @@ const {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.installed-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.installed-toolbar__actions,
|
||||
.installed-toolbar__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.installed-toolbar__controls {
|
||||
margin-left: auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.installed-status-toggle :deep(.v-btn) {
|
||||
min-height: 34px;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.view-mode-toggle :deep(.v-btn) {
|
||||
min-width: 30px;
|
||||
height: 28px;
|
||||
@@ -684,6 +750,14 @@ const {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.installed-toolbar__controls {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.fab-button {
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import MarketPluginCard from "@/components/extension/MarketPluginCard.vue";
|
||||
import PluginSortControl from "@/components/extension/PluginSortControl.vue";
|
||||
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
|
||||
import { computed } from "vue";
|
||||
|
||||
@@ -157,6 +158,13 @@ const currentSourceName = computed(() => {
|
||||
const matched = customSources.value.find((s) => s.url === selectedSource.value);
|
||||
return matched?.name || tm("market.defaultSource");
|
||||
});
|
||||
|
||||
const marketSortItems = computed(() => [
|
||||
{ title: tm("sort.default"), value: "default" },
|
||||
{ title: tm("sort.stars"), value: "stars" },
|
||||
{ title: tm("sort.author"), value: "author" },
|
||||
{ title: tm("sort.updated"), value: "updated" },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -327,44 +335,16 @@ const currentSourceName = computed(() => {
|
||||
class="d-flex align-center"
|
||||
style="gap: 8px; flex-wrap: wrap"
|
||||
>
|
||||
<v-select
|
||||
<PluginSortControl
|
||||
v-model="sortBy"
|
||||
:items="[
|
||||
{ title: tm('sort.default'), value: 'default' },
|
||||
{ title: tm('sort.stars'), value: 'stars' },
|
||||
{ title: tm('sort.author'), value: 'author' },
|
||||
{ title: tm('sort.updated'), value: 'updated' },
|
||||
]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
style="max-width: 150px"
|
||||
>
|
||||
<template v-slot:prepend-inner>
|
||||
<v-icon size="small">mdi-sort</v-icon>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
v-if="sortBy !== 'default'"
|
||||
@click="sortOrder = sortOrder === 'desc' ? 'asc' : 'desc'"
|
||||
variant="text"
|
||||
density="compact"
|
||||
>
|
||||
<v-icon>{{
|
||||
sortOrder === "desc"
|
||||
? "mdi-sort-descending"
|
||||
: "mdi-sort-ascending"
|
||||
}}</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{
|
||||
sortOrder === "desc"
|
||||
? tm("sort.descending")
|
||||
: tm("sort.ascending")
|
||||
}}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
:items="marketSortItems"
|
||||
:label="tm('sort.by')"
|
||||
:order="sortOrder"
|
||||
:ascending-label="tm('sort.ascending')"
|
||||
:descending-label="tm('sort.descending')"
|
||||
:show-order="sortBy !== 'default'"
|
||||
@update:order="sortOrder = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -186,6 +186,9 @@ export const useExtensionPage = () => {
|
||||
};
|
||||
const isListView = ref(getInitialListViewMode());
|
||||
const pluginSearch = ref("");
|
||||
const installedStatusFilter = ref("all");
|
||||
const installedSortBy = ref("default");
|
||||
const installedSortOrder = ref("desc");
|
||||
const loading_ = ref(false);
|
||||
|
||||
// 分页相关
|
||||
@@ -253,6 +256,18 @@ export const useExtensionPage = () => {
|
||||
{ title: tm("table.headers.specificType"), key: "type" },
|
||||
{ title: tm("table.headers.trigger"), key: "cmd" },
|
||||
]);
|
||||
|
||||
const installedSortItems = computed(() => [
|
||||
{ title: tm("sort.default"), value: "default" },
|
||||
{ title: tm("sort.installTime"), value: "install_time" },
|
||||
{ title: tm("sort.name"), value: "name" },
|
||||
{ title: tm("sort.author"), value: "author" },
|
||||
{ title: tm("sort.updateStatus"), value: "update_status" },
|
||||
]);
|
||||
|
||||
const installedSortUsesOrder = computed(
|
||||
() => installedSortBy.value !== "default",
|
||||
);
|
||||
|
||||
// 插件表格的表头定义
|
||||
const showAuthorColumn = computed(() => width.value >= 1280);
|
||||
@@ -261,16 +276,19 @@ export const useExtensionPage = () => {
|
||||
{
|
||||
title: tm("table.headers.name"),
|
||||
key: "name",
|
||||
sortable: false,
|
||||
width: showAuthorColumn.value ? "24%" : "26%",
|
||||
},
|
||||
{
|
||||
title: tm("table.headers.description"),
|
||||
key: "desc",
|
||||
sortable: false,
|
||||
width: showAuthorColumn.value ? "32%" : "36%",
|
||||
},
|
||||
{
|
||||
title: tm("table.headers.version"),
|
||||
key: "version",
|
||||
sortable: false,
|
||||
width: showAuthorColumn.value ? "12%" : "14%",
|
||||
},
|
||||
];
|
||||
@@ -279,6 +297,7 @@ export const useExtensionPage = () => {
|
||||
headers.push({
|
||||
title: tm("table.headers.author"),
|
||||
key: "author",
|
||||
sortable: false,
|
||||
width: "10%",
|
||||
});
|
||||
}
|
||||
@@ -301,33 +320,120 @@ export const useExtensionPage = () => {
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
const sortPluginsByName = (plugins) => {
|
||||
|
||||
const compareInstalledPluginNames = (left, right) =>
|
||||
normalizeStr(left?.name ?? "").localeCompare(
|
||||
normalizeStr(right?.name ?? ""),
|
||||
undefined,
|
||||
{
|
||||
sensitivity: "base",
|
||||
},
|
||||
);
|
||||
|
||||
const compareInstalledPluginAuthors = (left, right) =>
|
||||
normalizeStr(left?.author ?? "").localeCompare(
|
||||
normalizeStr(right?.author ?? ""),
|
||||
undefined,
|
||||
{ sensitivity: "base" },
|
||||
);
|
||||
|
||||
const getInstalledAtTimestamp = (plugin) => {
|
||||
const parsed = Date.parse(plugin?.installed_at ?? "");
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const sortInstalledPlugins = (plugins) => {
|
||||
return plugins
|
||||
.map((plugin, index) => ({ plugin, index }))
|
||||
.sort((a, b) => {
|
||||
const nameA = String(a.plugin?.name ?? "");
|
||||
const nameB = String(b.plugin?.name ?? "");
|
||||
const nameCompare = nameA.localeCompare(nameB, undefined, {
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (nameCompare !== 0) {
|
||||
return nameCompare;
|
||||
.map((plugin, index) => ({
|
||||
plugin,
|
||||
index,
|
||||
installedAtTimestamp: getInstalledAtTimestamp(plugin),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
const fallbackNameCompare = compareInstalledPluginNames(
|
||||
left.plugin,
|
||||
right.plugin,
|
||||
);
|
||||
const fallbackResult =
|
||||
fallbackNameCompare !== 0 ? fallbackNameCompare : left.index - right.index;
|
||||
|
||||
if (installedSortBy.value === "install_time") {
|
||||
const leftTimestamp = left.installedAtTimestamp;
|
||||
const rightTimestamp = right.installedAtTimestamp;
|
||||
|
||||
if (leftTimestamp == null && rightTimestamp == null) {
|
||||
return fallbackResult;
|
||||
}
|
||||
if (leftTimestamp == null) {
|
||||
return 1;
|
||||
}
|
||||
if (rightTimestamp == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const timeDiff =
|
||||
installedSortOrder.value === "desc"
|
||||
? rightTimestamp - leftTimestamp
|
||||
: leftTimestamp - rightTimestamp;
|
||||
return timeDiff !== 0 ? timeDiff : fallbackResult;
|
||||
}
|
||||
return a.index - b.index;
|
||||
|
||||
if (installedSortBy.value === "name") {
|
||||
const nameCompare = compareInstalledPluginNames(left.plugin, right.plugin);
|
||||
if (nameCompare !== 0) {
|
||||
return installedSortOrder.value === "desc"
|
||||
? -nameCompare
|
||||
: nameCompare;
|
||||
}
|
||||
return left.index - right.index;
|
||||
}
|
||||
|
||||
if (installedSortBy.value === "author") {
|
||||
const authorCompare = compareInstalledPluginAuthors(
|
||||
left.plugin,
|
||||
right.plugin,
|
||||
);
|
||||
if (authorCompare !== 0) {
|
||||
return installedSortOrder.value === "desc"
|
||||
? -authorCompare
|
||||
: authorCompare;
|
||||
}
|
||||
return fallbackResult;
|
||||
}
|
||||
|
||||
if (installedSortBy.value === "update_status") {
|
||||
const leftHasUpdate = left.plugin?.has_update ? 1 : 0;
|
||||
const rightHasUpdate = right.plugin?.has_update ? 1 : 0;
|
||||
const updateDiff =
|
||||
installedSortOrder.value === "desc"
|
||||
? rightHasUpdate - leftHasUpdate
|
||||
: leftHasUpdate - rightHasUpdate;
|
||||
return updateDiff !== 0 ? updateDiff : fallbackResult;
|
||||
}
|
||||
|
||||
return fallbackResult;
|
||||
})
|
||||
.map((item) => item.plugin);
|
||||
};
|
||||
|
||||
// 通过搜索过滤插件
|
||||
const filteredPlugins = computed(() => {
|
||||
const plugins = filteredExtensions.value;
|
||||
const plugins = filteredExtensions.value.filter((plugin) => {
|
||||
if (installedStatusFilter.value === "enabled") {
|
||||
return !!plugin.activated;
|
||||
}
|
||||
if (installedStatusFilter.value === "disabled") {
|
||||
return !plugin.activated;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const query = buildSearchQuery(pluginSearch.value);
|
||||
const filtered = query
|
||||
? plugins.filter((plugin) => matchesPluginSearch(plugin, query))
|
||||
: plugins;
|
||||
|
||||
return sortPluginsByName([...filtered]);
|
||||
return sortInstalledPlugins(filtered);
|
||||
});
|
||||
|
||||
// 过滤后的插件市场数据(带搜索)
|
||||
@@ -1481,6 +1587,9 @@ export const useExtensionPage = () => {
|
||||
getInitialListViewMode,
|
||||
isListView,
|
||||
pluginSearch,
|
||||
installedStatusFilter,
|
||||
installedSortBy,
|
||||
installedSortOrder,
|
||||
loading_,
|
||||
currentPage,
|
||||
dangerConfirmDialog,
|
||||
@@ -1516,6 +1625,8 @@ export const useExtensionPage = () => {
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
plugin_handler_info_headers,
|
||||
installedSortItems,
|
||||
installedSortUsesOrder,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
filteredPlugins,
|
||||
|
||||
@@ -3,6 +3,7 @@ import io
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -15,6 +16,7 @@ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.core.star.star import star_registry
|
||||
from astrbot.core.star.star_handler import star_handlers_registry
|
||||
from astrbot.dashboard.routes.plugin import PluginRoute
|
||||
from astrbot.dashboard.server import AstrBotDashboard
|
||||
from tests.fixtures.helpers import (
|
||||
MockPluginBuilder,
|
||||
@@ -118,6 +120,13 @@ async def test_plugins(
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
for plugin in data["data"]:
|
||||
assert "installed_at" in plugin
|
||||
installed_at = plugin["installed_at"]
|
||||
if installed_at is None:
|
||||
continue
|
||||
assert isinstance(installed_at, str)
|
||||
datetime.fromisoformat(installed_at)
|
||||
|
||||
# 插件市场
|
||||
response = await test_client.get(
|
||||
@@ -162,6 +171,18 @@ async def test_plugins(
|
||||
f"安装失败: {data.get('message', 'unknown error')}"
|
||||
)
|
||||
|
||||
response = await test_client.get(
|
||||
f"/api/plugin/get?name={test_plugin_name}",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert len(data["data"]) == 1
|
||||
installed_at = data["data"][0]["installed_at"]
|
||||
assert installed_at is not None
|
||||
datetime.fromisoformat(installed_at)
|
||||
|
||||
# 验证插件已注册
|
||||
exists = any(md.name == test_plugin_name for md in star_registry)
|
||||
assert exists is True, f"插件 {test_plugin_name} 未成功载入"
|
||||
@@ -203,6 +224,28 @@ async def test_plugins(
|
||||
builder.cleanup(test_plugin_name)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugins_when_installed_at_unresolved(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Tests plugin payload when installed_at cannot be resolved."""
|
||||
test_client = app.test_client()
|
||||
|
||||
monkeypatch.setattr(PluginRoute, "_get_plugin_installed_at", lambda *_args: None)
|
||||
|
||||
response = await test_client.get("/api/plugin/get", headers=authenticated_header)
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
for plugin in data["data"]:
|
||||
assert "name" in plugin
|
||||
assert "installed_at" in plugin
|
||||
assert plugin["installed_at"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_api(app: Quart, authenticated_header: dict):
|
||||
"""Tests the command management API endpoints."""
|
||||
|
||||
Reference in New Issue
Block a user