feat(extension): add category filtering for market plugins and enhance UI components (#6762)

This commit is contained in:
Soulter
2026-03-22 01:54:21 +08:00
committed by GitHub
parent 52beeef83c
commit 5419efbc9c
9 changed files with 412 additions and 188 deletions

View File

@@ -41,69 +41,22 @@ const handleInstall = (plugin) => {
<v-card
class="rounded-lg d-flex flex-column plugin-card"
elevation="0"
style="height: 13rem; position: relative"
>
<v-chip
v-if="plugin?.pinned"
color="warning"
size="x-small"
label
style="
position: absolute;
right: 8px;
top: 8px;
z-index: 10;
height: 20px;
font-weight: bold;
"
>
{{ tm("market.recommended") }}
</v-chip>
<v-card-text
style="
padding: 12px;
padding-bottom: 8px;
display: flex;
gap: 12px;
width: 100%;
flex: 1;
overflow: hidden;
"
class="plugin-card-content"
>
<div style="flex-shrink: 0">
<div class="plugin-cover">
<img
:src="plugin?.logo || defaultPluginIcon"
:alt="plugin.name"
style="
height: 75px;
width: 75px;
border-radius: 8px;
object-fit: cover;
"
class="plugin-cover__image"
/>
</div>
<div
style="
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
"
>
<div
class="font-weight-bold"
style="
margin-bottom: 4px;
line-height: 1.3;
font-size: 1.2rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
"
>
<span style="overflow: hidden; text-overflow: ellipsis">
<div class="plugin-info">
<div class="d-flex align-center plugin-title-row">
<div class="font-weight-bold plugin-title">
{{
plugin.display_name?.length
? plugin.display_name
@@ -111,10 +64,19 @@ const handleInstall = (plugin) => {
? plugin.name
: plugin.trimmedName
}}
</span>
</div>
<v-chip
v-if="plugin?.pinned"
color="warning"
size="x-small"
label
class="market-recommended-chip"
>
{{ tm("market.recommended") }}
</v-chip>
</div>
<div class="d-flex align-center" style="gap: 4px; margin-bottom: 6px">
<div class="d-flex align-center plugin-meta">
<v-icon
icon="mdi-account"
size="x-small"
@@ -159,6 +121,18 @@ const handleInstall = (plugin) => {
></v-icon>
<span>{{ plugin.version }}</span>
</div>
<div
v-if="plugin.stars !== undefined"
class="d-flex align-center text-subtitle-2 ml-2"
style="color: rgba(var(--v-theme-on-surface), 0.7)"
>
<v-icon
icon="mdi-star"
size="x-small"
style="margin-right: 2px"
></v-icon>
<span>{{ plugin.stars }}</span>
</div>
</div>
<div class="text-caption plugin-description">
@@ -167,8 +141,7 @@ const handleInstall = (plugin) => {
<div
v-if="plugin.astrbot_version || platformDisplayList.length"
class="d-flex align-center flex-wrap"
style="gap: 4px; margin-top: 4px; margin-bottom: 4px"
class="plugin-badges"
>
<v-chip
v-if="plugin.astrbot_version"
@@ -186,32 +159,7 @@ const handleInstall = (plugin) => {
/>
</div>
<div class="d-flex align-center" style="gap: 8px; margin-top: auto">
<div
v-if="plugin.stars !== undefined"
class="d-flex align-center text-subtitle-2"
style="color: rgba(var(--v-theme-on-surface), 0.7)"
>
<v-icon
icon="mdi-star"
size="x-small"
style="margin-right: 2px"
></v-icon>
<span>{{ plugin.stars }}</span>
</div>
<div
v-if="plugin.updated_at"
class="d-flex align-center text-subtitle-2"
style="color: rgba(var(--v-theme-on-surface), 0.7)"
>
<v-icon
icon="mdi-clock-outline"
size="x-small"
style="margin-right: 2px"
></v-icon>
<span>{{ new Date(plugin.updated_at).toLocaleString() }}</span>
</div>
</div>
<div class="plugin-stats"></div>
</div>
</v-card-text>
@@ -274,24 +222,112 @@ const handleInstall = (plugin) => {
>
{{ tm("buttons.install") }}
</v-btn>
<v-chip v-else color="success" size="x-small" label style="height: 20px">
<v-btn
v-else
color="success"
size="small"
variant="flat"
disabled
class="market-action-btn"
style="height: 32px"
>
{{ tm("status.installed") }}
</v-chip>
</v-btn>
</v-card-actions>
</v-card>
</template>
<style scoped>
.plugin-card-content {
padding: 12px;
padding-bottom: 8px;
display: flex;
flex-direction: row;
gap: 12px;
width: 100%;
flex: 1;
overflow: hidden;
min-height: 0;
}
.plugin-cover {
flex-shrink: 0;
width: 76px;
height: 76px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: transparent;
}
.plugin-cover__image {
width: 76px;
height: 76px;
border-radius: 8px;
object-fit: cover;
}
.plugin-info {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.plugin-title-row {
margin-bottom: 4px;
gap: 8px;
}
.market-recommended-chip {
flex-shrink: 0;
font-weight: bold;
height: 20px;
}
.plugin-title {
line-height: 1.3;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.plugin-meta {
gap: 4px;
margin-bottom: 6px;
flex-wrap: nowrap;
}
.plugin-description {
color: rgba(var(--v-theme-on-surface), 0.6);
line-height: 1.3;
margin-bottom: 6px;
flex: 1;
overflow-y: hidden;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
min-height: calc(1.3em * 3);
max-height: calc(1.3em * 3);
}
.plugin-card:hover .plugin-description {
overflow-y: auto;
.plugin-badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
margin-bottom: 4px;
}
.plugin-stats {
display: flex;
align-items: center;
gap: 8px;
margin-top: auto;
}
.plugin-description::-webkit-scrollbar {

View File

@@ -85,6 +85,7 @@ const toggleOrder = () => {
.plugin-sort-control__select {
min-width: 180px;
width: 190px;
max-width: 220px;
}

View File

@@ -96,6 +96,16 @@
"market": {
"recommended": "🥳 Recommended",
"allPlugins": "📦 All Extensions",
"category": "Category",
"categories": {
"all": "All",
"ai_tools": "AI Tools",
"entertainment": "Entertainment",
"productivity": "Productivity",
"integrations": "Integrations",
"utilities": "Utilities",
"other": "Other"
},
"showFullName": "Full Name",
"devDocs": "Extension Development Docs",
"submitRepo": "Submit Extension Repository",

View File

@@ -96,6 +96,15 @@
"market": {
"recommended": "🥳 Рекомендуем",
"allPlugins": "📦 Все плагины",
"categories": {
"all": "Все",
"ai_tools": "AI Tools",
"entertainment": "Развлечения",
"productivity": "Продуктивность",
"integrations": "Интеграции",
"utilities": "Утилиты",
"other": "Другое"
},
"showFullName": "Полное имя",
"devDocs": "Документация для разработчиков",
"submitRepo": "Добавить репозиторий",
@@ -355,4 +364,4 @@
"pluginChangelog": {
"menuTitle": "Журнал изменений"
}
}
}

View File

@@ -96,6 +96,16 @@
"market": {
"recommended": "🥳 推荐",
"allPlugins": "📦 全部插件",
"category": "分类",
"categories": {
"all": "全部",
"ai_tools": "AI 增强",
"entertainment": "娱乐",
"productivity": "效率",
"integrations": "外部集成",
"utilities": "生活实用",
"other": "其他"
},
"showFullName": "完整名称",
"devDocs": "插件开发文档",
"submitRepo": "提交插件仓库",

View File

@@ -178,6 +178,7 @@ export const useCommonStore = defineStore({
"updated_at": pluginData?.updated_at ? pluginData.updated_at : "",
"display_name": pluginData?.display_name ? pluginData.display_name : "",
"astrbot_version": pluginData?.astrbot_version ? pluginData.astrbot_version : "",
"category": pluginData?.category ? pluginData.category : "",
"support_platforms": Array.isArray(pluginData?.support_platforms)
? pluginData.support_platforms
: Array.isArray(pluginData?.support_platform)

View File

@@ -218,15 +218,6 @@ const {
<!-- 插件市场标签页内容 -->
<MarketPluginsTab :state="pageState" />
<v-row v-if="loading_">
<v-col cols="12" class="d-flex justify-center">
<v-progress-circular
indeterminate
color="primary"
size="48"
></v-progress-circular>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>

View File

@@ -81,6 +81,8 @@ const {
sortOrder,
randomPluginNames,
showRandomPlugins,
marketCategoryFilter,
marketCategoryItems,
normalizeStr,
toPinyinText,
toInitials,
@@ -166,6 +168,13 @@ const marketSortItems = computed(() => [
{ title: tm("sort.author"), value: "author" },
{ title: tm("sort.updated"), value: "updated" },
]);
const marketCategorySelectItems = computed(() =>
marketCategoryItems.value.map((item) => ({
title: `${item.label || ""} (${item.count || 0})`,
value: item.value,
})),
);
</script>
<template>
@@ -270,10 +279,94 @@ const marketSortItems = computed(() => [
</v-tooltip>
<div class="mt-4">
<div
class="d-flex align-center mb-2"
style="
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
"
>
<div class="d-flex align-center" style="gap: 6px">
<h2>
{{ tm("market.allPlugins") }}
</h2>
<v-btn
icon
variant="text"
@click="refreshPluginMarket"
:loading="loading_ || refreshingMarket"
:disabled="loading_ || refreshingMarket"
>
<v-icon>mdi-refresh</v-icon>
</v-btn>
</div>
<div
class="d-flex align-center"
style="gap: 8px; flex-wrap: wrap"
>
<v-select
v-if="marketCategoryItems.length > 0"
v-model="marketCategoryFilter"
:items="marketCategorySelectItems"
item-title="title"
item-value="value"
:label="tm('market.category')"
density="compact"
variant="outlined"
hide-details
class="market-filter-control"
:menu-props="{ openOnHover: true, closeOnContentClick: false }"
></v-select>
<PluginSortControl
v-model="sortBy"
: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>
<v-row style="min-height: 26rem" dense>
<v-col
v-for="plugin in paginatedPlugins"
:key="plugin.name"
cols="12"
md="6"
lg="4"
class="pb-2"
>
<MarketPluginCard
:plugin="plugin"
:default-plugin-icon="defaultPluginIcon"
:show-plugin-full-name="showPluginFullName"
@install="handleInstallPlugin"
/>
</v-col>
</v-row>
<div
class="d-flex justify-center mt-4"
v-if="totalPages > 1"
>
<v-pagination
v-model="currentPage"
:length="totalPages"
:total-visible="7"
size="small"
></v-pagination>
</div>
<v-expand-transition>
<div v-if="showRandomPlugins">
<div
class="d-flex align-center mb-2"
class="d-flex align-center mb-2 mt-4"
style="justify-content: space-between; flex-wrap: wrap; gap: 8px"
>
<h2>
@@ -309,74 +402,22 @@ const marketSortItems = computed(() => [
</v-row>
</div>
</v-expand-transition>
<div
class="d-flex align-center mb-2"
style="
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
"
>
<div class="d-flex align-center" style="gap: 6px">
<h2>
{{ tm("market.allPlugins") }}({{
filteredMarketPlugins.length
}})
</h2>
<v-btn
icon
variant="text"
@click="refreshPluginMarket"
:loading="refreshingMarket"
>
<v-icon>mdi-refresh</v-icon>
</v-btn>
</div>
<div
class="d-flex align-center"
style="gap: 8px; flex-wrap: wrap"
>
<PluginSortControl
v-model="sortBy"
: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>
<v-row style="min-height: 26rem" dense>
<v-col
v-for="plugin in paginatedPlugins"
:key="plugin.name"
cols="12"
md="6"
lg="4"
class="pb-2"
>
<MarketPluginCard
:plugin="plugin"
:default-plugin-icon="defaultPluginIcon"
:show-plugin-full-name="showPluginFullName"
@install="handleInstallPlugin"
/>
</v-col>
</v-row>
<div class="d-flex justify-center mt-4" v-if="totalPages > 1">
<v-pagination
v-model="currentPage"
:length="totalPages"
:total-visible="7"
size="small"
></v-pagination>
</div>
</div>
</v-tab-item>
</template>
<style scoped>
.market-filter-control {
min-width: 190px;
max-width: 220px;
}
.market-filter-control :deep(.v-field__input),
.market-filter-control :deep(.v-field-label),
.market-filter-control :deep(.v-select__selection-text),
.market-filter-control :deep(.v-field__prepend-inner) {
font-size: 0.875rem;
}
</style>

View File

@@ -237,6 +237,7 @@ export const useExtensionPage = () => {
const sortBy = ref("default"); // default, stars, author, updated
const sortOrder = ref("desc"); // desc (降序) or asc (升序)
const randomPluginNames = ref([]);
const marketCategoryFilter = ref("all");
const {
showRandomPlugins,
toggleRandomPluginsVisibility,
@@ -256,6 +257,90 @@ export const useExtensionPage = () => {
{ title: tm("table.headers.trigger"), key: "cmd" },
]);
const normalizeMarketCategory = (rawCategory) => {
const normalized = String(rawCategory || "").trim().toLowerCase();
if (!normalized) {
return "other";
}
return normalized.replace(/[\s-]+/g, "_");
};
const getMarketCategoryLabel = (key, rawCategory = "") => {
const fallbackMap = {
all: "All",
ai_tools: "AI Tools",
entertainment: "Entertainment",
productivity: "Productivity",
integrations: "Integrations",
utilities: "Utilities",
other: "Other",
};
const i18nKey = `market.categories.${key}`;
const translated = tm(i18nKey);
if (translated && !translated.includes("[MISSING:")) {
return translated;
}
if (fallbackMap[key]) {
return fallbackMap[key];
}
const normalizedRaw = String(rawCategory || "").trim();
if (normalizedRaw) {
return normalizedRaw;
}
return key
.split(/[_-]+/)
.filter(Boolean)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join(" ");
};
const marketCategoryMeta = computed(() => {
const categories = new Map();
for (const plugin of pluginMarketData.value) {
const categoryKey = normalizeMarketCategory(plugin?.category);
const categoryData = categories.get(categoryKey);
if (categoryData) {
categoryData.count += 1;
continue;
}
categories.set(categoryKey, {
count: 1,
rawLabel: String(plugin?.category || "").trim(),
});
}
return categories;
});
const marketCategoryCounts = computed(() => {
const counts = { all: pluginMarketData.value.length };
for (const [categoryKey, categoryData] of marketCategoryMeta.value.entries()) {
counts[categoryKey] = categoryData.count;
}
return counts;
});
const marketCategoryItems = computed(() => {
const items = [
{
value: "all",
label: getMarketCategoryLabel("all"),
count: marketCategoryCounts.value.all || 0,
},
];
for (const [categoryKey, categoryData] of marketCategoryMeta.value.entries()) {
items.push({
value: categoryKey,
label: getMarketCategoryLabel(categoryKey, categoryData.rawLabel),
count: categoryData.count,
});
}
return items;
});
const installedSortItems = computed(() => [
{ title: tm("sort.default"), value: "default" },
{ title: tm("sort.installTime"), value: "install_time" },
@@ -438,13 +523,24 @@ export const useExtensionPage = () => {
// 过滤后的插件市场数据(带搜索)
const filteredMarketPlugins = computed(() => {
const query = buildSearchQuery(debouncedMarketSearch.value);
const targetCategory = normalizeMarketCategory(marketCategoryFilter.value);
const shouldFilterByCategory = marketCategoryFilter.value !== "all";
if (!query) {
return pluginMarketData.value;
if (!shouldFilterByCategory) {
return pluginMarketData.value;
}
return pluginMarketData.value.filter(
(plugin) => normalizeMarketCategory(plugin?.category) === targetCategory,
);
}
return pluginMarketData.value.filter((plugin) =>
matchesPluginSearch(plugin, query),
);
return pluginMarketData.value.filter((plugin) => {
const matchesSearch = matchesPluginSearch(plugin, query);
const matchesCategory = shouldFilterByCategory
? normalizeMarketCategory(plugin?.category) === targetCategory
: true;
return matchesSearch && matchesCategory;
});
});
// 所有插件列表,推荐插件排在前面
@@ -570,8 +666,10 @@ export const useExtensionPage = () => {
buildFailedPluginItems(failedPluginsDict.value),
);
const getExtensions = async () => {
loading_.value = true;
const getExtensions = async ({ withLoading = true } = {}) => {
if (withLoading) {
loading_.value = true;
}
try {
const res = await axios.get("/api/plugin/get");
Object.assign(extension_data, res.data);
@@ -583,7 +681,9 @@ export const useExtensionPage = () => {
} catch (err) {
toast(err, "error");
} finally {
loading_.value = false;
if (withLoading) {
loading_.value = false;
}
}
};
@@ -1411,6 +1511,7 @@ export const useExtensionPage = () => {
// 刷新插件市场数据
const refreshPluginMarket = async () => {
refreshingMarket.value = true;
loading_.value = true;
try {
// 强制刷新插件市场数据
const data = await commonStore.getPluginCollections(
@@ -1429,6 +1530,7 @@ export const useExtensionPage = () => {
toast(tm("messages.refreshFailed") + " " + err, "error");
} finally {
refreshingMarket.value = false;
loading_.value = false;
}
};
@@ -1437,21 +1539,22 @@ export const useExtensionPage = () => {
if (!syncTabFromHash(getLocationHash())) {
await replaceTabRoute(router, route, activeTab.value);
}
await getExtensions();
// 加载自定义插件源
loadCustomSources();
// 检查是否有 open_config 参数
const plugin_name = Array.isArray(route.query.open_config)
? route.query.open_config[0]
: route.query.open_config;
if (plugin_name) {
console.log(`Opening config for plugin: ${plugin_name}`);
openExtensionConfig(plugin_name);
}
loading_.value = true;
try {
await getExtensions({ withLoading: false });
// 加载自定义插件源
loadCustomSources();
// 检查是否有 open_config 参数
const plugin_name = Array.isArray(route.query.open_config)
? route.query.open_config[0]
: route.query.open_config;
if (plugin_name) {
console.log(`Opening config for plugin: ${plugin_name}`);
openExtensionConfig(plugin_name);
}
const data = await commonStore.getPluginCollections(
false,
selectedSource.value,
@@ -1463,6 +1566,8 @@ export const useExtensionPage = () => {
refreshRandomPlugins();
} catch (err) {
toast(tm("messages.getMarketDataFailed") + " " + err, "error");
} finally {
loading_.value = false;
}
});
@@ -1532,6 +1637,23 @@ export const useExtensionPage = () => {
void replaceTabRoute(router, route, newTab);
});
watch(marketCategoryFilter, () => {
if (activeTab.value === "market") {
currentPage.value = 1;
}
});
watch(
marketCategoryItems,
(newItems) => {
const validValues = new Set(newItems.map((item) => item.value));
if (!validValues.has(marketCategoryFilter.value)) {
marketCategoryFilter.value = "all";
}
},
{ immediate: true },
);
return {
commonStore,
t,
@@ -1575,6 +1697,9 @@ export const useExtensionPage = () => {
installedSortOrder,
loading_,
currentPage,
marketCategoryFilter,
marketCategoryItems,
marketCategoryCounts,
dangerConfirmDialog,
selectedDangerPlugin,
selectedMarketInstallPlugin,