feat: add plugin marketplace renderer
This commit is contained in:
1 parent
8b6824a8ba
commit
97c9ad1aad
27 files changed
+1585
-23
No files matched your search
@@ -0,0 +1,129 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { CheckCircle2, Download, Puzzle, Search, ShieldCheck } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
MarketplaceCatalogPage,
|
||||
MarketplaceCatalogQuery,
|
||||
MarketplaceLibrarySnapshot,
|
||||
MarketplacePluginDetail,
|
||||
MarketplaceUsageBilling,
|
||||
} from '@/lib/plugin-marketplace';
|
||||
import { pluginMarketplaceStore, usePluginMarketplaceStore } from '@/stores/plugin-marketplace';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const USAGE_COPY: Record<MarketplaceUsageBilling, string> = {
|
||||
included: '当前包含,不按单次调用扣点',
|
||||
token_point: '按 Token Point 实际用量计费',
|
||||
mixed: '部分能力包含,部分按 Token Point 用量计费',
|
||||
};
|
||||
|
||||
export type PluginMarketplaceViewProps = {
|
||||
catalog: MarketplaceCatalogPage | null;
|
||||
detail: MarketplacePluginDetail | null;
|
||||
library: MarketplaceLibrarySnapshot | null;
|
||||
catalogState: 'idle' | 'loading' | 'ready' | 'error';
|
||||
catalogError?: string | null;
|
||||
pending: Record<string, true>;
|
||||
onSearch(input: MarketplaceCatalogQuery): void | Promise<void>;
|
||||
onSelect(pluginId: string): void | Promise<void>;
|
||||
onAcquire(pluginId: string): void | Promise<void>;
|
||||
};
|
||||
|
||||
export function PluginMarketplaceView(props: PluginMarketplaceViewProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const categories = useMemo(() => [...new Set(props.catalog?.items.map((item) => item.category) ?? [])].sort(), [props.catalog]);
|
||||
const acquiredIds = useMemo(() => new Set(props.library?.items
|
||||
.filter(({ removedAt }) => removedAt === null)
|
||||
.map(({ pluginId }) => pluginId) ?? []), [props.library]);
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
void props.onSearch({ ...(query.trim() ? { query: query.trim() } : {}), ...(category ? { category } : {}), limit: 24 });
|
||||
};
|
||||
|
||||
return (
|
||||
<main data-testid="plugin-marketplace-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">浏览 MakeLore 运营发行的插件。免费获取只加入账号插件库,不会自动下载、启用项目或分配给伙伴。</p>
|
||||
</div>
|
||||
{props.catalog ? <p className="tabular-nums rounded-xl bg-surface-subtle px-3 py-2 text-sm" aria-label={`共 ${props.catalog.total} 个插件`}><span className="tabular-nums">{props.catalog.total}</span> 个插件</p> : null}
|
||||
</header>
|
||||
|
||||
<form role="search" className="mt-6 flex flex-wrap gap-3" onSubmit={submit}>
|
||||
<label className="relative min-w-[220px] flex-1">
|
||||
<span className="sr-only">搜索插件</span>
|
||||
<Search aria-hidden="true" className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<input type="search" aria-label="搜索插件" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索名称、简介或标签" className="min-h-10 w-full rounded-xl border border-border bg-background py-2 pl-9 pr-3 text-sm outline-none transition-[border-color,box-shadow] duration-150 focus:border-brand/50 focus:ring-2 focus:ring-brand/20" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="sr-only">插件分类</span>
|
||||
<select aria-label="插件分类" value={category} onChange={(event) => setCategory(event.target.value)} className="min-h-10 rounded-xl border border-border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-brand/25">
|
||||
<option value="">全部分类</option>
|
||||
{categories.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit" className="min-h-10"><Search className="mr-2 h-4 w-4" />搜索</Button>
|
||||
</form>
|
||||
|
||||
{props.catalog?.stale ? <p role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">正在显示上次可信的缓存目录;刷新成功前不会把本地安装包伪装成新的目录信息。</p> : null}
|
||||
{props.catalogState === 'loading' && !props.catalog ? <p role="status" className="mt-8 text-sm text-muted-foreground">正在读取插件目录…</p> : null}
|
||||
{props.catalogState === 'error' && !props.catalog ? <div role="alert" className="mt-8 rounded-2xl bg-warning/10 p-5"><h2 className="text-balance font-semibold">插件目录暂不可用</h2><p className="mt-2 text-pretty text-sm text-muted-foreground">首次读取失败,不会从本地包推测目录或计费策略。{props.catalogError ? ` ${props.catalogError}` : ''}</p></div> : null}
|
||||
{props.catalog && props.catalog.items.length === 0 ? <div className="mt-8 rounded-2xl bg-surface-subtle p-6 text-center"><Puzzle 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></div> : null}
|
||||
|
||||
<section aria-label="插件目录" className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{props.catalog?.items.map((plugin) => {
|
||||
const acquired = acquiredIds.has(plugin.pluginId);
|
||||
const acquiring = Boolean(props.pending[`acquire:${plugin.pluginId}`]);
|
||||
return (
|
||||
<article key={plugin.pluginId} className="rounded-2xl bg-background p-5 shadow-soft ring-1 ring-border/70 transition-[box-shadow] duration-150 hover:shadow-float">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div><Badge variant="secondary">{plugin.category}</Badge><h2 className="mt-3 text-balance text-xl font-semibold">{plugin.title}</h2></div>
|
||||
{plugin.acquisition === 'system_included' ? <Badge variant="outline">系统内置</Badge> : <Badge variant="outline">免费获取</Badge>}
|
||||
</div>
|
||||
<p className="mt-3 text-pretty text-sm text-muted-foreground">{plugin.summary}</p>
|
||||
<dl className="mt-4 grid gap-2 text-sm">
|
||||
<div className="flex justify-between gap-3"><dt className="text-muted-foreground">提供方</dt><dd>{plugin.providerDisplayName}</dd></div>
|
||||
<div className="flex justify-between gap-3"><dt className="text-muted-foreground">稳定版</dt><dd className="tabular-nums">{plugin.stableVersion ?? '暂不可用'}</dd></div>
|
||||
</dl>
|
||||
<p className="mt-4 text-pretty text-sm font-medium">{USAGE_COPY[plugin.usageBilling]}</p>
|
||||
{plugin.runtimeStatus === 'suspended' ? <p className="mt-3 text-pretty text-sm text-destructive">运行已暂停,暂不能获取或下载。</p> : null}
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" className="min-h-10" onClick={() => void props.onSelect(plugin.pluginId)}>查看详情</Button>
|
||||
{plugin.acquisition === 'system_included' || acquired
|
||||
? <span className="inline-flex min-h-10 items-center gap-2 px-2 text-sm font-medium"><CheckCircle2 className="h-4 w-4 text-brand" />{plugin.acquisition === 'system_included' ? '随 MakeLore 提供' : '已免费获取'}</span>
|
||||
: <Button type="button" className="min-h-10" disabled={acquiring || plugin.runtimeStatus === 'suspended'} aria-label={`免费获取${plugin.title}`} onClick={() => void props.onAcquire(plugin.pluginId)}><Download className="mr-2 h-4 w-4" />免费获取</Button>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{props.detail ? <aside aria-label={`${props.detail.title}详情`} className="mt-6 rounded-2xl bg-surface-subtle p-5 sm:p-6"><div className="flex flex-wrap items-start justify-between gap-3"><div><p className="text-sm text-muted-foreground">插件详情</p><h2 className="mt-1 text-balance text-2xl font-semibold">{props.detail.title}</h2></div><Badge variant="outline"><ShieldCheck className="mr-1 h-3.5 w-3.5" />{props.detail.providerDisplayName}</Badge></div><p className="mt-3 whitespace-pre-wrap text-pretty text-sm">{props.detail.descriptionMarkdown}</p><h3 className="mt-5 text-balance font-semibold">权限</h3>{props.detail.permissions.length ? <ul className="mt-2 list-inside list-disc space-y-1 text-sm">{props.detail.permissions.map((permission) => <li key={permission} className="text-pretty">{permission}</li>)}</ul> : <p className="mt-2 text-sm text-muted-foreground">不请求额外权限。</p>}</aside> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginMarketplace() {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const accountKey = user?.userId ?? user?.username ?? null;
|
||||
const catalog = usePluginMarketplaceStore((state) => state.catalog);
|
||||
const catalogState = usePluginMarketplaceStore((state) => state.catalogState);
|
||||
const catalogError = usePluginMarketplaceStore((state) => state.catalogError);
|
||||
const library = usePluginMarketplaceStore((state) => state.library);
|
||||
const details = usePluginMarketplaceStore((state) => state.details);
|
||||
const pending = usePluginMarketplaceStore((state) => state.pending);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
pluginMarketplaceStore.getState().activateAccount(accountKey);
|
||||
void pluginMarketplaceStore.getState().loadCatalog({ limit: 24 }).catch(() => undefined);
|
||||
if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined);
|
||||
}, [accountKey]);
|
||||
const safe = (operation: Promise<void>) => operation.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
return <PluginMarketplaceView catalog={catalog} detail={selectedId ? details[selectedId] ?? null : null} library={library} catalogState={catalogState} catalogError={catalogError} pending={pending} onSearch={(input) => safe(pluginMarketplaceStore.getState().loadCatalog(input))} onSelect={(pluginId) => { setSelectedId(pluginId); return safe(pluginMarketplaceStore.getState().loadDetail(pluginId)); }} onAcquire={(pluginId) => safe(pluginMarketplaceStore.getState().acquire(pluginId))} />;
|
||||
}
|
||||
Reference in new issue
Block a user