feat: unify plugin workspace navigation

This commit is contained in:
2026-09-03 13:39:55 +08:00
parent 959e2faf03
commit 1b1f206dd8
27 changed files with 3097 additions and 1100 deletions

View File

@@ -0,0 +1,305 @@
import { useState } from 'react';
import { AlertTriangle, ExternalLink, ShieldCheck, X } from 'lucide-react';
import { DataServicePluginSettings } from '@/components/plugins/data-service-settings';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { DataServiceInstanceState } from '../../../shared/data-service';
import type { PluginWorkspaceCommand, PluginWorkspaceItem } from './plugin-workspace-model';
const SOURCE_TEXT = {
official: '官方',
local: '本机',
retained: '配置保留',
} as const;
const DELIVERY_TEXT = {
system_included: '随 MakeLore 提供',
account_acquired: '账号已获取',
account_removed: '已从账号移除',
account_unknown: '账号状态未知',
local_installed: '本机已安装',
not_acquired: '尚未获取',
retained: '仅保留项目配置',
} as const;
const PROJECT_TEXT = {
enabled: '当前项目已启用',
disabled: '当前项目未启用',
unavailable: '当前项目暂不可用',
unknown: '当前项目状态未知',
not_applicable: '不属于项目启用范围',
} as const;
function billingText(item: PluginWorkspaceItem): string {
if (item.billing === 'included') return '按平台包含,不按单次插件调用扣点。';
if (item.billing === 'token_point') return '按 Token Point 实际用量计费,价格与回执以服务端为准。';
if (item.billing === 'mixed') return '部分能力包含,部分能力按 Token Point 用量计费。';
if (item.billing === 'none') return '无 MakeLore 平台插件计费投影;外部服务或模型调用可能另行计费。';
return '当前没有可信的插件计费投影。';
}
function commandLabel(command: PluginWorkspaceCommand, title: string): string {
switch (command.kind) {
case 'acquire': return `免费获取${title}`;
case 'reacquire': return `重新免费获取${title}`;
case 'remove_from_library': return `从账号移除${title}`;
case 'install_stable': return `下载${title}稳定版`;
case 'install_beta': return `安装${title} Beta`;
case 'update_official': return `更新${title}设备包`;
case 'remove_official_device_package': return `删除${title}官方设备包`;
case 'enable_project': return `启用${title}到当前项目`;
case 'disable_project': return `从当前项目禁用${title}`;
case 'enable_local': return `本机全局启用${title}`;
case 'disable_local': return `本机全局停用${title}`;
case 'remove_local': return `移除本机包${title}`;
case 'open_agent_assignment': return `分配${title}给伙伴`;
case 'open_settings': return `查看${title}插件设置`;
}
}
function destructive(command: PluginWorkspaceCommand): boolean {
return command.kind === 'remove_from_library'
|| command.kind === 'remove_official_device_package'
|| command.kind === 'disable_project'
|| command.kind === 'remove_local';
}
function confirmationCopy(
command: PluginWorkspaceCommand,
item: PluginWorkspaceItem,
projectName: string | null,
): { title: string; description: string; confirm: string } {
switch (command.kind) {
case 'remove_from_library':
return {
title: `从账号移除${item.title}?`,
description: '这会移除账号插件库记录,不会自动删除已经下载的设备包或项目配置。',
confirm: '确认从账号移除',
};
case 'remove_official_device_package':
return {
title: `删除${item.title}的官方设备包?`,
description: '这只删除当前设备上的官方包,不会移除账号插件库或项目配置。',
confirm: '确认删除官方设备包',
};
case 'disable_project':
return {
title: `从当前项目禁用${item.title}?`,
description: `插件能力会从项目“${projectName ?? '当前项目'}”停止,但插件数据与账号记录会保留。`,
confirm: '确认从当前项目禁用',
};
case 'remove_local':
return {
title: `移除本机包${item.title}?`,
description: '这会从本机全局移除该包,新建或空闲的主伙伴与 worker 将不再加载它。',
confirm: '确认移除本机包',
};
default:
return { title: '', description: '', confirm: '' };
}
}
export interface PluginDetailsProps {
item: PluginWorkspaceItem;
activeProjectName: string | null;
dataService: DataServiceInstanceState | null;
dataServicePending: Record<string, true>;
isCommandPending(command: PluginWorkspaceCommand): boolean;
onClose(): void;
onCommand(command: PluginWorkspaceCommand): void | Promise<void>;
onConfigureDataService(collections: string[]): void | Promise<void>;
onResetDataService(): void | Promise<void>;
onRemoveDataServiceCollection(collection: string): void | Promise<void>;
onRemoveDataServiceProject(): void | Promise<void>;
}
export function PluginDetails(props: PluginDetailsProps) {
const [confirmation, setConfirmation] = useState<PluginWorkspaceCommand | null>(null);
const project = props.item.official?.project ?? null;
const detail = props.item.official?.detail ?? null;
const installation = props.item.official?.installation ?? null;
const showDataService = project?.enabled
&& project.settingsSurface === 'data-service';
const confirmCopy = confirmation
? confirmationCopy(confirmation, props.item, props.activeProjectName)
: null;
const run = (command: PluginWorkspaceCommand) => {
if (destructive(command)) {
setConfirmation(command);
return;
}
void props.onCommand(command);
};
return (
<>
<Dialog open onOpenChange={(open) => { if (!open) props.onClose(); }}>
<DialogContent className="max-h-[calc(100dvh-2rem)] max-w-3xl overflow-y-auto p-0">
<div className="sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-border/70 bg-background/95 px-5 py-4 backdrop-blur sm:px-6">
<DialogHeader className="min-w-0 text-left">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{SOURCE_TEXT[props.item.source]}</Badge>
{props.item.stale ? <Badge variant="warning">缓存</Badge> : null}
{props.item.unavailable ? <Badge variant="warning">暂不可用</Badge> : null}
</div>
<DialogTitle className="text-balance text-2xl">{props.item.title}</DialogTitle>
<DialogDescription>
{props.item.publisher ? `${props.item.publisher} · ` : ''}
{props.item.version ? `版本 ${props.item.version}` : '版本未知'}
</DialogDescription>
</DialogHeader>
<Button type="button" variant="ghost" size="icon" aria-label="关闭插件详情" onClick={props.onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-8 px-5 pb-6 sm:px-6">
<section aria-labelledby="plugin-description-heading">
<h3 id="plugin-description-heading" className="text-lg font-semibold">介绍</h3>
<p className="mt-2 whitespace-pre-wrap text-pretty text-sm leading-6 text-muted-foreground">
{detail?.descriptionMarkdown || props.item.summary || '暂无介绍。'}
</p>
</section>
<section aria-labelledby="plugin-status-heading">
<h3 id="plugin-status-heading" className="text-lg font-semibold">账号、设备与项目状态</h3>
<dl className="mt-3 grid gap-3 sm:grid-cols-3">
<div className="rounded-xl bg-surface-subtle p-3">
<dt className="text-xs text-muted-foreground">交付</dt>
<dd className="mt-1 text-sm font-semibold">{DELIVERY_TEXT[props.item.delivery]}</dd>
</div>
<div className="rounded-xl bg-surface-subtle p-3">
<dt className="text-xs text-muted-foreground">当前设备</dt>
<dd className="mt-1 text-sm font-semibold">
{props.item.source === 'local'
? (props.item.localEnabled ? '本机全局已启用' : '本机全局已停用')
: installation?.version ? `官方包 ${installation.version}` : props.item.delivery === 'system_included' ? '随应用提供' : '未下载官方包'}
</dd>
</div>
<div className="rounded-xl bg-surface-subtle p-3">
<dt className="text-xs text-muted-foreground">当前项目</dt>
<dd className="mt-1 text-sm font-semibold">{PROJECT_TEXT[props.item.projectState]}</dd>
</div>
</dl>
</section>
<section aria-labelledby="plugin-actions-heading">
<h3 id="plugin-actions-heading" className="text-lg font-semibold">可用操作</h3>
{props.item.commands.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{props.item.commands.map((command) => (
<Button
key={`${command.kind}:${'pluginId' in command ? command.pluginId : 'packageId' in command ? command.packageId : ''}`}
type="button"
variant={destructive(command) ? 'outline' : 'default'}
className="min-h-10"
disabled={props.isCommandPending(command)}
aria-label={commandLabel(command, props.item.title)}
onClick={() => run(command)}
>
{command.kind === 'open_agent_assignment' || command.kind === 'open_settings'
? <ExternalLink className="mr-2 h-4 w-4" /> : null}
{commandLabel(command, props.item.title)}
</Button>
))}
</div>
) : <p className="mt-2 text-sm text-muted-foreground">当前状态没有可执行操作。</p>}
</section>
<section aria-labelledby="plugin-capabilities-heading">
<h3 id="plugin-capabilities-heading" className="text-lg font-semibold">能力与权限</h3>
{project?.capabilities.length ? (
<div className="mt-3 space-y-3">
{project.capabilities.map((capability) => (
<article key={capability.id} className="rounded-xl border border-border/70 p-4">
<h4 className="font-mono text-sm font-semibold">{capability.id}</h4>
<ul className="mt-3 space-y-3">
{capability.operations.map((operation) => (
<li key={operation.id} className="rounded-lg bg-surface-subtle p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<code className="font-semibold">{operation.id}</code>
{operation.tool ? <Badge variant="outline">{operation.tool.mutation}</Badge> : <Badge variant="outline">SDK</Badge>}
</div>
{operation.tool ? <p className="mt-2 text-pretty text-muted-foreground">{operation.tool.description}</p> : null}
<p className="mt-2 text-xs">{operation.billing.notice}</p>
</li>
))}
</ul>
</article>
))}
</div>
) : props.item.local ? (
<div className="mt-3 rounded-xl bg-surface-subtle p-4 text-sm">
<p>{props.item.local.kind === 'skill-only' ? 'Skill' : props.item.local.kind === 'pi-extension' ? 'Pi extension' : 'Skill + Pi extension'}</p>
<p className="mt-1 text-muted-foreground">{props.item.local.skillEntries.length} 个 Skill · {props.item.local.extensionEntries.length} 个 extension</p>
{props.item.local.confirmedExecutableCode ? <p className="mt-3 flex gap-2 text-warning"><AlertTriangle className="h-4 w-4 shrink-0" />包含可执行 Pi extension,运行时拥有完整桌面权限。</p> : null}
</div>
) : <p className="mt-2 text-sm text-muted-foreground">当前没有可显示的能力投影。</p>}
{detail?.permissions.length ? (
<div className="mt-4 flex gap-2 rounded-xl bg-surface-subtle p-4 text-sm">
<ShieldCheck className="h-4 w-4 shrink-0 text-brand" />
<p>{detail.permissions.join('、')}</p>
</div>
) : null}
</section>
<section aria-labelledby="plugin-billing-heading">
<h3 id="plugin-billing-heading" className="text-lg font-semibold">Token Point 与计费</h3>
<p className="mt-2 text-pretty text-sm text-muted-foreground">{billingText(props.item)}</p>
</section>
<section aria-labelledby="plugin-agents-heading">
<h3 id="plugin-agents-heading" className="text-lg font-semibold">伙伴分配</h3>
{props.item.source === 'local'
? <p className="mt-2 text-pretty text-sm text-muted-foreground">本机包全局生效,不作为项目 Agent Skill 分配项;符合条件的新建或空闲主伙伴及其 worker 会按既有生命周期加载。</p>
: props.item.assignedAgentNames.length
? <div className="mt-3 flex flex-wrap gap-2">{props.item.assignedAgentNames.map((name) => <Badge key={name} variant="secondary">{name}</Badge>)}</div>
: <p className="mt-2 text-sm text-muted-foreground">尚未分配给伙伴。</p>}
</section>
{showDataService ? (
<section id="plugin-settings" aria-labelledby="plugin-settings-heading">
<h3 id="plugin-settings-heading" className="mb-3 text-lg font-semibold">插件专属设置</h3>
<DataServicePluginSettings
state={project.state}
projectName={props.activeProjectName ?? '当前项目'}
data={props.dataService}
pending={props.dataServicePending}
onConfigure={props.onConfigureDataService}
onReset={props.onResetDataService}
onRemoveCollection={props.onRemoveDataServiceCollection}
onRemoveProject={props.onRemoveDataServiceProject}
/>
</section>
) : null}
</div>
</DialogContent>
</Dialog>
<Dialog open={Boolean(confirmation)} onOpenChange={(open) => { if (!open) setConfirmation(null); }}>
<DialogContent role="alertdialog">
<DialogHeader>
<DialogTitle>{confirmCopy?.title}</DialogTitle>
<DialogDescription>{confirmCopy?.description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setConfirmation(null)}>取消</Button>
<Button type="button" variant="destructive" onClick={() => {
if (confirmation) void props.onCommand(confirmation);
setConfirmation(null);
}}>{confirmCopy?.confirm}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}