feat: add project plugin center
This commit is contained in:
@@ -37,7 +37,9 @@ export function MainLayout() {
|
||||
const isPaintingModule = activeModule === 'painting';
|
||||
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
|
||||
const isChatWorkspace = location.pathname === '/chat';
|
||||
const isInitializationSafeRoute = location.pathname === '/project-config' || !isProgrammingModule;
|
||||
const isInitializationSafeRoute = location.pathname === '/project-config'
|
||||
|| location.pathname === '/project-plugins'
|
||||
|| !isProgrammingModule;
|
||||
|
||||
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
|
||||
if (!sidebarCollapsed) return;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
FolderKanban,
|
||||
LogOut,
|
||||
Plus,
|
||||
Puzzle,
|
||||
Settings as SettingsIcon,
|
||||
UserCircle2,
|
||||
} from 'lucide-react';
|
||||
@@ -653,6 +654,23 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
</DisclosureContent>
|
||||
) : null}
|
||||
</div>
|
||||
{activeProject ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sidebar-nav-project-plugins"
|
||||
aria-current={location.pathname === '/project-plugins' ? 'page' : undefined}
|
||||
aria-label="项目插件"
|
||||
onClick={() => navigate('/project-plugins')}
|
||||
className={cn(
|
||||
'motion-press mt-3 flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-medium transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35',
|
||||
location.pathname === '/project-plugins' ? 'bg-brand-selected text-foreground' : 'text-foreground hover:bg-surface-subtle',
|
||||
sidebarCollapsed && 'justify-center px-0',
|
||||
)}
|
||||
>
|
||||
<Puzzle className="h-4 w-4 shrink-0 text-brand" />
|
||||
{!sidebarCollapsed ? <span>项目插件</span> : null}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : isLearningModule ? (
|
||||
<LearningSidebar sidebarCollapsed={sidebarCollapsed} />
|
||||
|
||||
113
src/components/plugins/data-service-settings.tsx
Normal file
113
src/components/plugins/data-service-settings.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, Database, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { CodingPluginEffectiveState } from '@/lib/coding-plugins';
|
||||
import type { DataServiceInstanceState } from '../../../shared/data-service';
|
||||
|
||||
type Props = {
|
||||
state: CodingPluginEffectiveState;
|
||||
projectName: string;
|
||||
data: DataServiceInstanceState | null;
|
||||
pending: Record<string, true>;
|
||||
onConfigure(collections: string[]): void | Promise<void>;
|
||||
onReset(): void | Promise<void>;
|
||||
onRemoveCollection(collection: string): void | Promise<void>;
|
||||
onRemoveProject(): void | Promise<void>;
|
||||
};
|
||||
|
||||
type DangerAction =
|
||||
| { kind: 'collection'; target: string; title: string; confirmLabel: string }
|
||||
| { kind: 'reset' | 'project'; target: string; title: string; confirmLabel: string };
|
||||
|
||||
function formatCount(value: number): string {
|
||||
return new Intl.NumberFormat('zh-CN').format(value);
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value >= 1024 * 1024) return `${Math.round(value / (1024 * 1024))} MB`;
|
||||
if (value >= 1024) return `${Math.round(value / 1024)} KB`;
|
||||
return `${value} B`;
|
||||
}
|
||||
|
||||
export function DataServicePluginSettings({
|
||||
state, projectName, data, pending, onConfigure, onReset, onRemoveCollection, onRemoveProject,
|
||||
}: Props) {
|
||||
const [collectionInput, setCollectionInput] = useState('');
|
||||
const [danger, setDanger] = useState<DangerAction | null>(null);
|
||||
const [confirmation, setConfirmation] = useState('');
|
||||
const configuring = Boolean(pending['data-service:configure']);
|
||||
const degraded = state === 'degraded';
|
||||
const collections = collectionInput.split(',').map((value) => value.trim()).filter(Boolean);
|
||||
|
||||
const confirmDanger = async () => {
|
||||
if (!danger || confirmation !== danger.target) return;
|
||||
if (danger.kind === 'collection') await onRemoveCollection(danger.target);
|
||||
else if (danger.kind === 'reset') await onReset();
|
||||
else await onRemoveProject();
|
||||
setDanger(null);
|
||||
setConfirmation('');
|
||||
};
|
||||
|
||||
if (state === 'configuration_required') {
|
||||
return (
|
||||
<section aria-labelledby="data-service-configure-heading" className="rounded-xl bg-surface-subtle p-4">
|
||||
<h3 id="data-service-configure-heading" className="text-balance font-semibold">创建开发数据空间</h3>
|
||||
<p className="mt-1 text-pretty text-sm text-muted-foreground">启用插件不会创建云端数据。请明确填写初始 collection 后再创建。</p>
|
||||
<label htmlFor="data-service-collections" className="mt-4 block text-sm font-medium">初始 collection</label>
|
||||
<Input id="data-service-collections" value={collectionInput} onChange={(event) => setCollectionInput(event.target.value)} placeholder="todos, settings" disabled={configuring} className="mt-2" />
|
||||
<Button type="button" className="mt-3" disabled={configuring || collections.length === 0} onClick={() => void onConfigure(collections)}>
|
||||
<Database className="mr-2 h-4 w-4" />{configuring ? '创建中…' : '创建开发数据空间'}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (degraded && !data) {
|
||||
return <p role="status" className="rounded-xl bg-warning/10 p-4 text-pretty text-sm">后端暂时不可用。已保留页面结构和上次用量,这不代表项目未配置。</p>;
|
||||
}
|
||||
|
||||
if ((state !== 'ready' && !degraded) || !data) return null;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="data-service-usage-heading" className="space-y-4">
|
||||
{degraded ? <p role="status" className="rounded-xl bg-warning/10 p-4 text-pretty text-sm">后端暂时不可用。已保留页面结构和上次用量,这不代表项目未配置。</p> : null}
|
||||
<div>
|
||||
<h3 id="data-service-usage-heading" className="text-balance font-semibold">用量与固定限额</h3>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">文档</p><p className="mt-1 font-semibold tabular-nums">{formatCount(data.usage.document_count)} / {formatCount(data.limits.max_documents)}</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 tabular-nums">{formatBytes(data.usage.total_bytes)} / {formatBytes(data.limits.max_total_bytes)}</p></div>
|
||||
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">Collections</p><p className="mt-1 font-semibold tabular-nums">{formatCount(data.collections.length)} / {formatCount(data.limits.max_collections)}</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 tabular-nums">{formatCount(data.limits.mutations_per_minute)}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.collections.map((collection) => (
|
||||
<div key={collection.name} className="flex min-h-10 items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2">
|
||||
<span className="min-w-0"><span className="block truncate font-medium">{collection.name}</span><span className="block text-xs tabular-nums text-muted-foreground">{formatCount(collection.document_count)} 个文档 · {formatBytes(collection.total_bytes)}</span></span>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label={`移除 collection ${collection.name}`} disabled={degraded || Boolean(pending[`data-service:collection:${collection.name}`])} onClick={() => { setDanger({ kind: 'collection', target: collection.name, title: '移除 collection', confirmLabel: '确认移除' }); setConfirmation(''); }}><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-xl bg-destructive/5 p-4">
|
||||
<h3 className="flex items-center gap-2 text-balance font-semibold text-destructive"><AlertTriangle className="h-4 w-4" />危险操作</h3>
|
||||
<p className="mt-1 text-pretty text-sm text-muted-foreground">这些操作会删除云端数据,与“禁用插件”不同。</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" disabled={degraded || Boolean(pending['data-service:reset'])} onClick={() => { setDanger({ kind: 'reset', target: projectName, title: '重置项目数据', confirmLabel: '确认重置' }); setConfirmation(''); }}>重置数据</Button>
|
||||
<Button type="button" variant="destructive" disabled={degraded || Boolean(pending['data-service:remove-project'])} onClick={() => { setDanger({ kind: 'project', target: projectName, title: '删除开发数据空间', confirmLabel: '确认删除' }); setConfirmation(''); }}>删除数据空间</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={Boolean(danger)} onOpenChange={(open) => { if (!open) { setDanger(null); setConfirmation(''); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle className="text-balance">{danger?.title}</DialogTitle><DialogDescription className="text-pretty">项目“{projectName}”的真实目标是“{danger?.target}”。请输入该目标以确认。</DialogDescription></DialogHeader>
|
||||
<label htmlFor="data-service-danger-confirmation" className="text-sm font-medium">输入确认目标</label>
|
||||
<Input id="data-service-danger-confirmation" value={confirmation} onChange={(event) => setConfirmation(event.target.value)} autoComplete="off" />
|
||||
<DialogFooter><Button type="button" variant="outline" onClick={() => setDanger(null)}>取消</Button><Button type="button" variant="destructive" disabled={!danger || confirmation !== danger.target} onClick={() => void confirmDanger()}>{danger?.confirmLabel}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user