# Conflicts: # README.md # src/components/layout/MainLayout.tsx # src/components/layout/Sidebar.tsx # tests/e2e/image-workspace-v2.spec.ts # tests/unit/app-module-provider-gate.test.tsx # tests/unit/title-bar.test.tsx
343 lines
14 KiB
TypeScript
343 lines
14 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
FolderKanban,
|
||
Loader2,
|
||
Pencil,
|
||
Plus,
|
||
Trash2,
|
||
} from 'lucide-react';
|
||
import { toast } from 'sonner';
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog';
|
||
import { Input } from '@/components/ui/input';
|
||
import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace';
|
||
import { cn } from '@/lib/utils';
|
||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||
import type { DesignWorkspace, DesignWorkspaceSummary } from '../../../shared/image-workspace';
|
||
import { DesignAssetThumbnail } from './DesignAssetThumbnail';
|
||
import { hasActiveCreationPlan } from './plan-lifecycle';
|
||
|
||
type ProjectDialogState =
|
||
| { mode: 'create'; project: null }
|
||
| { mode: 'rename'; project: DesignWorkspaceSummary };
|
||
|
||
function formatUpdatedAt(value: string): string {
|
||
const timestamp = Date.parse(value);
|
||
if (!Number.isFinite(timestamp)) return '';
|
||
const date = new Date(timestamp);
|
||
const now = new Date();
|
||
if (date.toDateString() === now.toDateString()) {
|
||
return `今天 ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||
}
|
||
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' });
|
||
}
|
||
|
||
function activeProjectStatus(workspace: DesignWorkspace | null): {
|
||
label: string;
|
||
tone: 'active' | 'success' | 'neutral';
|
||
} {
|
||
const activeTask = workspace?.tasks.find((task) => task.status === 'queued' || task.status === 'running');
|
||
if (activeTask) return { label: '制作中', tone: 'active' };
|
||
if (workspace?.form.activeQuotes.some((quote) => quote.status === 'offered')) {
|
||
return { label: '待确认', tone: 'active' };
|
||
}
|
||
if (workspace && hasActiveCreationPlan(workspace)) {
|
||
return { label: '刚刚更新', tone: 'neutral' };
|
||
}
|
||
const completed = workspace?.tasks.some((task) => task.status === 'succeeded');
|
||
if (completed) return { label: '已完成', tone: 'success' };
|
||
return { label: '刚刚更新', tone: 'neutral' };
|
||
}
|
||
|
||
export function DesignWorksRail({
|
||
workspace,
|
||
onProjectSelected,
|
||
}: {
|
||
workspace: DesignWorkspace | null;
|
||
onProjectSelected?: () => void;
|
||
}) {
|
||
const bootstrap = useImageWorkspaceStore((state) => state.bootstrap);
|
||
const activeWorkspaceId = useImageWorkspaceStore((state) => state.activeWorkspaceId);
|
||
const deletingWorkspaceId = useImageWorkspaceStore((state) => state.deletingWorkspaceId);
|
||
const createProject = useImageWorkspaceStore((state) => state.createProject);
|
||
const renameProject = useImageWorkspaceStore((state) => state.renameProject);
|
||
const deleteProject = useImageWorkspaceStore((state) => state.deleteProject);
|
||
const selectProject = useImageWorkspaceStore((state) => state.selectProject);
|
||
const [dialog, setDialog] = useState<ProjectDialogState | null>(null);
|
||
const [projectName, setProjectName] = useState('');
|
||
const [dialogError, setDialogError] = useState<string | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [deleteTarget, setDeleteTarget] = useState<DesignWorkspaceSummary | null>(null);
|
||
const [deleteConfirmation, setDeleteConfirmation] = useState('');
|
||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||
|
||
const projects = useMemo(
|
||
() => [...(bootstrap?.workspaces ?? [])].sort(
|
||
(left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt),
|
||
),
|
||
[bootstrap?.workspaces],
|
||
);
|
||
const currentPreview = useMemo(() => {
|
||
if (!workspace) return null;
|
||
return [...workspace.assets].sort(
|
||
(left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt),
|
||
)[0] ?? null;
|
||
}, [workspace]);
|
||
const currentStatus = activeProjectStatus(workspace);
|
||
|
||
const openCreate = () => {
|
||
setProjectName('');
|
||
setDialogError(null);
|
||
setDialog({ mode: 'create', project: null });
|
||
};
|
||
|
||
useEffect(() => {
|
||
const handleCreateRequest = () => {
|
||
setProjectName('');
|
||
setDialogError(null);
|
||
setDialog({ mode: 'create', project: null });
|
||
};
|
||
window.addEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, handleCreateRequest);
|
||
return () => window.removeEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, handleCreateRequest);
|
||
}, []);
|
||
|
||
const openRename = (project: DesignWorkspaceSummary) => {
|
||
setProjectName(project.title);
|
||
setDialogError(null);
|
||
setDialog({ mode: 'rename', project });
|
||
};
|
||
|
||
const saveProject = async () => {
|
||
const title = projectName.trim();
|
||
if (!title) {
|
||
setDialogError('请输入作品名称');
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
setDialogError(null);
|
||
try {
|
||
if (dialog?.mode === 'rename') {
|
||
await renameProject(dialog.project.workspaceId, title);
|
||
toast.success('作品名称已更新');
|
||
} else {
|
||
await createProject(title);
|
||
toast.success('新作品已创建');
|
||
}
|
||
setDialog(null);
|
||
onProjectSelected?.();
|
||
} catch (error) {
|
||
setDialogError(error instanceof Error ? error.message : '保存作品失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const confirmDelete = async () => {
|
||
if (!deleteTarget) return;
|
||
if (deleteConfirmation !== deleteTarget.title) {
|
||
setDeleteError('请输入完整作品名称');
|
||
return;
|
||
}
|
||
setDeleteError(null);
|
||
try {
|
||
await deleteProject(deleteTarget.workspaceId);
|
||
setDeleteTarget(null);
|
||
setDeleteConfirmation('');
|
||
toast.success('作品已删除');
|
||
} catch (error) {
|
||
setDeleteError(error instanceof Error ? error.message : '删除作品失败');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<aside
|
||
data-testid="image-workspace-works-rail"
|
||
className="flex h-full min-h-0 flex-col bg-background font-sans"
|
||
aria-label="我的作品"
|
||
>
|
||
<header className="flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/65 px-4">
|
||
<div>
|
||
<h2 className="text-base font-semibold tracking-tight text-foreground">我的作品</h2>
|
||
<p className="mt-0.5 text-[11px] text-muted-foreground">{projects.length} 个创作</p>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-9 rounded-xl border-brand/25 px-3 text-brand hover:bg-brand/[0.04] hover:text-brand"
|
||
onClick={openCreate}
|
||
>
|
||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||
新建作品
|
||
</Button>
|
||
</header>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
||
{projects.length === 0 ? (
|
||
<button
|
||
type="button"
|
||
className="w-full rounded-2xl border border-dashed border-border px-4 py-8 text-left transition-colors hover:border-brand/35 hover:bg-brand/[0.025]"
|
||
onClick={openCreate}
|
||
>
|
||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-brand/[0.07] text-brand">
|
||
<Plus className="h-4 w-4" />
|
||
</span>
|
||
<span className="mt-3 block text-sm font-medium text-foreground">开始第一个作品</span>
|
||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">从一句话开始,AI 会陪你把画面说清楚。</span>
|
||
</button>
|
||
) : (
|
||
<div className="space-y-1.5">
|
||
{projects.map((project) => {
|
||
const active = project.workspaceId === activeWorkspaceId;
|
||
const pending = active && !workspace;
|
||
const status = active ? currentStatus : { label: formatUpdatedAt(project.updatedAt), tone: 'neutral' as const };
|
||
return (
|
||
<div
|
||
key={project.workspaceId}
|
||
className={cn(
|
||
'group/work relative rounded-2xl border transition-colors',
|
||
active
|
||
? 'border-brand/20 bg-brand/[0.055]'
|
||
: 'border-transparent hover:border-border/60 hover:bg-surface-subtle/55',
|
||
)}
|
||
>
|
||
<button
|
||
type="button"
|
||
aria-label={`打开作品 ${project.title}`}
|
||
className="flex min-h-[68px] w-full items-center gap-3 rounded-2xl px-2.5 py-2.5 pr-16 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30"
|
||
onClick={() => {
|
||
void selectProject(project.workspaceId);
|
||
onProjectSelected?.();
|
||
}}
|
||
>
|
||
{active && currentPreview ? (
|
||
<DesignAssetThumbnail asset={currentPreview} className="h-12 w-12 rounded-xl" />
|
||
) : (
|
||
<span className={cn(
|
||
'flex h-12 w-12 shrink-0 items-center justify-center rounded-xl',
|
||
active ? 'bg-background text-brand' : 'bg-surface-subtle text-muted-foreground',
|
||
)}>
|
||
{pending
|
||
? <Loader2 className="h-5 w-5 animate-spin" />
|
||
: <FolderKanban className="h-5 w-5" />}
|
||
</span>
|
||
)}
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-sm font-semibold text-foreground">{project.title}</span>
|
||
<span className="mt-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||
<span className={cn(
|
||
'h-1.5 w-1.5 rounded-full',
|
||
status.tone === 'active'
|
||
? 'bg-brand'
|
||
: status.tone === 'success'
|
||
? 'bg-emerald-500'
|
||
: 'bg-muted-foreground/45',
|
||
)} />
|
||
{status.label}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
<div className="absolute right-2 top-2 flex opacity-0 transition-opacity group-hover/work:opacity-100 focus-within:opacity-100">
|
||
<button
|
||
type="button"
|
||
aria-label={`重命名 ${project.title}`}
|
||
title="重命名"
|
||
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-background hover:text-foreground"
|
||
onClick={() => openRename(project)}
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label={`删除 ${project.title}`}
|
||
title="删除"
|
||
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-background hover:text-destructive"
|
||
onClick={() => {
|
||
setDeleteTarget(project);
|
||
setDeleteConfirmation('');
|
||
setDeleteError(null);
|
||
}}
|
||
>
|
||
{deletingWorkspaceId === project.workspaceId
|
||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
: <Trash2 className="h-3.5 w-3.5" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<Dialog open={dialog !== null} onOpenChange={(open) => !open && !saving && setDialog(null)}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>{dialog?.mode === 'rename' ? '重命名作品' : '新建作品'}</DialogTitle>
|
||
<DialogDescription>给这次创作起个容易找到的名字,之后可以随时修改。</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-2">
|
||
<label htmlFor="canvas-work-name" className="text-sm font-medium text-foreground">作品名称</label>
|
||
<Input
|
||
id="canvas-work-name"
|
||
autoFocus
|
||
value={projectName}
|
||
placeholder="例如:小狗的快乐短片"
|
||
onChange={(event) => setProjectName(event.currentTarget.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter' && !saving) void saveProject();
|
||
}}
|
||
/>
|
||
{dialogError && <p className="text-sm text-destructive">{dialogError}</p>}
|
||
</div>
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<Button type="button" variant="ghost" disabled={saving} onClick={() => setDialog(null)}>取消</Button>
|
||
<Button type="button" disabled={saving} onClick={() => void saveProject()}>
|
||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{dialog?.mode === 'rename' ? '保存名称' : '创建作品'}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog
|
||
open={deleteTarget !== null}
|
||
onOpenChange={(open) => {
|
||
if (!open && !deletingWorkspaceId) setDeleteTarget(null);
|
||
}}
|
||
>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>删除作品</DialogTitle>
|
||
<DialogDescription>这会隐藏本作品的对话、方案、任务与素材。已另存到电脑的文件不受影响。</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-2">
|
||
<label htmlFor="delete-canvas-work" className="text-sm font-medium text-foreground">
|
||
输入“{deleteTarget?.title ?? ''}”确认
|
||
</label>
|
||
<Input
|
||
id="delete-canvas-work"
|
||
value={deleteConfirmation}
|
||
onChange={(event) => setDeleteConfirmation(event.currentTarget.value)}
|
||
/>
|
||
{deleteError && <p className="text-sm text-destructive">{deleteError}</p>}
|
||
</div>
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<Button type="button" variant="ghost" disabled={Boolean(deletingWorkspaceId)} onClick={() => setDeleteTarget(null)}>取消</Button>
|
||
<Button type="button" variant="destructive" disabled={Boolean(deletingWorkspaceId)} onClick={() => void confirmDelete()}>
|
||
{deletingWorkspaceId && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
删除作品
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</aside>
|
||
);
|
||
}
|