merge: integrate Canvas plan and reference flow
# Conflicts: # README.md
This commit is contained in:
@@ -27,6 +27,7 @@ export function MainLayout() {
|
||||
const activeModule = getAiModuleForPath(location.pathname);
|
||||
const isPaintingModule = activeModule === 'painting';
|
||||
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
|
||||
const isCanvasWorkspace = location.pathname === '/image-canvas';
|
||||
const isChatWorkspace = location.pathname === '/chat';
|
||||
|
||||
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
|
||||
@@ -79,6 +80,7 @@ export function MainLayout() {
|
||||
integrated
|
||||
workspaceLayout={isChatWorkspace}
|
||||
overlay={isPaintingModule && !isPromptMuseum}
|
||||
showSidebarControls={!isCanvasWorkspace}
|
||||
pageTitle={isPromptMuseum ? '获取灵感' : undefined}
|
||||
sidebarPeekOpen={sidebarPeekOpen}
|
||||
onSidebarPeekChange={handleSidebarPeekChange}
|
||||
@@ -86,11 +88,13 @@ export function MainLayout() {
|
||||
|
||||
{/* Below the title bar: sidebar + content */}
|
||||
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<Sidebar
|
||||
workspaceLayout={isChatWorkspace}
|
||||
sidebarPeekOpen={sidebarPeekOpen}
|
||||
onSidebarPeekChange={handleSidebarPeekChange}
|
||||
/>
|
||||
{!isCanvasWorkspace ? (
|
||||
<Sidebar
|
||||
workspaceLayout={isChatWorkspace}
|
||||
sidebarPeekOpen={sidebarPeekOpen}
|
||||
onSidebarPeekChange={handleSidebarPeekChange}
|
||||
/>
|
||||
) : null}
|
||||
<main
|
||||
data-testid="main-content"
|
||||
className={cn(
|
||||
|
||||
@@ -24,16 +24,17 @@ type TitleBarProps = {
|
||||
integrated?: boolean;
|
||||
workspaceLayout?: boolean;
|
||||
overlay?: boolean;
|
||||
showSidebarControls?: boolean;
|
||||
pageTitle?: string;
|
||||
sidebarPeekOpen?: boolean;
|
||||
onSidebarPeekChange?: SidebarPeekChange;
|
||||
};
|
||||
|
||||
export function TitleBar({ integrated = false, workspaceLayout = false, overlay = false, pageTitle, sidebarPeekOpen = false, onSidebarPeekChange }: TitleBarProps) {
|
||||
export function TitleBar({ integrated = false, workspaceLayout = false, overlay = false, showSidebarControls = true, pageTitle, sidebarPeekOpen = false, onSidebarPeekChange }: TitleBarProps) {
|
||||
const platform = window.electron?.platform;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return <ProductTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} pageTitle={pageTitle} nativeTrafficLights sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
return <ProductTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} showSidebarControls={showSidebarControls} pageTitle={pageTitle} nativeTrafficLights sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
}
|
||||
|
||||
// Linux keeps the native frame/title bar for better IME compatibility.
|
||||
@@ -41,13 +42,14 @@ export function TitleBar({ integrated = false, workspaceLayout = false, overlay
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WindowsTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} pageTitle={pageTitle} sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
return <WindowsTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} showSidebarControls={showSidebarControls} pageTitle={pageTitle} sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
}
|
||||
|
||||
function ProductTitleBar({
|
||||
integrated,
|
||||
workspaceLayout = false,
|
||||
overlay = false,
|
||||
showSidebarControls = true,
|
||||
pageTitle,
|
||||
children,
|
||||
windowControlsWidth = 0,
|
||||
@@ -58,6 +60,7 @@ function ProductTitleBar({
|
||||
integrated: boolean;
|
||||
workspaceLayout?: boolean;
|
||||
overlay?: boolean;
|
||||
showSidebarControls?: boolean;
|
||||
pageTitle?: string;
|
||||
children?: React.ReactNode;
|
||||
windowControlsWidth?: number;
|
||||
@@ -150,7 +153,7 @@ function ProductTitleBar({
|
||||
integrated && !overlay && sidebarTitlebarHidden && !workspaceLayout && 'border-b border-border/80',
|
||||
)}
|
||||
>
|
||||
{integrated ? (
|
||||
{integrated && showSidebarControls ? (
|
||||
<>
|
||||
<div
|
||||
data-testid="titlebar-sidebar-surface"
|
||||
@@ -236,7 +239,7 @@ function ProductTitleBar({
|
||||
);
|
||||
}
|
||||
|
||||
function WindowsTitleBar({ integrated, workspaceLayout, overlay, pageTitle, sidebarPeekOpen, onSidebarPeekChange }: Omit<TitleBarProps, 'integrated'> & { integrated: boolean }) {
|
||||
function WindowsTitleBar({ integrated, workspaceLayout, overlay, showSidebarControls, pageTitle, sidebarPeekOpen, onSidebarPeekChange }: Omit<TitleBarProps, 'integrated'> & { integrated: boolean }) {
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -267,6 +270,7 @@ function WindowsTitleBar({ integrated, workspaceLayout, overlay, pageTitle, side
|
||||
integrated={integrated}
|
||||
workspaceLayout={workspaceLayout}
|
||||
overlay={overlay}
|
||||
showSidebarControls={showSidebarControls}
|
||||
pageTitle={pageTitle}
|
||||
windowControlsWidth={WINDOWS_TITLEBAR_CONTROLS_WIDTH}
|
||||
sidebarPeekOpen={sidebarPeekOpen}
|
||||
|
||||
76
src/pages/ImageCanvas/DesignAssetThumbnail.tsx
Normal file
76
src/pages/ImageCanvas/DesignAssetThumbnail.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Film, ImageIcon } from 'lucide-react';
|
||||
import { resolveImageWorkspaceAssetUrl } from '@/lib/image-workspace';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DesignAsset } from '../../../shared/image-workspace';
|
||||
|
||||
export function DesignAssetThumbnail({
|
||||
asset,
|
||||
className,
|
||||
alt,
|
||||
}: {
|
||||
asset: DesignAsset | null;
|
||||
className?: string;
|
||||
alt?: string;
|
||||
}) {
|
||||
const [resolution, setResolution] = useState<{
|
||||
contentPath: string | null;
|
||||
source: string | null;
|
||||
failed: boolean;
|
||||
}>({ contentPath: asset?.contentPath ?? null, source: null, failed: false });
|
||||
const currentResolution = resolution.contentPath === (asset?.contentPath ?? null)
|
||||
? resolution
|
||||
: { contentPath: asset?.contentPath ?? null, source: null, failed: false };
|
||||
|
||||
useEffect(() => {
|
||||
if (!asset) return;
|
||||
let active = true;
|
||||
void resolveImageWorkspaceAssetUrl(asset.contentPath)
|
||||
.then((url) => {
|
||||
if (active) setResolution({ contentPath: asset.contentPath, source: url, failed: false });
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setResolution({ contentPath: asset.contentPath, source: null, failed: true });
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [asset]);
|
||||
|
||||
if (!asset || !currentResolution.source || currentResolution.failed) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center overflow-hidden bg-surface-subtle text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{asset?.mediaType === 'video'
|
||||
? <Film className="h-5 w-5" />
|
||||
: <ImageIcon className="h-5 w-5" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.mediaType === 'video') {
|
||||
return (
|
||||
<video
|
||||
src={currentResolution.source}
|
||||
className={cn('shrink-0 bg-surface-subtle object-cover', className)}
|
||||
aria-label={alt ?? '视频作品预览'}
|
||||
muted
|
||||
preload="metadata"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={currentResolution.source}
|
||||
alt={alt ?? (asset.role === 'uploaded' ? '参考图片预览' : 'AI 作品预览')}
|
||||
className={cn('shrink-0 bg-surface-subtle object-cover', className)}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
MessageSquareText,
|
||||
Send,
|
||||
Sparkles,
|
||||
WandSparkles,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -13,7 +12,10 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type { DesignWorkspace } from '../../../shared/image-workspace';
|
||||
import type { DesignCompilationIssue, DesignWorkspace } from '../../../shared/image-workspace';
|
||||
import { DesignPendingOperationsPanel } from './DesignProductionPanel';
|
||||
import { DesignPlanHistory } from './DesignPlanHistory';
|
||||
import { YouthCreationCard } from './YouthCreationCard';
|
||||
|
||||
const STARTER_MESSAGES = [
|
||||
'我想做一张热闹的社团招新海报',
|
||||
@@ -49,17 +51,16 @@ function chatFailureMessage(error: unknown): string {
|
||||
|
||||
export function DesignConversationPane({
|
||||
workspace,
|
||||
canRequestQuote,
|
||||
onQuoteOffered,
|
||||
quoteBlockers,
|
||||
generationAvailable,
|
||||
}: {
|
||||
workspace: DesignWorkspace;
|
||||
canRequestQuote: boolean;
|
||||
onQuoteOffered: () => void;
|
||||
quoteBlockers: DesignCompilationIssue[];
|
||||
generationAvailable: boolean;
|
||||
}) {
|
||||
const chatDraft = useImageWorkspaceStore((state) => state.chatDraft);
|
||||
const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft);
|
||||
const sendChat = useImageWorkspaceStore((state) => state.sendChat);
|
||||
const requestQuote = useImageWorkspaceStore((state) => state.requestQuote);
|
||||
const assistantStreams = useImageWorkspaceStore((state) => state.assistantStreams);
|
||||
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
@@ -79,10 +80,6 @@ export function DesignConversationPane({
|
||||
const submittingDesignInput = Object.values(pendingOperations).some(
|
||||
(operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input',
|
||||
);
|
||||
const quoteRequestPending = Object.values(pendingOperations).some(
|
||||
(operation) => operation.command.kind === 'request_quote'
|
||||
&& operation.command.workspaceId === workspace.workspace.workspaceId,
|
||||
);
|
||||
const streamingReplies = pendingChatMessages.flatMap((message) => {
|
||||
const text = assistantStreams[message.operationId]?.trim();
|
||||
return text ? [{ ...message, text }] : [];
|
||||
@@ -101,7 +98,14 @@ export function DesignConversationPane({
|
||||
if (typeof scrollAnchor?.scrollIntoView === 'function') {
|
||||
scrollAnchor.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
}, [canRequestQuote, pendingChatKey, streamingReplyKey, workspace.turns.length]);
|
||||
}, [
|
||||
pendingChatKey,
|
||||
streamingReplyKey,
|
||||
workspace.form.specificationRevision,
|
||||
workspace.form.activeQuotes.length,
|
||||
workspace.tasks.length,
|
||||
workspace.turns.length,
|
||||
]);
|
||||
|
||||
const submit = () => {
|
||||
if (!composerDraft.trim() || submittingDesignInput) return;
|
||||
@@ -110,31 +114,22 @@ export function DesignConversationPane({
|
||||
});
|
||||
};
|
||||
|
||||
const prepareQuote = () => {
|
||||
if (!canRequestQuote || quoteRequestPending || pendingChatMessages.length > 0) return;
|
||||
void requestQuote()
|
||||
.then(onQuoteOffered)
|
||||
.catch(() => {
|
||||
toast.error('制作方案还没准备好,请稍后再试');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="image-workspace-conversation"
|
||||
className="flex min-h-0 flex-1 flex-col bg-surface-subtle/45"
|
||||
>
|
||||
<header className="border-b border-border/70 bg-background px-5 py-4">
|
||||
<header className="border-b border-border/70 bg-background px-5 py-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-brand" />
|
||||
<h2 className="text-sm font-semibold text-foreground">和 AI 聊聊你的想法</h2>
|
||||
<h2 className="text-sm font-semibold text-foreground">和 AI 一起完善创作</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
像和同学讲故事一样说就好。AI 会先听懂,再一次问一个最重要的问题。
|
||||
前面的对话会逐步整理成下方可编辑的创作提示词。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
|
||||
{workspace.turns.length === 0
|
||||
&& pendingChatMessages.length === 0 && (
|
||||
<div className="mx-auto flex h-full max-w-sm flex-col justify-center py-8">
|
||||
@@ -161,11 +156,11 @@ export function DesignConversationPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="mx-auto max-w-5xl space-y-4">
|
||||
{workspace.turns.map((turn) => (
|
||||
<div key={turn.turnId} className="space-y-3">
|
||||
{turn.userMessage && (
|
||||
<div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-brand px-3.5 py-2.5 text-sm leading-6 text-primary-foreground">
|
||||
<div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md border border-brand/20 bg-brand/[0.055] px-3.5 py-2.5 text-sm leading-6 text-foreground">
|
||||
{turn.userMessage}
|
||||
</div>
|
||||
)}
|
||||
@@ -185,7 +180,7 @@ export function DesignConversationPane({
|
||||
aria-atomic="true"
|
||||
className="ml-auto max-w-[88%]"
|
||||
>
|
||||
<div className="rounded-2xl rounded-br-md bg-brand/90 px-3.5 py-2.5 text-sm leading-6 text-primary-foreground">
|
||||
<div className="rounded-2xl rounded-br-md border border-brand/20 bg-brand/[0.055] px-3.5 py-2.5 text-sm leading-6 text-foreground">
|
||||
{message.message}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-end gap-1.5 pr-1 text-[11px] text-muted-foreground">
|
||||
@@ -214,36 +209,21 @@ export function DesignConversationPane({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canRequestQuote && pendingChatMessages.length === 0 && (
|
||||
<div
|
||||
data-testid="design-conversation-quote-action"
|
||||
className="flex flex-col gap-3 rounded-2xl border border-brand/20 bg-brand/[0.045] p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">想法已经清楚了</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
先看看需要多少设计点,确认后才会开始制作。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 shrink-0 rounded-xl"
|
||||
disabled={quoteRequestPending}
|
||||
onClick={prepareQuote}
|
||||
>
|
||||
{quoteRequestPending
|
||||
? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
: <WandSparkles className="mr-2 h-4 w-4" aria-hidden="true" />}
|
||||
{quoteRequestPending ? '正在准备方案' : '下一步:看看制作方案'}
|
||||
</Button>
|
||||
</div>
|
||||
<DesignPendingOperationsPanel />
|
||||
<DesignPlanHistory workspace={workspace} />
|
||||
{pendingChatMessages.length === 0 && (
|
||||
<YouthCreationCard
|
||||
workspace={workspace}
|
||||
quoteBlockers={quoteBlockers}
|
||||
generationAvailable={generationAvailable}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div ref={scrollAnchorRef} />
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-border/70 bg-background p-4">
|
||||
<footer className="border-t border-border/70 bg-background p-3.5 sm:px-6">
|
||||
<div className={cn(
|
||||
'rounded-2xl border border-border/80 bg-surface-input p-2 transition-colors focus-within:border-brand/35 focus-within:ring-2 focus-within:ring-brand/10',
|
||||
submittingDesignInput && 'opacity-80',
|
||||
@@ -252,10 +232,10 @@ export function DesignConversationPane({
|
||||
id="design-chat-composer"
|
||||
aria-label="告诉 AI 你想创作什么"
|
||||
value={composerDraft}
|
||||
rows={3}
|
||||
rows={2}
|
||||
disabled={submittingDesignInput}
|
||||
placeholder="比如:我想做一张社团招新海报,要热闹、有活力……"
|
||||
className="min-h-[72px] resize-none border-0 bg-transparent px-2 py-1.5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
className="min-h-[52px] resize-none border-0 bg-transparent px-2 py-1.5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onChange={(event) => setChatDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
|
||||
138
src/pages/ImageCanvas/DesignPlanHistory.tsx
Normal file
138
src/pages/ImageCanvas/DesignPlanHistory.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { ChevronDown, Clock3, Download, Film, ImageIcon, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { saveImageWorkspaceAsset } from '@/lib/image-workspace';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DesignGenerationTask, DesignWorkspace } from '../../../shared/image-workspace';
|
||||
import { DesignAssetThumbnail } from './DesignAssetThumbnail';
|
||||
import { youthTaskIssueMessage } from './youth-issue-copy';
|
||||
|
||||
const STATUS_LABELS: Record<DesignGenerationTask['status'], string> = {
|
||||
queued: '等待制作',
|
||||
running: '制作中',
|
||||
succeeded: '已完成',
|
||||
failed: '未完成',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
|
||||
function formatTime(value: string): string {
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return '';
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
return date.toDateString() === now.toDateString()
|
||||
? `今天 ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
|
||||
: date.toLocaleDateString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function taskDescription(task: DesignGenerationTask): string {
|
||||
const parts = [
|
||||
task.medium === 'video' ? '视频' : '图片',
|
||||
task.outputSummary.aspectRatio,
|
||||
];
|
||||
if (task.outputSummary.durationSeconds) parts.push(`${task.outputSummary.durationSeconds} 秒`);
|
||||
parts.push(`${task.outputSummary.outputCount} 个`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function DesignPlanHistory({ workspace }: { workspace: DesignWorkspace }) {
|
||||
const tasks = [...workspace.tasks].sort(
|
||||
(left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt),
|
||||
);
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="design-plan-history">
|
||||
{tasks.map((task) => {
|
||||
const resultAssets = task.resultAssetIds
|
||||
.map((assetId) => workspace.assets.find((asset) => asset.assetId === assetId) ?? null)
|
||||
.filter((asset): asset is NonNullable<typeof asset> => Boolean(asset));
|
||||
const preview = resultAssets[0]
|
||||
?? workspace.assets.find((asset) => asset.generationTaskId === task.taskId)
|
||||
?? null;
|
||||
const active = task.status === 'queued' || task.status === 'running';
|
||||
|
||||
return (
|
||||
<details
|
||||
key={task.taskId}
|
||||
data-testid={`design-plan-history-${task.taskId}`}
|
||||
className="group overflow-hidden rounded-xl border border-border/70 bg-background"
|
||||
>
|
||||
<summary className="flex min-h-14 cursor-pointer list-none items-center gap-3 px-3 py-2.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand/30 [&::-webkit-details-marker]:hidden">
|
||||
<DesignAssetThumbnail asset={preview} className="h-9 w-9 rounded-lg" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span className="text-xs font-semibold text-foreground">制作记录</span>
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1 text-[11px] font-medium',
|
||||
task.status === 'succeeded'
|
||||
? 'text-emerald-700'
|
||||
: task.status === 'failed'
|
||||
? 'text-destructive'
|
||||
: active
|
||||
? 'text-brand'
|
||||
: 'text-muted-foreground',
|
||||
)}>
|
||||
{active && <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />}
|
||||
{STATUS_LABELS[task.status]}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] text-muted-foreground">
|
||||
{taskDescription(task)} · {Math.ceil(task.maximumCustomerChargeAtoms / 100)} 设计点
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-[11px] text-muted-foreground sm:inline">
|
||||
{formatTime(task.createdAt)}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" aria-hidden="true" />
|
||||
</summary>
|
||||
|
||||
<div className="border-t border-border/60 bg-surface-subtle/35 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{task.medium === 'video'
|
||||
? <Film className="h-3.5 w-3.5" />
|
||||
: <ImageIcon className="h-3.5 w-3.5" />}
|
||||
{taskDescription(task)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
{task.progress.completedRequiredSteps}/{task.progress.totalRequiredSteps} 步
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{task.issues.length > 0 && (
|
||||
<div className="mt-3 space-y-1 rounded-lg bg-destructive/[0.04] px-3 py-2 text-xs leading-5 text-destructive">
|
||||
{task.issues.map((issue) => <p key={issue.code}>{youthTaskIssueMessage(issue)}</p>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultAssets.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{resultAssets.map((asset) => (
|
||||
<div key={asset.assetId} className="group/result relative">
|
||||
<DesignAssetThumbnail asset={asset} className="h-20 w-20 rounded-xl" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="保存作品到电脑"
|
||||
title="保存作品到电脑"
|
||||
className="absolute bottom-1 right-1 flex h-7 w-7 items-center justify-center rounded-lg bg-background/90 text-muted-foreground shadow-sm hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void saveImageWorkspaceAsset(asset).catch(() => {
|
||||
toast.error('作品没有保存成功,请再试一次');
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
356
src/pages/ImageCanvas/DesignWorksRail.tsx
Normal file
356
src/pages/ImageCanvas/DesignWorksRail.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
FolderKanban,
|
||||
Lightbulb,
|
||||
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>
|
||||
|
||||
<footer className="shrink-0 border-t border-border/60 p-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-xs font-medium text-muted-foreground hover:bg-surface-subtle hover:text-foreground"
|
||||
onClick={() => {
|
||||
window.location.hash = '/image-prompts';
|
||||
}}
|
||||
>
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
获取灵感
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Cloud,
|
||||
CloudOff,
|
||||
PanelRight,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
@@ -20,14 +21,8 @@ import { useAuthStore } from '@/stores/auth';
|
||||
import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import { DesignConversationPane } from './DesignConversationPane';
|
||||
import { FineTuneDrawer } from './FineTuneDrawer';
|
||||
import {
|
||||
DesignAssetsPanel,
|
||||
DesignPendingOperationsPanel,
|
||||
DesignQuotePanel,
|
||||
DesignTaskPanel,
|
||||
} from './DesignProductionPanel';
|
||||
import { YouthCreationCard } from './YouthCreationCard';
|
||||
import { DesignWorksRail } from './DesignWorksRail';
|
||||
import { hasActiveCreationPlan } from './plan-lifecycle';
|
||||
import { projectYouthCreationCard } from './youth-form-projection';
|
||||
|
||||
function useDesktopLayout(): boolean {
|
||||
@@ -53,16 +48,16 @@ function LoadingWorkspace() {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background" aria-label="正在加载 AI 设计">
|
||||
<div className="h-16 animate-pulse border-b border-border/60 bg-surface-subtle/60" />
|
||||
<div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1.8fr)_minmax(320px,0.72fr)]">
|
||||
<div className="space-y-4 border-r border-border/60 bg-surface-subtle/40 p-5">
|
||||
<div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="space-y-4 bg-surface-subtle/35 p-6 lg:border-r lg:border-border/60">
|
||||
<div className="h-16 animate-pulse rounded-2xl bg-surface-subtle" />
|
||||
<div className="ml-auto h-20 w-4/5 animate-pulse rounded-2xl bg-brand/[0.07]" />
|
||||
<div className="h-28 w-5/6 animate-pulse rounded-2xl bg-surface-subtle" />
|
||||
<div className="h-48 w-5/6 animate-pulse rounded-2xl bg-surface-subtle" />
|
||||
</div>
|
||||
<div className="space-y-3 p-6">
|
||||
<div className="h-28 animate-pulse rounded-2xl bg-surface-subtle" />
|
||||
<div className="hidden space-y-3 p-4 lg:block">
|
||||
<div className="h-10 animate-pulse rounded-xl bg-surface-subtle" />
|
||||
{[0, 1, 2, 3].map((item) => (
|
||||
<div key={item} className="h-20 animate-pulse rounded-2xl bg-surface-subtle" />
|
||||
<div key={item} className="h-16 animate-pulse rounded-2xl bg-surface-subtle" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,7 +65,7 @@ function LoadingWorkspace() {
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyWorkspace() {
|
||||
function EmptyWorkspace({ onCreate }: { onCreate: () => void }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center bg-surface-subtle/35 px-6 py-10">
|
||||
<section data-testid="image-workspace-empty-state" className="w-full max-w-lg rounded-2xl border border-border/70 bg-background p-8 shadow-soft">
|
||||
@@ -79,28 +74,18 @@ function EmptyWorkspace() {
|
||||
</div>
|
||||
<h1 className="mt-5 text-2xl font-semibold tracking-tight text-foreground">你想创作什么?</h1>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-muted-foreground">
|
||||
可以先说一个想法,也可以之后放一张图片。AI 会和你一起把它变成作品。
|
||||
先建一个作品,再像聊天一样描述脑中的画面。AI 会帮你整理成可直接修改的制作方案。
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-6 min-h-11 rounded-xl"
|
||||
onClick={() => window.dispatchEvent(new Event(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT))}
|
||||
>
|
||||
<Button type="button" className="mt-6 h-10 rounded-xl" onClick={onCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
开始第一个创作
|
||||
开始第一个作品
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorWorkspace({
|
||||
message,
|
||||
retry,
|
||||
}: {
|
||||
message: string;
|
||||
retry: () => void;
|
||||
}) {
|
||||
function ErrorWorkspace({ message, retry }: { message: string; retry: () => void }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center bg-surface-subtle/35 px-6 py-10">
|
||||
<section data-testid="image-workspace-unavailable" className="w-full max-w-md rounded-2xl border border-border/70 bg-background p-8 text-center shadow-soft">
|
||||
@@ -109,7 +94,7 @@ function ErrorWorkspace({
|
||||
</div>
|
||||
<h1 className="mt-4 text-lg font-semibold text-foreground">AI 设计暂时无法连接</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{message}</p>
|
||||
<Button type="button" variant="outline" className="mt-5 min-h-11 rounded-xl" onClick={retry}>
|
||||
<Button type="button" variant="outline" className="mt-5 h-10 rounded-xl" onClick={retry}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
重新连接
|
||||
</Button>
|
||||
@@ -134,8 +119,7 @@ export function ImageCanvas() {
|
||||
const consumePendingPrompt = useImagePromptMuseumStore((state) => state.consumePendingPrompt);
|
||||
const workspaceId = workspace?.workspace.workspaceId ?? null;
|
||||
const desktopLayout = useDesktopLayout();
|
||||
const [creationCardOpen, setCreationCardOpen] = useState(false);
|
||||
const [fineTuneOpen, setFineTuneOpen] = useState(false);
|
||||
const [worksOpen, setWorksOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticated && (status === 'idle' || status === 'auth-required')) void load();
|
||||
@@ -150,9 +134,7 @@ export function ImageCanvas() {
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return;
|
||||
const pendingPrompt = consumePendingPrompt();
|
||||
if (pendingPrompt) {
|
||||
setChatDraft(pendingPrompt.prompt);
|
||||
}
|
||||
if (pendingPrompt) setChatDraft(pendingPrompt.prompt);
|
||||
}, [consumePendingPrompt, setChatDraft, workspaceId]);
|
||||
|
||||
if (status === 'loading' || (status === 'ready' && bootstrap?.workspaces.length && !workspace)) {
|
||||
@@ -160,75 +142,62 @@ export function ImageCanvas() {
|
||||
}
|
||||
|
||||
if (status === 'unavailable' || status === 'error') {
|
||||
return (
|
||||
<ErrorWorkspace
|
||||
message={error ?? '请检查网络后重试'}
|
||||
retry={() => void load()}
|
||||
/>
|
||||
);
|
||||
return <ErrorWorkspace message={error ?? '请检查网络后重试'} retry={() => void load()} />;
|
||||
}
|
||||
|
||||
if (status === 'ready' && !workspace) return <EmptyWorkspace />;
|
||||
if (!workspace) return <LoadingWorkspace />;
|
||||
if (!workspace && status !== 'ready') return <LoadingWorkspace />;
|
||||
|
||||
const streamHealthy = eventState === 'connected';
|
||||
const generationAvailable = bootstrap?.capabilities.generation !== false;
|
||||
const cardModel = projectYouthCreationCard(workspace, { quoteBlockers });
|
||||
const hasOfferedQuote = workspace.form.activeQuotes.some((quote) => quote.status === 'offered');
|
||||
const focusComposer = () => {
|
||||
setCreationCardOpen(false);
|
||||
const readiness = workspace
|
||||
? workspace.form.activeQuotes.some((quote) => quote.status === 'offered')
|
||||
? '制作方案已准备好,确认前仍可修改'
|
||||
: hasActiveCreationPlan(workspace)
|
||||
? '想法已整理清楚,可以检查制作方案'
|
||||
: projectYouthCreationCard(workspace, { quoteBlockers }).readinessMessage
|
||||
: '从一个新作品开始';
|
||||
const updatedAt = workspace
|
||||
? new Date(workspace.workspace.updatedAt).toLocaleString([], {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: null;
|
||||
const platform = window.electron?.platform;
|
||||
const openCreate = () => {
|
||||
setWorksOpen(true);
|
||||
globalThis.setTimeout(() => {
|
||||
document.getElementById('design-chat-composer')?.focus();
|
||||
window.dispatchEvent(new Event(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT));
|
||||
}, 0);
|
||||
};
|
||||
const openFineTune = () => {
|
||||
setCreationCardOpen(false);
|
||||
setFineTuneOpen(true);
|
||||
};
|
||||
const revealOfferedQuote = () => {
|
||||
if (!desktopLayout) setCreationCardOpen(true);
|
||||
globalThis.setTimeout(() => {
|
||||
const quote = document.getElementById('design-offered-quote');
|
||||
if (typeof quote?.scrollIntoView === 'function') {
|
||||
quote.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
const productionContent = (
|
||||
<div className="space-y-5">
|
||||
<DesignPendingOperationsPanel />
|
||||
{hasOfferedQuote && <DesignQuotePanel workspace={workspace} />}
|
||||
{!generationAvailable && (
|
||||
<div className="rounded-2xl border border-border/70 bg-background p-4 text-sm leading-6 text-muted-foreground">
|
||||
现在还不能开始制作,但你可以继续完善想法,已填写的内容会保存。
|
||||
</div>
|
||||
)}
|
||||
<DesignTaskPanel tasks={workspace.tasks} />
|
||||
<DesignAssetsPanel workspace={workspace} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="image-workspace" className="flex h-full min-h-0 flex-col overflow-hidden bg-background font-sans">
|
||||
<header className="flex min-h-16 shrink-0 items-center gap-4 border-b border-border/70 bg-background px-4 py-3 sm:px-5">
|
||||
<header className={cn(
|
||||
'flex min-h-16 shrink-0 items-center gap-4 border-b border-border/70 bg-background py-3',
|
||||
platform === 'darwin'
|
||||
? 'pl-[92px] pr-[116px]'
|
||||
: platform === 'win32'
|
||||
? 'pl-5 pr-[260px]'
|
||||
: 'px-4 sm:px-5',
|
||||
)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 shrink-0 text-brand" />
|
||||
<h1 className="truncate text-base font-semibold text-foreground">
|
||||
{workspace.workspace.title}
|
||||
{workspace?.workspace.title ?? 'Makelore Canvas'}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="mt-1 hidden sm:block">
|
||||
<p className="text-xs text-muted-foreground">{cardModel.readinessMessage}</p>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{updatedAt ? `${readiness} · 更新于 ${updatedAt}` : readiness}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'hidden items-center gap-1.5 rounded-xl px-2.5 py-1.5 text-xs sm:flex',
|
||||
streamHealthy
|
||||
? 'bg-brand/[0.06] text-brand'
|
||||
: 'bg-surface-subtle text-muted-foreground',
|
||||
'hidden items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs sm:flex',
|
||||
streamHealthy ? 'bg-brand/[0.055] text-brand' : 'bg-surface-subtle text-muted-foreground',
|
||||
)}
|
||||
title={streamHealthy ? '已经连接' : '正在重新连接,你的内容不会丢失'}
|
||||
>
|
||||
@@ -236,16 +205,32 @@ export function ImageCanvas() {
|
||||
{streamHealthy ? '实时同步' : '恢复连接'}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 rounded-xl"
|
||||
aria-label="刷新设计项目"
|
||||
onClick={() => void refreshWorkspace()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
{workspace && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-lg"
|
||||
aria-label="刷新作品"
|
||||
title="刷新"
|
||||
onClick={() => void refreshWorkspace()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!desktopLayout && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 rounded-xl"
|
||||
onClick={() => setWorksOpen(true)}
|
||||
>
|
||||
<PanelRight className="mr-1.5 h-4 w-4" />
|
||||
作品
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{error && workspace && (
|
||||
@@ -253,7 +238,7 @@ export function ImageCanvas() {
|
||||
<span>{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-11 shrink-0 rounded-xl px-3 font-medium hover:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-700/35"
|
||||
className="h-8 shrink-0 rounded-lg px-3 font-medium hover:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-700/35"
|
||||
onClick={() => void refreshWorkspace()}
|
||||
>
|
||||
刷新
|
||||
@@ -261,85 +246,35 @@ export function ImageCanvas() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!desktopLayout && (
|
||||
<div className="shrink-0 border-b border-border/70 bg-background p-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 rounded-xl bg-brand/[0.06] px-3 py-2.5 text-left transition-colors hover:bg-brand/[0.1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35"
|
||||
onClick={() => setCreationCardOpen(true)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 shrink-0 text-brand" />
|
||||
<span className="truncate text-sm font-semibold text-foreground">当前想法与作品</span>
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{cardModel.readinessMessage}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1.8fr)_minmax(320px,0.72fr)]">
|
||||
<main className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1fr)_320px] xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="flex min-h-0 lg:border-r lg:border-border/70">
|
||||
<DesignConversationPane
|
||||
workspace={workspace}
|
||||
canRequestQuote={generationAvailable
|
||||
&& cardModel.phase === 'ready'
|
||||
&& cardModel.canRequestQuote}
|
||||
onQuoteOffered={revealOfferedQuote}
|
||||
/>
|
||||
{workspace ? (
|
||||
<DesignConversationPane
|
||||
workspace={workspace}
|
||||
quoteBlockers={quoteBlockers}
|
||||
generationAvailable={generationAvailable}
|
||||
/>
|
||||
) : (
|
||||
<EmptyWorkspace onCreate={openCreate} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{desktopLayout && (
|
||||
<section
|
||||
data-testid="image-workspace-creation-pane"
|
||||
className="min-h-0 bg-surface-subtle/35"
|
||||
>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mx-auto max-w-3xl space-y-5 px-4 py-5 sm:px-5">
|
||||
<YouthCreationCard
|
||||
workspace={workspace}
|
||||
quoteBlockers={quoteBlockers}
|
||||
generationAvailable={generationAvailable}
|
||||
onOpenFineTune={openFineTune}
|
||||
onFocusComposer={focusComposer}
|
||||
onQuoteOffered={revealOfferedQuote}
|
||||
/>
|
||||
{productionContent}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<DesignWorksRail workspace={workspace} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{!desktopLayout && (
|
||||
<Sheet open={creationCardOpen} onOpenChange={setCreationCardOpen}>
|
||||
<SheetContent side="bottom" className="flex max-h-[88vh] flex-col overflow-hidden rounded-t-3xl p-0">
|
||||
<Sheet open={worksOpen} onOpenChange={setWorksOpen}>
|
||||
<SheetContent side="right" className="flex w-[min(92vw,360px)] flex-col overflow-hidden p-0 sm:max-w-[360px]">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>当前想法与作品</SheetTitle>
|
||||
<SheetDescription>查看 AI 已经听懂的内容、制作方案和作品。</SheetDescription>
|
||||
<SheetTitle>我的作品</SheetTitle>
|
||||
<SheetDescription>新建、切换、重命名或删除 AI 绘画作品。</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-surface-subtle/35 px-4 py-4">
|
||||
<div className="space-y-5 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
<YouthCreationCard
|
||||
workspace={workspace}
|
||||
quoteBlockers={quoteBlockers}
|
||||
generationAvailable={generationAvailable}
|
||||
onOpenFineTune={openFineTune}
|
||||
onFocusComposer={focusComposer}
|
||||
onQuoteOffered={revealOfferedQuote}
|
||||
/>
|
||||
{productionContent}
|
||||
</div>
|
||||
</div>
|
||||
<DesignWorksRail workspace={workspace} onProjectSelected={() => setWorksOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
<FineTuneDrawer
|
||||
open={fineTuneOpen}
|
||||
onOpenChange={setFineTuneOpen}
|
||||
workspace={workspace}
|
||||
quoteBlockers={quoteBlockers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
16
src/pages/ImageCanvas/plan-lifecycle.ts
Normal file
16
src/pages/ImageCanvas/plan-lifecycle.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { DesignWorkspace } from '../../../shared/image-workspace';
|
||||
|
||||
function hasCreatorPrompt(workspace: DesignWorkspace): boolean {
|
||||
const values = workspace.form.specification.values;
|
||||
return Boolean(values.content.concept?.trim() || values.content.narrative?.trim());
|
||||
}
|
||||
|
||||
export function hasActiveCreationPlan(workspace: DesignWorkspace): boolean {
|
||||
if (workspace.form.activeQuotes.some((quote) => quote.status === 'offered')) return true;
|
||||
if (!hasCreatorPrompt(workspace) && workspace.form.specification.values.references.length === 0) return false;
|
||||
const latestTaskRevision = workspace.tasks.reduce(
|
||||
(latest, task) => Math.max(latest, task.specificationRevision),
|
||||
-1,
|
||||
);
|
||||
return latestTaskRevision < workspace.form.specificationRevision;
|
||||
}
|
||||
68
src/pages/ImageCanvas/reference-tokens.ts
Normal file
68
src/pages/ImageCanvas/reference-tokens.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
const REFERENCE_TOKEN_PATTERN = /@(?:图片|图)(\d+)/g;
|
||||
|
||||
export function referenceToken(index: number): string {
|
||||
return `@图片${index + 1}`;
|
||||
}
|
||||
|
||||
export function promptReferencesImage(prompt: string, index: number): boolean {
|
||||
const expected = index + 1;
|
||||
for (const match of prompt.matchAll(REFERENCE_TOKEN_PATTERN)) {
|
||||
if (Number(match[1]) === expected) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function findUnboundReferenceNumbers(prompt: string, boundReferenceCount: number): number[] {
|
||||
const unbound = new Set<number>();
|
||||
for (const match of prompt.matchAll(REFERENCE_TOKEN_PATTERN)) {
|
||||
const number = Number(match[1]);
|
||||
if (number < 1 || number > boundReferenceCount) unbound.add(number);
|
||||
}
|
||||
return [...unbound].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
export function findReferenceMentionRange(prompt: string, cursor: number): {
|
||||
start: number;
|
||||
end: number;
|
||||
} | null {
|
||||
const prefix = prompt.slice(0, cursor);
|
||||
const match = /@(?:图(?:片)?)?\d*$/.exec(prefix);
|
||||
if (!match || match.index < 0) return null;
|
||||
return { start: match.index, end: cursor };
|
||||
}
|
||||
|
||||
export function insertReferenceToken(
|
||||
prompt: string,
|
||||
index: number,
|
||||
range?: { start: number; end: number } | null,
|
||||
): { value: string; cursor: number } {
|
||||
const token = referenceToken(index);
|
||||
if (range) {
|
||||
const trailingSpace = prompt.slice(range.end).startsWith(' ') ? '' : ' ';
|
||||
const value = `${prompt.slice(0, range.start)}${token}${trailingSpace}${prompt.slice(range.end)}`;
|
||||
return { value, cursor: range.start + token.length + trailingSpace.length };
|
||||
}
|
||||
|
||||
const trimmedRight = prompt.trimEnd();
|
||||
const separator = trimmedRight ? (/[,。!?、:;,.!?;:]$/.test(trimmedRight) ? ' ' : ',参考 ') : '参考 ';
|
||||
const value = `${trimmedRight}${separator}${token} `;
|
||||
return { value, cursor: value.length };
|
||||
}
|
||||
|
||||
export function removeReferenceTokenAndShift(prompt: string, removedIndex: number): string {
|
||||
const removedNumber = removedIndex + 1;
|
||||
const removedMarker = '__MAKEL0RE_REMOVED_REFERENCE__';
|
||||
return prompt
|
||||
.replace(REFERENCE_TOKEN_PATTERN, (token, rawNumber: string) => {
|
||||
const number = Number(rawNumber);
|
||||
if (number === removedNumber) return removedMarker;
|
||||
if (number > removedNumber) return `@图片${number - 1}`;
|
||||
return token.startsWith('@图') && !token.startsWith('@图片')
|
||||
? `@图片${number}`
|
||||
: token;
|
||||
})
|
||||
.replace(new RegExp(`\\s*${removedMarker}\\s*[,、,]?\\s*`, 'g'), ' ')
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.replace(/,\s*([,。!?、:;,.!?;:]|$)/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
Reference in New Issue
Block a user