feat(plugins): add plugin new version detection (#1865)

* feat(plugins): 添加插件更新检测功能

* perf: card style

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
sheetung
2025-12-18 12:17:25 +08:00
committed by GitHub
parent 82e2123fe7
commit 3ab9ffb7b7
8 changed files with 132 additions and 10 deletions

View File

@@ -13,6 +13,7 @@ export interface IPluginCardVO {
status: string;
components: PluginComponent[];
debug: boolean;
hasUpdate?: boolean;
}
export class PluginCardVO implements IPluginCardVO {
@@ -28,6 +29,7 @@ export class PluginCardVO implements IPluginCardVO {
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
status: string;
components: PluginComponent[];
hasUpdate?: boolean;
constructor(prop: IPluginCardVO) {
this.author = prop.author;
@@ -42,5 +44,6 @@ export class PluginCardVO implements IPluginCardVO {
this.debug = prop.debug;
this.install_source = prop.install_source;
this.install_info = prop.install_info;
this.hasUpdate = prop.hasUpdate;
}
}

View File

@@ -7,6 +7,8 @@ import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-fo
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
import styles from '@/app/home/plugins/plugins.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { isNewerVersion } from '@/app/utils/versionCompare';
import {
Dialog,
DialogContent,
@@ -72,10 +74,68 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
getPluginList();
}
function getPluginList() {
httpClient.getPlugins().then((value) => {
async function getPluginList() {
try {
// 获取已安装插件列表
const installedPluginsResp = await httpClient.getPlugins();
const installedPlugins = installedPluginsResp.plugins;
// 获取市场插件列表
const client = getCloudServiceClientSync();
const marketplaceResp = await client.getMarketplacePlugins(1, 100);
const marketplacePlugins = marketplaceResp.plugins;
// 创建市场插件映射,便于快速查找
const marketplacePluginMap = new Map();
marketplacePlugins.forEach((plugin) => {
const key = `${plugin.author}/${plugin.name}`;
marketplacePluginMap.set(key, plugin);
});
// 转换并比较版本号
const pluginCards = installedPlugins.map((plugin) => {
const cardVO = new PluginCardVO({
author: plugin.manifest.manifest.metadata.author ?? '',
label: extractI18nObject(plugin.manifest.manifest.metadata.label),
description: extractI18nObject(
plugin.manifest.manifest.metadata.description ?? {
en_US: '',
zh_Hans: '',
},
),
debug: plugin.debug,
enabled: plugin.enabled,
name: plugin.manifest.manifest.metadata.name,
version: plugin.manifest.manifest.metadata.version ?? '',
status: plugin.status,
components: plugin.components,
priority: plugin.priority,
install_source: plugin.install_source,
install_info: plugin.install_info,
});
// 检查是否来自市场且有更新
if (cardVO.install_source === 'marketplace') {
const marketplaceKey = `${cardVO.author}/${cardVO.name}`;
const marketplacePlugin = marketplacePluginMap.get(marketplaceKey);
if (marketplacePlugin && marketplacePlugin.latest_version) {
cardVO.hasUpdate = isNewerVersion(
marketplacePlugin.latest_version,
cardVO.version,
);
}
}
return cardVO;
});
setPluginList(pluginCards);
} catch (error) {
console.error('获取插件列表失败:', error);
// 失败时仍显示已安装插件,不影响用户体验
const installedPluginsResp = await httpClient.getPlugins();
setPluginList(
value.plugins.map((plugin) => {
installedPluginsResp.plugins.map((plugin) => {
return new PluginCardVO({
author: plugin.manifest.manifest.metadata.author ?? '',
label: extractI18nObject(plugin.manifest.manifest.metadata.label),
@@ -97,7 +157,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
});
}),
);
});
}
}
useImperativeHandle(ref, () => ({

View File

@@ -159,12 +159,17 @@ export default function PluginCardComponent({
}}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="bg-white dark:bg-[#1f1f22] hover:bg-gray-100 dark:hover:bg-[#2a2a2d]"
>
<Ellipsis className="w-4 h-4" />
</Button>
<div className="relative">
<Button
variant="ghost"
className="bg-white dark:bg-[#1f1f22] hover:bg-gray-100 dark:hover:bg-[#2a2a2d]"
>
<Ellipsis className="w-4 h-4" />
</Button>
{cardVO.hasUpdate && (
<div className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-white dark:border-[#1f1f22]"></div>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
{/**upgrade */}
@@ -179,6 +184,11 @@ export default function PluginCardComponent({
>
<ArrowUp className="w-4 h-4" />
<span>{t('plugins.update')}</span>
{cardVO.hasUpdate && (
<Badge className="ml-auto bg-red-500 hover:bg-red-500 text-white text-[0.6rem] px-1.5 py-0 h-4">
{t('plugins.new')}
</Badge>
)}
</DropdownMenuItem>
)}
{/**view source */}

View File

@@ -0,0 +1,45 @@
/**
* Compare two version strings and determine if the first is newer than the second.
* Supports semantic versioning format (e.g., "1.2.3", "1.0.0-beta.1").
*
* @param version1 - The version to compare (potentially newer)
* @param version2 - The version to compare against (base version)
* @returns true if version1 is newer than version2, false otherwise
*/
export function isNewerVersion(version1: string, version2: string): boolean {
if (!version1 || !version2) {
return false;
}
// Remove any leading 'v' prefix
const v1 = version1.replace(/^v/, '');
const v2 = version2.replace(/^v/, '');
// Split into main version and pre-release parts
const [main1, pre1] = v1.split('-');
const [main2, pre2] = v2.split('-');
// Split main version into numeric parts
const parts1 = main1.split('.').map((p) => parseInt(p, 10) || 0);
const parts2 = main2.split('.').map((p) => parseInt(p, 10) || 0);
// Normalize length
const maxLen = Math.max(parts1.length, parts2.length);
while (parts1.length < maxLen) parts1.push(0);
while (parts2.length < maxLen) parts2.push(0);
// Compare main version parts
for (let i = 0; i < maxLen; i++) {
if (parts1[i] > parts2[i]) return true;
if (parts1[i] < parts2[i]) return false;
}
// Main versions are equal, compare pre-release
// A version without pre-release is newer than one with pre-release
if (!pre1 && pre2) return true;
if (pre1 && !pre2) return false;
if (!pre1 && !pre2) return false;
// Both have pre-release, compare lexicographically
return pre1! > pre2!;
}

View File

@@ -288,6 +288,7 @@ const enUS = {
noComponents: 'No components',
delete: 'Delete Plugin',
update: 'Update Plugin',
new: 'New',
updateConfirm: 'Update Confirmation',
confirmUpdatePlugin:
'Are you sure you want to update the plugin ({{author}}/{{name}})?',

View File

@@ -289,6 +289,7 @@ const jaJP = {
noComponents: '部品がありません',
delete: 'プラグインを削除',
update: 'プラグインを更新',
new: '新着',
updateConfirm: '更新の確認',
confirmUpdatePlugin:
'プラグイン「{{author}}/{{name}}」を更新してもよろしいですか?',

View File

@@ -275,6 +275,7 @@ const zhHans = {
noComponents: '无组件',
delete: '删除插件',
update: '更新插件',
new: '新',
updateConfirm: '更新确认',
confirmUpdatePlugin: '你确定要更新插件({{author}}/{{name}})吗?',
confirmUpdate: '确认更新',

View File

@@ -274,6 +274,7 @@ const zhHant = {
noComponents: '無組件',
delete: '刪除插件',
update: '更新插件',
new: '新',
updateConfirm: '更新確認',
confirmUpdatePlugin: '您確定要更新插件({{author}}/{{name}})嗎?',
confirmUpdate: '確認更新',