feat: add project plugin center
This commit is contained in:
135
src/pages/ProjectPlugins/index.tsx
Normal file
135
src/pages/ProjectPlugins/index.tsx
Normal 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())} />;
|
||||
}
|
||||
Reference in New Issue
Block a user