feat(canvas): 创作入口 - 突出 Agent 主入口并优化响应式交互

This commit is contained in:
ddCat
2026-09-22 15:48:32 +08:00
parent c30393df14
commit 2088cf78cc
3 changed files with 250 additions and 78 deletions

View File

@@ -38,6 +38,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
- [ ] 更新并重启后端后,使用已声明图片能力的实际渠道模型,重新询问已上传图片节点的主体和背景;确认不再因 `unknown` 标记拒绝看图。真实模型调用会消耗额度,尚未执行。
- [ ] 实际部署验证 OSS 图片、同轮多图比较、跨轮换图与读取失败提示;检查其他依赖公网 URL 的生成协议配置,不能将本次看图修复视为存储公网地址已修正。
## 画布创作入口主次调整
- 前端类型检查和 4 项画布起点状态测试通过。
- [x] 真实画布路由使用隔离的身份与空画布测试数据完成局部浏览器验收:1440 × 900 明暗主题、390 × 844 窄屏、390 × 320 低高度。入口不横向溢出,点击目标至少 44px;Tab 顺序与焦点样式正常,入口滚轮不改变画布缩放,减少动态效果设置生效。
- [x] 入口改为左对齐分层标题、主题适配的金属质感片头符号和细分隔线,无卡片或实底大按钮。Agent 使用文字与小箭头入口、悬停下划线反馈;次级入口和素材操作分层展示。点击「交给 Agent」可展开原有 Agent 工作台。
- [ ] 用户确认视觉效果;使用真实登录账号验收全部入口、上传和 Agent 对话。本轮浏览器验收拦截了写请求,未修改真实项目;未模拟的辅助接口返回错误,因此局部视觉与面板展开检查不代表后端联调、保存或 Agent 执行通过。
## 技能文件包迁移元数据长度
- [x] 启动迁移对旧技能名称、简介的回填元数据按字符数限制长度,省略号计入上限;不改写原始名称、简介和正文,不放宽非迁移入口的校验。

View File

@@ -0,0 +1,184 @@
.canvas-entry-stage {
--canvas-entry-hairline: color-mix(in srgb, var(--canvas-entry-text) 18%, transparent);
--canvas-entry-metal-highlight: var(--canvas-entry-text);
--canvas-entry-metal-mid: color-mix(in srgb, var(--canvas-entry-text) 52%, var(--canvas-entry-muted));
--canvas-entry-metal-shadow: color-mix(in srgb, var(--canvas-entry-muted) 32%, transparent);
position: absolute;
inset: 0;
z-index: var(--z-node-active);
display: grid;
place-items: center;
padding: var(--space-20) var(--space-6);
color: var(--canvas-entry-text);
pointer-events: none;
}
.canvas-entry-shell {
width: min(100%, 520px);
max-height: 100%;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--space-2);
text-align: left;
pointer-events: auto;
}
.canvas-entry-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.canvas-entry-heading-copy { min-width: 0; }
.canvas-entry-header h2 {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin: 0;
font-size: var(--typ-display-md);
font-weight: 400;
letter-spacing: -0.035em;
line-height: 1.45;
}
.canvas-entry-header h2 > span { color: var(--canvas-entry-muted); }
.canvas-entry-header h2 > strong { font-weight: 600; }
.canvas-entry-header p {
margin: var(--space-4) 0 0;
color: var(--canvas-entry-muted);
font-size: var(--fs-caption);
line-height: 1.8;
text-wrap: balance;
}
.canvas-entry-film-mark {
width: calc(var(--space-24) + var(--space-4));
height: calc(var(--space-24) + var(--space-4));
flex-shrink: 0;
margin-right: var(--space-2);
}
.canvas-entry-shell button {
display: inline-flex;
min-height: calc(var(--space-10) + var(--space-1));
align-items: center;
gap: var(--space-2);
padding: 0;
border: 0;
border-radius: var(--r-sm);
background: transparent;
cursor: pointer;
}
.canvas-entry-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-3) var(--space-8);
margin-top: var(--space-10);
}
.canvas-entry-shell .canvas-entry-agent {
gap: var(--space-4);
color: var(--canvas-entry-text);
font-size: var(--fs-heading-lg);
font-weight: 600;
}
.canvas-entry-agent-label { position: relative; }
.canvas-entry-agent-label::after {
content: "";
position: absolute;
inset: auto 0 calc(-1 * var(--space-1));
height: var(--stroke-1);
background: currentColor;
transform: scaleX(0);
transform-origin: left;
transition: transform var(--motion-state) var(--motion-ease-out);
}
.canvas-entry-agent-arrow {
display: grid;
width: var(--space-8);
height: var(--space-8);
place-items: center;
border: var(--stroke-1) solid var(--canvas-entry-hairline);
border-radius: var(--r-full);
transition: border-color var(--motion-state) ease, transform var(--motion-state) var(--motion-ease-out);
}
.canvas-entry-agent-arrow svg { width: var(--space-4); height: var(--space-4); }
.canvas-entry-agent:is(:hover, :focus-visible) .canvas-entry-agent-label::after { transform: scaleX(1); }
.canvas-entry-agent:is(:hover, :focus-visible) .canvas-entry-agent-arrow {
border-color: var(--canvas-entry-muted);
transform: translate(var(--space-half), calc(-1 * var(--space-half)));
}
.canvas-entry-agent:active .canvas-entry-agent-arrow { transform: none; }
.canvas-entry-secondary-actions,
.canvas-entry-quick-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4);
}
.canvas-entry-secondary-actions button,
.canvas-entry-quick-actions button {
color: var(--canvas-entry-muted);
font-size: var(--fs-caption);
font-weight: 400;
transition: color var(--motion-state) ease;
}
.canvas-entry-secondary-actions svg { width: var(--space-3); height: var(--space-3); opacity: 0.6; }
.canvas-entry-secondary-actions button:hover,
.canvas-entry-quick-actions button:hover { color: var(--canvas-entry-text); }
.canvas-entry-shell button:focus-visible {
outline: var(--stroke-2) solid var(--canvas-entry-accent);
outline-offset: var(--space-1);
}
.canvas-entry-footer {
position: relative;
margin-top: var(--space-6);
padding-top: var(--space-4);
}
.canvas-entry-footer::before {
content: "";
position: absolute;
inset: 0 0 auto;
height: var(--stroke-1);
background: linear-gradient(90deg, var(--canvas-entry-hairline), transparent);
}
.canvas-entry-quick-actions { gap: var(--space-6); }
.canvas-entry-quick-actions svg { width: var(--space-3-half); height: var(--space-3-half); stroke-width: 1.5; }
@media (max-width: 520px) {
.canvas-entry-stage { padding-inline: var(--space-5); }
.canvas-entry-header h2 { font-size: var(--typ-display-sm); }
.canvas-entry-film-mark { width: var(--space-20); height: var(--space-20); margin-right: 0; }
.canvas-entry-actions { column-gap: var(--space-4); margin-top: var(--space-8); }
.canvas-entry-quick-actions { gap: var(--space-4); }
}
@media (max-height: 560px) {
.canvas-entry-stage { padding-block: var(--space-4); }
.canvas-entry-actions { margin-top: var(--space-5); }
.canvas-entry-footer { margin-top: var(--space-4); }
}
@media (prefers-reduced-motion: reduce) {
.canvas-entry-shell button,
.canvas-entry-agent-label::after,
.canvas-entry-agent-arrow { transition: none; }
.canvas-entry-agent:is(:hover, :focus-visible) .canvas-entry-agent-arrow { transform: none; }
}

View File

@@ -1,6 +1,6 @@
import { Fragment, useState, type CSSProperties, type ReactNode } from "react";
import { Dropdown, Popover } from "antd";
import { AlignLeft, ArrowRight, Bot, Check, ChevronDown, ChevronUp, Clapperboard, FolderKanban, Images, MoreHorizontal, Palette, Pencil, Plus, Sparkles, Type, Upload, X } from "lucide-react";
import { Fragment, useId, useState, type CSSProperties } from "react";
import { Popover } from "antd";
import { AlignLeft, ArrowUpRight, Check, ChevronUp, Clapperboard, FolderKanban, Images, Palette, Pencil, Plus, Sparkles, Type, Upload, X } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu";
@@ -8,6 +8,8 @@ import type { CanvasShortDramaProgress, CanvasShortDramaStepId } from "@/lib/can
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
import "./canvas-short-drama-entry.css";
export function CanvasLinkedProjectEmptyState({ projectName, hasChapter, onAddFirstChapter, onOpenAssets, onAddText }: { projectName: string; hasChapter: boolean; onAddFirstChapter: () => void; onOpenAssets: () => void; onAddText: () => void }) {
const theme = canvasThemes[useActiveTheme()];
return (
@@ -33,67 +35,68 @@ export function CanvasShortDramaEmptyState({ onCreatePipeline, onOpenAgent, onSt
onAddScript: () => void;
}) {
const theme = canvasThemes[useActiveTheme()];
const focusStyle = { "--tw-ring-color": theme.accent.primary } as CSSProperties;
const entryStyle = {
"--canvas-entry-text": theme.node.text,
"--canvas-entry-muted": theme.node.muted,
"--canvas-entry-accent": theme.accent.primary,
} as CSSProperties;
return (
<div className="pointer-events-none absolute inset-0 z-20 grid place-items-center px-4 pb-20 pt-24">
<div className="pointer-events-auto w-full max-w-[760px]" data-canvas-no-zoom>
<div className="mb-4 text-center">
<h2 className="text-lg font-semibold">从哪里开始?</h2>
<p className="mt-1 text-sm" style={{ color: theme.node.muted }}>选择一条主路径,之后仍可随时切换。</p>
<div className="canvas-entry-stage" style={entryStyle}>
<div className="canvas-entry-shell" data-canvas-no-zoom data-canvas-wheel-scroll>
<header className="canvas-entry-header">
<div className="canvas-entry-heading-copy">
<h2><span>让灵感,</span><strong>开始成片。</strong></h2>
<p>从一句想法开始,和 Agent 一起创作。</p>
</div>
<CanvasEntryFilmMark />
</header>
<div className="canvas-entry-actions">
<button type="button" className="canvas-entry-agent" onClick={onOpenAgent}>
<span className="canvas-entry-agent-label">交给 Agent</span>
<span className="canvas-entry-agent-arrow"><ArrowUpRight aria-hidden="true" /></span>
</button>
<div className="canvas-entry-secondary-actions">
<button type="button" onClick={onCreatePipeline}>自己创作<ArrowUpRight aria-hidden="true" /></button>
<button type="button" onClick={onStartFreeform}>空白画布<ArrowUpRight aria-hidden="true" /></button>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
<PathCard
icon={<Clapperboard className="size-5" />}
title="自己创作"
description="搭好短剧骨架,再逐镜头编辑和生成。"
action="创建短剧流水线"
accent={theme.accent.primary}
theme={theme}
focusStyle={focusStyle}
onClick={onCreatePipeline}
/>
<PathCard
icon={<Bot className="size-5" />}
title="交给 Agent"
description="用一句话描述题材、角色和核心冲突。"
action="一句话生成影视项目"
accent={theme.node.activeStroke}
theme={theme}
focusStyle={focusStyle}
onClick={onOpenAgent}
/>
<PathCard
icon={<Plus className="size-5" />}
title="自由空白画布"
description="不预设流程,自由添加文本、图片、音频和视频。"
action="从空白画布开始"
accent={theme.node.muted}
theme={theme}
focusStyle={focusStyle}
onClick={onStartFreeform}
/>
</div>
<div className="mt-3 flex justify-center">
<Dropdown
trigger={["click"]}
menu={{
items: [
{ key: "upload", icon: <Upload className="size-4" />, label: "导入素材", onClick: onUpload },
{ key: "text", icon: <Type className="size-4" />, label: "新建文本", onClick: onAddText },
{ key: "storyboard", icon: <Clapperboard className="size-4" />, label: "新建空白分镜", onClick: onAddScript },
],
}}
>
<button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ color: theme.node.muted, ...focusStyle }}>
<MoreHorizontal className="size-4" />其他起点<ChevronDown className="size-3" />
</button>
</Dropdown>
<div className="canvas-entry-footer">
<div className="canvas-entry-quick-actions">
<button type="button" onClick={onUpload}><Upload aria-hidden="true" />导入素材</button>
<button type="button" onClick={onAddText}><Type aria-hidden="true" />新建文本</button>
<button type="button" onClick={onAddScript}><Clapperboard aria-hidden="true" />空白分镜</button>
</div>
</div>
</div>
</div>
);
}
function CanvasEntryFilmMark() {
const id = useId();
return (
<svg className="canvas-entry-film-mark" viewBox="0 0 112 112" fill="none" aria-hidden="true" focusable="false">
<defs>
<linearGradient id={`${id}-front`} x1="27" y1="18" x2="76" y2="91" gradientUnits="userSpaceOnUse">
<stop stopColor="var(--canvas-entry-metal-highlight)" />
<stop offset="0.42" stopColor="var(--canvas-entry-metal-mid)" />
<stop offset="0.66" stopColor="var(--canvas-entry-metal-highlight)" />
<stop offset="1" stopColor="var(--canvas-entry-metal-shadow)" />
</linearGradient>
<linearGradient id={`${id}-edge`} x1="36" y1="21" x2="86" y2="83" gradientUnits="userSpaceOnUse">
<stop stopColor="var(--canvas-entry-metal-mid)" />
<stop offset="1" stopColor="var(--canvas-entry-metal-shadow)" />
</linearGradient>
</defs>
<path d="M35 17 92 50Q98 54 92 58L35 91 26 86 82 54 26 22Z" fill={`url(#${id}-edge)`} />
<path d="M26 22 82 54 26 86V22ZM36 39V69L62 54 36 39Z" fill={`url(#${id}-front)`} fillRule="evenodd" />
<path d="M26 22 82 54 26 86V22Z" stroke="var(--canvas-entry-metal-highlight)" strokeOpacity="0.45" strokeWidth="0.6" strokeLinejoin="round" />
<path d="M36 39V69L62 54" stroke="var(--canvas-entry-metal-shadow)" strokeWidth="1" strokeLinejoin="round" />
<path d="m35 17 57 33" stroke="var(--canvas-entry-metal-highlight)" strokeOpacity="0.5" strokeWidth="0.6" />
</svg>
);
}
export function CanvasFreeformEmptyState({ commands }: { commands: CanvasCreateCommand[] }) {
const theme = canvasThemes[useActiveTheme()];
const [createOpen, setCreateOpen] = useState(false);
@@ -132,28 +135,6 @@ export function CanvasFreeformEmptyState({ commands }: { commands: CanvasCreateC
);
}
function PathCard({ icon, title, description, action, accent, theme, focusStyle, onClick }: {
icon: ReactNode;
title: string;
description: string;
action: string;
accent: string;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
focusStyle: CSSProperties;
onClick: () => void;
}) {
return (
<section className="flex min-h-[176px] flex-col rounded-lg border p-4 shadow-sm backdrop-blur" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
<span className="grid size-9 place-items-center rounded-md" style={{ background: `${accent}16`, color: accent }}>{icon}</span>
<div className="mt-3 text-base font-semibold">{title}</div>
<p className="mt-1 min-h-10 text-sm leading-5" style={{ color: theme.node.muted }}>{description}</p>
<button type="button" className="mt-auto inline-flex h-9 w-full items-center justify-between rounded-md border px-3 text-sm font-semibold outline-none transition hover:brightness-105 focus-visible:ring-2" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text, ...focusStyle }} onClick={onClick}>
<span>{action}</span><ArrowRight className="size-4" />
</button>
</section>
);
}
export function CanvasShortDramaGuide({ progress, collapsed, onToggle, onSkip, onStepClick }: {
progress: CanvasShortDramaProgress;
collapsed: boolean;