116 lines
12 KiB
TypeScript
116 lines
12 KiB
TypeScript
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))} />;
|
||
}
|