feat: unify plugin workspace navigation
This commit is contained in:
305
src/pages/Plugins/PluginDetails.tsx
Normal file
305
src/pages/Plugins/PluginDetails.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user