Files
makelore/src/pages/MyPlugins/index.tsx

116 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { AlertCircle, Download, PackageCheck, RefreshCw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import type { MarketplaceInstallation, MarketplaceLibrarySnapshot } from '@/lib/plugin-marketplace';
import { pluginMarketplaceStore, usePluginMarketplaceStore } from '@/stores/plugin-marketplace';
import { useAuthStore } from '@/stores/auth';
function failureText(reason?: string): string | null {
if (!reason) return null;
const messages: string[] = [];
if (reason.includes('signature')) messages.push('签名校验失败,旧版本仍保留。');
if (reason.includes('artifact_invalid')) messages.push('插件包校验失败,旧版本仍保留。');
if (reason.includes('yanked')) messages.push('此版本已撤回,旧版本仍保留。');
if (reason.includes('not_ready')) messages.push('此版本尚未就绪,旧版本仍保留。');
if (reason.includes('incompatible')) messages.push('当前 MakeLore 版本不兼容,旧版本仍保留。');
if (reason.includes('runtime_suspended')) messages.push('插件运行已暂停,设备版本未替换。');
if (reason.includes('backend_unavailable')) messages.push('Marketplace 服务暂不可用,设备版本未替换。');
if (reason.includes('library_required')) messages.push('请先在账号插件库中获取此插件。');
if (messages.length === 0) messages.push('当前版本暂不可用,旧版本仍保留。');
return messages.join(' ');
}
function actionErrorText(error?: string | null): string | null {
if (!error) return null;
if (error.includes('plugin_artifact_invalid')) return '插件签名或包校验失败,设备上此前可用版本仍保留。';
if (error.includes('plugin_signature_invalid')) return '插件签名校验失败,设备上此前可用版本仍保留。';
if (error.includes('plugin_release_yanked')) return '此版本已撤回;不会替换此前可用版本。';
if (error.includes('plugin_release_not_ready')) return '版本尚未就绪;不会替换此前可用版本。';
if (error.includes('plugin_incompatible_client')) return '当前 MakeLore 版本不兼容;不会替换此前可用版本。';
if (error.includes('plugin_runtime_suspended')) return '插件运行已暂停;不会替换此前可用版本。';
if (error.includes('plugin_backend_unavailable')) return 'Marketplace 服务暂不可用;不会替换此前可用版本。';
if (error.includes('plugin_library_required')) return '请先在账号插件库中获取此插件。';
return error;
}
export type MyPluginsViewProps = {
library: MarketplaceLibrarySnapshot | null;
installations: Record<string, MarketplaceInstallation>;
state: 'idle' | 'loading' | 'ready' | 'error';
error?: string | null;
pending: Record<string, true>;
onRefresh(): void | Promise<void>;
onInstall(pluginId: string): void | Promise<void>;
onInstallBeta(pluginId: string): void | Promise<void>;
onUpdate(pluginId: string): void | Promise<void>;
onUninstall(pluginId: string): void | Promise<void>;
onRemove(pluginId: string): void | Promise<void>;
onReacquire(pluginId: string): void | Promise<void>;
};
export function MyPluginsView(props: MyPluginsViewProps) {
const visibleError = actionErrorText(props.error);
return (
<main data-testid="my-plugins-page" className="mx-auto w-full max-w-7xl text-foreground">
<header className="flex flex-wrap items-start justify-between gap-4"><div><h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]">我的插件</h1><p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground">账号插件库与本机下载分开管理。下载不会自动启用到项目,也不会分配给伙伴。</p></div><Button type="button" variant="outline" className="min-h-10" onClick={() => void props.onRefresh()}><RefreshCw className="mr-2 h-4 w-4" />刷新</Button></header>
{props.library?.stale ? <p role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">正在显示上次可信的插件库;服务恢复前,在线计量或托管能力保持不可用。</p> : null}
{props.state === 'loading' && !props.library ? <p role="status" className="mt-8 text-sm">正在读取我的插件…</p> : null}
{props.state === 'error' && !props.library ? <div role="alert" className="mt-8 rounded-2xl bg-warning/10 p-5"><h2 className="font-semibold">无法读取我的插件</h2><p className="mt-2 text-pretty text-sm">{props.error ?? '请稍后重试。'}</p></div> : null}
{visibleError && props.library ? <p role="alert" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">{visibleError}</p> : null}
{props.library && props.library.items.length === 0 ? <div className="mt-8 rounded-2xl bg-surface-subtle p-6 text-center"><PackageCheck className="mx-auto h-8 w-8 text-muted-foreground" /><h2 className="mt-3 text-balance font-semibold">账号插件库还是空的</h2><p className="mt-1 text-pretty text-sm text-muted-foreground">前往插件中心免费获取需要的插件。</p><Button asChild className="mt-4 min-h-10"><Link to="/plugin-marketplace">浏览插件中心</Link></Button></div> : null}
<section aria-label="账号插件库" className="mt-6 space-y-4">
{props.library?.items.map((plugin) => {
const installed = props.installations[plugin.pluginId];
const hasDevicePackage = Boolean(installed?.version);
const removed = plugin.removedAt !== null;
const suspended = plugin.runtimeStatus === 'suspended';
const retired = plugin.catalogStatus === 'retired';
const installedChannel = hasDevicePackage ? installed?.channel ?? 'stable' : 'stable';
const channelVersion = installedChannel === 'beta' ? plugin.betaVersion : plugin.stableVersion;
const updateAvailable = Boolean(hasDevicePackage && channelVersion && installed?.version !== channelVersion);
const betaChannelUnavailable = Boolean(hasDevicePackage && installedChannel === 'beta' && !plugin.betaVersion);
const busy = Object.keys(props.pending).some((key) => key.endsWith(`:${plugin.pluginId}`));
const failure = failureText(installed?.reason);
return <article key={plugin.pluginId} className="rounded-2xl bg-background p-5 shadow-soft ring-1 ring-border/70">
<div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex flex-wrap items-center gap-2"><h2 className="text-balance text-xl font-semibold">{plugin.title}</h2>{plugin.acquisition === 'system_included' ? <Badge variant="outline">系统内置</Badge> : removed ? <Badge variant="outline">已移除</Badge> : <Badge variant="secondary">已获取</Badge>}</div><p className="mt-2 text-pretty text-sm text-muted-foreground">{plugin.summary}</p></div><div className="text-right text-sm"><p className="tabular-nums">稳定版 {plugin.stableVersion ?? '不可用'}</p>{plugin.betaVersion ? <p className="mt-1 tabular-nums text-muted-foreground">Beta 版 {plugin.betaVersion}</p> : null}{hasDevicePackage ? <><p className="mt-1 tabular-nums text-muted-foreground">设备版本 {installed?.version}</p><p className="mt-1 text-muted-foreground">当前频道:{installedChannel === 'beta' ? 'Beta' : '稳定'}</p></> : <p className="mt-1 text-muted-foreground">尚未下载到设备</p>}</div></div>
<div className="mt-4 space-y-1 text-pretty text-sm">{suspended ? <p className="text-destructive">运行已暂停</p> : null}{retired ? <p>{removed ? '已退役,移除后不可重新获取' : '已退役;现有账号插件库仍可继续使用受支持版本'}</p> : null}{betaChannelUnavailable ? <p>当前 Beta 频道暂无可用版本;不会静默切回稳定版。</p> : null}{failure ? <p className="text-destructive"><AlertCircle className="mr-1 inline h-4 w-4" />{failure}</p> : null}</div>
<div className="mt-5 flex flex-wrap gap-2">
{removed ? <Button type="button" className="min-h-10" disabled={busy || retired || suspended} aria-label={`重新免费获取${plugin.title}`} onClick={() => void props.onReacquire(plugin.pluginId)}>重新免费获取</Button>
: !hasDevicePackage ? <Button type="button" className="min-h-10" disabled={busy || suspended} aria-label={`下载${plugin.title}`} onClick={() => void props.onInstall(plugin.pluginId)}><Download className="mr-2 h-4 w-4" />下载</Button>
: updateAvailable ? <Button type="button" className="min-h-10" disabled={busy || suspended} aria-label={`${installedChannel === 'beta' ? '更新 Beta' : '更新'}${plugin.title}`} onClick={() => void (installedChannel === 'beta' ? props.onInstallBeta(plugin.pluginId) : props.onUpdate(plugin.pluginId))}><RefreshCw className="mr-2 h-4 w-4" />{installedChannel === 'beta' ? '更新 Beta' : '更新'}</Button>
: <span className="inline-flex min-h-10 items-center px-2 text-sm font-medium"><PackageCheck className="mr-2 h-4 w-4 text-brand" />设备版本已是最新</span>}
{!removed && plugin.acquisition !== 'system_included' && plugin.betaVersion && installedChannel !== 'beta' ? <Button type="button" variant="outline" className="min-h-10" disabled={busy || suspended} aria-label={`安装 Beta${plugin.title}`} onClick={() => void props.onInstallBeta(plugin.pluginId)}>安装 Beta</Button> : null}
{!removed && plugin.acquisition !== 'system_included' ? <Button type="button" variant="outline" className="min-h-10" disabled={busy} aria-label={`移除${plugin.title}`} onClick={() => void props.onRemove(plugin.pluginId)}><Trash2 className="mr-2 h-4 w-4" />移除</Button> : null}
{hasDevicePackage ? <Button type="button" variant="ghost" className="min-h-10" disabled={busy} aria-label={`删除设备上的${plugin.title}`} onClick={() => void props.onUninstall(plugin.pluginId)}>删除设备包</Button> : null}
<Button asChild variant="outline" className="min-h-10"><Link to="/project-plugins">启用到项目</Link></Button>
<Button asChild variant="outline" className="min-h-10"><Link to="/project-config">分配给伙伴</Link></Button>
</div>
</article>;
})}
</section>
</main>
);
}
export function MyPlugins() {
const user = useAuthStore((state) => state.user);
const accountKey = user?.userId ?? user?.username ?? null;
const library = usePluginMarketplaceStore((state) => state.library);
const installations = usePluginMarketplaceStore((state) => state.installations);
const state = usePluginMarketplaceStore((value) => value.libraryState);
const error = usePluginMarketplaceStore((value) => value.libraryError);
const pending = usePluginMarketplaceStore((value) => value.pending);
useEffect(() => {
pluginMarketplaceStore.getState().activateAccount(accountKey);
if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined);
}, [accountKey]);
const safe = (operation: Promise<void>) => operation.catch((reason) => {
toast.error(reason instanceof Error ? reason.message : String(reason));
});
const store = pluginMarketplaceStore.getState;
return <MyPluginsView library={library} installations={installations} state={state} error={error} pending={pending} onRefresh={() => safe(store().loadLibrary())} onInstall={(id) => safe(store().install(id))} onInstallBeta={(id) => safe(store().installBeta(id))} onUpdate={(id) => safe(store().update(id))} onUninstall={(id) => safe(store().uninstall(id))} onRemove={(id) => safe(store().remove(id))} onReacquire={(id) => safe(store().acquire(id))} />;
}