ManimCat / frontend /src /components /Workspace.tsx
Bin29's picture
Sync from main: 68df783 feat: support multimodal studio reference images
d47b053
/**
* 工作空间 - 合并 历史记录 / 提示词管理 / 用量统计
*/
import { useState, type ReactNode } from 'react';
import { HistoryPanel } from './HistoryPanel';
import { usePrompts } from '../hooks/usePrompts';
import { PromptSidebar } from './PromptSidebar';
import { UsageDashboardContent } from './UsageDashboard';
import type { RoleType, SharedModuleType } from '../types/api';
import { useI18n } from '../i18n';
import { useModalTransition } from '../hooks/useModalTransition';
type WorkspaceModule = 'history' | 'prompts' | 'usage';
interface WorkspaceProps {
isOpen: boolean;
onClose: () => void;
initialModule?: WorkspaceModule;
onReusePrompt?: (prompt: string) => void;
}
export function Workspace({ isOpen, onClose, initialModule = 'history', onReusePrompt }: WorkspaceProps) {
const { t } = useI18n();
const { shouldRender, isExiting } = useModalTransition(isOpen, 400);
const [activeModule, setActiveModule] = useState<WorkspaceModule>(initialModule);
const {
isLoading: promptsLoading,
selection,
setSelection,
getCurrentContent,
setCurrentContent,
restoreCurrent,
hasOverride,
} = usePrompts();
if (!shouldRender) return null;
const breadcrumbMap: Record<WorkspaceModule, string> = {
history: t('workspace.breadcrumb.history'),
prompts: t('workspace.breadcrumb.prompts'),
usage: t('workspace.breadcrumb.usage'),
};
const railItems: { id: WorkspaceModule; label: string; icon: ReactNode }[] = [
{
id: 'history',
label: t('workspace.rail.history'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
id: 'prompts',
label: t('workspace.rail.prompts'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
),
},
{
id: 'usage',
label: t('workspace.rail.usage'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 19h16M6 16V9m6 7V5m6 11v-4" />
</svg>
),
},
];
const getPromptTitle = () => {
const roleLabels: Record<RoleType, string> = {
problemFraming: t('prompts.role.problemFraming'),
conceptDesigner: t('prompts.role.conceptDesigner'),
codeGeneration: t('prompts.role.codeGeneration'),
codeRetry: t('prompts.role.codeRetry'),
codeEdit: t('prompts.role.codeEdit'),
};
const sharedLabels: Record<SharedModuleType, string> = {
apiIndex: t('prompts.shared.apiIndex'),
specification: t('prompts.shared.specification'),
};
if (selection.kind === 'role') {
const roleLabel = roleLabels[selection.role];
return selection.promptType === 'system'
? t('prompts.role.systemTitle', { role: roleLabel })
: t('prompts.role.userTitle', { role: roleLabel });
}
return sharedLabels[selection.module];
};
const getPromptDescription = () => {
if (selection.kind === 'role') {
return selection.promptType === 'system'
? t('prompts.role.systemDescription')
: t('prompts.role.userDescription');
}
return selection.module === 'apiIndex'
? t('prompts.shared.apiIndexDescription')
: t('prompts.shared.specificationDescription');
};
const promptContent = getCurrentContent();
const isModified = hasOverride();
return (
<div
className={`fixed inset-0 z-[120] flex flex-col bg-bg-primary transition-all duration-500 ${
isExiting ? 'opacity-0 scale-[1.02] blur-xl' : 'opacity-100 scale-100 blur-0 animate-studio-entrance'
}`}
>
{/* 顶栏 */}
<div className="h-16 bg-bg-secondary/50 border-b border-border/5 flex items-center justify-between px-6">
<div className="flex items-center gap-4">
<button
onClick={onClose}
className="p-2.5 text-text-secondary/50 hover:text-text-primary hover:bg-bg-primary/50 rounded-2xl transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<div className="flex items-center gap-3">
<div className="w-1.5 h-1.5 rounded-full bg-accent-rgb/40" />
<span className="text-lg font-medium text-text-primary/90 tracking-tight">{t('workspace.title')}</span>
</div>
</div>
<div className="flex items-center gap-6">
{activeModule === 'prompts' && isModified && (
<div className="flex items-center gap-3 animate-fade-in">
<span className="text-[10px] uppercase tracking-widest text-accent-rgb/60">{t('prompts.modified')}</span>
<button
onClick={restoreCurrent}
className="px-4 py-1.5 text-[10px] uppercase tracking-widest bg-accent-rgb/5 text-accent-rgb/70 hover:bg-accent-rgb/10 hover:text-accent-rgb rounded-full transition-all"
>
{t('prompts.restore')}
</button>
</div>
)}
<div className="text-[10px] text-text-secondary/30 uppercase tracking-[0.25em] font-light">
Workspace / <span className="text-text-secondary/60 font-medium">{breadcrumbMap[activeModule]}</span>
</div>
</div>
</div>
{/* 核心交互区 */}
<div className="flex-1 flex overflow-hidden">
{/* 左侧 Rail 导航 */}
<aside className="w-20 bg-bg-secondary/20 border-r border-border/5 flex flex-col items-center py-8 gap-4">
{railItems.map(item => (
<button
key={item.id}
onClick={() => setActiveModule(item.id)}
className={`w-12 h-12 flex items-center justify-center rounded-2xl transition-all duration-300 ${
activeModule === item.id
? 'bg-bg-tertiary text-text-primary shadow-sm'
: 'text-text-secondary/40 hover:text-text-primary hover:bg-bg-secondary/50'
}`}
title={item.label}
>
{item.icon}
</button>
))}
</aside>
{/* 模块展示区 */}
<div className="flex-1 flex overflow-hidden">
{activeModule === 'history' && (
<main className="flex-1 p-10 overflow-y-auto bg-bg-primary/30 animate-fade-in">
<HistoryPanel isActive={activeModule === 'history'} onReusePrompt={onReusePrompt} />
</main>
)}
{activeModule === 'prompts' && (
<div className="flex-1 flex overflow-hidden animate-fade-in">
<PromptSidebar selection={selection} onSelect={setSelection} />
<div className="flex-1 flex flex-col overflow-hidden">
<div className="px-10 py-8 border-b border-border/5">
<h2 className="text-xl font-medium text-text-primary/90 tracking-tight">{getPromptTitle()}</h2>
<p className="text-sm text-text-secondary/50 mt-2 font-light">{getPromptDescription()}</p>
</div>
<div className="flex-1 p-8 overflow-hidden">
{promptsLoading ? (
<div className="h-full flex items-center justify-center text-text-secondary/30 text-sm tracking-widest uppercase">
{t('common.loading')}
</div>
) : (
<textarea
value={promptContent}
onChange={e => setCurrentContent(e.target.value)}
className="w-full h-full px-8 py-8 bg-bg-secondary/30 border border-border/5 rounded-[2rem] text-[15px] text-text-primary/80 font-mono leading-relaxed resize-none focus:outline-none focus:border-accent-rgb/20 focus:bg-bg-secondary/50 transition-all shadow-inner scrollbar-hide"
placeholder={t('prompts.placeholder')}
/>
)}
</div>
<div className="px-10 py-5 border-t border-border/5 flex items-center justify-between text-[10px] uppercase tracking-widest text-text-secondary/30 font-light">
<span>{t('prompts.characters', { count: promptContent.length })}</span>
<span className="flex items-center gap-2">
<div className="w-1 h-1 rounded-full bg-green-500/40 animate-pulse" />
{t('prompts.autosave')}
</span>
</div>
</div>
</div>
)}
{activeModule === 'usage' && (
<main className="flex-1 overflow-y-auto bg-bg-primary/30 animate-fade-in">
<UsageDashboardContent isActive={activeModule === 'usage'} />
</main>
)}
</div>
</div>
</div>
);
}