feat: add project plugin center

This commit is contained in:
2026-08-27 18:36:45 +08:00
parent b6d9e6156f
commit cb1fd2629c
15 changed files with 982 additions and 1 deletions

View File

@@ -0,0 +1,135 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { AlertCircle, CheckCircle2, ChevronRight, CircleOff, Plug, RefreshCw, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataServicePluginSettings } from '@/components/plugins/data-service-settings';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import type { CodingPluginItem, CodingPluginProject, PluginBilling } from '@/lib/coding-plugins';
import { cn } from '@/lib/utils';
import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import type { DataServiceInstanceState } from '../../../shared/data-service';
import { BUNDLED_CODING_PLUGIN_DEFINITIONS, type CodingPluginDefinition } from '../../../shared/coding-plugins';
const PLUGIN_SETTINGS_SURFACES = { 'data-service': DataServicePluginSettings } as const;
const definitions = new Map<string, CodingPluginDefinition>(BUNDLED_CODING_PLUGIN_DEFINITIONS.map((definition) => [definition.id, definition]));
const STATE_TEXT: Record<CodingPluginItem['state'], string> = {
unavailable: '暂不可用', disabled: '未启用', identity_required: '需要项目 ID',
authentication_required: '需要登录', configuration_required: '待配置', ready: '已就绪', degraded: '服务降级',
};
const MUTATION_TEXT = { read: '只读', write: '变更数据', destructive: '破坏性操作' } as const;
const PERMISSION_TEXT: Record<string, string> = {
'project.data.admin': '管理项目开发数据', 'project.data.configure': '配置项目数据空间',
'project.data.read': '读取项目开发数据', 'project.data.write': '写入项目开发数据',
};
function StateMark({ state }: { state: CodingPluginItem['state'] }) {
const Icon = state === 'ready' ? CheckCircle2 : state === 'disabled' ? CircleOff : AlertCircle;
return <span className="inline-flex items-center gap-1.5 text-sm font-medium"><Icon aria-hidden="true" className="h-4 w-4" />{STATE_TEXT[state]}</span>;
}
function billingText(billing: PluginBilling): string {
if (billing.availability === 'unavailable') return '计费策略暂不可用,相关调用已停用。';
if (billing.mode === 'included') return '当前包含,不按单次调用扣点';
if (billing.mode === 'external_account') return '由外部服务商计费,不计入 Token Point。';
const unit = billing.unitName ? `计量单位:${billing.unitName}` : '以服务端计量为准';
return `平台计量扣点。${unit},价格与回执以服务端为准。`;
}
type ViewProps = {
projectName: string;
projection: CodingPluginProject;
dataService: DataServiceInstanceState | null;
pending: Record<string, true>;
agentNames: Record<string, string>;
refreshError?: string | null;
onRefresh(): void | Promise<void>;
onSetEnabled(pluginId: string, enabled: boolean): void | Promise<void>;
onConfigure(collections: string[]): void | Promise<void>;
onReset(): void | Promise<void>;
onRemoveCollection(collection: string): void | Promise<void>;
onRemoveProject(): void | Promise<void>;
};
export function ProjectPluginsView(props: ViewProps) {
const navigate = useNavigate();
const [selectedId, setSelectedId] = useState(props.projection.items[0]?.id ?? null);
const [disableCandidate, setDisableCandidate] = useState<CodingPluginItem | null>(null);
const selected = props.projection.items.find(({ id }) => id === selectedId) ?? props.projection.items[0] ?? null;
const definition = selected ? definitions.get(selected.id) : undefined;
const billing = selected?.capabilities.flatMap(({ operations }) => operations.map(({ billing }) => billing))[0] ?? null;
const SettingsSurface = selected?.settingsSurface && selected.settingsSurface in PLUGIN_SETTINGS_SURFACES
? PLUGIN_SETTINGS_SURFACES[selected.settingsSurface as keyof typeof PLUGIN_SETTINGS_SURFACES]
: null;
const pendingToggle = selected ? Boolean(props.pending[`enabled:${props.projection.project.localProjectId}:${selected.id}`]) : false;
return (
<main data-testid="project-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" onClick={() => void props.onRefresh()} disabled={Boolean(props.pending[`load:${props.projection.project.localProjectId}`])}><RefreshCw className="mr-2 h-4 w-4" />刷新</Button>
</header>
{props.projection.policyStatus === 'stale' ? <p role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">正在显示上次可用的插件策略;服务恢复前不作为新的计费依据。</p> : null}
{props.refreshError ? <p role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">刷新失败,正在保留上次项目插件页面。{props.refreshError}</p> : null}
<div className="mt-6 grid gap-5 lg:grid-cols-[minmax(240px,0.75fr)_minmax(0,1.75fr)]">
<section aria-label="可用插件" className="space-y-3">
{props.projection.items.map((plugin) => (
<button key={plugin.id} type="button" onClick={() => setSelectedId(plugin.id)} className={cn('motion-press flex min-h-10 w-full items-center gap-3 rounded-2xl bg-background p-4 text-left shadow-soft ring-1 ring-border/70 transition-[box-shadow,background-color] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35', selected?.id === plugin.id && 'bg-brand-soft ring-brand/25')}>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-subtle"><Plug className="h-5 w-5 text-brand" /></span>
<span className="min-w-0 flex-1"><span className="block truncate font-semibold">{plugin.displayName}</span><span className="mt-1 block"><StateMark state={plugin.state} /></span></span><ChevronRight className="h-4 w-4 text-muted-foreground" />
</button>
))}
</section>
{selected ? (
<article className="rounded-3xl bg-background p-5 shadow-soft ring-1 ring-border/70 sm:p-6">
<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-2xl font-semibold">{selected.displayName}</h2><Badge variant="outline"><StateMark state={selected.state} /></Badge></div><p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground">{selected.description}</p></div>
{selected.enabled ? <Button type="button" variant="outline" disabled={pendingToggle} onClick={() => setDisableCandidate(selected)} aria-label={`禁用${selected.displayName}`}>禁用</Button> : <Button type="button" disabled={pendingToggle || selected.state === 'unavailable'} onClick={() => void props.onSetEnabled(selected.id, true)} aria-label={`启用${selected.displayName}`}>启用</Button>}
</div>
<section className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4" aria-label="插件概览">
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">发布者</p><p className="mt-1 font-semibold">MakeLore</p></div>
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">包版本</p><p className="mt-1 font-semibold">{selected.version}</p></div>
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">合同版本</p><p className="mt-1 font-semibold">v{definition?.contractVersion ?? '—'}</p></div>
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">后端</p><p className="mt-1 font-semibold">{definition?.requiresBackend ? '需要' : '不需要'}</p></div>
</section>
{selected.state === 'identity_required' ? <Button type="button" variant="outline" className="mt-5" onClick={() => navigate('/project-config')}>创建或绑定项目 ID</Button> : null}
{selected.state === 'authentication_required' ? <Button type="button" variant="outline" className="mt-5" onClick={() => navigate('/login', { state: { from: '/project-plugins' } })}>前往登录</Button> : null}
<section className="mt-7" aria-labelledby="plugin-capabilities-heading"><h3 id="plugin-capabilities-heading" className="text-balance text-lg font-semibold">能力与权限</h3>
<div className="mt-3 flex flex-wrap gap-2">{selected.capabilities.map((capability) => <Badge key={capability.id} variant="secondary">{capability.id}</Badge>)}</div>
<div className="mt-3 space-y-3">{definition?.tools.map((tool) => <div key={tool.name} className="rounded-xl bg-surface-subtle p-4"><div className="flex flex-wrap items-center gap-2"><code className="font-mono text-sm font-semibold">{tool.name}</code><Badge variant="outline">{MUTATION_TEXT[tool.mutation]}</Badge></div><p className="mt-2 text-pretty text-sm text-muted-foreground">{tool.description}</p><p className="mt-2 flex items-center gap-2 text-xs"><ShieldCheck className="h-4 w-4 text-brand" />{tool.permissions.map((permission) => PERMISSION_TEXT[permission] ?? permission).join('、')}</p></div>)}</div>
</section>
<section className="mt-7" aria-labelledby="plugin-agents-heading"><div className="flex flex-wrap items-center justify-between gap-3"><h3 id="plugin-agents-heading" className="text-balance text-lg font-semibold">伙伴分配</h3><Button type="button" variant="outline" onClick={() => navigate('/project-config')}>管理伙伴技能</Button></div>
<div className="mt-3 flex flex-wrap gap-2">{selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).length ? selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).map((id) => <Badge key={id} variant="secondary">{props.agentNames[id] ?? '未知伙伴'}</Badge>) : <p className="text-pretty text-sm text-muted-foreground">尚未分配给任何伙伴。</p>}</div>
</section>
<section className="mt-7" aria-labelledby="plugin-billing-heading"><h3 id="plugin-billing-heading" className="text-balance text-lg font-semibold">用量与计费</h3>{billing ? <><p className="mt-2 text-pretty text-sm">{billingText(billing)}</p>{billing.mode === 'platform_metered' ? <p className="mt-1 text-pretty text-xs text-muted-foreground">钱包流水由权益所有者查看,当前页面不扩大财务权限。</p> : null}</> : <p className="mt-2 text-pretty text-sm">计费策略暂不可用,相关调用已停用。</p>}</section>
{SettingsSurface && selected.enabled ? <section className="mt-7" aria-labelledby="plugin-settings-heading"><h3 id="plugin-settings-heading" className="mb-3 text-balance text-lg font-semibold">配置</h3><SettingsSurface state={selected.state} projectName={props.projectName} data={props.dataService} pending={props.pending} onConfigure={props.onConfigure} onReset={props.onReset} onRemoveCollection={props.onRemoveCollection} onRemoveProject={props.onRemoveProject} /></section> : null}
</article>
) : <p className="text-sm text-muted-foreground">当前没有可用插件。</p>}
</div>
<Dialog open={Boolean(disableCandidate)} onOpenChange={(open) => { if (!open) setDisableCandidate(null); }}><DialogContent><DialogHeader><DialogTitle className="text-balance">禁用{disableCandidate?.displayName}?</DialogTitle><DialogDescription className="text-pretty">禁用后,伙伴工具和预览访问会立即停止;云端数据会保留,不会被删除。</DialogDescription></DialogHeader><DialogFooter><Button type="button" variant="outline" onClick={() => setDisableCandidate(null)}>取消</Button><Button type="button" onClick={() => { if (disableCandidate) void props.onSetEnabled(disableCandidate.id, false); setDisableCandidate(null); }}>确认禁用并保留数据</Button></DialogFooter></DialogContent></Dialog>
</main>
);
}
export function ProjectPlugins() {
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
const config = useCodingWorkspaceStore((state) => state.config);
const projection = useCodingPluginsStore((state) => state.projection);
const dataService = useCodingPluginsStore((state) => state.dataService);
const pending = useCodingPluginsStore((state) => state.pending);
const loadState = useCodingPluginsStore((state) => state.loadState);
const error = useCodingPluginsStore((state) => state.error);
const agentNames = useMemo(() => Object.fromEntries((config?.agents ?? []).map((agent) => [agent.id, agent.name || agent.roleName])), [config]);
useEffect(() => { if (activeProject) void codingPluginsStore.getState().load(activeProject.id).catch(() => undefined); }, [activeProject]);
if (!activeProject) return <p className="p-6 text-pretty text-sm text-muted-foreground">请先选择一个项目。</p>;
const currentProjection = projection?.project.localProjectId === activeProject.id ? projection : null;
if (!currentProjection && loadState === 'loading') return <p role="status" className="p-6 text-sm">正在读取项目插件…</p>;
if (!currentProjection) return <div role="alert" className="p-6"><p className="text-pretty text-sm">无法读取项目插件。{error ? ` ${error}` : ''}</p><Button className="mt-3" onClick={() => void codingPluginsStore.getState().load(activeProject.id)}>重试</Button></div>;
const safe = (promise: Promise<void>) => promise.catch((reason) => {
toast.error(reason instanceof Error ? reason.message : String(reason));
});
return <ProjectPluginsView projectName={activeProject.name} projection={currentProjection} dataService={dataService} pending={pending} agentNames={agentNames} refreshError={loadState === 'error' ? error : null} onRefresh={() => safe(codingPluginsStore.getState().load(activeProject.id))} onSetEnabled={(pluginId, enabled) => safe(codingPluginsStore.getState().setEnabled(activeProject.id, pluginId, enabled))} onConfigure={(collections) => safe(codingPluginsStore.getState().configure(collections))} onReset={() => safe(codingPluginsStore.getState().reset())} onRemoveCollection={(collection) => safe(codingPluginsStore.getState().removeCollection(collection))} onRemoveProject={() => safe(codingPluginsStore.getState().removeProject())} />;
}