Files
makelore/src/pages/ImageCanvas/DesignWorksRail.tsx
inman e069ec6294 merge: retire Canvas inspiration entry
# 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
2026-09-07 10:24:24 +08:00

343 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}