feat(canvas): 设计token系统 - 建立三层架构骨架 (#101)
* feat(canvas): 设计token系统 - 建立三层架构骨架 #94 #96 建立 Primitive→Semantic→Component 三层设计 token 体系,覆盖 9 个维度: 色彩/间距/字号/圆角/阴影/Elevation/动效/描边/不透明度。 - Primitive 层:纯值无语义,含 neutral 色板+节点类型色板+状态色、 4px 栅格间距、10档字号、10档圆角、阴影+glow、12档 z-index、 动效(duration+easing+delay+motion-scale)、描边、不透明度 - Semantic 层:引用 Primitive,随主题切换,含通用语义色、 Canvas 语义(--cn-*)、Aceternity spatial 语义、节点类型语义、状态语义 - Component 层:引用 Semantic,含 Dock/Node/Modal/Panel/Prompt面板/ 进度条/缩放控制专属 token - @theme inline 映射:节点类型色、状态色、Canvas 语义色、字号映射到 Tailwind utility - prefers-reduced-motion 三级降级(L0正常/L1简化/L2禁用) - AGENTS.md 新增 Design Token 使用规范,禁止新增裸 px 值 - 附带修复 canvas-drawing-excalidraw-editor.tsx TS7006 类型注解 - 附带修复 canvas-drawing-editor-modal.tsx TS2339 engine 字段缺失 Wayfinder 决策来源:#95 #96 #97 #98 #99 * fix(canvas): 设计token系统 - 修复CSS变量循环引用和命名兼容性 #94 #96 修复两个问题: 1. CSS变量循环引用:Primitive层 --color-node-*/--color-status-* 与 @theme inline 中的 --color-node-*/--color-status-* 同名,经 Semantic 层中转后形成循环(A→B→A)。改用 --palette- 前缀隔离 Primitive 色板, 打断循环链路。 2. 变量名小数点兼容性:--space-0.5/1.5/2.5/3.5 中的小数点被部分 CSS linter 误判为类选择器。改为 --space-half/-1-half/-2-half/-3-half, 保持语义清晰的同时提升工具链兼容性。 * refactor(canvas): 画布核心组件 - 迁移硬编码值到设计token #94 批次1:5个画布核心组件的裸 text-[Npx]/rounded-[Npx]/z-[N] 替换为 对应设计token,统一视觉语言: - canvas-node.tsx:text-[10/11/9/17px]→fs-tiny/label/micro/heading-lg, rounded-[18px]→node-radius - canvas-node-hover-toolbar.tsx:text-[11/17/10/9px]→fs-*, rounded-[14/9px]→dock-radius/dock-item-radius,z-[70]→z-panel-floating - canvas-toolbar.tsx:text-[9/10/11px]→fs-*, rounded-[17/16/11/10px]→panel-radius/dock-item-radius-*, z-[30/40]→z-toolbar/z-toolbar-floating - canvas-assistant-panel.tsx:text-[8/9/10/11px]→fs-* - canvas-script-node.tsx:text-[9/10/11px]→fs-* 视觉零回归(token值与原硬编码值一致),代码可维护性提升。 * refactor(canvas): 画布辅助组件 - 迁移硬编码值到设计token #94 批次2:6个画布辅助组件的裸 text-[Npx]/rounded-[Npx]/z-[N] 替换为 对应设计token: - canvas-style-picker-modal.tsx:text-[10/11px]→fs-tiny/label - canvas-file-drop-overlay.tsx:z-[110]→z-modal-overlay, rounded-[28/20px]→r-5xl/r-3xl - canvas-character-reference-node.tsx:text-[10px]→fs-tiny - canvas-drawing-editor-modal.tsx:text-[11px]→fs-label - canvas-video-prompt-tools.tsx:z-[1500]→z-toast, rounded-[12/10px]→r-lg/dock-item-radius - asset-picker-modal.tsx:text-[10px]→fs-tiny 视觉零回归(token值与原硬编码值一致)。 * refactor(*): 全项目硬编码值 - 批量迁移到设计token #94 批次3:全项目 .tsx 文件中 372 处裸 text-[Npx]/rounded-[Npx] 批量 替换为对应设计token,剩余 25 处无直接 token 对应的低频值保留。 替换覆盖(按频率): - text-[10px]→fs-tiny (132处) - text-[11px]→fs-label (100处) - text-[9px]→fs-micro (45处) - text-[12px]→fs-caption, text-[13/14px]→fs-body, text-[15px]→fs-body-lg - text-[18px]→fs-heading-lg, text-[22px]→fs-title, text-[8px]→fs-micro - rounded-[9/10px]→dock-item-radius, rounded-[12px]→r-lg - rounded-[14px]→dock-radius, rounded-[16px]→r-2xl - rounded-[17px]→panel-radius, rounded-[8px]→r-md 涉及 components/ 和 pages/ 下所有 .tsx 文件。 视觉零回归(token值与原硬编码值一致)。 * feat(canvas): 节点视觉层次规范 - 色带+状态徽章+四态递进 #97 #94 基于 Wayfinder 地图 #97 决议执行节点视觉重设计: 1. 节点类型色带(决策1) - 新增 nodeTypeColorVar() 函数,10种节点类型对应色带色 - 节点左侧3px色条,引用 --cn-type-* token - 远距离最强信号,用户扫视画布即可识别节点类型 2. 节点状态徽章(决策2) - 新增 NodeStatusBadge 组件,loading/success/error 三态 - 左上角圆角徽章,backdrop-blur 玻璃质感 - success 态 2s 后自动淡出(canvas-status-success-fade 动画) 3. 四态递进视觉权重(决策3) - idle:无额外阴影 - hover:轻微抬升阴影(16px 48px) - selected:2px 边框环(accent.primary 40% 透明度) - selected-primary:4px 软色环 + resize handle - active:6px 外发光环 + inner glow(最强强调) - 120-150ms ease-out 过渡 4. 阴影迁移 - boxShadow 从硬编码改为分层语义化表达 - 边框色 hover 态使用 color-mix 动态加深 Closes #97 --------- Co-authored-by: sikongyue <3155157671@qq.com>
This commit is contained in:
@@ -53,7 +53,7 @@ function PickerCard({ asset, onClick }: { asset: InsertableAsset; onClick: () =>
|
||||
<div className="p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="line-clamp-1 text-xs font-medium text-stone-800 dark:text-stone-200">{title}</span>
|
||||
<Tag className="m-0 shrink-0 text-[10px]">{kind === "image" ? "图片" : kind === "video" ? "视频" : kind === "audio" ? "音频" : "文本"}</Tag>
|
||||
<Tag className="m-0 shrink-0 text-[var(--fs-tiny)]">{kind === "image" ? "图片" : kind === "video" ? "视频" : kind === "audio" ? "音频" : "文本"}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100">插入</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function CanvasActiveTaskPanel({ tasks }: { tasks: GenerationTask[] }) {
|
||||
<LayoutGroup id="canvas-active-tasks">
|
||||
<motion.section
|
||||
layout
|
||||
className="pointer-events-auto overflow-hidden rounded-[17px] border backdrop-blur-2xl"
|
||||
className="pointer-events-auto overflow-hidden rounded-[var(--panel-radius)] border backdrop-blur-2xl"
|
||||
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 24px 72px ${theme.spatial.shadow}` }}
|
||||
aria-label="当前画布生成任务"
|
||||
>
|
||||
@@ -57,12 +57,12 @@ export function CanvasActiveTaskPanel({ tasks }: { tasks: GenerationTask[] }) {
|
||||
aria-controls="canvas-active-task-list"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-[10px]" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--dock-item-radius)]" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}>
|
||||
<ListTodo className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold leading-5">生成任务</span>
|
||||
<span className="block truncate text-[11px]" style={{ color: theme.node.muted }}>当前画布 · {tasks.length} 个进行中</span>
|
||||
<span className="block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }}>当前画布 · {tasks.length} 个进行中</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2" style={{ color: theme.accent.primary }}>
|
||||
@@ -127,9 +127,9 @@ function ActiveTaskCard({ task, now, theme, expanded, onToggle, reducedMotion }:
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-semibold" title={formatTaskKind(task)}>{formatTaskKind(task)}</span>
|
||||
<span className="shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-medium" style={{ borderColor: `${statusTone}44`, color: statusTone }}>{statusLabel[task.status]}</span>
|
||||
<span className="shrink-0 rounded-full border px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium" style={{ borderColor: `${statusTone}44`, color: statusTone }}>{statusLabel[task.status]}</span>
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-[11px]" style={{ color: theme.node.muted }} title={task.stage || statusLabel[task.status]}>{task.stage || statusLabel[task.status]}</span>
|
||||
<span className="mt-1 block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }} title={task.stage || statusLabel[task.status]}>{task.stage || statusLabel[task.status]}</span>
|
||||
</span>
|
||||
{expanded ? <ChevronUp className="mt-0.5 size-3.5 shrink-0" style={{ color: theme.node.muted }} /> : <ChevronDown className="mt-0.5 size-3.5 shrink-0" style={{ color: theme.node.muted }} />}
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ function ActiveTaskCard({ task, now, theme, expanded, onToggle, reducedMotion }:
|
||||
<motion.div className="h-full rounded-full" animate={{ width: `${progress ?? 8}%` }} transition={reducedMotion ? { duration: 0 } : { duration: 0.3 }} style={{ background: statusTone }} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[10px]" style={{ color: theme.node.muted }}>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
|
||||
<span className="inline-flex min-w-0 items-center gap-1 truncate" title={durationLabel}><Clock3 className="size-3 shrink-0" />{durationLabel}</span>
|
||||
<span className="inline-flex min-w-0 items-center justify-end gap-1 truncate" title={billingLabel}><Coins className="size-3 shrink-0" />{billingLabel}</span>
|
||||
</div>
|
||||
@@ -151,7 +151,7 @@ function ActiveTaskCard({ task, now, theme, expanded, onToggle, reducedMotion }:
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={transition}
|
||||
className="border-t px-3 pb-3 pt-2 text-[11px]"
|
||||
className="border-t px-3 pb-3 pt-2 text-[var(--fs-label)]"
|
||||
style={{ borderColor: theme.toolbar.border, color: theme.node.muted }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
|
||||
@@ -49,7 +49,7 @@ export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveToo
|
||||
<div className={`min-w-0 max-w-[86%] text-sm leading-6 ${isUser ? "rounded-md px-3 py-2.5 text-right" : "text-left"}`} style={{ color, ...(isUser ? { background: theme.accent.primarySoft } : {}) }}>
|
||||
<div className="whitespace-pre-wrap break-words text-left">{item.text}</div>
|
||||
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} /> : null}
|
||||
{item.meta ? <div className="mt-1 text-[11px] opacity-45">{item.meta}</div> : null}
|
||||
{item.meta ? <div className="mt-1 text-[var(--fs-label)] opacity-45">{item.meta}</div> : null}
|
||||
</div>
|
||||
{isUser ? <AgentUserAvatar user={user} theme={theme} /> : null}
|
||||
</div>
|
||||
@@ -69,7 +69,7 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
|
||||
<span>确认工具调用</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium" style={{ color: "#d97706", background: "rgba(217,119,6,.1)" }}>等待确认</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[var(--fs-label)] font-medium" style={{ color: "#d97706", background: "rgba(217,119,6,.1)" }}>等待确认</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6" style={{ color: theme.node.text }}>{summary}</div>
|
||||
</div>
|
||||
@@ -103,7 +103,7 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
|
||||
}
|
||||
|
||||
function ImpactMetric({ label, value, attention = false, theme }: { label: string; value: number; attention?: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
return <div className="px-1 py-1"><div className="text-[10px]" style={{ color: theme.node.muted }}>{label}</div><div className="mt-0.5 text-sm font-semibold tabular-nums" style={{ color: attention ? "#d97706" : theme.node.text }}>{value}</div></div>;
|
||||
return <div className="px-1 py-1"><div className="text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{label}</div><div className="mt-0.5 text-sm font-semibold tabular-nums" style={{ color: attention ? "#d97706" : theme.node.text }}>{value}</div></div>;
|
||||
}
|
||||
|
||||
function agentImpactFromDetail(detail: unknown) {
|
||||
@@ -132,7 +132,7 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium" style={{ color: state.color, background: state.softBg }}>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[var(--fs-label)] font-medium" style={{ color: state.color, background: state.softBg }}>
|
||||
{state.label}
|
||||
</span>
|
||||
{detail ? <span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}>详情</span> : null}
|
||||
@@ -272,7 +272,7 @@ export function AgentPanelTabs<T extends string>({ value, items, theme, right, o
|
||||
|
||||
function AgentDetailBlock({ detail, theme }: { detail: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
return (
|
||||
<pre className="thin-scrollbar mt-3 max-h-64 overflow-auto rounded-md p-3 text-[11px] leading-4" style={{ background: theme.toolbar.panel, color: theme.node.muted }}>
|
||||
<pre className="thin-scrollbar mt-3 max-h-64 overflow-auto rounded-md p-3 text-[var(--fs-label)] leading-4" style={{ background: theme.toolbar.panel, color: theme.node.muted }}>
|
||||
{JSON.stringify(detail, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
|
||||
@@ -38,7 +38,7 @@ export function AgentPanelChrome({
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold leading-5">Agent</div>
|
||||
<div className="truncate text-[11px] leading-4" style={{ color: theme.node.muted }}>画布协作</div>
|
||||
<div className="truncate text-[var(--fs-label)] leading-4" style={{ color: theme.node.muted }}>画布协作</div>
|
||||
</div>
|
||||
<AgentModeSwitch value={mode} theme={theme} onChange={onModeChange} />
|
||||
<Tooltip title="收起 Agent">
|
||||
@@ -46,7 +46,7 @@ export function AgentPanelChrome({
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2.5 gap-y-1 px-0.5 text-[11px]" style={{ color: theme.node.muted }}>
|
||||
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2.5 gap-y-1 px-0.5 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
|
||||
<span className="font-medium" style={{ color: theme.node.text }}>{context.nodeCount} 个节点</span>
|
||||
{context.selectedCount ? <span className="inline-flex items-center gap-1"><Focus className="size-3" />选中 {context.selectedCount}</span> : <span>未选择节点</span>}
|
||||
{context.chapterLabel ? <span className="inline-flex min-w-0 items-center gap-1"><BookOpenText className="size-3 shrink-0" /><span className="max-w-32 truncate">{context.chapterLabel}{context.shotLabel ? ` · ${context.shotLabel}` : ""}</span></span> : null}
|
||||
@@ -67,7 +67,7 @@ export function AgentPanelChrome({
|
||||
|
||||
function AgentModeSwitch({ value, theme, onChange }: { value: CanvasAgentMode; theme: CanvasTheme; onChange: (value: CanvasAgentMode) => void }) {
|
||||
return (
|
||||
<div className="inline-flex h-8 shrink-0 items-center rounded-md p-0.5 text-[11px]" style={{ background: theme.spatial.surface }} role="group" aria-label="Agent 运行位置">
|
||||
<div className="inline-flex h-8 shrink-0 items-center rounded-md p-0.5 text-[var(--fs-label)]" style={{ background: theme.spatial.surface }} role="group" aria-label="Agent 运行位置">
|
||||
{(["online", "local"] as const).map((item) => {
|
||||
const active = value === item;
|
||||
const Icon = item === "online" ? Globe2 : Laptop;
|
||||
@@ -95,7 +95,7 @@ export function AgentChatEmptyState({ theme, nodeCount, onSelect }: { theme: Can
|
||||
<div className="mx-auto w-full max-w-[380px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid size-7 place-items-center rounded-md" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}><Bot className="size-3.5" /></span>
|
||||
<span className="text-[11px] font-medium" style={{ color: theme.node.muted }}>{nodeCount} 个节点已就绪</span>
|
||||
<span className="text-[var(--fs-label)] font-medium" style={{ color: theme.node.muted }}>{nodeCount} 个节点已就绪</span>
|
||||
</div>
|
||||
<h2 className="mt-3 text-[19px] font-semibold leading-6" style={{ color: theme.node.text }}>从当前画布开始</h2>
|
||||
<div className="mt-4 grid grid-cols-1 gap-1">
|
||||
|
||||
@@ -143,12 +143,12 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-1 pb-2.5 pt-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-[10px] border" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: theme.accent.primary }}>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--dock-item-radius)] border" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: theme.accent.primary }}>
|
||||
<FolderOpen className="size-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-semibold">素材空间</span>
|
||||
<span className="mt-0.5 block truncate text-[9px]" style={{ color: theme.node.muted }}>拖入画布,或定位已经使用的图片</span>
|
||||
<span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>拖入画布,或定位已经使用的图片</span>
|
||||
</span>
|
||||
</div>
|
||||
<motion.button type="button" whileHover={motionEnabled ? { rotate: -5, scale: 1.05 } : undefined} whileTap={motionEnabled ? { scale: 0.92 } : undefined} className="grid size-7 shrink-0 place-items-center rounded-full border outline-none focus-visible:ring-2" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: theme.node.muted }} onClick={() => setOpen(false)} aria-label="收起素材空间">
|
||||
@@ -156,14 +156,14 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className={cn("relative grid gap-1 rounded-[12px] border p-0.5", showLibrary ? "grid-cols-2" : "grid-cols-1")} style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
|
||||
<div className={cn("relative grid gap-1 rounded-[var(--r-lg)] border p-0.5", showLibrary ? "grid-cols-2" : "grid-cols-1")} style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
|
||||
{showLibrary ? <TrayTabButton active={tab === "library"} label={`素材库 ${assetImages.length}`} theme={theme} onClick={() => setTab("library")} /> : null}
|
||||
<TrayTabButton active={tab === "canvas"} label={`当前画布 ${canvasImages.length}`} theme={theme} onClick={() => setTab("canvas")} />
|
||||
</div>
|
||||
|
||||
<label className="mt-2 flex h-8 items-center gap-1.5 rounded-[11px] border px-2.5 focus-within:ring-2" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
|
||||
<Search className="size-3.5 shrink-0" style={{ color: theme.node.muted }} />
|
||||
<input type="search" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索图片素材..." className="min-w-0 flex-1 bg-transparent text-[10px] outline-none placeholder:opacity-55" aria-label="搜索图片素材" />
|
||||
<input type="search" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索图片素材..." className="min-w-0 flex-1 bg-transparent text-[var(--fs-tiny)] outline-none placeholder:opacity-55" aria-label="搜索图片素材" />
|
||||
{keyword ? <button type="button" className="grid size-6 shrink-0 place-items-center rounded-full opacity-55 hover:opacity-100" onClick={() => setKeyword("")} aria-label="清空搜索"><X className="size-3" /></button> : null}
|
||||
</label>
|
||||
|
||||
@@ -189,7 +189,7 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-1 pt-2.5 text-[10px]" style={{ color: theme.node.muted }}>
|
||||
<div className="flex items-center justify-between px-1 pt-2.5 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
|
||||
<span>{showLibrary && tab === "library" ? "点击插入 · 拖拽定位" : "点击回到节点"}</span>
|
||||
<span className="rounded-full border px-2 py-0.5 tabular-nums" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>{activeItems.length} 项</span>
|
||||
</div>
|
||||
@@ -206,8 +206,8 @@ type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
|
||||
function TrayTabButton({ active, label, theme, onClick }: { active: boolean; label: string; theme: CanvasTheme; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className={cn("relative z-10 h-7 rounded-[9px] px-2 text-[10px] font-semibold outline-none transition-colors focus-visible:ring-2", active ? "" : "opacity-55 hover:opacity-90")} style={{ color: active ? theme.node.text : theme.node.muted }} onClick={onClick}>
|
||||
{active ? <motion.span layoutId="canvas-asset-tray-active-tab" className="absolute inset-0 -z-10 rounded-[9px] border" style={{ background: theme.node.panel, borderColor: theme.toolbar.border, boxShadow: `0 6px 16px ${theme.spatial.shadow}` }} transition={aceternityMotion.spring.dock} /> : null}
|
||||
<button type="button" className={cn("relative z-10 h-7 rounded-[var(--dock-item-radius)] px-2 text-[var(--fs-tiny)] font-semibold outline-none transition-colors focus-visible:ring-2", active ? "" : "opacity-55 hover:opacity-90")} style={{ color: active ? theme.node.text : theme.node.muted }} onClick={onClick}>
|
||||
{active ? <motion.span layoutId="canvas-asset-tray-active-tab" className="absolute inset-0 -z-10 rounded-[var(--dock-item-radius)] border" style={{ background: theme.node.panel, borderColor: theme.toolbar.border, boxShadow: `0 6px 16px ${theme.spatial.shadow}` }} transition={aceternityMotion.spring.dock} /> : null}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
@@ -223,19 +223,19 @@ function AssetTrayRow({ title, imageUrl, storageKey, icon, active = false, dragg
|
||||
whileHover={motionEnabled ? { x: 4, scale: 1.008 } : undefined}
|
||||
whileTap={motionEnabled ? { scale: 0.985 } : undefined}
|
||||
transition={aceternityMotion.spring.dock}
|
||||
className="group grid h-12 w-full grid-cols-[36px_minmax(0,1fr)_auto_24px] items-center gap-1.5 rounded-[12px] border px-1.5 text-left outline-none focus-visible:ring-2"
|
||||
className="group grid h-12 w-full grid-cols-[36px_minmax(0,1fr)_auto_24px] items-center gap-1.5 rounded-[var(--r-lg)] border px-1.5 text-left outline-none focus-visible:ring-2"
|
||||
style={{ background: active ? theme.accent.primarySoft : theme.spatial.surface, borderColor: active ? theme.spatial.glowStrong : theme.toolbar.border, color: active ? theme.accent.primary : theme.node.text }}
|
||||
onClick={onClick}
|
||||
onDragStartCapture={onDragStart}
|
||||
>
|
||||
<span className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[9px] border" style={{ background: theme.node.fill, borderColor: theme.toolbar.border }}>
|
||||
<span className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--dock-item-radius)] border" style={{ background: theme.node.fill, borderColor: theme.toolbar.border }}>
|
||||
{imageUrl ? <img src={imageUrl} alt="" width={36} height={36} className="size-full object-cover" draggable={false} /> : <ImageIcon className="size-3.5 opacity-55" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[10px] font-semibold">{title}</span>
|
||||
<span className="mt-0.5 block text-[8px] opacity-45">{active ? "当前已选择" : draggable ? "拖入画布或点击插入" : "点击定位到画布"}</span>
|
||||
<span className="block truncate text-[var(--fs-tiny)] font-semibold">{title}</span>
|
||||
<span className="mt-0.5 block text-[var(--fs-micro)] opacity-45">{active ? "当前已选择" : draggable ? "拖入画布或点击插入" : "点击定位到画布"}</span>
|
||||
</span>
|
||||
<span className={cn("rounded-full px-1.5 py-0.5 text-[9px] font-semibold", location === "oss" ? "bg-emerald-500/12 text-emerald-700 dark:text-emerald-300" : location === "local" ? "bg-amber-500/12 text-amber-700 dark:text-amber-300" : "bg-black/5 text-stone-400 dark:bg-white/8 dark:text-stone-500")} title={resourceStorageTitle(storageKey)}>
|
||||
<span className={cn("rounded-full px-1.5 py-0.5 text-[var(--fs-micro)] font-semibold", location === "oss" ? "bg-emerald-500/12 text-emerald-700 dark:text-emerald-300" : location === "local" ? "bg-amber-500/12 text-amber-700 dark:text-amber-300" : "bg-black/5 text-stone-400 dark:bg-white/8 dark:text-stone-500")} title={resourceStorageTitle(storageKey)}>
|
||||
{resourceStorageLabel(storageKey)}
|
||||
</span>
|
||||
<span className="grid size-6 place-items-center rounded-full border opacity-45 transition-opacity group-hover:opacity-90" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>{icon}</span>
|
||||
@@ -245,7 +245,7 @@ function AssetTrayRow({ title, imageUrl, storageKey, icon, active = false, dragg
|
||||
|
||||
function TrayEmpty({ text, theme }: { text: string; theme: CanvasTheme }) {
|
||||
return (
|
||||
<div className="grid h-full min-h-[200px] place-items-center rounded-[14px] border border-dashed text-center" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: theme.node.muted }}>
|
||||
<div className="grid h-full min-h-[200px] place-items-center rounded-[var(--dock-radius)] border border-dashed text-center" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: theme.node.muted }}>
|
||||
<span><Images className="mx-auto size-5 opacity-35" /><span className="mt-2 block text-xs opacity-55">{text}</span></span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -795,7 +795,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
|
||||
left={
|
||||
<>
|
||||
<AgentTextModelPicker config={effectiveConfig} value={effectiveConfig.textModel} onChange={(model) => updateConfig("textModel", model)} />
|
||||
{cinematicEntryActive ? <span className="ml-2 inline-flex h-6 items-center rounded-md px-2 text-[10px] font-medium" style={{ background: theme.spatial.surface, color: theme.node.muted }}>影视项目</span> : null}
|
||||
{cinematicEntryActive ? <span className="ml-2 inline-flex h-6 items-center rounded-md px-2 text-[var(--fs-tiny)] font-medium" style={{ background: theme.spatial.surface, color: theme.node.muted }}>影视项目</span> : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -952,13 +952,13 @@ function AssistantHistory({
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{session.id === activeSession?.id ? <span className="shrink-0 text-[10px] font-medium" style={{ color: theme.node.text }}>当前</span> : null}
|
||||
{session.id === activeSession?.id ? <span className="shrink-0 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.text }}>当前</span> : null}
|
||||
<div className="truncate text-sm font-medium leading-5">{session.title}</div>
|
||||
</div>
|
||||
<div className="truncate text-[11px] leading-4 opacity-65">{sessionPreview(session)}</div>
|
||||
<div className="truncate text-[var(--fs-label)] leading-4 opacity-65">{sessionPreview(session)}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="text-[10px] opacity-55">{formatSessionTime(session.updatedAt || session.createdAt)}</span>
|
||||
<span className="text-[var(--fs-tiny)] opacity-55">{formatSessionTime(session.updatedAt || session.createdAt)}</span>
|
||||
<Button size="small" className="!h-6 !px-2" onClick={() => onOpen(session.id)}>
|
||||
进入
|
||||
</Button>
|
||||
@@ -1057,7 +1057,7 @@ function AssistantReferenceChip({ item, label, onRemove }: { item: CanvasAssista
|
||||
{item.dataUrl ? (
|
||||
<span className="relative block size-8 shrink-0">
|
||||
<img src={item.dataUrl} alt="" className="size-8 rounded-lg object-cover" />
|
||||
{label ? <span className="absolute left-0.5 top-0.5 rounded bg-black/60 px-1 py-0.5 text-[8px] font-medium leading-none text-white">{label}</span> : null}
|
||||
{label ? <span className="absolute left-0.5 top-0.5 rounded bg-black/60 px-1 py-0.5 text-[var(--fs-micro)] font-medium leading-none text-white">{label}</span> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="grid size-8 place-items-center rounded-md text-sm font-medium" style={{ background: theme.spatial.surface }}>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function CanvasCharacterReferenceModal({ node, open, onClose }: { node: C
|
||||
<div className="grid h-[min(720px,calc(100dvh-48px))] min-h-0 grid-rows-[minmax(240px,42vh)_minmax(0,1fr)] overflow-hidden bg-background text-foreground md:grid-cols-[minmax(0,1.55fr)_minmax(360px,.85fr)] md:grid-rows-1">
|
||||
<section className="relative min-h-0 overflow-hidden border-b border-border bg-foreground/[.035] md:border-b-0 md:border-r" aria-label="角色三视图">
|
||||
<div className="absolute inset-x-0 top-0 z-10 flex h-14 items-center justify-between px-5 pr-14">
|
||||
<span className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border/80 bg-background/80 px-2.5 text-[11px] font-medium shadow-sm backdrop-blur-xl">
|
||||
<span className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border/80 bg-background/80 px-2.5 text-[var(--fs-label)] font-medium shadow-sm backdrop-blur-xl">
|
||||
<ImageIcon className="size-3.5" />
|
||||
人物三视图
|
||||
</span>
|
||||
@@ -60,7 +60,7 @@ export function CanvasCharacterReferenceModal({ node, open, onClose }: { node: C
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-xl font-semibold leading-7">{name}</h2>
|
||||
<p className="mt-1 text-[11px] text-foreground/45">项目角色引用 · 当前版本</p>
|
||||
<p className="mt-1 text-[var(--fs-label)] text-foreground/45">项目角色引用 · 当前版本</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
@@ -109,7 +109,7 @@ function DetailSection({ icon, title, children }: { icon: ReactNode; title: stri
|
||||
|
||||
function DetailRow({ label, value, emphasis = false }: { label: string; value?: string; emphasis?: boolean }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[76px_minmax(0,1fr)] gap-3 py-3 text-[12px] leading-5">
|
||||
<div className="grid grid-cols-[76px_minmax(0,1fr)] gap-3 py-3 text-[var(--fs-caption)] leading-5">
|
||||
<dt className="text-foreground/42">{label}</dt>
|
||||
<dd className={value ? (emphasis ? "font-medium text-emerald-600 dark:text-emerald-300" : "text-foreground/78") : "text-foreground/28"}>{value || "未设置"}</dd>
|
||||
</div>
|
||||
@@ -118,7 +118,7 @@ function DetailRow({ label, value, emphasis = false }: { label: string; value?:
|
||||
|
||||
function StatusDot({ icon, ready, readyLabel, emptyLabel }: { icon: ReactNode; ready: boolean; readyLabel: string; emptyLabel: string }) {
|
||||
return (
|
||||
<span className={`inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-[10px] font-medium ${ready ? "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : "border-border bg-background/65 text-foreground/42"}`}>
|
||||
<span className={`inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-[var(--fs-tiny)] font-medium ${ready ? "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : "border-border bg-background/65 text-foreground/42"}`}>
|
||||
<span className="[&_svg]:size-3">{icon}</span>
|
||||
{ready ? readyLabel : emptyLabel}
|
||||
</span>
|
||||
|
||||
@@ -9,7 +9,7 @@ export function CanvasCharacterReferenceNodeContent({ node }: { node: CanvasNode
|
||||
return <div className="flex h-full w-full flex-col overflow-hidden bg-background">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-foreground/[.045]">
|
||||
{node.metadata?.characterCoverUrl ? <img src={node.metadata.characterCoverUrl} alt={node.metadata?.characterName || node.title} className="h-full w-full object-contain p-2" draggable={false} /> : <div className="grid h-full place-items-center text-foreground/22"><UserRound className="size-12" /></div>}
|
||||
<span className="absolute left-3 top-3 rounded bg-black/60 px-2 py-1 text-[10px] font-medium text-white">角色引用</span>
|
||||
<span className="absolute left-3 top-3 rounded bg-black/60 px-2 py-1 text-[var(--fs-tiny)] font-medium text-white">角色引用</span>
|
||||
</div>
|
||||
<div className="shrink-0 border-t border-border/70 px-3 py-2.5">
|
||||
<div className="truncate text-sm font-semibold">{node.metadata?.characterName || node.title}</div>
|
||||
@@ -22,5 +22,5 @@ export function CanvasCharacterReferenceNodeContent({ node }: { node: CanvasNode
|
||||
}
|
||||
|
||||
function State({ icon, ready, label }: { icon: ReactNode; ready: boolean; label: string }) {
|
||||
return <span className={`inline-flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-[10px] ${ready ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : "bg-foreground/[.055] text-foreground/45"}`}>{icon}<span className="truncate">{label}</span></span>;
|
||||
return <span className={`inline-flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-[var(--fs-tiny)] ${ready ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : "bg-foreground/[.055] text-foreground/45"}`}>{icon}<span className="truncate">{label}</span></span>;
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<div className="shrink-0 text-xs font-semibold">{simpleMode ? "快速生成" : "组装提示词"}</div>
|
||||
<div className="truncate text-[11px] opacity-55">{simpleMode ? "已连接素材会自动带入" : "@ 引用已连接素材或已激活技能,发送前自动组装"}</div>
|
||||
<div className="truncate text-[var(--fs-label)] opacity-55">{simpleMode ? "已连接素材会自动带入" : "@ 引用已连接素材或已激活技能,发送前自动组装"}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{simpleMode ? null : <CanvasPresetPicker mode={generationMode || "image"} skillReferences={skillReferences} open={presetOpen} onOpenChange={setPresetOpen} onSelect={insertPreset} />}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="shrink-0 text-sm font-semibold">{simpleMode ? "快速生成" : "生成配置"}</div>
|
||||
{simpleMode ? <span className="rounded-md px-2 py-1 text-[10px]" style={{ background: theme.node.fill, color: theme.node.muted }}>自动配置</span> : <div className="cursor-default" onMouseDown={(event) => event.stopPropagation()}>
|
||||
{simpleMode ? <span className="rounded-md px-2 py-1 text-[var(--fs-tiny)]" style={{ background: theme.node.fill, color: theme.node.muted }}>自动配置</span> : <div className="cursor-default" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<Segmented
|
||||
size="small"
|
||||
className="canvas-config-mode !rounded-md !p-0.5"
|
||||
@@ -110,7 +110,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
|
||||
<InputChip label="参考视频" value={`${inputSummary.videoCount} 个`} style={chipStyle} />
|
||||
<InputChip label="参考音频" value={`${inputSummary.audioCount} 个`} style={chipStyle} />
|
||||
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onMouseDown={(event) => event.stopPropagation()} onClick={onComposerToggle}>
|
||||
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[var(--fs-label)]" style={chipStyle} onMouseDown={(event) => event.stopPropagation()} onClick={onComposerToggle}>
|
||||
{simpleMode ? <MessageSquare className="size-3.5" /> : <Settings2 className="size-3.5" />}
|
||||
{simpleMode ? "编辑生成内容" : "组装提示词"}
|
||||
</button>
|
||||
@@ -137,7 +137,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
) : null}
|
||||
|
||||
{simpleMode ? (
|
||||
<div className="mb-2 rounded-lg px-2 py-2 text-[11px]" style={{ background: theme.node.fill, color: theme.node.muted }}>将使用当前默认模型与生成参数</div>
|
||||
<div className="mb-2 rounded-lg px-2 py-2 text-[var(--fs-label)]" style={{ background: theme.node.fill, color: theme.node.muted }}>将使用当前默认模型与生成参数</div>
|
||||
) : (
|
||||
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "image" || mode === "video" || mode === "audio" ? "grid-cols-[minmax(0,1fr)_148px]" : "grid-cols-1"}`} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth />
|
||||
@@ -197,7 +197,7 @@ function defaultVideoOperation(inputSummary: CanvasConfigNodePanelProps["inputSu
|
||||
|
||||
function InputChip({ label, value, style }: { label: string; value: string; style: CSSProperties }) {
|
||||
return (
|
||||
<div className="inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[11px]" style={style}>
|
||||
<div className="inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[var(--fs-label)]" style={style}>
|
||||
<span>{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
|
||||
@@ -274,7 +274,7 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, re
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, x: left > parentPosition.left ? -4 : 4, scale: 0.98 }}
|
||||
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
|
||||
className="aceternity-floating-panel fixed z-[181] w-[260px] origin-top overflow-hidden rounded-[14px] border p-2 backdrop-blur-2xl"
|
||||
className="aceternity-floating-panel fixed z-[181] w-[260px] origin-top overflow-hidden rounded-[var(--dock-radius)] border p-2 backdrop-blur-2xl"
|
||||
style={{ left, top: parentPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 30px 90px ${theme.spatial.shadow}` }}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
@@ -296,13 +296,13 @@ function MenuHeader({ title, description, onBack }: { title: string; description
|
||||
return (
|
||||
<div className="mb-0.5 flex items-start gap-1 px-1.5 py-1.5">
|
||||
{onBack ? <button type="button" onClick={onBack} className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md outline-none hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/8" aria-label="返回媒体操作"><ArrowLeft className="size-3.5" /></button> : null}
|
||||
<span className="min-w-0"><span className="block truncate text-xs font-semibold">{title}</span>{description && description !== title ? <span className="mt-0.5 block truncate text-[9px]" style={{ color: theme.node.muted }}>{description}</span> : null}</span>
|
||||
<span className="min-w-0"><span className="block truncate text-xs font-semibold">{title}</span>{description && description !== title ? <span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{description}</span> : null}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSection({ label }: { label: string }) {
|
||||
return <div className="px-2 pb-1 pt-1.5 text-[9px] font-medium opacity-45">{label}</div>;
|
||||
return <div className="px-2 pb-1 pt-1.5 text-[var(--fs-micro)] font-medium opacity-45">{label}</div>;
|
||||
}
|
||||
|
||||
function MenuButton({ icon, label, detail, shortcut, badge, chevron = false, active = false, disabled = false, danger = false, onClick }: { icon: ReactNode; label: string; detail?: string; shortcut?: string; badge?: string; chevron?: boolean; active?: boolean; disabled?: boolean; danger?: boolean; onClick?: () => void }) {
|
||||
@@ -317,8 +317,8 @@ function MenuButton({ icon, label, detail, shortcut, badge, chevron = false, act
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="canvas-menu-item-icon grid size-7 shrink-0 place-items-center rounded-md border opacity-75 group-hover:opacity-100 [&_svg]:size-3.5" style={{ background: danger ? `${theme.accent.danger}12` : theme.spatial.surface, borderColor: danger ? `${theme.accent.danger}33` : theme.toolbar.border, color: danger ? theme.accent.danger : theme.node.text }}>{icon}</span>
|
||||
<span className="min-w-0 flex-1"><span className="flex items-center gap-1 text-xs font-medium"><span className="truncate">{label}</span>{badge ? <span className="rounded-full border px-1 py-0.5 text-[7px] font-bold" style={{ background: theme.toolbar.activeBg, borderColor: theme.toolbar.border, color: theme.node.muted }}>{badge}</span> : null}</span>{detail ? <span className="mt-0.5 block truncate text-[9px]" style={{ color: theme.node.muted }}>{detail}</span> : null}</span>
|
||||
{shortcut ? <span className="shrink-0 text-[9px] opacity-38">{shortcut}</span> : null}
|
||||
<span className="min-w-0 flex-1"><span className="flex items-center gap-1 text-xs font-medium"><span className="truncate">{label}</span>{badge ? <span className="rounded-full border px-1 py-0.5 text-[7px] font-bold" style={{ background: theme.toolbar.activeBg, borderColor: theme.toolbar.border, color: theme.node.muted }}>{badge}</span> : null}</span>{detail ? <span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{detail}</span> : null}</span>
|
||||
{shortcut ? <span className="shrink-0 text-[var(--fs-micro)] opacity-38">{shortcut}</span> : null}
|
||||
{chevron ? <ChevronRight className="size-3 shrink-0 opacity-45 transition-transform group-hover:translate-x-0.5" /> : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ export function CanvasCreateCommandGrid({ commands, variant = "node" }: { comman
|
||||
transition={aceternityMotion.spring.dock}
|
||||
className={cn(
|
||||
"group relative min-w-0 border border-black/10 bg-white/70 text-center outline-none transition-colors hover:border-black/20 hover:bg-black/5 focus-visible:ring-2 dark:border-white/10 dark:bg-white/[.04] dark:hover:border-white/20 dark:hover:bg-white/8",
|
||||
variant === "node" ? "flex h-12 flex-col items-center justify-center gap-1 rounded-[10px]" : "flex h-9 items-center justify-center gap-1.5 rounded-[9px] px-2",
|
||||
variant === "node" ? "flex h-12 flex-col items-center justify-center gap-1 rounded-[var(--dock-item-radius)]" : "flex h-9 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2",
|
||||
)}
|
||||
style={{ color: theme.node.text, "--tw-ring-color": theme.node.muted } as CSSProperties}
|
||||
title={command.label}
|
||||
@@ -36,7 +36,7 @@ export function CanvasCreateCommandGrid({ commands, variant = "node" }: { comman
|
||||
onClick={command.onClick}
|
||||
>
|
||||
<span className="grid size-5 shrink-0 place-items-center opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-3.5">{command.icon}</span>
|
||||
<span className="max-w-full truncate text-[9px] font-semibold leading-none">{command.label}</span>
|
||||
<span className="max-w-full truncate text-[var(--fs-micro)] font-semibold leading-none">{command.label}</span>
|
||||
{command.badge ? <span className="absolute right-1 top-1 rounded-full border px-1 py-0.5 text-[6px] font-bold leading-none" style={{ background: theme.toolbar.activeBg, borderColor: theme.toolbar.border, color: theme.node.muted }}>{command.badge}</span> : null}
|
||||
</motion.button>
|
||||
))}
|
||||
|
||||
@@ -83,7 +83,7 @@ export function CanvasDrawingEditorModal({ open, projectId, node, onClose, onSav
|
||||
<Modal open={open} onCancel={() => void handleClose()} footer={null} closable={false} destroyOnHidden width="100vw" centered styles={{ body: { padding: 0 }, container: { padding: 0, overflow: "hidden" } }} className="canvas-drawing-editor-modal">
|
||||
<div className="flex h-[min(92dvh,980px)] flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4" style={{ background: "var(--background)", borderColor: "var(--border)" }}>
|
||||
<div className="flex min-w-0 items-center gap-2"><Maximize2 className="size-4 opacity-55" /><span className="truncate text-sm font-semibold">{node?.title || "绘图"}</span><span className="text-[11px] opacity-45">{drawingEngineLabel(engine)} · {ready ? "已加载" : "正在加载"}</span></div>
|
||||
<div className="flex min-w-0 items-center gap-2"><Maximize2 className="size-4 opacity-55" /><span className="truncate text-sm font-semibold">{node?.title || "绘图"}</span><span className="text-[var(--fs-label)] opacity-45">{drawingEngineLabel(engine)} · {ready ? "已加载" : "正在加载"}</span></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-md border px-3 text-xs font-medium transition hover:bg-black/5 disabled:opacity-45 dark:hover:bg-white/10" disabled={!ready || saving || unavailable} onClick={() => void handleSave()}><Check className="size-3.5" />{saving ? "保存中" : "保存绘图"}</button>
|
||||
<button type="button" className="grid size-8 place-items-center rounded-md border transition hover:bg-black/5 dark:hover:bg-white/10" aria-label="关闭绘图编辑器" onClick={() => void handleClose()}><X className="size-4" /></button>
|
||||
|
||||
@@ -207,7 +207,7 @@ function FaceSelectionOverlay({ node, viewport, containerRef, imageWidth, imageH
|
||||
<div
|
||||
ref={overlayRef}
|
||||
data-canvas-no-zoom
|
||||
className={`absolute z-[105] overflow-hidden rounded-[16px] ${status === "manual" ? "cursor-crosshair touch-none" : "pointer-events-none"}`}
|
||||
className={`absolute z-[105] overflow-hidden rounded-[var(--r-2xl)] ${status === "manual" ? "cursor-crosshair touch-none" : "pointer-events-none"}`}
|
||||
style={{ left: 0, top: 0, width: node.width * viewport.k, height: node.height * viewport.k }}
|
||||
onPointerDown={status === "manual" ? (event) => {
|
||||
event.preventDefault();
|
||||
@@ -263,7 +263,7 @@ function FaceSelectionOverlay({ node, viewport, containerRef, imageWidth, imageH
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => { event.stopPropagation(); onFaceSelect(face); }}
|
||||
>
|
||||
{selected ? <span className="absolute -top-2.5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full px-1.5 py-0.5 text-[8px] font-semibold text-white" style={{ background: active ? theme.accent.primary : "rgba(20,20,22,.82)" }}>{selected.name}</span> : null}
|
||||
{selected ? <span className="absolute -top-2.5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full px-1.5 py-0.5 text-[var(--fs-micro)] font-semibold text-white" style={{ background: active ? theme.accent.primary : "rgba(20,20,22,.82)" }}>{selected.name}</span> : null}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
@@ -295,8 +295,8 @@ function SelectionToolbar({ node, viewport, containerRef, status, faceCount, err
|
||||
<button type="button" aria-label="关闭情绪调节" className="grid size-8 shrink-0 place-items-center rounded-full hover:bg-black/5 dark:hover:bg-white/10" onClick={onClose}><X className="size-4" /></button>
|
||||
<span className="mx-2 h-5 w-px" style={{ background: theme.toolbar.border }} />
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-full" style={{ background: theme.toolbar.itemHover }}>{status === "detecting" ? <LoaderCircle className="size-4 animate-spin" /> : <ScanFace className="size-4" />}</span>
|
||||
<span className="min-w-0 flex-1 truncate px-2 text-[11px] font-medium leading-none">{label}</span>
|
||||
{status !== "detecting" ? <button type="button" className="flex h-8 shrink-0 items-center gap-1.5 rounded-[9px] px-2 text-[11px] font-medium leading-none transition hover:bg-black/5 dark:hover:bg-white/10" onClick={onManualSelect}><SquareDashedMousePointer className="size-3.5" />手动框选</button> : null}
|
||||
<span className="min-w-0 flex-1 truncate px-2 text-[var(--fs-label)] font-medium leading-none">{label}</span>
|
||||
{status !== "detecting" ? <button type="button" className="flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-[var(--fs-label)] font-medium leading-none transition hover:bg-black/5 dark:hover:bg-white/10" onClick={onManualSelect}><SquareDashedMousePointer className="size-3.5" />手动框选</button> : null}
|
||||
</div>
|
||||
</SpotlightSurface>
|
||||
);
|
||||
|
||||
@@ -15,15 +15,15 @@ export function CanvasFileDropOverlay({ active, theme }: { active: boolean; them
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: aceternityMotion.duration.state }}
|
||||
className="pointer-events-none absolute inset-3 z-[110] grid place-items-center overflow-hidden rounded-[28px] border backdrop-blur-2xl"
|
||||
className="pointer-events-none absolute inset-3 z-[var(--z-modal-overlay)] grid place-items-center overflow-hidden rounded-[var(--r-5xl)] border backdrop-blur-2xl"
|
||||
style={{ background: theme.spatial.dropzone, borderColor: theme.spatial.glowStrong, color: theme.node.text, boxShadow: `inset 0 0 0 1px ${theme.spatial.glow}, 0 30px 100px rgba(0,0,0,.22)` }}
|
||||
>
|
||||
<div className="aceternity-drop-grid absolute inset-0 opacity-70" style={{ color: theme.spatial.glowStrong }} />
|
||||
<motion.div initial={{ y: 20, scale: 0.94 }} animate={{ y: 0, scale: 1 }} exit={{ y: 12, scale: 0.96 }} transition={aceternityMotion.spring.panel} className="relative flex w-[min(480px,calc(100vw-48px))] flex-col items-center px-8 py-10 text-center">
|
||||
<div className="relative mb-8 h-32 w-44">
|
||||
<motion.div animate={{ x: -22, y: 10, rotate: -8 }} className="absolute inset-0 rounded-[20px] border backdrop-blur-xl" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }} />
|
||||
<motion.div animate={{ x: 22, y: 10, rotate: 8 }} className="absolute inset-0 rounded-[20px] border backdrop-blur-xl" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }} />
|
||||
<motion.div animate={{ y: -10, scale: 1.04 }} transition={aceternityMotion.spring.panel} className="absolute inset-0 grid place-items-center rounded-[20px] border" style={{ background: theme.node.panel, borderColor: theme.spatial.glowStrong, boxShadow: `0 24px 60px ${theme.spatial.shadow}` }}>
|
||||
<motion.div animate={{ x: -22, y: 10, rotate: -8 }} className="absolute inset-0 rounded-[var(--r-3xl)] border backdrop-blur-xl" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }} />
|
||||
<motion.div animate={{ x: 22, y: 10, rotate: 8 }} className="absolute inset-0 rounded-[var(--r-3xl)] border backdrop-blur-xl" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }} />
|
||||
<motion.div animate={{ y: -10, scale: 1.04 }} transition={aceternityMotion.spring.panel} className="absolute inset-0 grid place-items-center rounded-[var(--r-3xl)] border" style={{ background: theme.node.panel, borderColor: theme.spatial.glowStrong, boxShadow: `0 24px 60px ${theme.spatial.shadow}` }}>
|
||||
<UploadCloud className="size-10" style={{ color: theme.accent.primary }} />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -158,7 +158,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
|
||||
>
|
||||
<CometCard
|
||||
containerClassName="h-full w-full"
|
||||
className="canvas-frame-shell overflow-hidden rounded-[14px] border"
|
||||
className="canvas-frame-shell overflow-hidden rounded-[var(--dock-radius)] border"
|
||||
rotateDepth={2.4}
|
||||
translateDepth={2}
|
||||
disabled={Boolean(dragOffset) || !collapsed || editing || scale < 0.32}
|
||||
@@ -216,7 +216,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
|
||||
{data.title}
|
||||
</button>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 pr-1 text-[11px] tabular-nums" style={{ color: theme.node.muted }}>
|
||||
<span className="ml-auto shrink-0 pr-1 text-[var(--fs-label)] tabular-nums" style={{ color: theme.node.muted }}>
|
||||
{childNodes.length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -272,7 +272,7 @@ function FramePreview({ nodes, frame, theme }: { nodes: CanvasNodeData[]; frame:
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="grid h-full place-items-center text-[11px]" style={{ color: theme.node.faint }}>空背板</div>
|
||||
<div className="grid h-full place-items-center text-[var(--fs-label)]" style={{ color: theme.node.faint }}>空背板</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -57,7 +57,7 @@ export function ImageToolSettingsModal({ open, tools, selectedIds, showLabels, o
|
||||
>
|
||||
<div className="flex h-11 items-center justify-between gap-3 border-b px-4" style={{ borderColor: theme.toolbar.border }}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-[8px]" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}><Type className="size-3.5" /></span>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-md)]" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}><Type className="size-3.5" /></span>
|
||||
<span className="flex h-5 items-center text-xs font-medium leading-none">显示功能名</span>
|
||||
</span>
|
||||
<Switch size="small" checked={showLabels} onChange={onShowLabelsChange} aria-label="显示节点 Dock 功能名" />
|
||||
@@ -69,13 +69,13 @@ export function ImageToolSettingsModal({ open, tools, selectedIds, showLabels, o
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-2 flex h-5 items-center justify-between"><span className="text-xs font-semibold">快捷工具</span><Tag className="m-0 leading-5 text-[10px]" style={{ background: theme.accent.primarySoft, borderColor: theme.spatial.glowStrong, color: theme.accent.primary }}>{selectedTools.length}/{maxSelected}</Tag></div>
|
||||
<div className="mb-2 flex h-5 items-center justify-between"><span className="text-xs font-semibold">快捷工具</span><Tag className="m-0 leading-5 text-[var(--fs-tiny)]" style={{ background: theme.accent.primarySoft, borderColor: theme.spatial.glowStrong, color: theme.accent.primary }}>{selectedTools.length}/{maxSelected}</Tag></div>
|
||||
<Checkbox.Group value={selectedIds} className="grid w-full grid-cols-2 gap-1 sm:grid-cols-4" onChange={(values) => updateSelectedTools(values as ImageQuickToolId[])}>
|
||||
{tools.map((tool) => (
|
||||
<label key={tool.id} className="flex h-8 min-w-0 cursor-pointer items-center gap-1 rounded-[7px] border px-1.5 transition-colors" style={{ background: selected.has(tool.id) ? theme.accent.primarySoft : "transparent", borderColor: selected.has(tool.id) ? theme.accent.primary : theme.toolbar.border, color: selected.has(tool.id) ? theme.accent.primary : theme.node.text }}>
|
||||
<Checkbox className="canvas-image-tool-checkbox shrink-0" style={{ "--tool-accent": theme.accent.primary } as CSSProperties} value={tool.id} disabled={!selected.has(tool.id) && selectedTools.length >= maxSelected} />
|
||||
<span className="grid size-5 shrink-0 place-items-center rounded-[5px] [&_svg]:size-3" style={{ background: selected.has(tool.id) ? theme.accent.primary : theme.toolbar.itemHover, color: selected.has(tool.id) ? "#ffffff" : theme.node.muted }}>{tool.icon}</span>
|
||||
<span className="min-w-0 truncate text-[10px] font-medium leading-none">{tool.label}</span>
|
||||
<span className="min-w-0 truncate text-[var(--fs-tiny)] font-medium leading-none">{tool.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
|
||||
@@ -562,7 +562,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
|
||||
onSubmit={sendPrompt}
|
||||
onAddFiles={addAttachments}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
left={attachments.length ? <span className="text-[11px]" style={{ color: theme.node.muted }}>{formatBytes(attachmentPayloadBytes(attachments))} / 30MB</span> : null}
|
||||
left={attachments.length ? <span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>{formatBytes(attachmentPayloadBytes(attachments))} / 30MB</span> : null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -662,7 +662,7 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-sm font-medium leading-5">网页连接</span>
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] leading-4" style={{ background: theme.node.fill, color: statusColor }}>
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[var(--fs-label)] leading-4" style={{ background: theme.node.fill, color: statusColor }}>
|
||||
<span className="size-1.5 shrink-0 rounded-full" style={{ background: statusColor }} />
|
||||
<span className="truncate">{statusText}</span>
|
||||
</span>
|
||||
@@ -734,13 +734,13 @@ function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loadi
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{active ? <span className="shrink-0 text-[10px] font-medium" style={{ color: theme.node.text }}>当前</span> : null}
|
||||
{active ? <span className="shrink-0 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.text }}>当前</span> : null}
|
||||
<div className="truncate text-sm font-medium leading-5">{thread.name || thread.preview || "未命名对话"}</div>
|
||||
</div>
|
||||
<div className="truncate text-[11px] leading-4 opacity-65">{thread.preview || thread.id}</div>
|
||||
<div className="truncate text-[var(--fs-label)] leading-4 opacity-65">{thread.preview || thread.id}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="text-[10px] opacity-55">{formatThreadTime(thread.updatedAt || thread.createdAt)}</span>
|
||||
<span className="text-[var(--fs-tiny)] opacity-55">{formatThreadTime(thread.updatedAt || thread.createdAt)}</span>
|
||||
<Button size="small" className="!h-6 !px-2" disabled={loading} onClick={() => onResumeThread(thread.id)}>
|
||||
进入
|
||||
</Button>
|
||||
|
||||
@@ -54,13 +54,13 @@ export function CanvasNodeAnglePanel({ dataUrl, onClose, onConfirm }: { dataUrl:
|
||||
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.panel}
|
||||
className="aceternity-floating-panel w-[580px] max-w-full overflow-hidden rounded-[16px] border backdrop-blur-2xl"
|
||||
className="aceternity-floating-panel w-[580px] max-w-full overflow-hidden rounded-[var(--r-2xl)] border backdrop-blur-2xl"
|
||||
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 28px 80px ${theme.spatial.shadow}` }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex h-11 items-center gap-2 border-b px-2.5" style={{ borderColor: theme.toolbar.border }}>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-[8px]" style={{ background: theme.toolbar.itemHover }}><Camera className="size-3.5" /></span>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-md)]" style={{ background: theme.toolbar.itemHover }}><Camera className="size-3.5" /></span>
|
||||
<span className="text-xs font-semibold">多角度编辑器</span>
|
||||
<span className="min-w-0 flex-1" />
|
||||
<Tooltip title="关闭"><button type="button" aria-label="关闭多角度编辑器" className="grid size-7 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" onClick={onClose}><X className="size-3.5" /></button></Tooltip>
|
||||
@@ -70,40 +70,40 @@ export function CanvasNodeAnglePanel({ dataUrl, onClose, onConfirm }: { dataUrl:
|
||||
{presets.map((preset) => <AnglePresetButton key={preset.label} active={activePreset?.label === preset.label} label={preset.label} theme={theme} onClick={() => setParams((current) => ({ ...current, horizontalAngle: preset.horizontalAngle, pitchAngle: preset.pitchAngle }))} />)}
|
||||
</div>
|
||||
<div className="grid h-[208px] grid-cols-[188px_minmax(0,1fr)] gap-2.5 p-2.5">
|
||||
<div className="relative grid size-[188px] cursor-grab place-items-center overflow-hidden rounded-[12px] border active:cursor-grabbing" style={{ borderColor: theme.toolbar.border, background: theme.spatial.surface }} onPointerDown={startCameraDrag} onPointerMove={moveCamera} onPointerUp={(event) => { dragRef.current = null; event.currentTarget.releasePointerCapture(event.pointerId); }} onPointerCancel={() => { dragRef.current = null; }}>
|
||||
<div className="relative grid size-[188px] cursor-grab place-items-center overflow-hidden rounded-[var(--r-lg)] border active:cursor-grabbing" style={{ borderColor: theme.toolbar.border, background: theme.spatial.surface }} onPointerDown={startCameraDrag} onPointerMove={moveCamera} onPointerUp={(event) => { dragRef.current = null; event.currentTarget.releasePointerCapture(event.pointerId); }} onPointerCancel={() => { dragRef.current = null; }}>
|
||||
<GlobeGrid color={theme.node.muted} />
|
||||
<img src={dataUrl} alt="角度参考" className="relative z-10 size-[72px] rounded-[8px] object-cover shadow-lg" draggable={false} style={{ transform: previewTransform(params) }} />
|
||||
<img src={dataUrl} alt="角度参考" className="relative z-10 size-[72px] rounded-[var(--r-md)] object-cover shadow-lg" draggable={false} style={{ transform: previewTransform(params) }} />
|
||||
<div className="pointer-events-none absolute z-20 grid size-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border shadow" style={{ left: `${marker.x}%`, top: `${marker.y}%`, background: theme.toolbar.panel, borderColor: theme.node.activeStroke, color: theme.node.activeStroke }}><Camera className="size-3.5" /></div>
|
||||
<span className="pointer-events-none absolute bottom-2 text-[9px]" style={{ color: theme.node.muted }}>拖动调整摄影机</span>
|
||||
<span className="pointer-events-none absolute bottom-2 text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>拖动调整摄影机</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col justify-center gap-2 rounded-[12px] border px-2.5 py-2" style={{ background: theme.toolbar.itemHover, borderColor: theme.toolbar.border }}>
|
||||
<div className="flex min-w-0 flex-col justify-center gap-2 rounded-[var(--r-lg)] border px-2.5 py-2" style={{ background: theme.toolbar.itemHover, borderColor: theme.toolbar.border }}>
|
||||
<AngleSlider label="水平环绕" value={params.horizontalAngle} min={-180} max={180} suffix="°" onChange={(value) => update("horizontalAngle", value)} />
|
||||
<AngleSlider label="垂直俯仰" value={params.pitchAngle} min={-75} max={75} suffix="°" onChange={(value) => update("pitchAngle", value)} />
|
||||
<AngleSlider label="景别缩放" value={params.cameraDistance} min={1} max={10} step={0.1} suffix={distanceLabel(params.cameraDistance)} onChange={(value) => update("cameraDistance", value)} />
|
||||
<div className="grid h-8 grid-cols-[62px_minmax(0,1fr)] items-center gap-2">
|
||||
<span className="text-[10px] font-medium" style={{ color: theme.node.muted }}>镜头</span>
|
||||
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>镜头</span>
|
||||
<Segmented block size="small" value={params.wideAngle ? "wide" : "standard"} options={[{ label: "标准", value: "standard" }, { label: "广角", value: "wide" }]} onChange={(value) => update("wideAngle", value === "wide")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-11 items-center gap-2 border-t px-3" style={{ borderColor: theme.toolbar.border }}>
|
||||
<span className="text-[10px]" style={{ color: theme.node.muted }}>当前视角</span>
|
||||
<span className="text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>当前视角</span>
|
||||
<span className="text-xs font-semibold">{activePreset?.label || "自定义"}</span>
|
||||
<span className="text-[10px]" style={{ color: theme.node.muted }}>{params.horizontalAngle}° / {params.pitchAngle}° · {params.cameraDistance.toFixed(1)} {distanceLabel(params.cameraDistance)}</span>
|
||||
<span className="text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{params.horizontalAngle}° / {params.pitchAngle}° · {params.cameraDistance.toFixed(1)} {distanceLabel(params.cameraDistance)}</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" className="flex h-8 items-center gap-1.5 rounded-[9px] px-2 text-[11px] font-medium transition hover:bg-black/5 dark:hover:bg-white/10" onClick={() => setParams(defaultParams)}><RotateCcw className="size-3.5" />重置</button>
|
||||
<motion.button type="button" whileHover={reducedMotion ? undefined : { y: -1 }} whileTap={reducedMotion ? undefined : { scale: 0.97 }} className="flex h-8 items-center gap-1.5 rounded-[9px] px-3 text-[11px] font-semibold" style={{ background: theme.node.activeStroke, color: theme.node.panel }} onClick={() => onConfirm(params)}><Send className="size-3.5" />生成新角度</motion.button>
|
||||
<button type="button" className="flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-[var(--fs-label)] font-medium transition hover:bg-black/5 dark:hover:bg-white/10" onClick={() => setParams(defaultParams)}><RotateCcw className="size-3.5" />重置</button>
|
||||
<motion.button type="button" whileHover={reducedMotion ? undefined : { y: -1 }} whileTap={reducedMotion ? undefined : { scale: 0.97 }} className="flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-3 text-[var(--fs-label)] font-semibold" style={{ background: theme.node.activeStroke, color: theme.node.panel }} onClick={() => onConfirm(params)}><Send className="size-3.5" />生成新角度</motion.button>
|
||||
</div>
|
||||
</SpotlightSurface>
|
||||
);
|
||||
}
|
||||
|
||||
function AngleSlider({ label, value, min, max, step = 1, suffix, onChange }: { label: string; value: number; min: number; max: number; step?: number; suffix: string; onChange: (value: number) => void }) {
|
||||
return <div className="grid h-8 grid-cols-[62px_minmax(0,1fr)_60px] items-center gap-2"><span className="text-[10px] font-medium opacity-60">{label}</span><Slider min={min} max={max} step={step} value={value} onChange={onChange} /><span className="text-right text-[10px] font-semibold">{Number.isInteger(value) ? value : value.toFixed(1)}{suffix.startsWith("°") ? suffix : ""}{!suffix.startsWith("°") ? ` ${suffix}` : ""}</span></div>;
|
||||
return <div className="grid h-8 grid-cols-[62px_minmax(0,1fr)_60px] items-center gap-2"><span className="text-[var(--fs-tiny)] font-medium opacity-60">{label}</span><Slider min={min} max={max} step={step} value={value} onChange={onChange} /><span className="text-right text-[var(--fs-tiny)] font-semibold">{Number.isInteger(value) ? value : value.toFixed(1)}{suffix.startsWith("°") ? suffix : ""}{!suffix.startsWith("°") ? ` ${suffix}` : ""}</span></div>;
|
||||
}
|
||||
|
||||
function AnglePresetButton({ active, label, theme, onClick }: { active: boolean; label: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onClick?: () => void }) {
|
||||
return <button type="button" aria-pressed={active} className="h-7 shrink-0 rounded-[8px] border px-2.5 text-[10px] font-medium transition hover:bg-black/5 dark:hover:bg-white/10" style={{ background: active ? theme.toolbar.activeBg : theme.spatial.surface, borderColor: active ? theme.spatial.glowStrong : theme.toolbar.border }} onClick={onClick}>{label}</button>;
|
||||
return <button type="button" aria-pressed={active} className="h-7 shrink-0 rounded-[var(--r-md)] border px-2.5 text-[var(--fs-tiny)] font-medium transition hover:bg-black/5 dark:hover:bg-white/10" style={{ background: active ? theme.toolbar.activeBg : theme.spatial.surface, borderColor: active ? theme.spatial.glowStrong : theme.toolbar.border }} onClick={onClick}>{label}</button>;
|
||||
}
|
||||
|
||||
function GlobeGrid({ color }: { color: string }) {
|
||||
|
||||
@@ -64,7 +64,7 @@ export function CanvasNodeEmotionPanel({ dataUrl, imageWidth, imageHeight, chara
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 5, scale: 0.98 }}
|
||||
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.panel}
|
||||
className="aceternity-floating-panel w-[580px] max-w-full overflow-hidden rounded-[16px] border backdrop-blur-2xl"
|
||||
className="aceternity-floating-panel w-[580px] max-w-full overflow-hidden rounded-[var(--r-2xl)] border backdrop-blur-2xl"
|
||||
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 28px 80px ${theme.spatial.shadow}` }}
|
||||
>
|
||||
<div className="flex h-11 items-center gap-1.5 border-b px-2.5" style={{ borderColor: theme.toolbar.border }}>
|
||||
@@ -75,16 +75,16 @@ export function CanvasNodeEmotionPanel({ dataUrl, imageWidth, imageHeight, chara
|
||||
type="button"
|
||||
layout
|
||||
whileTap={reducedMotion ? undefined : { scale: 0.96 }}
|
||||
className="relative flex h-8 shrink-0 items-center gap-1.5 rounded-[9px] border px-1.5 pr-2 text-[11px] font-medium outline-none"
|
||||
className="relative flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--dock-item-radius)] border px-1.5 pr-2 text-[var(--fs-label)] font-medium outline-none"
|
||||
style={{ background: activeCharacterId === character.id ? theme.toolbar.activeBg : theme.spatial.surface, borderColor: activeCharacterId === character.id ? theme.spatial.glowStrong : theme.toolbar.border }}
|
||||
onClick={() => onSelectCharacter(character.id)}
|
||||
>
|
||||
{activeCharacterId === character.id ? <motion.span layoutId="emotion-active-character" className="absolute inset-0 -z-10 rounded-[8px]" style={{ boxShadow: `inset 0 0 0 1px ${theme.accent.primarySoft}` }} transition={aceternityMotion.spring.dock} /> : null}
|
||||
{activeCharacterId === character.id ? <motion.span layoutId="emotion-active-character" className="absolute inset-0 -z-10 rounded-[var(--r-md)]" style={{ boxShadow: `inset 0 0 0 1px ${theme.accent.primarySoft}` }} transition={aceternityMotion.spring.dock} /> : null}
|
||||
<FaceThumbnail dataUrl={dataUrl} imageWidth={imageWidth} imageHeight={imageHeight} box={character.faceBox} />
|
||||
<span>{character.name}</span>
|
||||
</motion.button>
|
||||
))}
|
||||
<button type="button" className="flex h-8 shrink-0 items-center gap-1.5 rounded-[9px] border px-2 text-[11px] font-medium opacity-70 transition hover:opacity-100" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }} onClick={onManualSelect}>
|
||||
<button type="button" className="flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--dock-item-radius)] border px-2 text-[var(--fs-label)] font-medium opacity-70 transition hover:opacity-100" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }} onClick={onManualSelect}>
|
||||
<ScanFace className="size-3.5" />手动框选
|
||||
</button>
|
||||
</div>
|
||||
@@ -97,17 +97,17 @@ export function CanvasNodeEmotionPanel({ dataUrl, imageWidth, imageHeight, chara
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-11 items-center gap-2 border-t px-3" style={{ borderColor: theme.toolbar.border }}>
|
||||
<span className="text-[10px]" style={{ color: theme.node.muted }}>情绪定位</span>
|
||||
<span className="text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>情绪定位</span>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span key={preset.id} initial={reducedMotion ? false : { opacity: 0, y: 4, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={reducedMotion ? undefined : { opacity: 0, y: -3, filter: "blur(3px)" }} transition={{ duration: reducedMotion ? 0 : 0.18 }} className="text-xs font-semibold">{preset.label}</motion.span>
|
||||
</AnimatePresence>
|
||||
{error ? <span className="min-w-0 flex-1 truncate text-right text-[10px]" style={{ color: theme.accent.danger }} title={error}>{error}</span> : <span className="flex-1" />}
|
||||
{error ? <span className="min-w-0 flex-1 truncate text-right text-[var(--fs-tiny)]" style={{ color: theme.accent.danger }} title={error}>{error}</span> : <span className="flex-1" />}
|
||||
<motion.button
|
||||
type="button"
|
||||
disabled={generating}
|
||||
whileHover={reducedMotion || generating ? undefined : { y: -1 }}
|
||||
whileTap={reducedMotion || generating ? undefined : { scale: 0.97 }}
|
||||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-[9px] px-3 text-[11px] font-semibold disabled:cursor-wait disabled:opacity-55"
|
||||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-3 text-[var(--fs-label)] font-semibold disabled:cursor-wait disabled:opacity-55"
|
||||
style={{ background: theme.node.activeStroke, color: theme.node.panel }}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
@@ -144,11 +144,11 @@ function EmotionPad({ preset, onChange }: { preset: CanvasEmotionPreset; onChang
|
||||
const selectedColumn = 2 - preset.intimacy;
|
||||
const selectedRow = 2 - preset.arousal;
|
||||
return (
|
||||
<div className="relative rounded-[12px] border px-[25px] pb-[22px] pt-[24px]" style={{ background: theme.toolbar.itemHover, borderColor: theme.toolbar.border }}>
|
||||
<span className="pointer-events-none absolute inset-x-0 top-1.5 text-center text-[9px]" style={{ color: theme.node.muted }}>激动</span>
|
||||
<span className="pointer-events-none absolute inset-x-0 bottom-1.5 text-center text-[9px]" style={{ color: theme.node.muted }}>平静</span>
|
||||
<span className="pointer-events-none absolute left-1 top-1/2 -translate-y-1/2 text-[9px] [writing-mode:vertical-rl]" style={{ color: theme.node.muted }}>亲近</span>
|
||||
<span className="pointer-events-none absolute right-1 top-1/2 -translate-y-1/2 text-[9px] [writing-mode:vertical-rl]" style={{ color: theme.node.muted }}>疏离</span>
|
||||
<div className="relative rounded-[var(--r-lg)] border px-[25px] pb-[22px] pt-[24px]" style={{ background: theme.toolbar.itemHover, borderColor: theme.toolbar.border }}>
|
||||
<span className="pointer-events-none absolute inset-x-0 top-1.5 text-center text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>激动</span>
|
||||
<span className="pointer-events-none absolute inset-x-0 bottom-1.5 text-center text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>平静</span>
|
||||
<span className="pointer-events-none absolute left-1 top-1/2 -translate-y-1/2 text-[var(--fs-micro)] [writing-mode:vertical-rl]" style={{ color: theme.node.muted }}>亲近</span>
|
||||
<span className="pointer-events-none absolute right-1 top-1/2 -translate-y-1/2 text-[var(--fs-micro)] [writing-mode:vertical-rl]" style={{ color: theme.node.muted }}>疏离</span>
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="情绪强度"
|
||||
@@ -192,7 +192,7 @@ function EmotionPad({ preset, onChange }: { preset: CanvasEmotionPreset; onChang
|
||||
function EmotionHeadPreview({ preset }: { preset: CanvasEmotionPreset }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-[12px] border" style={{ background: "#26272a", borderColor: theme.toolbar.border }}>
|
||||
<div className="relative overflow-hidden rounded-[var(--r-lg)] border" style={{ background: "#26272a", borderColor: theme.toolbar.border }}>
|
||||
<Canvas frameloop="demand" dpr={[1, 1.5]} camera={{ fov: 38, near: 0.1, far: 20, position: [0, 0, 4.15] }} gl={{ antialias: true, alpha: false, powerPreference: "high-performance" }}>
|
||||
<color attach="background" args={["#26272a"]} />
|
||||
<ambientLight intensity={0.82} />
|
||||
@@ -202,7 +202,7 @@ function EmotionHeadPreview({ preset }: { preset: CanvasEmotionPreset }) {
|
||||
</Canvas>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-black/55 to-transparent" />
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span key={preset.id} initial={{ opacity: 0, filter: "blur(6px)" }} animate={{ opacity: 1, filter: "blur(0px)" }} exit={{ opacity: 0, filter: "blur(5px)" }} transition={{ duration: 0.2 }} className="pointer-events-none absolute bottom-2 left-2.5 text-[10px] font-medium text-white/72">实时预览 · {preset.label}</motion.span>
|
||||
<motion.span key={preset.id} initial={{ opacity: 0, filter: "blur(6px)" }} animate={{ opacity: 1, filter: "blur(0px)" }} exit={{ opacity: 0, filter: "blur(5px)" }} transition={{ duration: 0.2 }} className="pointer-events-none absolute bottom-2 left-2.5 text-[var(--fs-tiny)] font-medium text-white/72">实时预览 · {preset.label}</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -314,7 +314,7 @@ export function CanvasNodeHoverToolbar({
|
||||
<>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className="canvas-node-toolbar absolute z-[70] flex -translate-x-1/2 -translate-y-full items-end justify-center overflow-visible"
|
||||
className="canvas-node-toolbar absolute z-[var(--z-panel-floating)] flex -translate-x-1/2 -translate-y-full items-end justify-center overflow-visible"
|
||||
style={{ left: anchor.left, top: anchor.top, width: "max-content", maxWidth: `min(calc(100% - 20px), ${showDockLabels ? 840 : 560}px)`, color: theme.node.text }}
|
||||
onMouseEnter={() => onKeep(node.id)}
|
||||
onMouseLeave={() => {
|
||||
@@ -323,7 +323,7 @@ export function CanvasNodeHoverToolbar({
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className={`aceternity-floating-dock thin-scrollbar relative flex max-w-full overflow-x-auto rounded-[14px] border backdrop-blur-2xl ${showDockLabels ? "h-11 items-center px-2 py-1" : "h-10 items-end gap-1 px-1.5 pb-1"}`} style={showDockLabels ? labeledDockStyle : dockShellStyle}>
|
||||
<div className={`aceternity-floating-dock thin-scrollbar relative flex max-w-full overflow-x-auto rounded-[var(--dock-radius)] border backdrop-blur-2xl ${showDockLabels ? "h-11 items-center px-2 py-1" : "h-10 items-end gap-1 px-1.5 pb-1"}`} style={showDockLabels ? labeledDockStyle : dockShellStyle}>
|
||||
{dockItems.length ? <FloatingDock embedded items={dockItems} size="compact" showLabels={showDockLabels} ariaLabel="节点快捷工具" className={`pointer-events-auto shrink-0 ${showDockLabels ? "" : "max-w-[min(calc(100vw-20px),400px)]"}`} style={embeddedDockStyle} /> : null}
|
||||
{hasImage && !simpleMode ? (
|
||||
<Dropdown
|
||||
@@ -341,13 +341,13 @@ export function CanvasNodeHoverToolbar({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`aceternity-dock-command pointer-events-auto shrink-0 outline-none focus-visible:ring-2 ${showDockLabels ? "is-labeled inline-flex h-8 items-center justify-center gap-1.5 rounded-[9px] px-2.5" : "grid size-8 place-items-center rounded-full"}`}
|
||||
className={`aceternity-dock-command pointer-events-auto shrink-0 outline-none focus-visible:ring-2 ${showDockLabels ? "is-labeled inline-flex h-8 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2.5" : "grid size-8 place-items-center rounded-full"}`}
|
||||
style={{ color: theme.node.text, "--tw-ring-color": theme.accent.primary } as CSSProperties}
|
||||
aria-label="更多图片工具"
|
||||
title="更多图片工具"
|
||||
>
|
||||
<Ellipsis className="size-3.5" />
|
||||
{showDockLabels ? <span className="inline-flex h-4 items-center text-[11px] font-medium leading-none">更多</span> : null}
|
||||
{showDockLabels ? <span className="inline-flex h-4 items-center text-[var(--fs-label)] font-medium leading-none">更多</span> : null}
|
||||
</button>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
@@ -433,7 +433,7 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
|
||||
const title = (
|
||||
<div className="flex items-center justify-between gap-4 pr-10">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[17px] font-semibold tracking-[-0.02em]">节点信息</div>
|
||||
<div className="text-[var(--fs-heading-lg)] font-semibold tracking-[-0.02em]">节点信息</div>
|
||||
{node ? <div className="mt-0.5 truncate text-xs opacity-45">{node.id}</div> : null}
|
||||
</div>
|
||||
<Segmented
|
||||
@@ -481,7 +481,7 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
|
||||
return <button key={option.value} type="button" disabled={readOnly} onClick={() => saveAssetCategory(option.value)} className="h-7 rounded-md border px-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60" style={{ borderColor: active ? theme.accent.primary : theme.toolbar.border, background: active ? theme.accent.primarySoft : theme.toolbar.panel, color: active ? theme.accent.primary : theme.node.muted }}>{option.label}</button>;
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] leading-5 opacity-45">生成后会按此分类进入项目资产;角色、场景和画风工作流会自动预填。</div>
|
||||
<div className="mt-2 text-[var(--fs-label)] leading-5 opacity-45">生成后会按此分类进入项目资产;角色、场景和画风工作流会自动预填。</div>
|
||||
</div>
|
||||
) : null}
|
||||
{node.metadata?.prompt ? (
|
||||
|
||||
@@ -148,7 +148,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
<span className="grid size-3.5 shrink-0 place-items-center" style={{ color: monochromeAccent }}>
|
||||
<GenerationModeIcon mode={mode} />
|
||||
</span>
|
||||
<span className="truncate text-[10px] font-medium">{modeDisplayName(mode)}创作</span>
|
||||
<span className="truncate text-[var(--fs-tiny)] font-medium">{modeDisplayName(mode)}创作</span>
|
||||
</div>
|
||||
)}
|
||||
{!simpleMode ? (
|
||||
@@ -182,12 +182,12 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
|
||||
const renderComposerControls = (expanded: boolean) => simpleMode ? (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 p-1" style={{ background: controlsSurface }}>
|
||||
<span className="min-w-0 truncate px-2 text-[10px]" style={{ color: theme.node.muted }}>
|
||||
<span className="min-w-0 truncate px-2 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
|
||||
{activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"}
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
className="!inline-flex !h-8 shrink-0 !items-center !gap-1 !rounded-full !px-2.5 !text-[10px] !font-medium"
|
||||
className="!inline-flex !h-8 shrink-0 !items-center !gap-1 !rounded-full !px-2.5 !text-[var(--fs-tiny)] !font-medium"
|
||||
danger={isRunning}
|
||||
disabled={isSubmitDisabled}
|
||||
style={{ background: isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : monochromeAccent, color: isSubmitDisabled ? theme.node.faint : isRunning ? "#ffffff" : theme.canvas.background, boxShadow: isSubmitDisabled ? "none" : `0 8px 20px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.18)` }}
|
||||
@@ -201,22 +201,22 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center justify-between gap-0.5 p-1" style={{ background: controlsSurface }}>
|
||||
<div className={`${expanded ? "max-w-[320px]" : mode === "image" || mode === "video" ? "max-w-[240px]" : "max-w-[174px]"} min-w-[104px] flex-1`}>
|
||||
<ModelPicker className="!h-7 !w-full !min-w-0 !text-[10px] !font-normal [&_img]:!size-3 [&_.lucide]:!size-3" fullWidth config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => navigateToSettings({ continueCreation: true })} showSelectedPrice={false} />
|
||||
<ModelPicker className="!h-7 !w-full !min-w-0 !text-[var(--fs-tiny)] !font-normal [&_img]:!size-3 [&_.lucide]:!size-3" fullWidth config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => navigateToSettings({ continueCreation: true })} showSelectedPrice={false} />
|
||||
</div>
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-1">
|
||||
{mode === "image" ? (
|
||||
<CanvasImageSettingsPopover
|
||||
config={config}
|
||||
placement={expanded ? "topRight" : "topLeft"}
|
||||
buttonClassName="!h-7 !w-[146px] !justify-start !rounded-full !border-0 !px-2.5 !text-[10px] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3"
|
||||
buttonClassName="!h-7 !w-[146px] !justify-start !rounded-full !border-0 !px-2.5 !text-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3"
|
||||
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
|
||||
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
|
||||
onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
|
||||
/>
|
||||
) : mode === "video" ? (
|
||||
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-7 !w-[144px] !justify-start !rounded-full !border-0 !px-2.5 !text-[10px] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
|
||||
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-7 !w-[144px] !justify-start !rounded-full !border-0 !px-2.5 !text-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
|
||||
) : mode === "audio" ? (
|
||||
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-7 !w-[146px] !justify-start !rounded-full !border-0 !px-2.5 !text-[10px] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
|
||||
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-7 !w-[146px] !justify-start !rounded-full !border-0 !px-2.5 !text-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
|
||||
) : null}
|
||||
<GenerationCostBadge credits={credits} theme={theme} />
|
||||
<Button
|
||||
@@ -254,7 +254,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
references={mentionReferences}
|
||||
onChange={updatePrompt}
|
||||
containerClassName="min-h-0 flex-1"
|
||||
className="thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-2.5 py-2 text-[13px] leading-5 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35"
|
||||
className="thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-2.5 py-2 text-[var(--fs-body)] leading-5 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35"
|
||||
style={{ color: theme.node.text, outline: "none", boxShadow: "none" }}
|
||||
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
|
||||
onContentSizeChange={updatePromptContentHeight}
|
||||
@@ -292,7 +292,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
references={mentionReferences}
|
||||
onChange={updatePrompt}
|
||||
containerClassName="min-h-0 flex-1"
|
||||
className="thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-3 py-2.5 text-[15px] leading-6 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35"
|
||||
className="thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-3 py-2.5 text-[var(--fs-body-lg)] leading-6 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35"
|
||||
style={{ color: theme.node.text, outline: "none", boxShadow: "none" }}
|
||||
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
|
||||
aria-label={`${modeDisplayName(mode)}提示词`}
|
||||
@@ -313,7 +313,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
function ComposerPill({ theme, borderColor, icon, label }: { theme: CanvasTheme; borderColor: string; icon: ReactNode; label: string }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md border px-1.5 text-[9px] font-medium transition hover:brightness-125"
|
||||
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md border px-1.5 text-[var(--fs-micro)] font-medium transition hover:brightness-125"
|
||||
style={{ background: theme.canvas.background, borderColor, color: theme.node.activeStroke }}
|
||||
>
|
||||
{icon}
|
||||
@@ -355,7 +355,7 @@ function ConnectedReferenceShelf({ references, theme, onInsert }: { references:
|
||||
<span className="block size-full overflow-hidden rounded-md">
|
||||
<ReferenceThumbnail reference={reference} />
|
||||
</span>
|
||||
<span className="absolute left-0.5 top-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-[8px] font-semibold text-white backdrop-blur-sm">{index + 1}</span>
|
||||
<span className="absolute left-0.5 top-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-[var(--fs-micro)] font-semibold text-white backdrop-blur-sm">{index + 1}</span>
|
||||
<span className="absolute bottom-0.5 right-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm"><AtSign className="size-2" /></span>
|
||||
</button>
|
||||
))}
|
||||
@@ -379,8 +379,8 @@ function ReferenceThumbnail({ reference }: { reference: CanvasResourceReference
|
||||
function GenerationCostBadge({ credits, theme }: { credits: number | null; theme: CanvasTheme }) {
|
||||
if (credits === null) return null;
|
||||
return (
|
||||
<span className="canvas-generation-cost-badge inline-flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-[9px] font-semibold tabular-nums" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }} title={`预计消耗 ${credits.toLocaleString()} 积分`} aria-label={`预计消耗 ${credits.toLocaleString()} 积分`}>
|
||||
<CreditSymbol className="text-[11px]" />
|
||||
<span className="canvas-generation-cost-badge inline-flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-[var(--fs-micro)] font-semibold tabular-nums" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }} title={`预计消耗 ${credits.toLocaleString()} 积分`} aria-label={`预计消耗 ${credits.toLocaleString()} 积分`}>
|
||||
<CreditSymbol className="text-[var(--fs-label)]" />
|
||||
{credits.toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { BookOpenCheck, ChevronRight, Clapperboard, Clock3, FileText, Image as ImageIcon, LoaderCircle, Lock, Maximize2, Music2, Pencil, Play, Plus, RefreshCw, Replace, Settings2, Square, Star, Type, Video } from "lucide-react";
|
||||
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Clock3, FileText, Image as ImageIcon, LoaderCircle, Lock, Maximize2, Music2, Pencil, Play, Plus, RefreshCw, Replace, Settings2, Square, Star, Type, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { CometCard } from "@/components/ui/aceternity/comet-card";
|
||||
@@ -320,7 +320,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
/>
|
||||
<CometCard
|
||||
containerClassName="overflow-visible"
|
||||
className={`canvas-node-shell relative h-full w-full overflow-visible rounded-[18px] ${flushMediaContent ? "border-0" : "border"} ${isGeneratingNode ? "canvas-node-shell-generating" : ""}`}
|
||||
className={`canvas-node-shell relative h-full w-full overflow-visible rounded-[var(--node-radius)] ${flushMediaContent ? "border-0" : "border"} ${isGeneratingNode ? "canvas-node-shell-generating" : ""}`}
|
||||
rotateDepth={cometDepth}
|
||||
translateDepth={cometTranslate}
|
||||
disabled={cometDisabled}
|
||||
@@ -328,8 +328,17 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
data-state={data.metadata?.status || (isActive ? "active" : isRelated ? "related" : "idle")}
|
||||
style={{
|
||||
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||
borderColor: flushMediaContent ? undefined : isActive ? theme.accent.primary : isRelated ? theme.accent.primary : theme.node.stroke,
|
||||
boxShadow: isActive ? `0 0 0 1px ${theme.accent.primary}66, 0 28px 80px ${theme.spatial.shadow}` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.accent.primary}35, 0 22px 60px ${theme.spatial.shadow}` : undefined,
|
||||
borderColor: flushMediaContent ? undefined : isActive ? theme.accent.primary : isRelated ? theme.accent.primary : hovered ? "color-mix(in oklch, var(--workspace-border) 80%, var(--workspace-fg) 12%)" : theme.node.stroke,
|
||||
boxShadow: isFocusRelated
|
||||
? `0 0 0 1px ${theme.accent.primary}66, 0 0 0 6px ${theme.accent.primary}1a, 0 28px 80px ${theme.spatial.shadow}` // active:最强强调(外发光环6px + inner glow)
|
||||
: isConnectionTarget
|
||||
? `0 0 0 1px ${theme.accent.primary}66, 0 0 0 4px ${theme.accent.primary}1a, 0 24px 72px ${theme.spatial.shadow}` // selected-primary:4px软色环 + resize handle
|
||||
: isSelected || (isRelated && !isBatchChild)
|
||||
? `0 0 0 2px ${theme.accent.primary}40, 0 22px 60px ${theme.spatial.shadow}` // selected:2px边框环
|
||||
: hovered
|
||||
? `0 16px 48px ${theme.spatial.shadow}` // hover:轻微抬升阴影
|
||||
: undefined, // idle:无额外阴影
|
||||
transition: "border-color 120ms ease-out, box-shadow 150ms ease-out",
|
||||
}}
|
||||
onMouseDown={(event) => onMouseDown(event, data.id)}
|
||||
onDoubleClick={(event) => {
|
||||
@@ -376,6 +385,16 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* 节点类型色带(对应 #97 决策1:左侧3px色条,远距离最强信号)*/}
|
||||
<div
|
||||
className="pointer-events-none absolute left-0 top-0 z-10 h-full"
|
||||
style={{ width: "var(--node-type-strip-width)", background: nodeTypeColorVar(data.type, data.metadata?.workflowKind === "character"), borderRadius: "var(--node-radius) 0 0 var(--node-radius)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
{/* 节点状态徽章(对应 #97 决策2:左上角 loading/success/error,近距离确认信号)*/}
|
||||
{data.metadata?.status && data.metadata.status !== "idle" && data.type !== CanvasNodeType.Frame ? (
|
||||
<NodeStatusBadge status={data.metadata.status} />
|
||||
) : null}
|
||||
<NodeContent
|
||||
node={data}
|
||||
theme={theme}
|
||||
@@ -449,7 +468,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
{data.metadata?.versionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-3 top-3 z-40 inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[10px] font-semibold backdrop-blur transition hover:brightness-110"
|
||||
className="absolute left-3 top-3 z-40 inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[var(--fs-tiny)] font-semibold backdrop-blur transition hover:brightness-110"
|
||||
style={{ background: theme.toolbar.panel, borderColor: data.metadata.versionPrimary ? theme.node.activeStroke : theme.toolbar.border, color: data.metadata.versionPrimary ? theme.node.activeStroke : theme.node.text }}
|
||||
title="查看版本对比"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
@@ -560,7 +579,7 @@ function DrawingContent({ node, theme, drawingProjectId }: NodeContentRendererPr
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 px-4 pb-3 pt-12" style={{ background: `linear-gradient(to top, ${theme.node.fill}, ${theme.node.fill}e6 55%, transparent)` }}>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-semibold" title={node.title || "绘图"}>{node.title || "绘图"}</div>
|
||||
<div className="mt-0.5 text-[10px]" style={{ color: theme.node.muted }}>{shapeCount} 个图形 · {pageCount} 个页面</div>
|
||||
<div className="mt-0.5 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{shapeCount} 个图形 · {pageCount} 个页面</div>
|
||||
</div>
|
||||
<Pencil className="size-3.5 shrink-0" style={{ color: theme.accent.primary }} />
|
||||
</div>
|
||||
@@ -576,10 +595,10 @@ function LoadingContent({ node, theme, onCancelTask, onOpenTaskDetails }: Pick<N
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2.5 px-5 text-center" style={{ color: theme.node.activeStroke }}>
|
||||
<div className="size-10 animate-spin rounded-full border-2" style={{ borderColor: theme.node.stroke, borderTopColor: theme.node.activeStroke }} />
|
||||
<span className="text-[10px] font-semibold">{node.metadata?.taskStage || (taskId ? "任务处理中" : "正在创建任务")}</span>
|
||||
<span className="text-[var(--fs-tiny)] font-semibold">{node.metadata?.taskStage || (taskId ? "任务处理中" : "正在创建任务")}</span>
|
||||
{taskId ? (
|
||||
<div className="flex w-full max-w-[210px] flex-col items-center gap-1.5">
|
||||
<div className="max-w-full truncate text-[11px] font-medium" style={{ color: theme.node.text }}>
|
||||
<div className="max-w-full truncate text-[var(--fs-label)] font-medium" style={{ color: theme.node.text }}>
|
||||
{statusLabel}
|
||||
{progress !== null ? ` · ${progress}%` : ""}
|
||||
</div>
|
||||
@@ -588,12 +607,12 @@ function LoadingContent({ node, theme, onCancelTask, onOpenTaskDetails }: Pick<N
|
||||
<div className="h-full rounded-full transition-[width]" style={{ width: `${progress}%`, background: theme.node.activeStroke }} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-w-full truncate text-[10px] tabular-nums" style={{ color: theme.node.muted }}>
|
||||
<div className="max-w-full truncate text-[var(--fs-tiny)] tabular-nums" style={{ color: theme.node.muted }}>
|
||||
<Clock3 className="mr-1 inline size-3" />{elapsed} · {shortTaskId(taskId)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
<button type="button" className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-[10px] font-medium transition hover:brightness-110" style={{ background: theme.toolbar.itemHover, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onOpenTaskDetails?.(node); }}><FileText className="size-3" />详情</button>
|
||||
<button type="button" className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-[10px] font-medium transition hover:brightness-110" style={{ background: `${theme.accent.danger}16`, color: theme.accent.danger }} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onCancelTask?.(node); }}><Square className="size-2.5 fill-current" />取消</button>
|
||||
<button type="button" className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-[var(--fs-tiny)] font-medium transition hover:brightness-110" style={{ background: theme.toolbar.itemHover, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onOpenTaskDetails?.(node); }}><FileText className="size-3" />详情</button>
|
||||
<button type="button" className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-[var(--fs-tiny)] font-medium transition hover:brightness-110" style={{ background: `${theme.accent.danger}16`, color: theme.accent.danger }} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onCancelTask?.(node); }}><Square className="size-2.5 fill-current" />取消</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -635,7 +654,7 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
|
||||
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
|
||||
<div className="text-xs leading-5" style={{ color: theme.accent.danger }}>{generationErrorMessage(node.metadata?.errorDetails)}</div>
|
||||
{moderationFailure ? (
|
||||
<div className="rounded-md border px-3 py-2 text-[11px] leading-4" style={{ background: theme.node.fill, borderColor: theme.toolbar.border, color: theme.node.muted }}>
|
||||
<div className="rounded-md border px-3 py-2 text-[var(--fs-label)] leading-4" style={{ background: theme.node.fill, borderColor: theme.toolbar.border, color: theme.node.muted }}>
|
||||
修改节点提示词后,可重新点击生成。
|
||||
</div>
|
||||
) : (
|
||||
@@ -724,7 +743,7 @@ function SkillContent({ node, theme }: NodeContentRendererProps) {
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold" title={skill?.name || node.title || "技能"}>{skill?.name || node.title || "技能"}</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[11px]" style={{ color: theme.node.muted }}>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
|
||||
<span>{skillCategoryLabel(skill?.category)}</span>
|
||||
<span>·</span>
|
||||
<span>{skillOutputModeLabel(skill?.outputMode)}</span>
|
||||
@@ -749,10 +768,10 @@ function SkillContent({ node, theme }: NodeContentRendererProps) {
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{tags.length ? tags.map((tag) => (
|
||||
<span key={tag} className="rounded-md border px-1.5 py-0.5 text-[10px]" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
|
||||
<span key={tag} className="rounded-md border px-1.5 py-0.5 text-[var(--fs-tiny)]" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
|
||||
{tag}
|
||||
</span>
|
||||
)) : <span className="text-[11px]" style={{ color: theme.node.muted }}>连接到图片、视频、音频或文本节点后生效</span>}
|
||||
)) : <span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>连接到图片、视频、音频或文本节点后生效</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -775,7 +794,7 @@ function skillOutputModeLabel(mode?: string) {
|
||||
|
||||
function ResourceLabelBadge({ reference, theme }: { reference: CanvasResourceReference; theme: CanvasTheme }) {
|
||||
return (
|
||||
<span className="pointer-events-none min-w-0 max-w-28 truncate rounded-md px-1.5 py-1 text-[10px] font-medium leading-none text-white shadow-sm" style={{ background: reference.active ? theme.accent.primary : "rgba(0,0,0,.35)", opacity: reference.active ? 1 : 0.75 }} title={reference.title || reference.label}>
|
||||
<span className="pointer-events-none min-w-0 max-w-28 truncate rounded-md px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none text-white shadow-sm" style={{ background: reference.active ? theme.accent.primary : "rgba(0,0,0,.35)", opacity: reference.active ? 1 : 0.75 }} title={reference.title || reference.label}>
|
||||
{reference.label}
|
||||
</span>
|
||||
);
|
||||
@@ -785,7 +804,7 @@ function ResourceStorageBadge({ storageKey, active, theme }: { storageKey?: stri
|
||||
const location = resourceStorageLocation(storageKey);
|
||||
const background = active ? (location === "local" ? "rgba(245,158,11,.9)" : theme.accent.primary) : "rgba(0,0,0,.35)";
|
||||
return (
|
||||
<span className="pointer-events-auto shrink-0 rounded-md px-1.5 py-1 text-[10px] font-medium leading-none text-white shadow-sm" style={{ background, opacity: active ? 1 : 0.75 }} title={resourceStorageTitle(storageKey)}>
|
||||
<span className="pointer-events-auto shrink-0 rounded-md px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none text-white shadow-sm" style={{ background, opacity: active ? 1 : 0.75 }} title={resourceStorageTitle(storageKey)}>
|
||||
{resourceStorageLabel(storageKey)}
|
||||
</span>
|
||||
);
|
||||
@@ -797,7 +816,7 @@ function NodeLockBadge({ theme }: { theme: CanvasTheme }) {
|
||||
|
||||
function BatchToggleBadge({ count, expanded, theme, onToggle }: { count: number; expanded: boolean; theme: CanvasTheme; onToggle: () => void }) {
|
||||
return (
|
||||
<button type="button" className="canvas-node-tool-button inline-flex h-7 shrink-0 items-center gap-1 rounded-md border px-2 text-[10px] font-semibold backdrop-blur-md" style={{ background: `${theme.toolbar.panel}d9`, borderColor: `${theme.toolbar.border}cc`, color: theme.node.text }} aria-label={expanded ? "图片组已展开" : "图片组已收起"} onClick={(event) => { event.stopPropagation(); onToggle(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<button type="button" className="canvas-node-tool-button inline-flex h-7 shrink-0 items-center gap-1 rounded-md border px-2 text-[var(--fs-tiny)] font-semibold backdrop-blur-md" style={{ background: `${theme.toolbar.panel}d9`, borderColor: `${theme.toolbar.border}cc`, color: theme.node.text }} aria-label={expanded ? "图片组已展开" : "图片组已收起"} onClick={(event) => { event.stopPropagation(); onToggle(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<span className="leading-none" style={{ color: theme.accent.primary }}>{count}</span>
|
||||
<ChevronRight className={`size-3 opacity-55 transition-transform ${expanded ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
@@ -806,7 +825,7 @@ function BatchToggleBadge({ count, expanded, theme, onToggle }: { count: number;
|
||||
|
||||
function BatchPrimaryBadge({ visible, selected, theme, onSelect }: { visible: boolean; selected: boolean; theme: CanvasTheme; onSelect: () => void }) {
|
||||
return (
|
||||
<button type="button" className={`canvas-node-tool-button inline-flex h-7 shrink-0 items-center gap-1 rounded-md border px-2 text-[10px] font-medium backdrop-blur-md transition-opacity ${visible ? "opacity-100" : "pointer-events-none opacity-0"}`} style={{ background: theme.toolbar.panel, borderColor: selected ? theme.accent.primary : theme.toolbar.border, color: selected ? theme.accent.primary : theme.node.text }} aria-label={selected ? "当前主图" : "设置为主图"} aria-pressed={selected} onClick={(event) => { event.stopPropagation(); onSelect(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<button type="button" className={`canvas-node-tool-button inline-flex h-7 shrink-0 items-center gap-1 rounded-md border px-2 text-[var(--fs-tiny)] font-medium backdrop-blur-md transition-opacity ${visible ? "opacity-100" : "pointer-events-none opacity-0"}`} style={{ background: theme.toolbar.panel, borderColor: selected ? theme.accent.primary : theme.toolbar.border, color: selected ? theme.accent.primary : theme.node.text }} aria-label={selected ? "当前主图" : "设置为主图"} aria-pressed={selected} onClick={(event) => { event.stopPropagation(); onSelect(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<Star className={`size-3 ${selected ? "fill-current" : ""}`} style={{ color: theme.accent.primary }} />
|
||||
{selected ? "当前主图" : "主图"}
|
||||
</button>
|
||||
@@ -819,7 +838,7 @@ function AssetTagBadges({ tags, theme }: { tags: string[]; theme: (typeof canvas
|
||||
{tags.map((tag, index) => (
|
||||
<span
|
||||
key={`${tag}-${index}`}
|
||||
className="max-w-full truncate rounded-md border px-1.5 py-1 text-[10px] font-medium leading-none backdrop-blur-sm"
|
||||
className="max-w-full truncate rounded-md border px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none backdrop-blur-sm"
|
||||
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
>
|
||||
{tag.trim()}
|
||||
@@ -871,9 +890,9 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded
|
||||
{isCharacterReference ? (
|
||||
<div className="max-w-[80%] text-center">
|
||||
<div className="truncate text-xs font-medium" title={node.metadata?.characterName || node.title} style={{ color: theme.node.muted }}>{node.metadata?.characterName || node.title}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.12em] opacity-50">多视角参考 · 待生成</div>
|
||||
<div className="mt-1 text-[var(--fs-tiny)] tracking-[0.12em] opacity-50">多视角参考 · 待生成</div>
|
||||
</div>
|
||||
) : <span className="text-[10px] tracking-[0.18em] opacity-50">空图片节点</span>}
|
||||
) : <span className="text-[var(--fs-tiny)] tracking-[0.18em] opacity-50">空图片节点</span>}
|
||||
</div>
|
||||
);
|
||||
if (isBatchRoot)
|
||||
@@ -898,7 +917,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects }: NodeContentRender
|
||||
if (!url) {
|
||||
return <DeferredMediaLoad icon={loading ? <LoaderCircle className="size-5 animate-spin" /> : <Play className="size-5 fill-current" />} label={loading ? "正在缓存视频" : "加载并缓存视频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />;
|
||||
}
|
||||
return <VideoPlayer src={url} mimeType={node.metadata?.mimeType} title={node.title || "视频"} preload={reduceMediaEffects ? "none" : "metadata"} autoPlay={playWhenReadyRef.current} onCanPlay={() => { playWhenReadyRef.current = false; }} brandColor={theme.accent.primary} className="h-full w-full rounded-[18px] bg-black" dataCanvasNoZoom compactControls />;
|
||||
return <VideoPlayer src={url} mimeType={node.metadata?.mimeType} title={node.title || "视频"} preload={reduceMediaEffects ? "none" : "metadata"} autoPlay={playWhenReadyRef.current} onCanPlay={() => { playWhenReadyRef.current = false; }} brandColor={theme.accent.primary} className="h-full w-full rounded-[var(--node-radius)] bg-black" dataCanvasNoZoom compactControls />;
|
||||
}
|
||||
|
||||
function AudioNodeContent({ node, theme }: NodeContentRendererProps) {
|
||||
@@ -956,7 +975,7 @@ function ImageContent({
|
||||
|
||||
return (
|
||||
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} theme={theme} onToggleBatch={onToggleBatch}>
|
||||
<div ref={imageContainerRef} className="h-full w-full overflow-hidden rounded-[18px]">
|
||||
<div ref={imageContainerRef} className="h-full w-full overflow-hidden rounded-[var(--node-radius)]">
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
@@ -975,7 +994,7 @@ function ImageContent({
|
||||
|
||||
function DeferredMediaLoad({ icon, label, disabled, onClick }: { icon: ReactNode; label: string; disabled: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" data-canvas-no-zoom className="flex size-full flex-col items-center justify-center gap-2 rounded-[16px] bg-black text-white/75 transition hover:text-white disabled:cursor-wait" disabled={disabled} onClick={(event) => { event.stopPropagation(); onClick(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<button type="button" data-canvas-no-zoom className="flex size-full flex-col items-center justify-center gap-2 rounded-[var(--r-2xl)] bg-black text-white/75 transition hover:text-white disabled:cursor-wait" disabled={disabled} onClick={(event) => { event.stopPropagation(); onClick(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<span className="grid size-10 place-items-center rounded-full bg-white/10">{icon}</span>
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
</button>
|
||||
@@ -1063,7 +1082,7 @@ function ImageInfoBar({ node }: { node: CanvasNodeData }) {
|
||||
const height = Math.round(node.metadata?.naturalHeight || node.height);
|
||||
const size = formatBytes(node.metadata?.bytes || 0);
|
||||
return (
|
||||
<span className="ml-auto max-w-full shrink-0 truncate rounded-md bg-black/55 px-2 py-1 text-[11px] font-medium leading-none text-white backdrop-blur-sm">
|
||||
<span className="ml-auto max-w-full shrink-0 truncate rounded-md bg-black/55 px-2 py-1 text-[var(--fs-label)] font-medium leading-none text-white backdrop-blur-sm">
|
||||
{width} x {height}
|
||||
{size ? ` · ${size}` : ""}
|
||||
</span>
|
||||
@@ -1185,6 +1204,59 @@ function nodeTypeIcon(type: CanvasNodeType) {
|
||||
return Type;
|
||||
}
|
||||
|
||||
// 节点类型色带色(对应 #97 决策1 色带方案,引用 --cn-type-* token)
|
||||
function nodeTypeColorVar(type: CanvasNodeType, isCharacter?: boolean): string {
|
||||
if (isCharacter) return "var(--cn-type-character)";
|
||||
if (type === CanvasNodeType.Image) return "var(--cn-type-image)";
|
||||
if (type === CanvasNodeType.Video) return "var(--cn-type-video)";
|
||||
if (type === CanvasNodeType.Audio) return "var(--cn-type-audio)";
|
||||
if (type === CanvasNodeType.Drawing) return "var(--cn-type-drawing)";
|
||||
if (type === CanvasNodeType.Script) return "var(--cn-type-script)";
|
||||
if (type === CanvasNodeType.Config) return "var(--cn-type-config)";
|
||||
if (type === CanvasNodeType.Skill) return "var(--cn-type-skill)";
|
||||
if (type === CanvasNodeType.Frame) return "var(--cn-type-frame)";
|
||||
return "var(--cn-type-text)";
|
||||
}
|
||||
|
||||
// 节点状态徽章(对应 #97 决策2:左上角状态指示,loading/success/error)
|
||||
function NodeStatusBadge({ status }: { status: "loading" | "success" | "error" }) {
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute left-2 top-2 z-20 flex items-center gap-1 rounded-full px-2 py-0.5 backdrop-blur-sm"
|
||||
style={{ background: "color-mix(in oklch, var(--status-loading) 20%, transparent)", color: "var(--status-loading)" }}
|
||||
aria-label="生成中"
|
||||
>
|
||||
<span className="size-1.5 animate-pulse rounded-full" style={{ background: "var(--status-loading)" }} />
|
||||
<span className="text-[var(--fs-micro)] font-medium leading-none">生成中</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute left-2 top-2 z-20 flex items-center gap-1 rounded-full px-2 py-0.5 backdrop-blur-sm"
|
||||
style={{ background: "color-mix(in oklch, var(--status-error) 20%, transparent)", color: "var(--status-error)" }}
|
||||
aria-label="生成失败"
|
||||
>
|
||||
<AlertCircle className="size-3" strokeWidth={2} />
|
||||
<span className="text-[var(--fs-micro)] font-medium leading-none">失败</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// success:短暂闪现后淡出(由 CSS animation 控制,2s 后消失)
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute left-2 top-2 z-20 flex items-center gap-1 rounded-full px-2 py-0.5 backdrop-blur-sm"
|
||||
style={{ background: "color-mix(in oklch, var(--status-success) 20%, transparent)", color: "var(--status-success)", animation: "canvas-status-success-fade 2s ease-out forwards" }}
|
||||
aria-label="生成完成"
|
||||
>
|
||||
<CheckCircle2 className="size-3" strokeWidth={2} />
|
||||
<span className="text-[var(--fs-micro)] font-medium leading-none">完成</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { side: "left" | "right"; scale: number; visible: boolean; theme: CanvasTheme; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) {
|
||||
const handleRef = useRef<HTMLSpanElement>(null);
|
||||
const anchorRatioRef = useRef(0.5);
|
||||
|
||||
@@ -33,7 +33,7 @@ export function CanvasPortraitTexturePopover({ value, placement = "topLeft", onC
|
||||
<div className="space-y-1">
|
||||
{PORTRAIT_TEXTURE_GROUPS.map((group) => (
|
||||
<div key={group.key} className="grid grid-cols-[60px_minmax(0,1fr)] items-center gap-2 py-1">
|
||||
<span className="text-[11px]" style={{ color: theme.node.muted }}>{group.label}</span>
|
||||
<span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>{group.label}</span>
|
||||
<div className="grid min-w-0 grid-cols-3 gap-1" role="radiogroup" aria-label={group.label}>
|
||||
{group.options.map((option) => {
|
||||
const selected = settings[group.key] === option.value;
|
||||
@@ -43,7 +43,7 @@ export function CanvasPortraitTexturePopover({ value, placement = "topLeft", onC
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className="h-7 min-w-0 rounded-md border px-1 text-[10px] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 motion-reduce:transition-none"
|
||||
className="h-7 min-w-0 rounded-md border px-1 text-[var(--fs-tiny)] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 motion-reduce:transition-none"
|
||||
style={{
|
||||
background: selected ? theme.accent.primarySoft : theme.toolbar.itemHover,
|
||||
borderColor: selected ? theme.accent.primary : "transparent",
|
||||
@@ -78,7 +78,7 @@ export function CanvasPortraitTexturePopover({ value, placement = "topLeft", onC
|
||||
aria-label="打开人物质感调节面板"
|
||||
>
|
||||
<SlidersHorizontal className="size-3 shrink-0" />
|
||||
<span className="truncate text-[10px] font-medium">人物质感调节</span>
|
||||
<span className="truncate text-[var(--fs-tiny)] font-medium">人物质感调节</span>
|
||||
</button>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -104,9 +104,9 @@ export function CanvasPresetPicker({ mode, skillReferences = [], open, onOpenCha
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold" style={{ color: theme.node.text }}>
|
||||
<span className="truncate">{preset.name}</span>
|
||||
<span className="shrink-0 text-[9px] font-medium" style={{ color: theme.accent.primary }}>{preset.source === "skill" ? "技能" : "预设"}</span>
|
||||
<span className="shrink-0 text-[var(--fs-micro)] font-medium" style={{ color: theme.accent.primary }}>{preset.source === "skill" ? "技能" : "预设"}</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[10px] leading-4" style={{ color: theme.node.muted }}>{preset.description}</span>
|
||||
<span className="mt-0.5 block truncate text-[var(--fs-tiny)] leading-4" style={{ color: theme.node.muted }}>{preset.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
)) : <div className="py-8 text-center text-xs" style={{ color: theme.node.muted }}>没有匹配的预设</div>}
|
||||
@@ -118,7 +118,7 @@ export function CanvasPresetPicker({ mode, skillReferences = [], open, onOpenCha
|
||||
<Popover open={actualOpen} onOpenChange={setOpen} trigger="click" placement="topLeft" arrow={false} content={content} classNames={{ root: "canvas-preset-picker-popover", container: "canvas-composer-popover-surface", content: "canvas-composer-popover-content" }}>
|
||||
<button type="button" className={`canvas-preset-picker-trigger inline-flex shrink-0 items-center justify-center gap-1 rounded-lg transition hover:brightness-110 focus-visible:outline-none ${compact ? "size-6" : dense ? "h-6 px-1.5" : "h-7 px-2"}`} style={{ background: theme.accent.primarySoft, color: theme.accent.primary }} title="打开提示词预设" aria-label="打开提示词预设" aria-expanded={actualOpen}>
|
||||
<WandSparkles className={dense ? "size-3" : "size-3.5"} />
|
||||
{compact ? null : <span className="text-[10px] font-semibold">预设</span>}
|
||||
{compact ? null : <span className="text-[var(--fs-tiny)] font-semibold">预设</span>}
|
||||
</button>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -44,12 +44,12 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
|
||||
|
||||
return <Modal open={open} title={null} footer={null} destroyOnHidden onCancel={onClose} width="min(920px, calc(100vw - 24px))" styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}>
|
||||
<div className="flex h-[min(620px,calc(100vh-80px))] min-h-[440px] flex-col overflow-hidden">
|
||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-border py-0 pl-4 pr-12"><h2 className="text-sm font-semibold">引用项目角色与资产</h2><span className="text-[11px] text-foreground/42">已选 {selectedIds.size} 项</span></header>
|
||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-border py-0 pl-4 pr-12"><h2 className="text-sm font-semibold">引用项目角色与资产</h2><span className="text-[var(--fs-label)] text-foreground/42">已选 {selectedIds.size} 项</span></header>
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] sm:grid-cols-[150px_minmax(0,1fr)] sm:grid-rows-1">
|
||||
<nav className="thin-scrollbar flex min-w-0 gap-1 overflow-x-auto border-b border-border p-2 sm:block sm:overflow-y-auto sm:border-b-0 sm:border-r" aria-label="项目资产分类">{categories.map((item) => <button key={item} type="button" onClick={() => setCategory(item)} className={`flex h-11 min-w-[104px] shrink-0 items-center justify-between rounded-md px-2 text-xs sm:w-full sm:min-w-0 ${category === item ? "bg-foreground/[.08] font-medium" : "text-foreground/55 hover:bg-foreground/[.04]"}`}><span>{categoryLabels[item] || "其他"}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[10px] tabular-nums">{item === "all" ? items.length : items.filter((asset) => asset.category === item).length}</span></button>)}</nav>
|
||||
<nav className="thin-scrollbar flex min-w-0 gap-1 overflow-x-auto border-b border-border p-2 sm:block sm:overflow-y-auto sm:border-b-0 sm:border-r" aria-label="项目资产分类">{categories.map((item) => <button key={item} type="button" onClick={() => setCategory(item)} className={`flex h-11 min-w-[104px] shrink-0 items-center justify-between rounded-md px-2 text-xs sm:w-full sm:min-w-0 ${category === item ? "bg-foreground/[.08] font-medium" : "text-foreground/55 hover:bg-foreground/[.04]"}`}><span>{categoryLabels[item] || "其他"}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item === "all" ? items.length : items.filter((asset) => asset.category === item).length}</span></button>)}</nav>
|
||||
<div className="thin-scrollbar min-h-0 overflow-y-auto p-3">{visible.length ? <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">{visible.map((item) => <ProjectAssetCard key={item.id} item={item} selected={selectedIds.has(item.id)} onToggle={() => toggle(item.id)} />)}</div> : <WorkspaceState icon="assets" compact className="h-full" title="此分类没有可引用资产" description="先在项目角色与资产中完成角色确认或素材关联。" />}</div>
|
||||
</div>
|
||||
<footer className="flex h-12 shrink-0 items-center justify-between border-t border-border px-3"><span className="text-[10px] text-foreground/42">角色引用会在生成时解析当前角色版本</span><div className="flex gap-2"><Button size="small" onClick={onClose}>取消</Button><Button size="small" type="primary" disabled={!selectedIds.size} loading={inserting} onClick={() => void insert()}>引入 {selectedIds.size || ""} 项</Button></div></footer>
|
||||
<footer className="flex h-12 shrink-0 items-center justify-between border-t border-border px-3"><span className="text-[var(--fs-tiny)] text-foreground/42">角色引用会在生成时解析当前角色版本</span><div className="flex gap-2"><Button size="small" onClick={onClose}>取消</Button><Button size="small" type="primary" disabled={!selectedIds.size} loading={inserting} onClick={() => void insert()}>引入 {selectedIds.size || ""} 项</Button></div></footer>
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ function ProjectAssetCard({ item, selected, onToggle }: { item: ProjectPickerIte
|
||||
const Icon = character ? UserRound : media?.kind === "video" ? Video : media?.kind === "audio" ? Music2 : media?.kind === "text" ? FileText : ImageIcon;
|
||||
const label = character ? "角色卡" : media?.kind === "video" ? "视频" : media?.kind === "audio" ? "音频" : media?.kind === "text" ? "文本" : "图片";
|
||||
const title = character?.title || media?.title || "未命名资产";
|
||||
return <button type="button" onClick={onToggle} className={`relative min-w-0 overflow-hidden rounded-md border text-left transition-colors ${selected ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent-soft)]" : "border-border/80 hover:border-foreground/30"}`}><div className="relative aspect-[4/3] overflow-hidden bg-foreground/[.04]">{cover ? <img src={cover} alt={title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" /> : <AssetMediaPreview asset={media} alt={title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><Icon className="size-7" /></div>} />}<span className={`absolute right-1.5 top-1.5 grid size-5 place-items-center rounded border ${selected ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent)] text-white" : "border-white/60 bg-black/25 text-transparent backdrop-blur"}`}><Check className="size-3" /></span><span className="absolute bottom-1.5 left-1.5 rounded bg-black/55 px-1.5 py-0.5 text-[9px] text-white">{label}</span></div><div className="px-2 py-1.5"><div className="truncate text-[11px] font-medium">{title}</div>{character ? <div className="mt-0.5 truncate text-[9px] text-foreground/42">{character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · {character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}</div> : null}</div></button>;
|
||||
return <button type="button" onClick={onToggle} className={`relative min-w-0 overflow-hidden rounded-md border text-left transition-colors ${selected ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent-soft)]" : "border-border/80 hover:border-foreground/30"}`}><div className="relative aspect-[4/3] overflow-hidden bg-foreground/[.04]">{cover ? <img src={cover} alt={title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" /> : <AssetMediaPreview asset={media} alt={title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><Icon className="size-7" /></div>} />}<span className={`absolute right-1.5 top-1.5 grid size-5 place-items-center rounded border ${selected ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent)] text-white" : "border-white/60 bg-black/25 text-transparent backdrop-blur"}`}><Check className="size-3" /></span><span className="absolute bottom-1.5 left-1.5 rounded bg-black/55 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{label}</span></div><div className="px-2 py-1.5"><div className="truncate text-[var(--fs-label)] font-medium">{title}</div>{character ? <div className="mt-0.5 truncate text-[var(--fs-micro)] text-foreground/42">{character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · {character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}</div> : null}</div></button>;
|
||||
}
|
||||
|
||||
function toInsertPayload(item: ProjectPickerItem): InsertAssetPayload {
|
||||
|
||||
@@ -45,7 +45,7 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
|
||||
<ProjectPreview project={project} />
|
||||
</button>
|
||||
{!compact ? <span className={`absolute left-2.5 top-2.5 grid size-6 place-items-center rounded-md border border-black/10 bg-white/90 shadow-sm backdrop-blur transition-opacity dark:border-white/10 dark:bg-stone-900/90 ${selected ? "opacity-100" : "opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100"}`} onClick={(event) => event.stopPropagation()}><input type="checkbox" checked={selected} onChange={(event) => toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox size-3.5" aria-label={`选择 ${project.title}`} /></span> : null}
|
||||
<span className="absolute bottom-2 right-2 rounded-md border border-white/15 bg-stone-950/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-xl">{project.nodes.length} 节点</span>
|
||||
<span className="absolute bottom-2 right-2 rounded-md border border-white/15 bg-stone-950/80 px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium text-white backdrop-blur-xl">{project.nodes.length} 节点</span>
|
||||
</div>
|
||||
|
||||
<div className={cn("app-canvas-project-body", compact ? "px-1 pb-1 pt-2.5" : "px-3 py-2.5")}>
|
||||
@@ -61,7 +61,7 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
|
||||
open();
|
||||
}}
|
||||
>
|
||||
<h2 className="truncate text-[13px] font-semibold leading-5 text-foreground">{project.title}</h2>
|
||||
<h2 className="truncate text-[var(--fs-body)] font-semibold leading-5 text-foreground">{project.title}</h2>
|
||||
</button>
|
||||
)}
|
||||
{editing ? (
|
||||
@@ -88,12 +88,12 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] leading-4 text-foreground/52">
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[var(--fs-label)] leading-4 text-foreground/52">
|
||||
<span className="truncate">{projectName || "自由画布"}</span>
|
||||
<span className="shrink-0 text-foreground/28" aria-hidden="true">·</span>
|
||||
<span className="shrink-0 tabular-nums">{project.connections.length} 条连线</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-4 tabular-nums text-foreground/38">更新于 {formatProjectTime(project.updatedAt)}</p>
|
||||
<p className="mt-1 text-[var(--fs-tiny)] leading-4 tabular-nums text-foreground/38">更新于 {formatProjectTime(project.updatedAt)}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -128,7 +128,7 @@ function ProjectPreview({ project }: { project: CanvasProject }) {
|
||||
return (
|
||||
<span key={node.id} className="absolute flex min-w-0 items-center gap-1.5 overflow-hidden rounded-md border border-stone-300/90 bg-white/90 px-2 text-left shadow-sm backdrop-blur-sm dark:border-stone-700 dark:bg-stone-900/92" style={style}>
|
||||
<span className="grid size-5 shrink-0 place-items-center text-stone-500 dark:text-stone-300">{presentation.icon}</span>
|
||||
<span className="min-w-0 truncate text-[8px] font-semibold text-stone-700 dark:text-stone-200">{node.title || presentation.label}</span>
|
||||
<span className="min-w-0 truncate text-[var(--fs-micro)] font-semibold text-stone-700 dark:text-stone-200">{node.title || presentation.label}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -133,17 +133,17 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
</header>
|
||||
|
||||
<section className="shrink-0 border-b border-border/70 p-2">
|
||||
<div className="mb-1.5 flex h-5 items-center justify-between px-1 text-[11px] font-medium text-foreground/48"><span className="flex items-center gap-1.5"><Palette className="size-3.5" />项目画风</span><Link to={`/projects/${projectId}/settings`} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-foreground/[.06] hover:text-foreground" title="编辑项目画风" aria-label="编辑项目画风"><Settings2 className="size-3.5" /></Link></div>
|
||||
<div className="mb-1.5 flex h-5 items-center justify-between px-1 text-[var(--fs-label)] font-medium text-foreground/48"><span className="flex items-center gap-1.5"><Palette className="size-3.5" />项目画风</span><Link to={`/projects/${projectId}/settings`} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-foreground/[.06] hover:text-foreground" title="编辑项目画风" aria-label="编辑项目画风"><Settings2 className="size-3.5" /></Link></div>
|
||||
<button type="button" disabled={!style} onClick={onLocateStyle} className="group flex h-11 w-full items-center gap-2 rounded-md px-1.5 text-left hover:bg-foreground/[.045] disabled:cursor-default disabled:hover:bg-transparent" title={style ? "定位画布中的项目画风节点" : "项目尚未设置画风"}>
|
||||
{style ? <img src={style.imageUrl} alt="" width={48} height={32} className="h-8 w-12 shrink-0 rounded object-cover" /> : <span className="grid h-8 w-12 shrink-0 place-items-center rounded border border-dashed border-border text-foreground/30"><Palette className="size-3.5" /></span>}
|
||||
<span className="min-w-0 flex-1"><span className="block truncate text-xs font-medium">{style?.title || "尚未设置画风"}</span><span className="mt-0.5 block truncate text-[9px] text-foreground/38">{style ? "随项目设置自动同步" : "前往项目设置选择"}</span></span>
|
||||
<span className="min-w-0 flex-1"><span className="block truncate text-xs font-medium">{style?.title || "尚未设置画风"}</span><span className="mt-0.5 block truncate text-[var(--fs-micro)] text-foreground/38">{style ? "随项目设置自动同步" : "前往项目设置选择"}</span></span>
|
||||
{style ? <LocateFixed className="size-3.5 shrink-0 text-foreground/25 group-hover:text-[var(--workspace-accent)]" /> : null}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex h-9 shrink-0 items-center justify-between px-3">
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium text-foreground/48"><BookOpenText className="size-3.5" />剧情章节 <span className="tabular-nums text-foreground/32">{orderedUnits.length.toLocaleString("zh-CN")}</span></span>
|
||||
<span className="flex items-center gap-1.5 text-[var(--fs-label)] font-medium text-foreground/48"><BookOpenText className="size-3.5" />剧情章节 <span className="tabular-nums text-foreground/32">{orderedUnits.length.toLocaleString("zh-CN")}</span></span>
|
||||
{selectedId ? <button type="button" onClick={revealSelectedChapter} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-foreground/[.06] hover:text-foreground" title="回到当前章节" aria-label="回到当前章节"><Crosshair className="size-3.5" /></button> : null}
|
||||
</div>
|
||||
<div className="shrink-0 px-2 pb-2">
|
||||
@@ -152,7 +152,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") selectFirstSearchResult(); }} className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-foreground/28" placeholder="搜索标题或输入章节序号" aria-label="搜索项目章节" />
|
||||
{query ? <button type="button" onClick={() => setQuery("")} className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-foreground/[.06] hover:text-foreground" aria-label="清空章节搜索"><X className="size-3" /></button> : null}
|
||||
</label>
|
||||
{deferredQuery ? <div className="mt-1 px-0.5 text-[9px] tabular-nums text-foreground/35">找到 {visibleUnits.length.toLocaleString("zh-CN")} 章</div> : null}
|
||||
{deferredQuery ? <div className="mt-1 px-0.5 text-[var(--fs-micro)] tabular-nums text-foreground/35">找到 {visibleUnits.length.toLocaleString("zh-CN")} 章</div> : null}
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-1.5" aria-label="项目章节列表">
|
||||
@@ -168,7 +168,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
<div draggable onDragStart={(event) => { const payload: CanvasProjectChapterPayload = { id: unit.id, projectId, title: unit.title, position: unit.position }; event.dataTransfer.setData(CANVAS_PROJECT_CHAPTER_DND_TYPE, JSON.stringify(payload)); event.dataTransfer.effectAllowed = "copy"; }} className={`group flex h-9 cursor-grab items-center gap-1 rounded-md px-1 text-xs active:cursor-grabbing ${active ? "bg-foreground/[.08]" : "hover:bg-foreground/[.045]"}`}>
|
||||
<GripVertical className="size-3.5 shrink-0 text-foreground/22" />
|
||||
<button type="button" onClick={() => setSelectedId(unit.id)} className="flex min-w-0 flex-1 items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]" aria-label={`预览第 ${index + 1} 章 ${unit.title}`}>
|
||||
<span className="w-7 shrink-0 text-[9px] tabular-nums text-foreground/34">{String(index + 1).padStart(Math.max(2, String(orderedUnits.length).length), "0")}</span>
|
||||
<span className="w-7 shrink-0 text-[var(--fs-micro)] tabular-nums text-foreground/34">{String(index + 1).padStart(Math.max(2, String(orderedUnits.length).length), "0")}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-foreground/68">{unit.title}</span>
|
||||
</button>
|
||||
<button type="button" disabled={Boolean(addingId)} className="grid size-6 shrink-0 place-items-center rounded text-foreground/30 opacity-0 hover:bg-[var(--workspace-accent-soft)] hover:text-[var(--workspace-accent)] group-hover:opacity-100 focus-visible:opacity-100 disabled:cursor-wait" title="添加到画布中央" aria-label={`添加${unit.title}到画布`} onClick={() => void insertChapter(unit)}>{addingId === unit.id ? <LoaderCircle className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}</button>
|
||||
@@ -182,7 +182,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
</section>
|
||||
|
||||
<section className="shrink-0 border-t border-border/70 p-2">
|
||||
<button type="button" onClick={onOpenAssets} className="flex h-9 w-full items-center gap-2 rounded-md border border-border/80 px-2 text-xs text-foreground/65 hover:border-[var(--workspace-accent)] hover:bg-[var(--workspace-accent-soft)]"><Images className="size-3.5 text-foreground/42" /><span className="flex-1 text-left">引用项目资产</span><span className="rounded bg-foreground/[.06] px-1.5 py-0.5 text-[9px] tabular-nums text-foreground/45">{mediaAssetCount.toLocaleString("zh-CN")}</span></button>
|
||||
<button type="button" onClick={onOpenAssets} className="flex h-9 w-full items-center gap-2 rounded-md border border-border/80 px-2 text-xs text-foreground/65 hover:border-[var(--workspace-accent)] hover:bg-[var(--workspace-accent-soft)]"><Images className="size-3.5 text-foreground/42" /><span className="flex-1 text-left">引用项目资产</span><span className="rounded bg-foreground/[.06] px-1.5 py-0.5 text-[var(--fs-micro)] tabular-nums text-foreground/45">{mediaAssetCount.toLocaleString("zh-CN")}</span></button>
|
||||
</section>
|
||||
|
||||
{selectedUnit ? <ChapterPreview projectId={projectId} unit={selectedUnitQuery.data?.unit || selectedUnit} chapterNumber={orderedUnits.findIndex((unit) => unit.id === selectedUnit.id) + 1} loading={selectedUnitQuery.isLoading} loadError={selectedUnitQuery.isError} adding={addingId === selectedUnit.id} onAdd={() => insertChapter(selectedUnit, selectedUnitQuery.data?.unit.sourceText)} onClose={() => setSelectedId("")} /> : null}
|
||||
@@ -194,8 +194,8 @@ function ChapterPreview({ projectId, unit, chapterNumber, loading, loadError, ad
|
||||
const text = htmlToPlainText(unit.sourceText);
|
||||
return (
|
||||
<section className="absolute bottom-20 left-[calc(100%+8px)] top-14 z-50 flex w-[320px] flex-col overflow-hidden rounded-lg border border-border/85 bg-background/[.96] shadow-[0_18px_48px_rgba(0,0,0,.22)] backdrop-blur-xl" aria-label={`第 ${chapterNumber} 章预览`}>
|
||||
<header className="flex items-start gap-2 border-b border-border/70 px-3 py-2.5"><div className="min-w-0 flex-1"><div className="text-[10px] tabular-nums text-foreground/38">第 {chapterNumber.toLocaleString("zh-CN")} 章</div><h2 className="mt-0.5 truncate text-sm font-semibold">{unit.title}</h2></div><button type="button" onClick={onClose} className="grid size-6 shrink-0 place-items-center rounded text-foreground/38 hover:bg-foreground/[.06] hover:text-foreground" aria-label="关闭章节预览"><X className="size-3.5" /></button></header>
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-3 py-3"><p className="whitespace-pre-wrap text-xs leading-5 text-foreground/58">{loading ? "正在读取本章正文…" : loadError ? "正文读取失败,请稍后重试" : text || "本章还没有正文"}</p>{!loading && !loadError ? <div className="mt-2 text-[10px] tabular-nums text-foreground/35">{text.length.toLocaleString("zh-CN")} 字</div> : null}</div>
|
||||
<header className="flex items-start gap-2 border-b border-border/70 px-3 py-2.5"><div className="min-w-0 flex-1"><div className="text-[var(--fs-tiny)] tabular-nums text-foreground/38">第 {chapterNumber.toLocaleString("zh-CN")} 章</div><h2 className="mt-0.5 truncate text-sm font-semibold">{unit.title}</h2></div><button type="button" onClick={onClose} className="grid size-6 shrink-0 place-items-center rounded text-foreground/38 hover:bg-foreground/[.06] hover:text-foreground" aria-label="关闭章节预览"><X className="size-3.5" /></button></header>
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-3 py-3"><p className="whitespace-pre-wrap text-xs leading-5 text-foreground/58">{loading ? "正在读取本章正文…" : loadError ? "正文读取失败,请稍后重试" : text || "本章还没有正文"}</p>{!loading && !loadError ? <div className="mt-2 text-[var(--fs-tiny)] tabular-nums text-foreground/35">{text.length.toLocaleString("zh-CN")} 字</div> : null}</div>
|
||||
<footer className="flex shrink-0 items-center justify-between border-t border-border/70 px-2 py-2"><button type="button" disabled={adding} onClick={() => void onAdd()} className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-[var(--workspace-accent)] hover:bg-[var(--workspace-accent-soft)] disabled:cursor-wait">{adding ? <LoaderCircle className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}添加到画布</button><Link to={`/projects/${projectId}/chapters/${unit.id}`} className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs text-foreground/62 hover:bg-foreground/[.06] hover:text-foreground">打开编辑器<ArrowUpRight className="size-3.5" /></Link></footer>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -127,7 +127,7 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
|
||||
<div className="relative flex h-10 shrink-0 items-center gap-2 rounded-t-[17px] border-b px-4" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
|
||||
<Clapperboard className="size-4" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold" title={node.title || "分镜脚本"}>{node.title || "分镜脚本"}</span>
|
||||
{batchSummary ? <span className="min-w-0 max-w-[42%] truncate text-[11px] font-medium" title={batchSummary} style={{ color: batch?.status === "partial_failed" ? theme.accent.danger : theme.node.muted }}>{batchSummary}</span> : taskFeedback ? <span className="min-w-0 max-w-[38%] truncate text-[11px] font-medium" title={taskFeedback} style={{ color: node.metadata?.status === "error" ? theme.accent.danger : theme.node.muted }}>{taskFeedback}</span> : null}
|
||||
{batchSummary ? <span className="min-w-0 max-w-[42%] truncate text-[var(--fs-label)] font-medium" title={batchSummary} style={{ color: batch?.status === "partial_failed" ? theme.accent.danger : theme.node.muted }}>{batchSummary}</span> : taskFeedback ? <span className="min-w-0 max-w-[38%] truncate text-[var(--fs-label)] font-medium" title={taskFeedback} style={{ color: node.metadata?.status === "error" ? theme.accent.danger : theme.node.muted }}>{taskFeedback}</span> : null}
|
||||
<span className="text-xs font-medium" style={{ color: theme.node.muted }}>{rows.length} 镜 · {totalDuration}s</span>
|
||||
{batch ? <>
|
||||
{hasFailedBatchItems ? <Tooltip title="重试失败项"><button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onRetryBatch(batch.id); }} aria-label="重试失败项"><RefreshCw className="size-3.5" /></button></Tooltip> : null}
|
||||
@@ -167,7 +167,7 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
|
||||
>
|
||||
{rows.length ? rows.map((row) => (
|
||||
<div key={row.id} className="relative grid border-b" style={{ height: STORYBOARD_ROW_HEIGHT, borderColor: theme.node.stroke, gridTemplateColumns: SCRIPT_GRID_TEMPLATE }}>
|
||||
<div className="flex flex-col items-center justify-center border-r tabular-nums" style={{ color: theme.node.muted, borderColor: theme.node.stroke }}><span className="text-sm">{row.shotNumber}</span>{batchItemByRowId.get(row.id) ? <span className="max-w-16 truncate text-[9px] leading-3" title={generationBatchItemLabel(batchItemByRowId.get(row.id)!)}>{generationBatchItemLabel(batchItemByRowId.get(row.id)!)}</span> : null}</div>
|
||||
<div className="flex flex-col items-center justify-center border-r tabular-nums" style={{ color: theme.node.muted, borderColor: theme.node.stroke }}><span className="text-sm">{row.shotNumber}</span>{batchItemByRowId.get(row.id) ? <span className="max-w-16 truncate text-[var(--fs-micro)] leading-3" title={generationBatchItemLabel(batchItemByRowId.get(row.id)!)}>{generationBatchItemLabel(batchItemByRowId.get(row.id)!)}</span> : null}</div>
|
||||
<div className="grid grid-cols-[32px_1fr_32px] items-center border-r px-2" style={{ borderColor: theme.node.stroke }}>
|
||||
<SmallButton title="减少 1 秒" onClick={() => onUpdateRow(row.id, { durationSeconds: Math.max(1, row.durationSeconds - 1) })}><Minus className="size-3" /></SmallButton>
|
||||
<span className="text-center text-sm font-medium tabular-nums">{row.durationSeconds}s</span>
|
||||
@@ -213,7 +213,7 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
|
||||
<Tooltip title="脚本生成需要文本理解与结构化输出能力,仅展示文本模型;视频/图片模型无法生成分镜表" placement="topLeft">
|
||||
<div className="mr-auto min-w-36 max-w-56 flex-1">
|
||||
<ModelPicker
|
||||
className="!h-7 !w-full !min-w-0 !text-[10px] !font-normal [&_img]:!size-3 [&_.lucide]:!size-3"
|
||||
className="!h-7 !w-full !min-w-0 !text-[var(--fs-tiny)] !font-normal [&_img]:!size-3 [&_.lucide]:!size-3"
|
||||
fullWidth
|
||||
config={generationConfig}
|
||||
value={generationConfig.model}
|
||||
@@ -225,7 +225,7 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{simpleMode ? <span className="text-[11px]" style={{ color: theme.node.muted }}>自动拆分 · 默认时长</span> : <Select<StoryboardShotCount>
|
||||
{simpleMode ? <span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>自动拆分 · 默认时长</span> : <Select<StoryboardShotCount>
|
||||
className="min-w-32"
|
||||
size="small"
|
||||
value={shotCount}
|
||||
@@ -328,10 +328,10 @@ function PipelineStageCell({ label, stage, theme, children, last = false }: { la
|
||||
return (
|
||||
<div className={`flex min-w-0 items-center gap-2 px-3 ${last ? "" : "border-r"}`} style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="min-w-[64px] shrink-0">
|
||||
<div className="text-[11px] font-semibold">{label}</div>
|
||||
<div className="text-[9px] leading-3" style={{ color: stage.failed ? theme.accent.danger : theme.node.muted }}>{pipelineStatusLabel(stage)}</div>
|
||||
<div className="text-[var(--fs-label)] font-semibold">{label}</div>
|
||||
<div className="text-[var(--fs-micro)] leading-3" style={{ color: stage.failed ? theme.accent.danger : theme.node.muted }}>{pipelineStatusLabel(stage)}</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1 overflow-hidden [&_.ant-btn]:!h-7 [&_.ant-btn]:!px-2 [&_.ant-btn]:!text-[10px]">{children}</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1 overflow-hidden [&_.ant-btn]:!h-7 [&_.ant-btn]:!px-2 [&_.ant-btn]:!text-[var(--fs-tiny)]">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@ export function CanvasLinkedProjectEmptyState({ projectName, hasChapter, onAddFi
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 grid place-items-center px-4 pb-16 pt-20">
|
||||
<div className="pointer-events-auto w-full max-w-[440px] rounded-lg border p-3 shadow-sm backdrop-blur" data-canvas-no-zoom style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
|
||||
<div className="flex items-center gap-2.5"><span className="grid size-8 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}><FolderKanban className="size-4" /></span><div className="min-w-0"><h2 className="truncate text-sm font-semibold">{projectName}</h2><p className="mt-0.5 text-[11px]" style={{ color: theme.node.muted }}>项目画布为空</p></div></div>
|
||||
<div className="flex items-center gap-2.5"><span className="grid size-8 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}><FolderKanban className="size-4" /></span><div className="min-w-0"><h2 className="truncate text-sm font-semibold">{projectName}</h2><p className="mt-0.5 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>项目画布为空</p></div></div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-1.5">
|
||||
<button type="button" disabled={!hasChapter} onClick={onAddFirstChapter} className="flex h-9 min-w-0 items-center justify-center gap-1 rounded-md border px-2 text-[11px] font-medium disabled:opacity-35" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Plus className="size-3.5 shrink-0" /><span className="truncate">添加首章</span></button>
|
||||
<button type="button" onClick={onOpenAssets} className="flex h-9 min-w-0 items-center justify-center gap-1 rounded-md border px-2 text-[11px] font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Images className="size-3.5 shrink-0" /><span className="truncate">项目资产</span></button>
|
||||
<button type="button" onClick={onAddText} className="flex h-9 min-w-0 items-center justify-center gap-1 rounded-md border px-2 text-[11px] font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Type className="size-3.5 shrink-0" /><span className="truncate">新建文本</span></button>
|
||||
<button type="button" disabled={!hasChapter} onClick={onAddFirstChapter} className="flex h-9 min-w-0 items-center justify-center gap-1 rounded-md border px-2 text-[var(--fs-label)] font-medium disabled:opacity-35" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Plus className="size-3.5 shrink-0" /><span className="truncate">添加首章</span></button>
|
||||
<button type="button" onClick={onOpenAssets} className="flex h-9 min-w-0 items-center justify-center gap-1 rounded-md border px-2 text-[var(--fs-label)] font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Images className="size-3.5 shrink-0" /><span className="truncate">项目资产</span></button>
|
||||
<button type="button" onClick={onAddText} className="flex h-9 min-w-0 items-center justify-center gap-1 rounded-md border px-2 text-[var(--fs-label)] font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Type className="size-3.5 shrink-0" /><span className="truncate">新建文本</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,14 +138,14 @@ export function CanvasShortDramaGuide({ progress, collapsed, onToggle, onSkip, o
|
||||
} as CSSProperties}
|
||||
onClick={() => onStepClick(step.id)}
|
||||
>
|
||||
<span className="grid size-4 place-items-center rounded-full border text-[9px]" style={{ borderColor: step.status === "current" ? theme.accent.primary : theme.node.stroke, background: step.status === "completed" ? theme.accent.primary : "transparent", color: step.status === "completed" ? "#fff" : "currentColor" }}>{step.status === "completed" ? <Check className="size-2.5" /> : index + 1}</span>
|
||||
<span className="grid size-4 place-items-center rounded-full border text-[var(--fs-micro)]" style={{ borderColor: step.status === "current" ? theme.accent.primary : theme.node.stroke, background: step.status === "completed" ? theme.accent.primary : "transparent", color: step.status === "completed" ? "#fff" : "currentColor" }}>{step.status === "completed" ? <Check className="size-2.5" /> : index + 1}</span>
|
||||
{step.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="mx-1 h-4 w-px shrink-0" style={{ background: theme.toolbar.border }} />
|
||||
{!progress.completed ? <button type="button" className="inline-flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[11px] outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ color: theme.node.muted, "--tw-ring-color": theme.accent.primary } as CSSProperties} onClick={onSkip}><X className="size-3" />跳过导引</button> : null}
|
||||
{!progress.completed ? <button type="button" className="inline-flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[var(--fs-label)] outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ color: theme.node.muted, "--tw-ring-color": theme.accent.primary } as CSSProperties} onClick={onSkip}><X className="size-3" />跳过导引</button> : null}
|
||||
<button type="button" className="grid size-8 shrink-0 place-items-center rounded-md outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ color: theme.node.muted, "--tw-ring-color": theme.accent.primary } as CSSProperties} onClick={onToggle} aria-label="折叠短剧流程"><ChevronUp className="size-3.5" /></button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -373,10 +373,10 @@ export function CanvasStylePickerModal({ open, value, onClose, onSelect }: { ope
|
||||
<header className="flex items-center gap-3 border-b px-4 py-4 sm:px-5" style={{ borderColor: theme.node.stroke }}>
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover, color: theme.node.activeStroke }}><Palette className="size-4" /></span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold"><span>选择项目画风</span><span className="rounded-full px-2 py-0.5 text-[10px] font-medium" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{canvasStylePresets.length} 套视觉预设</span></div>
|
||||
<div className="mt-0.5 truncate text-[11px]" style={{ color: theme.node.muted }}>画风会统一角色三视图、项目资产、分镜和视频提示词。</div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold"><span>选择项目画风</span><span className="rounded-full px-2 py-0.5 text-[var(--fs-tiny)] font-medium" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{canvasStylePresets.length} 套视觉预设</span></div>
|
||||
<div className="mt-0.5 truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }}>画风会统一角色三视图、项目资产、分镜和视频提示词。</div>
|
||||
</div>
|
||||
<span className="ml-auto hidden items-center gap-1 text-[11px] sm:flex" style={{ color: theme.node.faint }}><Sparkles className="size-3.5" />先看规范,再做选择</span>
|
||||
<span className="ml-auto hidden items-center gap-1 text-[var(--fs-label)] sm:flex" style={{ color: theme.node.faint }}><Sparkles className="size-3.5" />先看规范,再做选择</span>
|
||||
</header>
|
||||
<div className="thin-scrollbar grid max-h-[76vh] grid-cols-1 gap-3 overflow-y-auto p-3 sm:grid-cols-2 sm:p-4 lg:grid-cols-3">
|
||||
{canvasStylePresets.map((preset) => {
|
||||
@@ -386,12 +386,12 @@ export function CanvasStylePickerModal({ open, value, onClose, onSelect }: { ope
|
||||
<button type="button" className="h-full w-full overflow-hidden rounded-lg border text-left transition hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2" style={{ background: theme.node.panel, borderColor: active ? theme.node.activeStroke : theme.node.stroke, boxShadow: active ? `0 0 0 1px ${theme.node.activeStroke}` : undefined, "--tw-ring-color": theme.node.activeStroke } as CSSProperties} onClick={() => onSelect(preset)}>
|
||||
<span className="relative block aspect-[16/9] overflow-hidden" style={{ background: theme.canvas.background }}>
|
||||
<img src={preset.imageUrl} width="960" height="540" alt={`${preset.title}画风示意`} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" style={preset.id === "black-white-noir" ? { filter: "grayscale(1) contrast(1.08)" } : undefined} loading="lazy" />
|
||||
<span className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 px-3 py-2 text-white" style={{ background: "linear-gradient(180deg, transparent, rgba(0,0,0,.72))" }}><span className="text-[10px] font-medium tracking-wide">{preset.category}</span>{active ? <span className="grid size-5 place-items-center rounded-full bg-white text-black"><Check className="size-3.5" /></span> : null}</span>
|
||||
<span className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 px-3 py-2 text-white" style={{ background: "linear-gradient(180deg, transparent, rgba(0,0,0,.72))" }}><span className="text-[var(--fs-tiny)] font-medium tracking-wide">{preset.category}</span>{active ? <span className="grid size-5 place-items-center rounded-full bg-white text-black"><Check className="size-3.5" /></span> : null}</span>
|
||||
</span>
|
||||
<span className="block p-3">
|
||||
<span className="flex items-center gap-2"><span className="text-sm font-semibold">{preset.title}</span>{active ? <span className="text-[10px]" style={{ color: theme.node.activeStroke }}>已选择</span> : null}</span>
|
||||
<span className="flex items-center gap-2"><span className="text-sm font-semibold">{preset.title}</span>{active ? <span className="text-[var(--fs-tiny)]" style={{ color: theme.node.activeStroke }}>已选择</span> : null}</span>
|
||||
<span className="mt-1 block text-xs leading-5" style={{ color: theme.node.muted }}>{preset.description}</span>
|
||||
<span className="mt-2 flex flex-wrap gap-1">{preset.tags.map((tag) => <span key={tag} className="rounded px-1.5 py-0.5 text-[10px]" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{tag}</span>)}</span>
|
||||
<span className="mt-2 flex flex-wrap gap-1">{preset.tags.map((tag) => <span key={tag} className="rounded px-1.5 py-0.5 text-[var(--fs-tiny)]" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{tag}</span>)}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="absolute right-2 top-2 grid size-8 place-items-center rounded-md bg-black/60 text-white shadow-sm backdrop-blur transition-colors hover:bg-black/75 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white" onClick={() => setDetailPreset(preset)} aria-label={`查看${preset.title}详情`} title="查看画风详情"><Eye className="size-4" /></button>
|
||||
@@ -399,7 +399,7 @@ export function CanvasStylePickerModal({ open, value, onClose, onSelect }: { ope
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<footer className="flex items-center gap-2 border-t px-4 py-3 text-[11px]" style={{ borderColor: theme.node.stroke, color: theme.node.faint }}><Clapperboard className="size-3.5" />图片是风格示意,实际效果取决于模型、角色参考图与分镜提示词。</footer>
|
||||
<footer className="flex items-center gap-2 border-t px-4 py-3 text-[var(--fs-label)]" style={{ borderColor: theme.node.stroke, color: theme.node.faint }}><Clapperboard className="size-3.5" />图片是风格示意,实际效果取决于模型、角色参考图与分镜提示词。</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
<CanvasStyleDetailModal open={Boolean(detailPreset)} preset={detailPreset} selected={detailPreset?.id === value} onClose={() => setDetailPreset(null)} onSelect={(preset) => { setDetailPreset(null); onSelect(preset); }} />
|
||||
@@ -418,15 +418,15 @@ export function CanvasStyleDetailModal({ open, preset, selected = false, onClose
|
||||
</aside>
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<header className="border-b px-5 py-4" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="text-[10px] font-medium" style={{ color: theme.node.activeStroke }}>{preset.category}</div>
|
||||
<div className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.activeStroke }}>{preset.category}</div>
|
||||
<h2 className="mt-1 text-xl font-semibold">{preset.title}</h2>
|
||||
<p className="mt-2 text-xs leading-5" style={{ color: theme.node.muted }}>{preset.description}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">{preset.tags.map((tag) => <span key={tag} className="rounded px-2 py-1 text-[10px]" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{tag}</span>)}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">{preset.tags.map((tag) => <span key={tag} className="rounded px-2 py-1 text-[var(--fs-tiny)]" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{tag}</span>)}</div>
|
||||
</header>
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-2">
|
||||
{sections.map((section) => <div key={section.title} className="border-b py-3 last:border-b-0" style={{ borderColor: theme.node.stroke }}><h3 className="text-xs font-semibold">{section.title}</h3><p className="mt-1.5 text-xs leading-5" style={{ color: theme.node.muted }}>{section.content}</p></div>)}
|
||||
</div>
|
||||
<footer className="flex flex-col gap-2 border-t px-5 py-3 sm:flex-row sm:items-center sm:justify-between" style={{ borderColor: theme.node.stroke }}><span className="text-[10px]" style={{ color: theme.node.faint }}>角色三视图会自动提取其中的色彩、造型和材质规范</span><div className="flex shrink-0 justify-end gap-2"><Button onClick={onClose}>关闭</Button>{onSelect ? <Button type="primary" disabled={selected} icon={selected ? <Check className="size-3.5" /> : <Palette className="size-3.5" />} onClick={() => onSelect(preset)}>{selected ? "当前画风" : "选择该画风"}</Button> : null}</div></footer>
|
||||
<footer className="flex flex-col gap-2 border-t px-5 py-3 sm:flex-row sm:items-center sm:justify-between" style={{ borderColor: theme.node.stroke }}><span className="text-[var(--fs-tiny)]" style={{ color: theme.node.faint }}>角色三视图会自动提取其中的色彩、造型和材质规范</span><div className="flex shrink-0 justify-end gap-2"><Button onClick={onClose}>关闭</Button>{onSelect ? <Button type="primary" disabled={selected} icon={selected ? <Check className="size-3.5" /> : <Palette className="size-3.5" />} onClick={() => onSelect(preset)}>{selected ? "当前画风" : "选择该画风"}</Button> : null}</div></footer>
|
||||
</section>
|
||||
</div> : null}
|
||||
</Modal>
|
||||
|
||||
@@ -51,7 +51,7 @@ export function CanvasTextEditorModal({ node, open, onClose, onSave }: CanvasTex
|
||||
content: emptyTextDocument(),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "min-h-full px-8 py-7 text-[15px] leading-7 outline-none sm:px-12 lg:px-16",
|
||||
class: "min-h-full px-8 py-7 text-[var(--fs-body-lg)] leading-7 outline-none sm:px-12 lg:px-16",
|
||||
"aria-label": "文本节点富文本编辑区",
|
||||
},
|
||||
},
|
||||
@@ -129,8 +129,8 @@ export function CanvasTextEditorModal({ node, open, onClose, onSave }: CanvasTex
|
||||
placeholder="文本节点标题"
|
||||
aria-label="文本节点标题"
|
||||
/>
|
||||
<span className="hidden shrink-0 text-[11px] sm:inline" style={{ color: theme.node.muted }}>{characterCount.toLocaleString("zh-CN")} 字 · {wordCount.toLocaleString("zh-CN")} 词</span>
|
||||
<span className="ml-auto hidden text-[11px] sm:inline" style={{ color: dirty ? theme.accent.primary : theme.node.muted }}>{dirty ? "有未保存修改" : "已保存"}</span>
|
||||
<span className="hidden shrink-0 text-[var(--fs-label)] sm:inline" style={{ color: theme.node.muted }}>{characterCount.toLocaleString("zh-CN")} 字 · {wordCount.toLocaleString("zh-CN")} 词</span>
|
||||
<span className="ml-auto hidden text-[var(--fs-label)] sm:inline" style={{ color: dirty ? theme.accent.primary : theme.node.muted }}>{dirty ? "有未保存修改" : "已保存"}</span>
|
||||
<Button size="small" type="primary" icon={<Save className="size-3.5" />} loading={saving} disabled={!dirty} onClick={() => void save()}>保存</Button>
|
||||
<Tooltip title="关闭">
|
||||
<Button size="small" type="text" icon={<X className="size-4" />} aria-label="关闭文本编辑器" onClick={close} />
|
||||
@@ -147,7 +147,7 @@ export function CanvasTextEditorModal({ node, open, onClose, onSave }: CanvasTex
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="flex h-8 shrink-0 items-center gap-3 border-t px-3 text-[10px]" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
|
||||
<footer className="flex h-8 shrink-0 items-center gap-3 border-t px-3 text-[var(--fs-tiny)]" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
|
||||
<span className="hidden sm:inline">支持标题、列表、引用、链接、代码和颜色格式</span>
|
||||
<span className="ml-auto">Ctrl/⌘S 保存</span>
|
||||
</footer>
|
||||
@@ -253,7 +253,7 @@ function TextEditorToolbar({ editor }: { editor: Editor | null }) {
|
||||
menu={{
|
||||
items: [
|
||||
{ key: "code", icon: <Code2 className="size-3.5" />, label: "行内代码" },
|
||||
{ key: "codeBlock", icon: <span className="text-[10px] font-bold">{"<>"}</span>, label: "代码块" },
|
||||
{ key: "codeBlock", icon: <span className="text-[var(--fs-tiny)] font-bold">{"<>"}</span>, label: "代码块" },
|
||||
{ key: "rule", icon: <Minus className="size-3.5" />, label: "插入分隔线" },
|
||||
{ type: "divider" },
|
||||
{ key: "clear", icon: <Eraser className="size-3.5" />, label: "清除格式" },
|
||||
|
||||
@@ -143,16 +143,16 @@ export function CanvasToolbar({
|
||||
<AnimatePresence>
|
||||
{appearanceOpen ? (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: aceternityMotion.duration.instant }} className="pointer-events-auto absolute bottom-[50px] z-30 w-[224px] -translate-x-1/2" style={{ left: panelX || "50%" }}>
|
||||
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ y: 6, scale: 0.97 }} animate={{ y: 0, scale: 1 }} exit={{ y: 4, scale: 0.98 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[17px] border p-2.5 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: `0 24px 64px ${theme.spatial.shadow}` }} onWheel={(event) => event.stopPropagation()}>
|
||||
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ y: 6, scale: 0.97 }} animate={{ y: 0, scale: 1 }} exit={{ y: 4, scale: 0.98 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2.5 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: `0 24px 64px ${theme.spatial.shadow}` }} onWheel={(event) => event.stopPropagation()}>
|
||||
<PanelHeading icon={<Palette className="size-4" />} title="画布外观" subtitle="调整整个创作空间" theme={theme} />
|
||||
<div className="mt-3 text-[9px] font-semibold uppercase opacity-45">主题模式</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-1 rounded-[11px] border p-1" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
|
||||
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">主题模式</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-1 rounded-[var(--dock-item-radius-labeled)] border p-1" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
|
||||
<CanvasThemeButton colorTheme={colorTheme} targetTheme="light" onThemeChange={setTheme}><Sun className="size-3.5" />浅色</CanvasThemeButton>
|
||||
<CanvasThemeButton colorTheme={colorTheme} targetTheme="dark" onThemeChange={setTheme}><Moon className="size-3.5" />深色</CanvasThemeButton>
|
||||
</div>
|
||||
<div className="mt-3 text-[9px] font-semibold uppercase opacity-45">空间网格</div>
|
||||
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">空间网格</div>
|
||||
<Segmented
|
||||
className="mt-1 w-full !rounded-[11px] !p-0.5 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-7 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-7 [&_.ant-segmented-item-label]:!text-[10px] [&_.ant-segmented-item-label]:!leading-7"
|
||||
className="mt-1 w-full !rounded-[var(--dock-item-radius-labeled)] !p-0.5 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-7 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-7 [&_.ant-segmented-item-label]:!text-[var(--fs-tiny)] [&_.ant-segmented-item-label]:!leading-7"
|
||||
value={backgroundMode}
|
||||
onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)}
|
||||
options={[
|
||||
@@ -161,8 +161,8 @@ export function CanvasToolbar({
|
||||
{ value: "blank", label: <span className="inline-flex items-center gap-1.5"><Square className="size-3.5" />空白</span> },
|
||||
]}
|
||||
/>
|
||||
<div className="mt-2.5 flex items-center justify-between gap-2 rounded-[11px] border px-2.5 py-2" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 text-[10px] font-semibold"><Info className="size-3" />图片信息</span>
|
||||
<div className="mt-2.5 flex items-center justify-between gap-2 rounded-[var(--dock-item-radius-labeled)] border px-2.5 py-2" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 text-[var(--fs-tiny)] font-semibold"><Info className="size-3" />图片信息</span>
|
||||
<Switch size="small" checked={showImageInfo} onChange={onShowImageInfoChange} />
|
||||
</div>
|
||||
</SpotlightSurface>
|
||||
@@ -214,7 +214,7 @@ function AddNodeMenu({ x, theme, workspaceMode, isProjectLinked, onAddText, onCh
|
||||
];
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: aceternityMotion.duration.instant }} className="pointer-events-auto absolute bottom-[50px] z-40 w-[260px] max-w-[calc(100vw-24px)] -translate-x-1/2" style={{ left: x || "50%" }}>
|
||||
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ y: 6, scale: 0.97 }} animate={{ y: 0, scale: 1 }} exit={{ y: 4, scale: 0.98 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[16px] border p-2 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 24px 64px ${theme.spatial.shadow}` }} onWheel={(event) => event.stopPropagation()}>
|
||||
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ y: 6, scale: 0.97 }} animate={{ y: 0, scale: 1 }} exit={{ y: 4, scale: 0.98 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 24px 64px ${theme.spatial.shadow}` }} onWheel={(event) => event.stopPropagation()}>
|
||||
<PanelHeading icon={<Plus className="size-4" />} title="创建内容" subtitle="选择节点类型" theme={theme} />
|
||||
<MenuSection title="创作节点" />
|
||||
<CanvasCreateCommandGrid commands={nodeCommands} />
|
||||
@@ -228,14 +228,14 @@ function AddNodeMenu({ x, theme, workspaceMode, isProjectLinked, onAddText, onCh
|
||||
function PanelHeading({ icon, title, subtitle, theme }: { icon: ReactNode; title: string; subtitle: string; theme: CanvasTheme }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-[10px] border opacity-75 [&_svg]:size-3.5" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>{icon}</span>
|
||||
<span className="min-w-0"><span className="block text-xs font-semibold">{title}</span><span className="mt-0.5 block text-[9px]" style={{ color: theme.node.muted }}>{subtitle}</span></span>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--dock-item-radius)] border opacity-75 [&_svg]:size-3.5" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>{icon}</span>
|
||||
<span className="min-w-0"><span className="block text-xs font-semibold">{title}</span><span className="mt-0.5 block text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{subtitle}</span></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSection({ title }: { title: string }) {
|
||||
return <div className="mb-1 mt-3 px-1 text-[9px] font-semibold uppercase opacity-42">{title}</div>;
|
||||
return <div className="mb-1 mt-3 px-1 text-[var(--fs-micro)] font-semibold uppercase opacity-42">{title}</div>;
|
||||
}
|
||||
|
||||
function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }: { colorTheme: CanvasColorTheme; targetTheme: CanvasColorTheme; onThemeChange: (theme: CanvasColorTheme) => void; children: ReactNode }) {
|
||||
@@ -246,7 +246,7 @@ function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }:
|
||||
theme={colorTheme}
|
||||
targetTheme={targetTheme}
|
||||
onThemeChange={onThemeChange}
|
||||
className="inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-[10px] px-2 text-xs font-semibold transition-colors"
|
||||
className="inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-xs font-semibold transition-colors"
|
||||
style={active ? { background: theme.node.text, color: theme.node.panel } : { color: theme.toolbar.item }}
|
||||
aria-label={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
|
||||
title={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
|
||||
|
||||
@@ -15,12 +15,12 @@ export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimar
|
||||
<article key={node.id} className="overflow-hidden rounded-lg border" style={{ borderColor: node.metadata?.versionPrimary ? theme.node.activeStroke : theme.node.stroke, background: theme.node.panel }}>
|
||||
<div className="flex h-11 items-center justify-between gap-3 border-b px-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2"><span className="grid size-6 shrink-0 place-items-center rounded-md text-xs font-bold" style={{ background: theme.toolbar.itemHover }}>{node.metadata?.versionLabel || "-"}</span><span className="min-w-0 truncate text-xs font-semibold" title={node.title}>{node.title}</span></div>
|
||||
{node.metadata?.versionPrimary ? <span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[10px] font-medium" style={{ color: theme.node.activeStroke }}><Check className="size-3" />主版本</span> : null}
|
||||
{node.metadata?.versionPrimary ? <span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.activeStroke }}><Check className="size-3" />主版本</span> : null}
|
||||
</div>
|
||||
<button type="button" className="block h-52 w-full overflow-hidden" style={{ background: theme.node.fill }} onClick={() => onFocus(node.id)}>
|
||||
{node.type === CanvasNodeType.Image && node.metadata?.content ? <img src={node.metadata.content} alt={node.title || "版本图片"} className="size-full object-contain" /> : node.type === CanvasNodeType.Video && node.metadata?.content ? <video src={node.metadata.content} className="size-full object-contain" muted preload="metadata" aria-label={node.title || "版本视频"} /> : <span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>点击定位到画布节点</span>}
|
||||
</button>
|
||||
<div className="space-y-2 p-3 text-[11px]">
|
||||
<div className="space-y-2 p-3 text-[var(--fs-label)]">
|
||||
<Info label="模型" value={node.metadata?.model || "默认模型"} />
|
||||
<Info label="尺寸" value={node.metadata?.size || "默认尺寸"} />
|
||||
<div><div className="whitespace-nowrap opacity-45">提示词</div><div className="mt-1 line-clamp-5 whitespace-pre-wrap break-words leading-5">{node.metadata?.composerContent || node.metadata?.prompt || "未填写"}</div></div>
|
||||
|
||||
@@ -161,7 +161,7 @@ function CompactMenuButton({
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-canvas-no-zoom
|
||||
className="fixed z-[1500] overflow-hidden rounded-[12px] border p-1 backdrop-blur-xl"
|
||||
className="fixed z-[var(--z-toast)] overflow-hidden rounded-[var(--r-lg)] border p-1 backdrop-blur-xl"
|
||||
style={menuStyle}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
@@ -204,7 +204,7 @@ function CompactMenuButton({
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="inline-flex h-6 w-full min-w-0 items-center gap-1 rounded-[10px] border-0 bg-transparent px-1.5 shadow-none transition hover:opacity-80 focus-visible:outline-none focus-visible:ring-1"
|
||||
className="inline-flex h-6 w-full min-w-0 items-center gap-1 rounded-[var(--dock-item-radius)] border-0 bg-transparent px-1.5 shadow-none transition hover:opacity-80 focus-visible:outline-none focus-visible:ring-1"
|
||||
style={buttonStyle}
|
||||
title={title}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function CanvasSelectionToolbar({ anchorRef, containerRef, count, childre
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<motion.div initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9, y: anchor.placement === "above" ? 8 : -8 }} animate={{ opacity: 1, scale: 1, y: 0 }} transition={aceternityMotion.spring.panel} className="flex items-center gap-2">
|
||||
<span className="aceternity-floating-panel shrink-0 rounded-full border px-2.5 py-1.5 text-[10px] font-semibold tabular-nums backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.spatial.glowStrong, color: theme.accent.primary, boxShadow: `0 14px 36px ${theme.spatial.shadow}` }}>已选 {count}</span>
|
||||
<span className="aceternity-floating-panel shrink-0 rounded-full border px-2.5 py-1.5 text-[var(--fs-tiny)] font-semibold tabular-nums backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.spatial.glowStrong, color: theme.accent.primary, boxShadow: `0 14px 36px ${theme.spatial.shadow}` }}>已选 {count}</span>
|
||||
<div className="max-w-[min(560px,calc(100vw-90px))]">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
@@ -154,7 +154,7 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
|
||||
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.97, rotateX: 2 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1, rotateX: 0 }}
|
||||
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
|
||||
className="aceternity-floating-panel absolute z-[120] w-[248px] origin-top-left overflow-hidden rounded-[16px] border p-2 backdrop-blur-2xl"
|
||||
className="aceternity-floating-panel absolute z-[120] w-[248px] origin-top-left overflow-hidden rounded-[var(--r-2xl)] border p-2 backdrop-blur-2xl"
|
||||
data-canvas-no-zoom
|
||||
data-connection-create-menu
|
||||
style={{ left: initialPosition.left, top: initialPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 30px 90px ${theme.spatial.shadow}` }}
|
||||
@@ -164,8 +164,8 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
|
||||
<div className="absolute inset-x-8 top-0 h-px" style={{ background: `linear-gradient(90deg, transparent, ${theme.toolbar.border}, transparent)` }} />
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2 px-1 py-0.5">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-[10px] border opacity-75" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}><WandSparkles className="size-3.5" /></span>
|
||||
<span className="min-w-0"><span className="block truncate text-[11px] font-semibold">创建下一步</span><span className="mt-0.5 block truncate text-[8px]" style={{ color: theme.node.muted }}>引用当前节点</span></span>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--dock-item-radius)] border opacity-75" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}><WandSparkles className="size-3.5" /></span>
|
||||
<span className="min-w-0"><span className="block truncate text-[var(--fs-label)] font-semibold">创建下一步</span><span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>引用当前节点</span></span>
|
||||
</span>
|
||||
<button type="button" className="grid size-6 shrink-0 place-items-center rounded-full border opacity-55 transition-opacity hover:opacity-100" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }} onClick={onClose} aria-label="关闭连线创建菜单"><X className="size-3" /></button>
|
||||
</div>
|
||||
@@ -184,11 +184,11 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
|
||||
function ConnectionCreateOption({ motionEnabled, icon, title, description, onClick }: { motionEnabled: boolean; icon: ReactNode; title: string; description?: string; onClick: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
return (
|
||||
<motion.button type="button" whileHover={motionEnabled ? { x: 2 } : undefined} whileTap={motionEnabled ? { scale: 0.98 } : undefined} transition={aceternityMotion.spring.dock} className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-[9px] border border-transparent px-2 py-1.5 text-left outline-none hover:border-black/10 hover:bg-black/5 focus-visible:ring-2 dark:hover:border-white/10 dark:hover:bg-white/8" style={{ color: theme.node.text, "--tw-ring-color": theme.node.muted } as CSSProperties} onClick={onClick}>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-[8px] opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-3.5" style={{ background: theme.toolbar.itemHover }}>{icon}</span>
|
||||
<motion.button type="button" whileHover={motionEnabled ? { x: 2 } : undefined} whileTap={motionEnabled ? { scale: 0.98 } : undefined} transition={aceternityMotion.spring.dock} className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-[var(--dock-item-radius)] border border-transparent px-2 py-1.5 text-left outline-none hover:border-black/10 hover:bg-black/5 focus-visible:ring-2 dark:hover:border-white/10 dark:hover:bg-white/8" style={{ color: theme.node.text, "--tw-ring-color": theme.node.muted } as CSSProperties} onClick={onClick}>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-md)] opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-3.5" style={{ background: theme.toolbar.itemHover }}>{icon}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2 text-[10px] font-semibold leading-4">{title}</span>
|
||||
{description ? <span className="mt-0.5 block truncate text-[8px]" style={{ color: theme.node.muted }}>{description}</span> : null}
|
||||
<span className="flex items-center gap-2 text-[var(--fs-tiny)] font-semibold leading-4">{title}</span>
|
||||
{description ? <span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{description}</span> : null}
|
||||
</span>
|
||||
<ChevronRight className="size-3.5 shrink-0 opacity-35 transition-transform group-hover:translate-x-0.5" />
|
||||
</motion.button>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
|
||||
{
|
||||
id: "zoom-precision",
|
||||
label: "精确缩放",
|
||||
icon: <span className="flex items-baseline text-[9px] font-bold tabular-nums"><span ref={dockLabelRef}>{Math.round(scale * 100)}</span><span className="ml-px text-[7px] opacity-55">%</span></span>,
|
||||
icon: <span className="flex items-baseline text-[var(--fs-micro)] font-bold tabular-nums"><span ref={dockLabelRef}>{Math.round(scale * 100)}</span><span className="ml-px text-[7px] opacity-55">%</span></span>,
|
||||
active: precisionOpen,
|
||||
onClick: () => setPrecisionOpen((value) => !value),
|
||||
},
|
||||
@@ -94,16 +94,16 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 9, scale: 0.96 }}
|
||||
transition={aceternityMotion.spring.panel}
|
||||
className="aceternity-floating-panel absolute bottom-[50px] left-0 w-[220px] overflow-hidden rounded-[17px] border p-2.5 backdrop-blur-2xl"
|
||||
className="aceternity-floating-panel absolute bottom-[50px] left-0 w-[220px] overflow-hidden rounded-[var(--panel-radius)] border p-2.5 backdrop-blur-2xl"
|
||||
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 28px 80px ${theme.spatial.shadow}` }}
|
||||
>
|
||||
<div className="absolute inset-x-10 top-0 h-px" style={{ background: `linear-gradient(90deg, transparent, ${theme.spatial.glowStrong}, transparent)` }} />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>
|
||||
<span className="block text-[10px] font-semibold">画布尺度</span>
|
||||
<span className="mt-0.5 block text-[9px]" style={{ color: theme.node.muted }}>精确控制视野密度</span>
|
||||
<span className="block text-[var(--fs-tiny)] font-semibold">画布尺度</span>
|
||||
<span className="mt-0.5 block text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>精确控制视野密度</span>
|
||||
</span>
|
||||
<span ref={panelLabelRef} className="rounded-full border px-2 py-0.5 text-[10px] font-semibold tabular-nums" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: theme.accent.primary }}>
|
||||
<span ref={panelLabelRef} className="rounded-full border px-2 py-0.5 text-[var(--fs-tiny)] font-semibold tabular-nums" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: theme.accent.primary }}>
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -127,7 +127,7 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
|
||||
whileHover={{ y: -1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={aceternityMotion.spring.dock}
|
||||
className="h-7 rounded-[9px] border text-[9px] font-semibold tabular-nums outline-none focus-visible:ring-2"
|
||||
className="h-7 rounded-[var(--dock-item-radius)] border text-[var(--fs-micro)] font-semibold tabular-nums outline-none focus-visible:ring-2"
|
||||
style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: theme.node.muted }}
|
||||
onClick={() => commitScale(level)}
|
||||
>
|
||||
|
||||
@@ -17,10 +17,10 @@ export function CanvasDirectorNodePanel({ node, scene, previewUrl, onOpen, profe
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover }}><Clapperboard className="size-3.5" /></span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{node.metadata?.workflowTitle || node.title}</div>
|
||||
<div className="truncate text-[10px]" style={{ color: theme.node.muted }}>{shot?.name || "未设置镜头"}</div>
|
||||
<div className="truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{shot?.name || "未设置镜头"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] font-semibold" style={{ color: theme.accent.primary }}>3D</span>
|
||||
<span className="shrink-0 text-[var(--fs-tiny)] font-semibold" style={{ color: theme.accent.primary }}>3D</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -38,7 +38,7 @@ export function CanvasDirectorNodePanel({ node, scene, previewUrl, onOpen, profe
|
||||
<span className={`absolute inset-x-0 bottom-0 flex h-10 items-center justify-center gap-1.5 text-xs font-semibold backdrop-blur-sm transition-opacity ${professional ? "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100" : "opacity-100"}`} style={{ background: `${theme.toolbar.panel}dd`, color: theme.node.text }}>{professional ? <><Move3d className="size-3.5" />进入导演台</> : <><LockKeyhole className="size-3.5" />专业模式可编辑</>}</span>
|
||||
</button>
|
||||
|
||||
<div className="mt-2 grid grid-cols-3 gap-1 text-[10px]" style={{ color: theme.node.muted }}>
|
||||
<div className="mt-2 grid grid-cols-3 gap-1 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
|
||||
<Stat icon={<Box className="size-3" />} value={scene?.objects.length || 0} label="对象" />
|
||||
<Stat icon={<Camera className="size-3" />} value={scene?.cameras.length || 0} label="机位" />
|
||||
<Stat icon={<Lightbulb className="size-3" />} value={scene?.lights.length || 0} label="灯光" />
|
||||
@@ -61,7 +61,7 @@ function SceneSchematic({ scene }: { scene: DirectorScene | null }) {
|
||||
})}
|
||||
<Camera className="absolute bottom-[14%] left-[12%] size-5 text-white/75" />
|
||||
<span className="absolute left-[18%] top-[18%] size-16 rounded-full bg-white/10 blur-xl" />
|
||||
<span className="absolute inset-x-3 top-3 text-[10px] font-medium text-white/70">{scene ? "场景预览" : "正在准备场景"}</span>
|
||||
<span className="absolute inset-x-3 top-3 text-[var(--fs-tiny)] font-medium text-white/70">{scene ? "场景预览" : "正在准备场景"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
|
||||
|
||||
<main className="relative min-h-0 overflow-hidden bg-neutral-900">
|
||||
<DirectorViewport ref={viewportRef} scene={draft} selectedObjectId={selectedObjectId} transformMode={transformMode} renderMode={renderMode} playhead={playhead} onSelectObject={setSelectedObjectId} onObjectTransform={(id, transform) => updateObject(id, { transform })} />
|
||||
<div className="pointer-events-none absolute left-3 top-3 text-[10px] font-medium text-white/70">{activeShot.name} · {activeCamera?.name || "无摄影机"} · {activeShot.duration}s</div>
|
||||
<div className="pointer-events-none absolute left-3 top-3 text-[var(--fs-tiny)] font-medium text-white/70">{activeShot.name} · {activeCamera?.name || "无摄影机"} · {activeShot.duration}s</div>
|
||||
</main>
|
||||
|
||||
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-l max-lg:hidden" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
|
||||
@@ -249,12 +249,12 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
|
||||
<footer className="shrink-0 border-t px-3 py-2" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconButton label={playing ? "暂停" : "播放"} onClick={() => setPlaying(!playing)}>{playing ? <Pause className="size-4" /> : <Play className="size-4" />}</IconButton>
|
||||
<span className="w-12 text-right text-[11px] tabular-nums">{playhead.toFixed(1)}s</span>
|
||||
<span className="w-12 text-right text-[var(--fs-label)] tabular-nums">{playhead.toFixed(1)}s</span>
|
||||
<Slider className="min-w-0 flex-1" min={0} max={activeShot.duration} step={0.05} value={playhead} onChange={setPlayhead} />
|
||||
<Button size="small" type="text" icon={<Focus className="size-3.5" />} onClick={selectedObject ? addObjectKeyframe : addCameraKeyframe}>记录关键帧</Button>
|
||||
</div>
|
||||
<div className="mt-1.5 flex gap-1 overflow-x-auto">
|
||||
{draft.shots.map((shot, index) => <button key={shot.id} type="button" className="h-8 min-w-28 shrink-0 border-l-2 px-2 text-left text-[11px] transition" style={{ borderColor: draft.activeShotId === shot.id ? theme.node.activeStroke : theme.toolbar.border, background: draft.activeShotId === shot.id ? theme.toolbar.itemHover : "transparent" }} onClick={() => { commit((current) => ({ ...current, activeShotId: shot.id })); setPlayhead(0); }}><span className="block truncate">{index + 1}. {shot.name}</span><span className="block opacity-45">{shot.duration}s</span></button>)}
|
||||
{draft.shots.map((shot, index) => <button key={shot.id} type="button" className="h-8 min-w-28 shrink-0 border-l-2 px-2 text-left text-[var(--fs-label)] transition" style={{ borderColor: draft.activeShotId === shot.id ? theme.node.activeStroke : theme.toolbar.border, background: draft.activeShotId === shot.id ? theme.toolbar.itemHover : "transparent" }} onClick={() => { commit((current) => ({ ...current, activeShotId: shot.id })); setPlayhead(0); }}><span className="block truncate">{index + 1}. {shot.name}</span><span className="block opacity-45">{shot.duration}s</span></button>)}
|
||||
<button type="button" className="grid h-8 w-9 shrink-0 place-items-center" title="新增镜头" onClick={addShot}><Plus className="size-4" /></button>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -263,7 +263,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
|
||||
}
|
||||
|
||||
function ObjectInspector({ object, playhead, onUpdate, onAddKeyframe, onDelete }: { object: DirectorObject; playhead: number; onUpdate: (patch: Partial<DirectorObject>) => void; onAddKeyframe: () => void; onDelete: () => void }) {
|
||||
return <Inspector title={object.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}><TransformFields transform={object.transform} onChange={(transform) => onUpdate({ transform })} /><Field label="颜色"><ColorPicker value={object.color} onChange={(_, color) => onUpdate({ color })} /></Field>{object.primitive === "character" ? <Field label="姿势"><Select className="w-full" value={object.pose} options={poseOptions} onChange={(pose) => onUpdate({ pose })} /></Field> : null}<Field label="可见"><Switch checked={object.visible} onChange={(visible) => onUpdate({ visible })} /></Field><Field label="投射阴影"><Switch checked={object.castShadow} onChange={(castShadow) => onUpdate({ castShadow })} /></Field><Button block icon={<Focus className="size-3.5" />} onClick={onAddKeyframe}>在 {playhead.toFixed(1)}s 记录关键帧</Button><div className="text-[10px] opacity-50">已记录 {object.keyframes.length} 个关键帧</div></Inspector>;
|
||||
return <Inspector title={object.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}><TransformFields transform={object.transform} onChange={(transform) => onUpdate({ transform })} /><Field label="颜色"><ColorPicker value={object.color} onChange={(_, color) => onUpdate({ color })} /></Field>{object.primitive === "character" ? <Field label="姿势"><Select className="w-full" value={object.pose} options={poseOptions} onChange={(pose) => onUpdate({ pose })} /></Field> : null}<Field label="可见"><Switch checked={object.visible} onChange={(visible) => onUpdate({ visible })} /></Field><Field label="投射阴影"><Switch checked={object.castShadow} onChange={(castShadow) => onUpdate({ castShadow })} /></Field><Button block icon={<Focus className="size-3.5" />} onClick={onAddKeyframe}>在 {playhead.toFixed(1)}s 记录关键帧</Button><div className="text-[var(--fs-tiny)] opacity-50">已记录 {object.keyframes.length} 个关键帧</div></Inspector>;
|
||||
}
|
||||
|
||||
function LightInspector({ light, onUpdate, onDelete }: { light: DirectorLight; onUpdate: (patch: Partial<DirectorLight>) => void; onDelete: () => void }) {
|
||||
@@ -286,10 +286,10 @@ function Vec3Field({ label, value, step = 0.1, onChange }: { label: string; valu
|
||||
return <Field label={label}><div className="grid grid-cols-3 gap-1">{value.map((item, index) => <InputNumber key={index} className="w-full" size="small" step={step} value={Number(item.toFixed(2))} onChange={(next) => onChange(value.map((entry, itemIndex) => itemIndex === index ? next || 0 : entry) as DirectorVec3)} />)}</div></Field>;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) { return <label className="block"><span className="mb-1 block text-[11px] opacity-55">{label}</span>{children}</label>; }
|
||||
function PanelTitle({ title, action }: { title: string; action?: ReactNode }) { return <div className="flex h-9 items-center px-3 text-[10px] font-semibold uppercase opacity-55"><span className="flex-1">{title}</span>{action}</div>; }
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) { return <label className="block"><span className="mb-1 block text-[var(--fs-label)] opacity-55">{label}</span>{children}</label>; }
|
||||
function PanelTitle({ title, action }: { title: string; action?: ReactNode }) { return <div className="flex h-9 items-center px-3 text-[var(--fs-tiny)] font-semibold uppercase opacity-55"><span className="flex-1">{title}</span>{action}</div>; }
|
||||
function SceneRow({ active, icon, label, onClick }: { active?: boolean; icon: ReactElement; label: string; onClick: () => void }) { return <button type="button" className={`flex h-8 w-full items-center gap-2 px-2 text-left text-xs transition ${active ? "bg-black/10 dark:bg-white/10" : "hover:bg-black/5 dark:hover:bg-white/5"}`} onClick={onClick}><span className="[&>svg]:size-3.5">{icon}</span><span className="truncate">{label}</span></button>; }
|
||||
function QuickAdd({ label, icon, onClick }: { label: string; icon: ReactElement; onClick: () => void }) { return <button type="button" className="flex h-8 items-center gap-1.5 border px-2 text-[10px] transition hover:bg-black/5 dark:hover:bg-white/5" onClick={onClick}><span className="[&>svg]:size-3.5">{icon}</span><span className="truncate">{label}</span></button>; }
|
||||
function QuickAdd({ label, icon, onClick }: { label: string; icon: ReactElement; onClick: () => void }) { return <button type="button" className="flex h-8 items-center gap-1.5 border px-2 text-[var(--fs-tiny)] transition hover:bg-black/5 dark:hover:bg-white/5" onClick={onClick}><span className="[&>svg]:size-3.5">{icon}</span><span className="truncate">{label}</span></button>; }
|
||||
function IconButton({ label, disabled, children, onClick }: { label: string; disabled?: boolean; children: ReactNode; onClick: () => void }) { return <button type="button" aria-label={label} title={label} disabled={disabled} className="grid size-8 shrink-0 place-items-center rounded-md transition hover:bg-black/5 disabled:opacity-30 dark:hover:bg-white/10" onClick={onClick}>{children}</button>; }
|
||||
function ToolButton({ label, active, children, onClick }: { label: string; active: boolean; children: ReactNode; onClick: () => void }) { return <button type="button" aria-label={label} title={label} className={`grid size-8 place-items-center rounded-md transition ${active ? "bg-black text-white dark:bg-white dark:text-black" : "hover:bg-black/5 dark:hover:bg-white/10"}`} onClick={onClick}>{children}</button>; }
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export function ImageGenerationPending({ className, label, compact = false }: {
|
||||
maskImage: "radial-gradient(ellipse at 38% 68%, black 0%, black 28%, transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute left-4 top-4 flex items-center gap-2 text-[15px] font-medium text-stone-500 dark:text-stone-300">
|
||||
<div className="absolute left-4 top-4 flex items-center gap-2 text-[var(--fs-body-lg)] font-medium text-stone-500 dark:text-stone-300">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
<span>{label || pendingMessages[index]}</span>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +84,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<SettingTitle color={theme.node.muted}>透明背景</SettingTitle>
|
||||
<div className="mt-1 text-[11px]" style={{ color: theme.node.muted }}>
|
||||
<div className="mt-1 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
|
||||
请求模型输出保留 Alpha 通道的 PNG
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,7 +121,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className="flex h-[52px] cursor-pointer flex-col items-center justify-center gap-0.5 rounded-lg bg-transparent text-[11px] transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
|
||||
className="flex h-[52px] cursor-pointer flex-col items-center justify-center gap-0.5 rounded-lg bg-transparent text-[var(--fs-label)] transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
|
||||
style={{ background: selectedAspect?.value === item.value ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={() => selectAspect(item.value)}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
>
|
||||
<Link to="/" className={cn("min-w-0 items-center gap-2", mobileSidebarExpanded || spatialWorkbench ? "flex" : "hidden", "lg:flex")} title="影策">
|
||||
<span className="app-workspace-brand-mark grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background"><InfinityIcon className="size-4" /></span>
|
||||
{spatialWorkbench ? <span className="min-w-0"><span className="block truncate text-[13px] font-semibold">影策</span><span className="block truncate text-[9px] text-foreground/36">AI 叙事工作台</span></span> : <span className="truncate text-[13px] font-semibold">影策</span>}
|
||||
{spatialWorkbench ? <span className="min-w-0"><span className="block truncate text-[var(--fs-body)] font-semibold">影策</span><span className="block truncate text-[var(--fs-micro)] text-foreground/36">AI 叙事工作台</span></span> : <span className="truncate text-[var(--fs-body)] font-semibold">影策</span>}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
const showSection = index === 0 || tool.section !== visibleNavigationTools[index - 1]?.section;
|
||||
return (
|
||||
<Fragment key={tool.slug}>
|
||||
{showSection ? <div className={cn("mb-2 px-2 text-[10px] font-medium text-foreground/34", index > 0 && "mt-4", mobileSidebarExpanded ? "block" : "hidden", "lg:hidden")}>{tool.section}</div> : null}
|
||||
{showSection ? <div className={cn("mb-2 px-2 text-[var(--fs-tiny)] font-medium text-foreground/34", index > 0 && "mt-4", mobileSidebarExpanded ? "block" : "hidden", "lg:hidden")}>{tool.section}</div> : null}
|
||||
<Link
|
||||
to={`/${tool.slug}`}
|
||||
title={tool.label}
|
||||
@@ -91,7 +91,7 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
if (window.innerWidth < 1024) setMobileSidebarExpanded(false);
|
||||
}}
|
||||
className={cn(
|
||||
"app-workspace-nav-link relative mb-1 flex shrink-0 items-center rounded-md text-[13px] transition-colors",
|
||||
"app-workspace-nav-link relative mb-1 flex shrink-0 items-center rounded-md text-[var(--fs-body)] transition-colors",
|
||||
spatialWorkbench ? "h-11" : "h-9",
|
||||
mobileSidebarExpanded ? "gap-3 px-2.5" : "justify-center px-0",
|
||||
"lg:justify-center lg:px-0",
|
||||
|
||||
@@ -12,7 +12,7 @@ export function IdentityProviderBadge({ user, compact = false, className }: { us
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center border border-border bg-background font-semibold text-foreground/65",
|
||||
compact ? "size-3.5 rounded-full text-[8px] leading-none" : "h-4 rounded px-1 text-[9px] leading-none",
|
||||
compact ? "size-3.5 rounded-full text-[var(--fs-micro)] leading-none" : "h-4 rounded px-1 text-[var(--fs-micro)] leading-none",
|
||||
className,
|
||||
)}
|
||||
title={user.identityUsername ? `Linux.do · @${user.identityUsername}` : "Linux.do 用户"}
|
||||
|
||||
@@ -114,7 +114,7 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
|
||||
{showLabel ? (
|
||||
<span className={`min-w-0 flex-1 items-center justify-between gap-2 whitespace-nowrap ${labelClassName || ""}`}>
|
||||
<span>系统公告</span>
|
||||
<Tag color={unreadCount > 0 ? "gold" : undefined} className="!m-0 !min-w-6 !px-1.5 !text-center !text-[9px] !font-medium !leading-[18px] tabular-nums">{announcements.length}</Tag>
|
||||
<Tag color={unreadCount > 0 ? "gold" : undefined} className="!m-0 !min-w-6 !px-1.5 !text-center !text-[var(--fs-micro)] !font-medium !leading-[18px] tabular-nums">{announcements.length}</Tag>
|
||||
</span>
|
||||
) : null}
|
||||
</motion.button>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function PageHeader({ title, description, meta, actions, icon }: { title:
|
||||
{icon ? <WorkspaceSignalIcon variant={icon} /> : null}
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-2.5">
|
||||
<h1 className="truncate text-[22px] font-semibold leading-7">{title}</h1>
|
||||
<h1 className="truncate text-[var(--fs-title)] font-semibold leading-7">{title}</h1>
|
||||
{meta}
|
||||
</div>
|
||||
{description ? <p className="mt-1 text-xs leading-5 text-foreground/58">{description}</p> : null}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
|
||||
<UserAvatar user={user} className="size-8" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5"><span className="truncate text-sm font-medium">{user.displayName || user.username}</span><IdentityProviderBadge user={user} /></div>
|
||||
<div className="mt-0.5 truncate text-[11px] tabular-nums text-foreground/45">可用 {balance} 积分</div>
|
||||
<div className="mt-0.5 truncate text-[var(--fs-label)] tabular-nums text-foreground/45">可用 {balance} 积分</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
|
||||
) : null}
|
||||
|
||||
<div className="border-y border-border/65 py-2">
|
||||
<AppChangelogButton className="flex h-8 w-full items-center gap-2 rounded px-2 text-[11px] text-foreground/58 hover:bg-foreground/[.055] hover:text-foreground [&_svg]:size-3.5" showLabel showVersion versionClassName="ml-auto text-[9px] tabular-nums text-foreground/32" />
|
||||
<AppChangelogButton className="flex h-8 w-full items-center gap-2 rounded px-2 text-[var(--fs-label)] text-foreground/58 hover:bg-foreground/[.055] hover:text-foreground [&_svg]:size-3.5" showLabel showVersion versionClassName="ml-auto text-[var(--fs-micro)] tabular-nums text-foreground/32" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-10 items-center px-2">
|
||||
@@ -96,7 +96,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
|
||||
<UserAvatar user={user} className="size-7" />
|
||||
<span className={cn("min-w-0 flex-1 flex-col", expandedClassName)}>
|
||||
<span className="truncate text-xs font-medium">{user.displayName || user.username}</span>
|
||||
<span className="mt-0.5 block truncate text-[9px] tabular-nums text-foreground/42">{balance} 积分</span>
|
||||
<span className="mt-0.5 block truncate text-[var(--fs-micro)] tabular-nums text-foreground/42">{balance} 积分</span>
|
||||
</span>
|
||||
<ChevronRight className={cn("size-3.5 shrink-0 text-foreground/30", expandedClassName)} />
|
||||
</button>
|
||||
|
||||
@@ -8,7 +8,7 @@ export function WorkspaceState({ icon = "empty", title, description, action, com
|
||||
return (
|
||||
<section className={cn("workspace-state flex flex-col items-center justify-center text-center", compact ? "min-h-44 py-8" : "min-h-[320px] py-12", className)}>
|
||||
<WorkspaceSignalIcon variant={icon} size={compact ? "md" : "lg"} />
|
||||
<h2 className="mt-4 text-[15px] font-semibold leading-6 text-foreground">{title}</h2>
|
||||
<h2 className="mt-4 text-[var(--fs-body-lg)] font-semibold leading-6 text-foreground">{title}</h2>
|
||||
{description ? <p className="mt-1.5 max-w-[42ch] text-xs leading-5 text-foreground/58">{description}</p> : null}
|
||||
{action ? <div className="mt-5">{action}</div> : null}
|
||||
</section>
|
||||
|
||||
@@ -195,11 +195,11 @@ function ModelLabel({ config, model, capability, theme, creationVariant }: { con
|
||||
<ModelIcon model={model} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
<span className="block min-w-0 truncate text-[11px] font-medium leading-none">{modelDisplayName(config, model)}</span>
|
||||
<span className="mt-1 block truncate text-[10px]" style={{ color: theme.node.muted }} title={meta.description}>{meta.description}</span>
|
||||
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{modelDisplayName(config, model)}</span>
|
||||
<span className="mt-1 block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }} title={meta.description}>{meta.description}</span>
|
||||
</span>
|
||||
<ModelPrice price={modelMenuPrice(config, model)} />
|
||||
{!creationVariant && meta.time ? <span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{meta.time}</span> : null}
|
||||
{!creationVariant && meta.time ? <span className="shrink-0 rounded-full px-1.5 py-0.5 text-[var(--fs-tiny)] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{meta.time}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -214,9 +214,9 @@ function modelMenuPrice(config: AiConfig, model: string): { value: number; unit:
|
||||
|
||||
function ModelPrice({ price, compact = false }: { price: { value: number; unit: "次" | "秒" } | null | undefined; compact?: boolean }) {
|
||||
if (price === undefined) return null;
|
||||
if (price === null) return compact ? null : <span className="shrink-0 text-[10px] text-foreground/40">未配置</span>;
|
||||
if (price === null) return compact ? null : <span className="shrink-0 text-[var(--fs-tiny)] text-foreground/40">未配置</span>;
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-[10px] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`每${price.unit}消耗 ${price.value.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`}>
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`每${price.unit}消耗 ${price.value.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`}>
|
||||
<Coins className="size-3" />
|
||||
{price.value.toLocaleString("zh-CN", { maximumFractionDigits: compact ? 3 : 6 })}/{price.unit}
|
||||
</span>
|
||||
|
||||
@@ -106,10 +106,10 @@ function AnnouncementTimelineItem({ announcement, last, reducedMotion }: { annou
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h3 className="text-[15px] font-semibold leading-6 tracking-normal text-foreground sm:text-base">{announcement.title}</h3>
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-foreground/45"><Icon className="size-3" />{meta.label}</span>
|
||||
<h3 className="text-[var(--fs-body-lg)] font-semibold leading-6 tracking-normal text-foreground sm:text-base">{announcement.title}</h3>
|
||||
<span className="inline-flex items-center gap-1 text-[var(--fs-label)] font-medium text-foreground/45"><Icon className="size-3" />{meta.label}</span>
|
||||
</div>
|
||||
<AnnouncementContent content={announcement.content} className="mt-1 text-sm leading-6 text-foreground/75 sm:text-[15px]" />
|
||||
<AnnouncementContent content={announcement.content} className="mt-1 text-sm leading-6 text-foreground/75 sm:text-[var(--fs-body-lg)]" />
|
||||
<time dateTime={announcement.publishedAt} className="mt-2 block text-xs tabular-nums text-foreground/40">{relativeTime(announcement.publishedAt)} · {formatDateTime(announcement.publishedAt)}</time>
|
||||
</div>
|
||||
</motion.article>
|
||||
|
||||
@@ -78,10 +78,10 @@ export const FloatingDock = forwardRef<HTMLDivElement, FloatingDockProps>(functi
|
||||
: coarsePointer
|
||||
? embedded
|
||||
? size === "compact" ? "h-10 gap-1 px-0.5" : "h-11 gap-1 px-0.5"
|
||||
: size === "compact" ? "h-11 gap-1 rounded-[15px] px-1.5 pb-1" : "h-12 gap-1 rounded-[17px] px-2 pb-1"
|
||||
: size === "compact" ? "h-11 gap-1 rounded-[15px] px-1.5 pb-1" : "h-12 gap-1 rounded-[var(--panel-radius)] px-2 pb-1"
|
||||
: embedded
|
||||
? size === "compact" ? "h-8 gap-0.5 px-0.5 pb-0.5" : "h-9 gap-0.5 px-0.5 pb-0.5"
|
||||
: size === "compact" ? "h-8 gap-0.5 rounded-[12px] px-1 pb-1" : "h-10 gap-0.5 rounded-[14px] px-1.5 pb-1",
|
||||
: size === "compact" ? "h-8 gap-0.5 rounded-[var(--r-lg)] px-1 pb-1" : "h-10 gap-0.5 rounded-[var(--dock-radius)] px-1.5 pb-1",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
@@ -118,7 +118,7 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s
|
||||
aria-label={command.label}
|
||||
aria-pressed={command.active || undefined}
|
||||
disabled={command.disabled}
|
||||
className={cn("aceternity-dock-command is-labeled group inline-flex h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-[9px] border-0 px-2.5 outline-none", command.active && "is-active", command.danger && "is-danger")}
|
||||
className={cn("aceternity-dock-command is-labeled group inline-flex h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--dock-item-radius)] border-0 px-2.5 outline-none", command.active && "is-active", command.danger && "is-danger")}
|
||||
whileTap={!command.disabled ? { scale: 0.96 } : undefined}
|
||||
transition={aceternityMotion.spring.dock}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
@@ -128,7 +128,7 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s
|
||||
onClick={command.onClick}
|
||||
>
|
||||
<span className="grid size-3.5 shrink-0 place-items-center">{command.icon}</span>
|
||||
<span className="inline-flex h-4 items-center text-[11px] font-medium leading-none">{command.displayLabel || command.label}</span>
|
||||
<span className="inline-flex h-4 items-center text-[var(--fs-label)] font-medium leading-none">{command.displayLabel || command.label}</span>
|
||||
</motion.button>
|
||||
</motion.span>
|
||||
);
|
||||
@@ -161,7 +161,7 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 4, scale: 0.96 }}
|
||||
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
|
||||
className={cn("aceternity-dock-tooltip pointer-events-none absolute left-1/2 z-[140] -translate-x-1/2 whitespace-nowrap border font-medium shadow-xl backdrop-blur-xl", compact ? "-top-7 rounded-md px-1.5 py-0.5 text-[9px]" : "-top-8 rounded-md px-2 py-1 text-[10px]")}
|
||||
className={cn("aceternity-dock-tooltip pointer-events-none absolute left-1/2 z-[140] -translate-x-1/2 whitespace-nowrap border font-medium shadow-xl backdrop-blur-xl", compact ? "-top-7 rounded-md px-1.5 py-0.5 text-[var(--fs-micro)]" : "-top-8 rounded-md px-2 py-1 text-[var(--fs-tiny)]")}
|
||||
>
|
||||
{command.label}
|
||||
</motion.span>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className="flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-md px-1 text-[11px] font-medium transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
|
||||
className="flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-md px-1 text-[var(--fs-label)] font-medium transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
|
||||
style={{ background: size === item.value ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={() => onConfigChange("size", item.value)}
|
||||
@@ -141,7 +141,7 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isSeedanceFastModel(model) ? <div className="text-[10px] leading-4 opacity-55">fast 模型自动使用 720P</div> : null}
|
||||
{isSeedanceFastModel(model) ? <div className="text-[var(--fs-tiny)] leading-4 opacity-55">fast 模型自动使用 720P</div> : null}
|
||||
</SettingGroup>
|
||||
<SettingGroup title="比例" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
@@ -149,7 +149,7 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className="flex h-11 min-w-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-md px-1 text-[10px] font-medium leading-none transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
|
||||
className="flex h-11 min-w-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-md px-1 text-[var(--fs-tiny)] font-medium leading-none transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
|
||||
style={{ background: ratio === item.value ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={() => onConfigChange("size", item.value)}
|
||||
@@ -211,7 +211,7 @@ export function normalizeVideoResolutionValue(value: string) {
|
||||
|
||||
function OptionPill({ selected, disabled = false, theme, onClick, children }: { selected: boolean; disabled?: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button type="button" disabled={disabled} className="h-8 cursor-pointer whitespace-nowrap rounded-md px-1 text-[11px] font-medium leading-none transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:opacity-35" style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }} onMouseDown={(event) => event.stopPropagation()} onClick={onClick}>
|
||||
<button type="button" disabled={disabled} className="h-8 cursor-pointer whitespace-nowrap rounded-md px-1 text-[var(--fs-label)] font-medium leading-none transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:opacity-35" style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }} onMouseDown={(event) => event.stopPropagation()} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -220,7 +220,7 @@ function OptionPill({ selected, disabled = false, theme, onClick, children }: {
|
||||
function SettingGroup({ title, color, children }: { title: string; color: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] font-semibold" style={{ color }}>
|
||||
<div className="text-[var(--fs-tiny)] font-semibold" style={{ color }}>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
@@ -230,7 +230,7 @@ function SettingGroup({ title, color, children }: { title: string; color: string
|
||||
|
||||
function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix: string; value: number; disabled: boolean; theme: CanvasTheme; onChange: (value: number | null) => void }) {
|
||||
return (
|
||||
<label className="flex h-8 overflow-hidden rounded-md text-[11px]" style={{ background: theme.toolbar.itemHover, color: theme.node.text, opacity: disabled ? 0.55 : 1 }}>
|
||||
<label className="flex h-8 overflow-hidden rounded-md text-[var(--fs-label)]" style={{ background: theme.toolbar.itemHover, color: theme.node.text, opacity: disabled ? 0.55 : 1 }}>
|
||||
<span className="grid w-7 place-items-center" style={{ color: theme.node.muted }}>
|
||||
{prefix}
|
||||
</span>
|
||||
@@ -247,7 +247,7 @@ function DurationInput({ value, min, theme, onChange }: { value: number; min: nu
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="flex h-8 min-w-0 items-center overflow-hidden rounded-md border text-[11px]" style={{ background: theme.toolbar.itemHover, borderColor: theme.toolbar.border, color: theme.node.text }}>
|
||||
<label className="flex h-8 min-w-0 items-center overflow-hidden rounded-md border text-[var(--fs-label)]" style={{ background: theme.toolbar.itemHover, borderColor: theme.toolbar.border, color: theme.node.text }}>
|
||||
<input
|
||||
key={`${min}-${value}`}
|
||||
type="number"
|
||||
@@ -288,7 +288,7 @@ function ratioPreview(ratio: string) {
|
||||
function SwitchRow({ label, checked, theme, onChange }: { label: string; checked: boolean; theme: CanvasTheme; onChange: (checked: boolean) => void }) {
|
||||
return (
|
||||
<div className="flex h-8 items-center justify-between gap-2">
|
||||
<span className="min-w-0 whitespace-nowrap text-[11px]" style={{ color: theme.node.text }}>
|
||||
<span className="min-w-0 whitespace-nowrap text-[var(--fs-label)]" style={{ color: theme.node.text }}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="shrink-0" onMouseDown={(event) => event.stopPropagation()}>
|
||||
|
||||
@@ -66,7 +66,7 @@ export function AdminShell() {
|
||||
{!collapsed ? (
|
||||
<Link to="/" className="flex min-w-0 flex-1 items-center gap-2" title="影策">
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background"><InfinityIcon className="size-4" /></span>
|
||||
<span className="min-w-0"><span className="block truncate text-[13px] font-semibold">影策</span><span className="block truncate text-[9px] text-foreground/42">管理后台</span></span>
|
||||
<span className="min-w-0"><span className="block truncate text-[var(--fs-body)] font-semibold">影策</span><span className="block truncate text-[var(--fs-micro)] text-foreground/42">管理后台</span></span>
|
||||
</Link>
|
||||
) : null}
|
||||
<Tooltip title={collapsed ? "展开侧栏" : "折叠侧栏"} placement="right">
|
||||
@@ -78,10 +78,10 @@ export function AdminShell() {
|
||||
<AdminNavigation collapsed={collapsed} />
|
||||
<div className="shrink-0 border-t border-border/70 p-2">
|
||||
<Tooltip title={collapsed ? "更新日志" : undefined} placement="right">
|
||||
<AppChangelogButton className={cn("flex h-8 w-full items-center rounded text-[11px] text-foreground/52 transition-colors hover:bg-foreground/[.055] hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2 px-2")} showVersion={!collapsed} />
|
||||
<AppChangelogButton className={cn("flex h-8 w-full items-center rounded text-[var(--fs-label)] text-foreground/52 transition-colors hover:bg-foreground/[.055] hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2 px-2")} showVersion={!collapsed} />
|
||||
</Tooltip>
|
||||
<Tooltip title={collapsed ? "返回创作台" : undefined} placement="right">
|
||||
<NavLink to="/canvas" className={cn("flex h-8 items-center rounded text-[11px] text-foreground/52 transition-colors hover:bg-foreground/[.055] hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2 px-2")}>
|
||||
<NavLink to="/canvas" className={cn("flex h-8 items-center rounded text-[var(--fs-label)] text-foreground/52 transition-colors hover:bg-foreground/[.055] hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2 px-2")}>
|
||||
<Home className="size-3.5" />
|
||||
{!collapsed ? <span>返回创作台</span> : null}
|
||||
</NavLink>
|
||||
@@ -140,7 +140,7 @@ function AdminNavigation({ collapsed }: { collapsed: boolean }) {
|
||||
<nav className="thin-scrollbar flex-1 overflow-y-auto px-2 py-2" aria-label="管理后台菜单">
|
||||
{adminNavigation.map((group) => (
|
||||
<div key={group.label} className="mb-3">
|
||||
{!collapsed ? <div className="mb-1 px-2.5 text-[10px] font-medium text-foreground/38">{group.label}</div> : <div className="mx-auto mb-1.5 h-px w-7 bg-border/80" />}
|
||||
{!collapsed ? <div className="mb-1 px-2.5 text-[var(--fs-tiny)] font-medium text-foreground/38">{group.label}</div> : <div className="mx-auto mb-1.5 h-px w-7 bg-border/80" />}
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => (
|
||||
<Tooltip key={item.path} title={collapsed ? item.label : undefined} placement="right">
|
||||
@@ -148,7 +148,7 @@ function AdminNavigation({ collapsed }: { collapsed: boolean }) {
|
||||
to={item.path}
|
||||
end={item.path === "/admin"}
|
||||
className={({ isActive }) => cn(
|
||||
"app-workspace-nav-link flex h-9 items-center rounded-md text-[13px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
"app-workspace-nav-link flex h-9 items-center rounded-md text-[var(--fs-body)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
collapsed ? "justify-center px-0" : "gap-2.5 px-2.5",
|
||||
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-foreground/[.05] hover:text-foreground",
|
||||
)}
|
||||
|
||||
@@ -156,7 +156,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
),
|
||||
},
|
||||
{ title: "能力", dataIndex: "capability", width: 90, render: capabilityLabel },
|
||||
{ title: "请求协议", dataIndex: "protocol", width: 230, render: (value: ModelProtocol) => value ? <div><div className="text-xs font-medium">{modelProtocolLabel(value)}</div><div className="truncate text-[10px] text-foreground/45">{modelProtocolDefinition(value)?.create}</div></div> : <Tag color="orange">待配置</Tag> },
|
||||
{ title: "请求协议", dataIndex: "protocol", width: 230, render: (value: ModelProtocol) => value ? <div><div className="text-xs font-medium">{modelProtocolLabel(value)}</div><div className="truncate text-[var(--fs-tiny)] text-foreground/45">{modelProtocolDefinition(value)?.create}</div></div> : <Tag color="orange">待配置</Tag> },
|
||||
{ title: "计费", width: 165, render: (_, item) => (item.priceConfigured ? `${formatCredits(item.unitPriceMicrocredits)} 积分 / ${item.billingMode === "per_second" ? "秒" : "次"}` : <Tag color="orange">未配置价格</Tag>) },
|
||||
{ title: "版本", dataIndex: "priceVersion", width: 75, render: (value) => `v${value}` },
|
||||
{ title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => (enabled ? <Tag color="green">启用</Tag> : <Tag>停用</Tag>) },
|
||||
@@ -223,7 +223,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
</Form.Item>
|
||||
<div className="mb-5 rounded-md border border-border/70 bg-foreground/[.025] px-3 py-2.5">
|
||||
<div className="text-xs font-semibold">实际请求预览</div>
|
||||
<div className="mt-1 font-mono text-[11px] leading-5 text-foreground/60">{modelProtocolSummary(modelProtocol)}</div>
|
||||
<div className="mt-1 font-mono text-[var(--fs-label)] leading-5 text-foreground/60">{modelProtocolSummary(modelProtocol)}</div>
|
||||
</div>
|
||||
<Form.Item name="billingMode" label="计费方式" rules={[{ required: true }]}>
|
||||
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }]} />
|
||||
|
||||
@@ -102,7 +102,7 @@ function MediaResult({ log, onPreview }: { log: ApiCallLog; onPreview: (url: str
|
||||
<button type="button" title={`预览${kind === "video" ? "视频" : "图片"}`} className="group relative h-11 w-16 shrink-0 overflow-hidden rounded border border-border/75 bg-black/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={() => onPreview(url, kind)}>
|
||||
{kind === "video" ? <video src={url} muted playsInline preload="metadata" className="size-full object-cover" /> : <img src={url} alt="生成结果" loading="lazy" decoding="async" className="size-full object-cover" />}
|
||||
<span className="absolute inset-0 grid place-items-center bg-black/0 text-white opacity-0 transition group-hover:bg-black/35 group-hover:opacity-100 group-focus-visible:bg-black/35 group-focus-visible:opacity-100">{kind === "video" ? <Play className="size-4 fill-current" /> : <Eye className="size-4" />}</span>
|
||||
{log.mediaCount > 1 ? <span className="absolute bottom-0.5 right-0.5 rounded-sm bg-black/65 px-1 text-[9px] leading-4 text-white">{log.mediaCount}</span> : null}
|
||||
{log.mediaCount > 1 ? <span className="absolute bottom-0.5 right-0.5 rounded-sm bg-black/65 px-1 text-[var(--fs-micro)] leading-4 text-white">{log.mediaCount}</span> : null}
|
||||
</button>
|
||||
<Button type="text" size="small" className="!size-7 !min-w-7 !p-0" icon={<Download className="size-3.5" />} onClick={() => downloadMedia(url, kind)} title="下载原文件" aria-label="下载原文件" />
|
||||
</div>;
|
||||
|
||||
@@ -444,19 +444,19 @@ function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownlo
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<span className="flex shrink-0 flex-wrap justify-end gap-1">
|
||||
<span className="rounded bg-foreground/[.055] px-1.5 py-0.5 text-[10px] text-foreground/55">{assetKindLabel(asset.kind)}</span>
|
||||
<span className="rounded bg-foreground/[.055] px-1.5 py-0.5 text-[10px] text-foreground/55">{assetCategoryLabel(asset.category)}</span>
|
||||
<span className="rounded bg-foreground/[.055] px-1.5 py-0.5 text-[var(--fs-tiny)] text-foreground/55">{assetKindLabel(asset.kind)}</span>
|
||||
<span className="rounded bg-foreground/[.055] px-1.5 py-0.5 text-[var(--fs-tiny)] text-foreground/55">{assetCategoryLabel(asset.category)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Typography.Paragraph type="secondary" ellipsis={{ rows: 2 }} className="!mb-0 !mt-2 !text-xs !leading-5">
|
||||
{summary}
|
||||
</Typography.Paragraph>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-[10px] text-foreground/42"><span className="truncate">{assetProjectLabel(asset)}</span><span className="shrink-0">{formatAssetTime(asset.updatedAt)}</span></div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-[var(--fs-tiny)] text-foreground/42"><span className="truncate">{assetProjectLabel(asset)}</span><span className="shrink-0">{formatAssetTime(asset.updatedAt)}</span></div>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{(asset.tags || []).slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="rounded bg-foreground/[.055] px-1.5 py-0.5 text-[10px] text-foreground/55">{tag}</span>
|
||||
<span key={tag} className="rounded bg-foreground/[.055] px-1.5 py-0.5 text-[var(--fs-tiny)] text-foreground/55">{tag}</span>
|
||||
))}
|
||||
{!asset.tags?.length ? <span className="text-[10px] text-foreground/35">无标签</span> : null}
|
||||
{!asset.tags?.length ? <span className="text-[var(--fs-tiny)] text-foreground/35">无标签</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -484,11 +484,11 @@ function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownlo
|
||||
function AssetFilterGroup({ title, options, value, counts, onChange, className = "" }: { title: string; options: Array<{ label: string; value: string }>; value: string; counts: Map<string, number>; onChange: (value: string) => void; className?: string }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-1.5 px-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-foreground/38">{title}</div>
|
||||
<div className="mb-1.5 px-1 text-[var(--fs-tiny)] font-semibold uppercase tracking-[0.08em] text-foreground/38">{title}</div>
|
||||
<div className="flex gap-1 lg:block">
|
||||
{options.map((option) => (
|
||||
<button key={option.value} type="button" className={`flex h-8 shrink-0 items-center justify-between gap-3 rounded px-2 text-xs transition-colors lg:mb-0.5 lg:w-full ${value === option.value ? "bg-foreground/[.08] font-medium text-foreground" : "text-foreground/56 hover:bg-foreground/[.04] hover:text-foreground"}`} onClick={() => onChange(option.value)}>
|
||||
<span>{option.label}</span><span className="rounded bg-foreground/[.06] px-1.5 py-0.5 text-[10px] tabular-nums text-foreground/45">{counts.get(option.value) || 0}</span>
|
||||
<span>{option.label}</span><span className="rounded bg-foreground/[.06] px-1.5 py-0.5 text-[var(--fs-tiny)] tabular-nums text-foreground/45">{counts.get(option.value) || 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -574,7 +574,7 @@ function StorageTag({ asset }: { asset: LibraryAsset }) {
|
||||
const location = resourceStorageLocation(asset.data.storageKey);
|
||||
const color = location === "oss" ? "green" : location === "local" ? "gold" : "default";
|
||||
return (
|
||||
<Tag color={color} className="m-0 text-[11px]" title={resourceStorageTitle(asset.data.storageKey)}>
|
||||
<Tag color={color} className="m-0 text-[var(--fs-label)]" title={resourceStorageTitle(asset.data.storageKey)}>
|
||||
{resourceStorageLabel(asset.data.storageKey)}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
@@ -67,7 +67,7 @@ export function AuthScene() {
|
||||
<span className="size-7 bg-current" style={{ mask: "url(/logo.svg) center / contain no-repeat", WebkitMask: "url(/logo.svg) center / contain no-repeat" }} />
|
||||
影策
|
||||
</Link>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/16 bg-black/20 px-3 py-1.5 text-[11px] text-white/76 backdrop-blur-xl">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/16 bg-black/20 px-3 py-1.5 text-[var(--fs-label)] text-white/76 backdrop-blur-xl">
|
||||
<Play className="size-3 fill-current" />
|
||||
创作正在发生
|
||||
</span>
|
||||
|
||||
@@ -34,14 +34,14 @@ export function CanvasUploadStatusToast({ status, theme }: { status: CanvasUploa
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-px" style={{ background: `linear-gradient(90deg, transparent, ${accent}, transparent)` }} />
|
||||
<div className="flex items-start gap-3">
|
||||
<motion.span animate={status.done || status.error ? { scale: [0.8, 1.08, 1] } : { y: [0, -2, 0] }} transition={status.done || status.error ? { duration: 0.3 } : { duration: 1.5, repeat: Number.POSITIVE_INFINITY }} className="mt-0.5 grid size-9 shrink-0 place-items-center rounded-[14px] border" style={{ background: theme.spatial.surface, borderColor: `${accent}44`, color: accent }}>
|
||||
<motion.span animate={status.done || status.error ? { scale: [0.8, 1.08, 1] } : { y: [0, -2, 0] }} transition={status.done || status.error ? { duration: 0.3 } : { duration: 1.5, repeat: Number.POSITIVE_INFINITY }} className="mt-0.5 grid size-9 shrink-0 place-items-center rounded-[var(--dock-radius)] border" style={{ background: theme.spatial.surface, borderColor: `${accent}44`, color: accent }}>
|
||||
{status.error ? <TriangleAlert className="size-4" /> : status.done ? <CheckCircle2 className="size-4" /> : <CloudUpload className="size-4" />}
|
||||
</motion.span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">{status.title}</span>
|
||||
<span className="mt-1 block truncate text-[11px]" style={{ color: theme.node.muted }}>{status.detail}</span>
|
||||
<span className="mt-1 block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }}>{status.detail}</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border px-2 py-1 text-[10px] font-semibold tabular-nums" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: theme.node.muted }}>{status.step}/{status.total}</span>
|
||||
<span className="shrink-0 rounded-full border px-2 py-1 text-[var(--fs-tiny)] font-semibold tabular-nums" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: theme.node.muted }}>{status.step}/{status.total}</span>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-1.5">
|
||||
{Array.from({ length: status.total }, (_, index) => (
|
||||
@@ -54,7 +54,7 @@ export function CanvasUploadStatusToast({ status, theme }: { status: CanvasUploa
|
||||
}
|
||||
|
||||
export function TaskDetailItem({ label, value }: { label: string; value: string }) {
|
||||
return <div className="min-w-0"><div className="text-[10px] opacity-50">{label}</div><div className="mt-1 truncate text-xs font-medium" title={value}>{value}</div></div>;
|
||||
return <div className="min-w-0"><div className="text-[var(--fs-tiny)] opacity-50">{label}</div><div className="mt-1 truncate text-xs font-medium" title={value}>{value}</div></div>;
|
||||
}
|
||||
|
||||
export function CanvasMergeStatusToast({ progress, theme }: { progress: MergeVideoProgress; theme: CanvasTheme }) {
|
||||
@@ -68,9 +68,9 @@ export function CanvasMergeStatusToast({ progress, theme }: { progress: MergeVid
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-semibold">合并成片</span>
|
||||
<span className="mt-0.5 block truncate text-[11px]" style={{ color: theme.node.muted }}>{detail}</span>
|
||||
<span className="mt-0.5 block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }}>{detail}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] tabular-nums" style={{ color: theme.node.faint }}>{percent}%</span>
|
||||
<span className="shrink-0 text-[var(--fs-tiny)] tabular-nums" style={{ color: theme.node.faint }}>{percent}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1 overflow-hidden rounded-full" style={{ background: theme.toolbar.itemHover }}>
|
||||
<div className="h-full rounded-full transition-all duration-300" style={{ width: `${percent}%`, background: theme.node.activeStroke }} />
|
||||
@@ -84,7 +84,7 @@ export function CanvasAgentChangeToast({ change, theme, onView, onUndo, onClose
|
||||
<div data-canvas-no-zoom aria-live="polite" className="absolute bottom-20 right-4 z-[90] w-[320px] rounded-lg border p-3 shadow-lg backdrop-blur-xl" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover, color: theme.node.activeStroke }}><span className="size-2 rounded-full bg-current" /></span>
|
||||
<span className="min-w-0 flex-1"><span className="block text-xs font-semibold">Agent 已写回画布</span><span className="mt-0.5 block truncate text-[11px]" style={{ color: theme.node.muted }}>{change.summary} · 可撤销最近 {change.undoCount} 批</span></span>
|
||||
<span className="min-w-0 flex-1"><span className="block text-xs font-semibold">Agent 已写回画布</span><span className="mt-0.5 block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }}>{change.summary} · 可撤销最近 {change.undoCount} 批</span></span>
|
||||
<button type="button" className="grid size-7 place-items-center rounded-md opacity-55 transition hover:opacity-100" onClick={onClose} aria-label="关闭"><X className="size-3.5" /></button>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-end gap-1.5">
|
||||
|
||||
@@ -40,7 +40,7 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold" style={{ color: theme.node.muted }}>任务日志</div>
|
||||
<pre className="max-h-64 overflow-auto rounded-lg bg-neutral-950 p-3 text-[11px] leading-5 text-neutral-100">{taskLoading ? "加载中..." : taskLogs.length ? taskLogs.map((log) => `[${new Date(log.createdAt).toLocaleString()}] ${log.level.toUpperCase()} ${log.message}`).join("\n") : "暂无日志"}</pre>
|
||||
<pre className="max-h-64 overflow-auto rounded-lg bg-neutral-950 p-3 text-[var(--fs-label)] leading-5 text-neutral-100">{taskLoading ? "加载中..." : taskLogs.length ? taskLogs.map((log) => `[${new Date(log.createdAt).toLocaleString()}] ${log.level.toUpperCase()} ${log.message}`).join("\n") : "暂无日志"}</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -152,7 +152,7 @@ export function CanvasTopBar({
|
||||
</div>
|
||||
)}
|
||||
{projectContext && !isTitleEditing ? (
|
||||
<div className="mt-0.5 flex max-w-[360px] items-center gap-1.5 text-[10px]" style={{ color: theme.node.muted }}>
|
||||
<div className="mt-0.5 flex max-w-[360px] items-center gap-1.5 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
|
||||
<Link to={`/projects/${projectContext.projectId}/overview`} className="inline-flex min-w-0 items-center gap-1 hover:underline" title={`返回项目:${projectContext.projectName}`}>
|
||||
<FolderKanban className="size-3 shrink-0" />
|
||||
<span className="max-w-[120px] truncate">{projectContext.projectName}</span>
|
||||
@@ -285,8 +285,8 @@ function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWorkspaceMo
|
||||
{simple ? <Sparkles className="size-3" /> : <Settings2 className="size-3" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[8px] leading-none" style={{ color: theme.node.muted }}>工作空间</span>
|
||||
<span className="mt-0.5 block text-[10px] font-semibold leading-none">{simple ? "简洁模式" : "专业模式"}</span>
|
||||
<span className="block text-[var(--fs-micro)] leading-none" style={{ color: theme.node.muted }}>工作空间</span>
|
||||
<span className="mt-0.5 block text-[var(--fs-tiny)] font-semibold leading-none">{simple ? "简洁模式" : "专业模式"}</span>
|
||||
</span>
|
||||
<motion.span animate={{ rotate: open ? 180 : 0 }} transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.dock} className="grid size-5 place-items-center rounded-full" style={{ background: theme.toolbar.itemHover }}>
|
||||
<ChevronDown className="size-2.5" />
|
||||
@@ -303,7 +303,7 @@ function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWorkspaceMo
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.95 }}
|
||||
transition={aceternityMotion.spring.panel}
|
||||
className="aceternity-floating-panel w-full overflow-hidden rounded-[17px] border p-1.5 backdrop-blur-2xl"
|
||||
className="aceternity-floating-panel w-full overflow-hidden rounded-[var(--panel-radius)] border p-1.5 backdrop-blur-2xl"
|
||||
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 28px 80px ${theme.spatial.shadow}` }}
|
||||
>
|
||||
<div className="absolute inset-x-10 top-0 h-px" style={{ background: `linear-gradient(90deg, transparent, ${theme.spatial.glowStrong}, transparent)` }} />
|
||||
@@ -328,12 +328,12 @@ function ModeOption({ active, motionEnabled, icon, title, description, theme, on
|
||||
whileHover={motionEnabled ? { x: 3 } : undefined}
|
||||
whileTap={motionEnabled ? { scale: 0.98 } : undefined}
|
||||
transition={aceternityMotion.spring.dock}
|
||||
className="group flex min-h-11 w-full items-center gap-2 rounded-[12px] border px-2 py-1.5 text-left outline-none focus-visible:ring-2"
|
||||
className="group flex min-h-11 w-full items-center gap-2 rounded-[var(--r-lg)] border px-2 py-1.5 text-left outline-none focus-visible:ring-2"
|
||||
style={{ background: active ? theme.accent.primarySoft : "transparent", borderColor: active ? theme.spatial.glowStrong : "transparent", color: theme.node.text }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-[10px] border [&_svg]:size-3.5" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: active ? theme.accent.primary : theme.node.muted }}>{icon}</span>
|
||||
<span className="min-w-0 flex-1"><span className="block text-[10px] font-semibold">{title}</span><span className="mt-0.5 block text-[8px]" style={{ color: theme.node.muted }}>{description}</span></span>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--dock-item-radius)] border [&_svg]:size-3.5" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: active ? theme.accent.primary : theme.node.muted }}>{icon}</span>
|
||||
<span className="min-w-0 flex-1"><span className="block text-[var(--fs-tiny)] font-semibold">{title}</span><span className="mt-0.5 block text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{description}</span></span>
|
||||
<span className="grid size-5 shrink-0 place-items-center rounded-full border transition-opacity" style={{ background: active ? theme.accent.primary : theme.spatial.surface, borderColor: active ? theme.accent.primary : theme.toolbar.border, color: active ? "white" : theme.node.muted, opacity: active ? 1 : 0.28 }}><Check className="size-3" /></span>
|
||||
</motion.button>
|
||||
);
|
||||
|
||||
@@ -271,7 +271,7 @@ function SharedContextMenu({ menu, onAdd, onInfo, onUnauthorized }: { menu: Cont
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
return <div data-canvas-no-zoom className="absolute z-[90] min-w-48 rounded-lg border p-1.5 shadow-xl" style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{menu.nodeId ? <><MenuButton icon={<Eye />} label="查看节点信息" onClick={onInfo} /><MenuButton icon={<LockKeyhole />} label="编辑或生成" onClick={onUnauthorized} /></> : <>
|
||||
<div className="px-2 py-1.5 text-[11px]" style={{ color: theme.node.muted }}>添加临时节点</div>
|
||||
<div className="px-2 py-1.5 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>添加临时节点</div>
|
||||
<MenuButton icon={<FileText />} label="文本节点" onClick={() => onAdd(CanvasNodeType.Text)} />
|
||||
<MenuButton icon={<ImageIcon />} label="图片节点" onClick={() => onAdd(CanvasNodeType.Image)} />
|
||||
<MenuButton icon={<Video />} label="视频节点" onClick={() => onAdd(CanvasNodeType.Video)} />
|
||||
@@ -286,7 +286,7 @@ function MenuButton({ icon, label, onClick }: { icon: ReactNode; label: string;
|
||||
|
||||
function SharedConfigNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
return <div className="flex h-full w-full flex-col overflow-hidden rounded-[17px]">
|
||||
return <div className="flex h-full w-full flex-col overflow-hidden rounded-[var(--panel-radius)]">
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-4" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}><ImageIcon className="size-4" /><span className="min-w-0 flex-1 truncate text-sm font-semibold">{node.title}</span></div>
|
||||
<div className="min-h-0 flex-1 whitespace-pre-wrap break-words p-4 text-sm leading-6" style={{ color: theme.node.muted }}>{node.metadata?.composerContent || node.metadata?.prompt || "未填写提示词"}</div>
|
||||
<div className="flex h-12 shrink-0 items-center justify-end border-t px-3" style={{ borderColor: theme.node.stroke }}><Button size="small" icon={<Send className="size-3.5" />} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onUnauthorized(); }}>生成</Button></div>
|
||||
@@ -296,7 +296,7 @@ function SharedConfigNode({ node, onUnauthorized }: { node: CanvasNodeData; onUn
|
||||
function SharedScriptNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const rows = node.metadata?.storyboard?.rows || [];
|
||||
return <div className="flex h-full w-full flex-col overflow-hidden rounded-[17px]">
|
||||
return <div className="flex h-full w-full flex-col overflow-hidden rounded-[var(--panel-radius)]">
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-4" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}><Clapperboard className="size-4" /><span className="min-w-0 flex-1 truncate text-sm font-semibold">{node.title}</span><span className="text-xs" style={{ color: theme.node.muted }}>{rows.length} 镜</span><button type="button" className="grid size-7 place-items-center rounded hover:bg-black/5 dark:hover:bg-white/10" onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onUnauthorized(); }} aria-label="一键创建视频节点"><Video className="size-3.5" /></button></div>
|
||||
<div data-canvas-wheel-scroll className="min-h-0 flex-1 overflow-y-auto" onWheel={(event) => event.stopPropagation()}>{rows.length ? rows.map((row) => <div key={row.id} className="grid grid-cols-[52px_72px_minmax(180px,1fr)_minmax(150px,.8fr)] border-b text-xs leading-5" style={{ minHeight: 48, borderColor: theme.node.stroke }}><span className="grid place-items-center border-r" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>#{row.shotNumber}</span><span className="grid place-items-center border-r" style={{ borderColor: theme.node.stroke }}>{row.durationSeconds}s</span><span className="border-r px-3 py-2" style={{ borderColor: theme.node.stroke }}>{row.plotDescription || "-"}</span><span className="px-3 py-2" style={{ color: theme.node.muted }}>{row.dialogue || "-"}</span></div>) : <div className="grid h-full place-items-center text-sm" style={{ color: theme.node.muted }}>暂无分镜</div>}</div>
|
||||
{node.metadata?.composerContent ? <div className="max-h-24 shrink-0 overflow-y-auto border-t px-3 py-2 text-xs leading-5" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>{node.metadata.composerContent}</div> : null}
|
||||
|
||||
@@ -106,7 +106,7 @@ function ReturningWorkspace({ summary, detail, detailLoading, detailError, recen
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<WorkspaceSignalIcon variant="home" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[22px] font-semibold leading-7">继续创作</h1>
|
||||
<h1 className="text-[var(--fs-title)] font-semibold leading-7">继续创作</h1>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/55">回到最近工作,或先处理阻塞制作的事项。</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,7 +218,7 @@ function FirstProjectWorkspace({ authenticated, canvasHydrated, recentIndependen
|
||||
<div className="app-workflow-rail grid border-t border-border/75 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{workflow.map((item, index) => (
|
||||
<div key={item.title} className={`app-workflow-step min-w-0 border-b border-border/75 py-4 sm:px-4 xl:border-b-0 xl:border-r ${index % 2 === 0 ? "sm:pl-0" : "sm:border-l"} ${index === workflow.length - 1 ? "xl:border-r-0" : ""}`}>
|
||||
<span className="text-[11px] font-semibold tabular-nums text-[var(--workspace-accent)]">0{index + 1}</span>
|
||||
<span className="text-[var(--fs-label)] font-semibold tabular-nums text-[var(--workspace-accent)]">0{index + 1}</span>
|
||||
<h3 className="mt-2 text-sm font-semibold">{item.title}</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/48">{item.description}</p>
|
||||
</div>
|
||||
@@ -261,9 +261,9 @@ function WorkspaceMetric({ icon, label, value, detail, attention = false }: { ic
|
||||
<span className={attention ? "text-foreground/75" : "text-[var(--workspace-accent)]"}>{icon}</span>
|
||||
<span className={`size-1.5 rounded-full ${attention ? "bg-foreground/75" : "bg-[var(--workspace-accent)]"}`} />
|
||||
</div>
|
||||
<div className="mt-5 text-[11px] text-foreground/45">{label}</div>
|
||||
<div className="mt-5 text-[var(--fs-label)] text-foreground/45">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tabular-nums">{value}</div>
|
||||
<div className="mt-1 truncate text-[10px] text-foreground/38">{detail}</div>
|
||||
<div className="mt-1 truncate text-[var(--fs-tiny)] text-foreground/38">{detail}</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -274,8 +274,8 @@ function SpatialChapterStack({ detail, loading, projectId }: { detail?: Awaited<
|
||||
return (
|
||||
<div className="spatial-chapter-panel min-w-0 border-t border-border/75 p-5 sm:p-6 lg:border-l lg:border-t-0 lg:p-7">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div><div className="text-[10px] font-medium text-foreground/38">制作层级</div><h2 className="mt-1 text-sm font-semibold">章节轨道</h2></div>
|
||||
<span className="text-[10px] tabular-nums text-foreground/35">{units.length ? `${units.length} 章` : "待建立"}</span>
|
||||
<div><div className="text-[var(--fs-tiny)] font-medium text-foreground/38">制作层级</div><h2 className="mt-1 text-sm font-semibold">章节轨道</h2></div>
|
||||
<span className="text-[var(--fs-tiny)] tabular-nums text-foreground/35">{units.length ? `${units.length} 章` : "待建立"}</span>
|
||||
</div>
|
||||
<div className="spatial-chapter-deck mt-4" aria-label="项目章节制作层级">
|
||||
{loading ? Array.from({ length: 4 }, (_, index) => <span key={index} className="spatial-chapter-card is-loading" style={{ "--deck-x": `${index * 12}px`, "--deck-y": `${index * 28}px`, "--deck-z": `${-index * 18}px`, zIndex: 8 - index } as CSSProperties} />) : null}
|
||||
@@ -286,9 +286,9 @@ function SpatialChapterStack({ detail, loading, projectId }: { detail?: Awaited<
|
||||
className={`spatial-chapter-card ${unit.id === activeUnitId ? "is-active" : ""}`}
|
||||
style={{ "--deck-x": `${index * 12}px`, "--deck-y": `${index * 28}px`, "--deck-z": `${-index * 18}px`, zIndex: 8 - index } as CSSProperties}
|
||||
>
|
||||
<span className="text-[9px] font-semibold tabular-nums opacity-55">{String(unit.position + 1).padStart(2, "0")}</span>
|
||||
<span className="text-[var(--fs-micro)] font-semibold tabular-nums opacity-55">{String(unit.position + 1).padStart(2, "0")}</span>
|
||||
<span className="mt-2 block truncate text-xs font-semibold">{unit.title}</span>
|
||||
<span className="mt-1 block text-[9px] opacity-50">{unit.status === "completed" ? "已完成" : unit.id === activeUnitId ? "当前制作" : "等待推进"}</span>
|
||||
<span className="mt-1 block text-[var(--fs-micro)] opacity-50">{unit.status === "completed" ? "已完成" : unit.id === activeUnitId ? "当前制作" : "等待推进"}</span>
|
||||
</Link>
|
||||
)) : null}
|
||||
{!loading && !units.length ? <div className="grid min-h-52 place-items-center text-center text-xs leading-5 text-foreground/42">创建剧情章节后<br />这里会形成制作层级</div> : null}
|
||||
@@ -302,8 +302,8 @@ function WorkbenchActionLink({ action }: { action: ReturnType<typeof projectNext
|
||||
return (
|
||||
<Link to={action.href} className="group grid grid-cols-[20px_minmax(0,1fr)_auto] gap-2 py-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/20">
|
||||
<Icon className={`mt-0.5 size-4 ${action.tone === "danger" ? "text-foreground/80" : action.tone === "attention" ? "text-foreground/60" : "text-foreground/35"}`} />
|
||||
<span className="min-w-0"><span className="block text-xs font-medium">{action.title}</span><span className="mt-1 line-clamp-2 block text-[11px] leading-4 text-foreground/45">{action.description}</span></span>
|
||||
<span className="self-center text-[11px] font-medium text-foreground/45 transition-colors group-hover:text-foreground">{action.actionLabel}</span>
|
||||
<span className="min-w-0"><span className="block text-xs font-medium">{action.title}</span><span className="mt-1 line-clamp-2 block text-[var(--fs-label)] leading-4 text-foreground/45">{action.description}</span></span>
|
||||
<span className="self-center text-[var(--fs-label)] font-medium text-foreground/45 transition-colors group-hover:text-foreground">{action.actionLabel}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -312,9 +312,9 @@ function RecentProjectRow({ summary, divided }: { summary: ProjectSummary; divid
|
||||
const completion = projectSummaryCompletion(summary);
|
||||
return (
|
||||
<Link to={`/projects/${summary.project.id}/overview`} className={`group grid min-h-[68px] grid-cols-[minmax(0,1fr)_80px_20px] items-center gap-3 px-3 py-2.5 hover:bg-foreground/[.025] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-foreground/20 sm:grid-cols-[minmax(0,1fr)_100px_120px_20px] ${divided ? "border-t border-border/65" : ""}`}>
|
||||
<span className="min-w-0"><span className="block truncate text-sm font-medium">{summary.project.name}</span><span className="mt-1 block truncate text-[11px] text-foreground/42">{summary.unitCount} 章 · {summary.canvasCount} 张项目画布 · {summary.assetCount} 项资产</span></span>
|
||||
<span className="hidden text-[11px] text-foreground/45 sm:block">更新于<br />{formatRelativeTime(summary.project.updatedAt)}</span>
|
||||
<span className="min-w-0"><span className="flex items-center justify-between text-[10px] text-foreground/42"><span>章节</span><span>{completion}%</span></span><span className="mt-1.5 block h-1 overflow-hidden rounded-full bg-foreground/[.08]"><span className="block h-full rounded-full bg-foreground/65" style={{ width: `${completion}%` }} /></span></span>
|
||||
<span className="min-w-0"><span className="block truncate text-sm font-medium">{summary.project.name}</span><span className="mt-1 block truncate text-[var(--fs-label)] text-foreground/42">{summary.unitCount} 章 · {summary.canvasCount} 张项目画布 · {summary.assetCount} 项资产</span></span>
|
||||
<span className="hidden text-[var(--fs-label)] text-foreground/45 sm:block">更新于<br />{formatRelativeTime(summary.project.updatedAt)}</span>
|
||||
<span className="min-w-0"><span className="flex items-center justify-between text-[var(--fs-tiny)] text-foreground/42"><span>章节</span><span>{completion}%</span></span><span className="mt-1.5 block h-1 overflow-hidden rounded-full bg-foreground/[.08]"><span className="block h-full rounded-full bg-foreground/65" style={{ width: `${completion}%` }} /></span></span>
|
||||
<ArrowRight className="size-4 text-foreground/25 transition-transform group-hover:translate-x-0.5 group-hover:text-foreground/60" />
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function NotFound() {
|
||||
<h1 className="text-3xl font-semibold tracking-normal">页面不存在</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400">这个地址没有对应的页面,可能已经移动或被合并到其他入口。</p>
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||
<Link to="/" className="inline-flex h-9 items-center gap-2 rounded-md bg-stone-950 px-4 text-[13px] font-medium text-white transition-colors hover:bg-stone-800 dark:bg-stone-100 dark:text-stone-950 dark:hover:bg-stone-200">
|
||||
<Link to="/" className="inline-flex h-9 items-center gap-2 rounded-md bg-stone-950 px-4 text-[var(--fs-body)] font-medium text-white transition-colors hover:bg-stone-800 dark:bg-stone-100 dark:text-stone-950 dark:hover:bg-stone-200">
|
||||
<Home className="size-4" />
|
||||
返回首页
|
||||
</Link>
|
||||
|
||||
@@ -78,13 +78,13 @@ export default function ProjectDetailPage() {
|
||||
<button type="button" onClick={() => navigate("/projects")} className="grid size-9 shrink-0 place-items-center rounded-md text-foreground/42 transition-colors hover:bg-foreground/[.055] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="返回项目" title="返回项目"><ArrowLeft className="size-4" /></button>
|
||||
<WorkspaceSignalIcon variant="projects" size="sm" />
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1 className="min-w-0 truncate text-[13px] font-semibold text-foreground/90">{detail.data.project.name}</h1>
|
||||
<h1 className="min-w-0 truncate text-[var(--fs-body)] font-semibold text-foreground/90">{detail.data.project.name}</h1>
|
||||
<span className={`size-1.5 shrink-0 rounded-full ${detail.data.project.status === "archived" ? "bg-foreground/30" : "bg-[var(--workspace-accent)]"}`} />
|
||||
<span className="hidden shrink-0 text-[10px] text-foreground/42 sm:inline">{detail.data.project.status === "archived" ? "已归档" : "进行中"}</span>
|
||||
<span className="hidden shrink-0 text-[var(--fs-tiny)] text-foreground/42 sm:inline">{detail.data.project.status === "archived" ? "已归档" : "进行中"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="thin-scrollbar order-last mt-1 flex h-11 w-full min-w-0 items-center gap-0.5 overflow-x-auto lg:order-none lg:mt-0 lg:h-16 lg:flex-1 lg:border-l lg:border-border/55 lg:pl-3" aria-label="项目导航">
|
||||
{views.map((item) => { const Icon = item.icon; const active = item.key === activeView; const href = item.key === "chapters" ? chapterHref : `/projects/${projectId}/${item.key}`; return <Link key={item.key} to={href} className={`relative flex h-11 shrink-0 items-center gap-2 rounded-md px-2.5 text-[13px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-3 ${active ? "bg-[var(--workspace-accent-soft)] font-medium text-foreground lg:after:absolute lg:after:inset-x-3 lg:after:bottom-0 lg:after:h-0.5 lg:after:rounded-full lg:after:bg-[var(--workspace-accent)]" : "text-foreground/52 hover:bg-foreground/[.045] hover:text-foreground"}`} aria-current={active ? "page" : undefined}><Icon className={`size-4 shrink-0 ${active ? "text-[var(--workspace-accent)]" : "text-foreground/45"}`} /><span className="sm:hidden">{item.shortLabel}</span><span className="hidden sm:inline">{item.label}</span></Link>; })}
|
||||
{views.map((item) => { const Icon = item.icon; const active = item.key === activeView; const href = item.key === "chapters" ? chapterHref : `/projects/${projectId}/${item.key}`; return <Link key={item.key} to={href} className={`relative flex h-11 shrink-0 items-center gap-2 rounded-md px-2.5 text-[var(--fs-body)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-3 ${active ? "bg-[var(--workspace-accent-soft)] font-medium text-foreground lg:after:absolute lg:after:inset-x-3 lg:after:bottom-0 lg:after:h-0.5 lg:after:rounded-full lg:after:bg-[var(--workspace-accent)]" : "text-foreground/52 hover:bg-foreground/[.045] hover:text-foreground"}`} aria-current={active ? "page" : undefined}><Icon className={`size-4 shrink-0 ${active ? "text-[var(--workspace-accent)]" : "text-foreground/45"}`} /><span className="sm:hidden">{item.shortLabel}</span><span className="hidden sm:inline">{item.label}</span></Link>; })}
|
||||
</nav>
|
||||
<Tooltip title={activeView === "chapters" && detail.data.units.length ? "新建当前章节画布" : "新建项目画布"}><Button size="small" className="!h-9 !shrink-0 !px-2 sm:!px-3" icon={<Plus className="size-4" />} onClick={createCanvas} aria-label={activeView === "chapters" && detail.data.units.length ? "新建当前章节画布" : "新建项目画布"}><span className="hidden sm:inline">新建画布</span></Button></Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -156,10 +156,10 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
<WorkspaceSignalIcon variant="assets" size="sm" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||||
<h2 className="text-[18px] font-semibold leading-6">角色与资产</h2>
|
||||
<span className="rounded bg-foreground/[.055] px-1.5 py-1 text-[10px] font-medium tabular-nums text-foreground/45">{detail.assets.length} 项已确认</span>
|
||||
<h2 className="text-[var(--fs-heading-lg)] font-semibold leading-6">角色与资产</h2>
|
||||
<span className="rounded bg-foreground/[.055] px-1.5 py-1 text-[var(--fs-tiny)] font-medium tabular-nums text-foreground/45">{detail.assets.length} 项已确认</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-foreground/48">
|
||||
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[var(--fs-label)] text-foreground/48">
|
||||
<span className="inline-flex items-center gap-1.5"><UserRound className="size-3.5" />{characterAssets.length} 个角色</span>
|
||||
<span className="inline-flex items-center gap-1.5"><Box className="size-3.5" />{mediaAssetCount} 项媒体</span>
|
||||
{pendingCandidates.length ? <span className="inline-flex items-center gap-1.5 text-[var(--workspace-accent)]"><Sparkles className="size-3.5" />{pendingCandidates.length} 个待确认</span> : null}
|
||||
@@ -172,13 +172,13 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
</div>
|
||||
</header>
|
||||
<div className="mt-3 grid gap-3 lg:grid-cols-[156px_minmax(0,1fr)]">
|
||||
<nav className="space-y-0.5 border-r border-border/70 pr-2" aria-label="资产分类">{categoryCounts.map((item) => <button key={item.value} type="button" onClick={() => setCategory(item.value)} className={`flex h-11 w-full items-center justify-between rounded-md px-2 text-left text-xs ${category === item.value ? "bg-foreground/[.08] font-medium" : "text-foreground/55 hover:bg-foreground/[.04]"}`}><span>{item.value === "all" ? "全部资产" : categoryLabels[item.value]}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[10px] tabular-nums">{item.count}</span></button>)}</nav>
|
||||
<nav className="space-y-0.5 border-r border-border/70 pr-2" aria-label="资产分类">{categoryCounts.map((item) => <button key={item.value} type="button" onClick={() => setCategory(item.value)} className={`flex h-11 w-full items-center justify-between rounded-md px-2 text-left text-xs ${category === item.value ? "bg-foreground/[.08] font-medium" : "text-foreground/55 hover:bg-foreground/[.04]"}`}><span>{item.value === "all" ? "全部资产" : categoryLabels[item.value]}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item.count}</span></button>)}</nav>
|
||||
<div className="min-w-0">
|
||||
{(category === "all" || category === "character") && pendingCandidates.length ? (
|
||||
<section className="mb-4" aria-label="待确认角色">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium"><Sparkles className="size-3.5 text-[var(--workspace-accent)]" />剧情识别出的角色</div>
|
||||
<span className="text-[10px] tabular-nums text-foreground/42">剩余 {pendingCandidates.length} 个待确认</span>
|
||||
<span className="text-[var(--fs-tiny)] tabular-nums text-foreground/42">剩余 {pendingCandidates.length} 个待确认</span>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{pendingCandidates.map((candidate) => {
|
||||
@@ -188,7 +188,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
<span className="grid size-12 shrink-0 place-items-center rounded-md bg-foreground/[.045] text-foreground/25"><UserRound className="size-5" /></span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold">{candidate.name}</div>
|
||||
<div className="mt-1 text-[10px] text-foreground/42">待确认角色卡 · 来自章节分析</div>
|
||||
<div className="mt-1 text-[var(--fs-tiny)] text-foreground/42">待确认角色卡 · 来自章节分析</div>
|
||||
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1">
|
||||
<Button type="text" size="small" icon={<Check className="size-3.5" />} loading={confirming} disabled={Boolean(confirmingCandidateId) && !confirming} onClick={() => confirmMutation.mutate({ candidateId: candidate.id })}>确认新角色</Button>
|
||||
{characterAssets.length ? <Dropdown trigger={["click"]} menu={{ items: characterAssets.map((asset) => ({ key: asset.id, label: asset.title })), onClick: ({ key }) => confirmMutation.mutate({ candidateId: candidate.id, targetAssetId: key }) }}><Button type="text" size="small" disabled={Boolean(confirmingCandidateId)}>归并到角色<ChevronDown className="size-3" /></Button></Dropdown> : null}
|
||||
@@ -207,8 +207,8 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
|
||||
<Modal title="引用个人素材" open={addOpen} okText="加入项目" cancelText="取消" okButtonProps={{ disabled: !assetId, loading: addMutation.isPending }} onCancel={() => setAddOpen(false)} onOk={() => addMutation.mutate()} width={480}><div className="grid gap-3"><Select showSearch optionFilterProp="label" value={assetId || undefined} placeholder="选择未加入项目的素材" options={availableAssets.map((asset) => ({ label: `${asset.title} · ${mediaLabel(asset.kind)}`, value: asset.id }))} onChange={(value) => { setAssetId(value); const next = personalAssets.find((asset) => asset.id === value); setAssetCategory(next?.kind === "entity" ? "character" : next?.category || "other"); }} /><Select value={assetCategory} disabled={selectedPersonalAsset?.kind === "entity"} options={selectedPersonalAsset?.kind === "entity" ? [{ value: "character", label: "角色" }] : Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ value, label }))} onChange={setAssetCategory} /></div></Modal>
|
||||
<CharacterEditorModal open={Boolean(editorAsset)} editing={editorAsset !== "new"} form={form} loading={saveCharacter.isPending} onClose={() => setEditorAsset(null)} onSave={() => form.validateFields().then((values) => saveCharacter.mutate(values))} />
|
||||
<Modal title={`绑定单张三视图 · ${imageAsset?.title || ""}`} open={Boolean(imageAsset)} okText="绑定并生成新版本" cancelText="取消" width={720} okButtonProps={{ loading: bindImagesMutation.isPending, disabled: !imageSelection }} onCancel={() => setImageAsset(null)} onOk={() => bindImagesMutation.mutate()}>{imageAssets.length ? <div className="grid max-h-[420px] gap-3 overflow-y-auto sm:grid-cols-3">{imageAssets.map((asset) => <button type="button" key={asset.id} onClick={() => setImageSelection(asset.id)} className={`overflow-hidden rounded-lg border text-left transition ${imageSelection === asset.id ? "border-[var(--workspace-accent)] ring-2 ring-[var(--workspace-accent)]/20" : "border-border/70 hover:border-foreground/35"}`}><div className="relative aspect-[4/3] bg-foreground/[.045]"><img src={asset.data.dataUrl} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-cover" />{imageSelection === asset.id ? <span className="absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-[var(--workspace-accent)] text-white"><Check className="size-4" /></span> : null}</div><div className="truncate px-2 py-2 text-[11px] font-medium">{asset.title}</div></button>)}</div> : <WorkspaceState icon="assets" compact title="个人图片素材为空" description="需要一张包含正面、侧面、背面的角色设定图。" />}</Modal>
|
||||
<Modal title={`选择声音素材 · ${voiceAsset?.title || ""}`} open={Boolean(voiceAsset)} okText="绑定并生成新版本" cancelText="取消" okButtonProps={{ loading: bindVoiceMutation.isPending, disabled: !voiceProfileId }} onCancel={() => setVoiceAsset(null)} onOk={() => bindVoiceMutation.mutate()}><div className="grid gap-3"><Select loading={voices.isLoading} showSearch optionFilterProp="label" value={voiceProfileId || undefined} placeholder="选择声音" options={(voices.data?.profiles || []).map((voice) => ({ label: `${voice.name} · ${voice.language}`, value: voice.id }))} onChange={setVoiceProfileId} /><Input.TextArea rows={3} value={voiceInstructions} placeholder="表演指令,例如:克制、温暖、语速稍慢" onChange={(event) => setVoiceInstructions(event.target.value)} />{voiceAsset?.character && voiceAsset.character.voiceStatus !== "missing" ? <div className="flex items-center justify-between border-t border-border/70 pt-2"><span className="text-[11px] text-foreground/45">当前绑定:{voiceAsset.character.voice?.profile.name || "声音素材不可用"}</span><Popconfirm title="解除当前声音绑定?" description="该操作会保留历史版本,并创建一个未绑定声音的新版本。" okText="解除" cancelText="取消" onConfirm={() => unbindVoiceMutation.mutate()}><Button type="text" danger size="small" loading={unbindVoiceMutation.isPending} icon={<VolumeX className="size-3.5" />}>解除声音</Button></Popconfirm></div> : null}</div></Modal>
|
||||
<Modal title={`绑定单张三视图 · ${imageAsset?.title || ""}`} open={Boolean(imageAsset)} okText="绑定并生成新版本" cancelText="取消" width={720} okButtonProps={{ loading: bindImagesMutation.isPending, disabled: !imageSelection }} onCancel={() => setImageAsset(null)} onOk={() => bindImagesMutation.mutate()}>{imageAssets.length ? <div className="grid max-h-[420px] gap-3 overflow-y-auto sm:grid-cols-3">{imageAssets.map((asset) => <button type="button" key={asset.id} onClick={() => setImageSelection(asset.id)} className={`overflow-hidden rounded-lg border text-left transition ${imageSelection === asset.id ? "border-[var(--workspace-accent)] ring-2 ring-[var(--workspace-accent)]/20" : "border-border/70 hover:border-foreground/35"}`}><div className="relative aspect-[4/3] bg-foreground/[.045]"><img src={asset.data.dataUrl} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-cover" />{imageSelection === asset.id ? <span className="absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-[var(--workspace-accent)] text-white"><Check className="size-4" /></span> : null}</div><div className="truncate px-2 py-2 text-[var(--fs-label)] font-medium">{asset.title}</div></button>)}</div> : <WorkspaceState icon="assets" compact title="个人图片素材为空" description="需要一张包含正面、侧面、背面的角色设定图。" />}</Modal>
|
||||
<Modal title={`选择声音素材 · ${voiceAsset?.title || ""}`} open={Boolean(voiceAsset)} okText="绑定并生成新版本" cancelText="取消" okButtonProps={{ loading: bindVoiceMutation.isPending, disabled: !voiceProfileId }} onCancel={() => setVoiceAsset(null)} onOk={() => bindVoiceMutation.mutate()}><div className="grid gap-3"><Select loading={voices.isLoading} showSearch optionFilterProp="label" value={voiceProfileId || undefined} placeholder="选择声音" options={(voices.data?.profiles || []).map((voice) => ({ label: `${voice.name} · ${voice.language}`, value: voice.id }))} onChange={setVoiceProfileId} /><Input.TextArea rows={3} value={voiceInstructions} placeholder="表演指令,例如:克制、温暖、语速稍慢" onChange={(event) => setVoiceInstructions(event.target.value)} />{voiceAsset?.character && voiceAsset.character.voiceStatus !== "missing" ? <div className="flex items-center justify-between border-t border-border/70 pt-2"><span className="text-[var(--fs-label)] text-foreground/45">当前绑定:{voiceAsset.character.voice?.profile.name || "声音素材不可用"}</span><Popconfirm title="解除当前声音绑定?" description="该操作会保留历史版本,并创建一个未绑定声音的新版本。" okText="解除" cancelText="取消" onConfirm={() => unbindVoiceMutation.mutate()}><Button type="text" danger size="small" loading={unbindVoiceMutation.isPending} icon={<VolumeX className="size-3.5" />}>解除声音</Button></Popconfirm></div> : null}</div></Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -218,7 +218,7 @@ function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }:
|
||||
const textArea = (key: CharacterFormKey, rows = 3) => <Form.Item name={key} label={field(key)[1]}><Input.TextArea rows={rows} placeholder={key === "appearance" ? "先描述用户能看到的稳定特征…" : undefined} /></Form.Item>;
|
||||
const input = (key: CharacterFormKey) => <Form.Item name={key} label={field(key)[1]}><Input /></Form.Item>;
|
||||
return <Modal title={null} open={open} forceRender width={860} okText={editing ? "保存角色设定" : "创建角色卡"} cancelText="取消" okButtonProps={{ loading }} onCancel={onClose} onOk={onSave} styles={{ body: { paddingTop: 0 } }}>
|
||||
<div className="mb-1 border-b border-border/70 pb-4"><div className="text-[11px] font-medium text-[var(--workspace-accent)]">角色设定</div><h2 className="mt-1 text-xl font-semibold">{editing ? "调整角色设定" : "建立一张角色卡"}</h2></div>
|
||||
<div className="mb-1 border-b border-border/70 pb-4"><div className="text-[var(--fs-label)] font-medium text-[var(--workspace-accent)]">角色设定</div><h2 className="mt-1 text-xl font-semibold">{editing ? "调整角色设定" : "建立一张角色卡"}</h2></div>
|
||||
<Form form={form} layout="vertical" requiredMark={false} className="pt-2"><Form.Item name="name" label="角色名称" rules={[{ required: true, message: "请输入角色名称" }]}><Input size="large" placeholder="例如:林默" /></Form.Item><Tabs items={[{ key: "identity", label: "身份与外观", forceRender: true, children: <div className="grid gap-x-5 sm:grid-cols-2">{input("role")}{input("aliases")}{textArea("appearance", 4)}{input("physique")}{input("clothing")}{input("props")}{textArea("consistencyPrompt", 4)}{textArea("multiViewPrompt", 3)}</div> }, { key: "performance", label: "表演与声音", forceRender: true, children: <div className="grid gap-x-5 sm:grid-cols-2">{textArea("personality", 4)}{input("voiceLanguage")}{input("voiceAge")}{input("voiceTimbre")}</div> }]} /></Form>
|
||||
</Modal>;
|
||||
}
|
||||
@@ -226,7 +226,7 @@ function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }:
|
||||
type CharacterFormKey = (typeof characterFields)[number][0];
|
||||
|
||||
function MediaAssetCard({ asset, personalAsset, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; onCategoryChange: (category: string) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) {
|
||||
return <article className="overflow-hidden rounded-lg border border-border/80 bg-background"><div className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><AssetMediaPreview asset={personalAsset} alt={asset.title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[9px] text-white">{mediaLabel(asset.mediaType)}</span></div></div><div className="p-2.5"><div className="flex items-start justify-between gap-2"><div className="min-w-0"><h3 className="truncate text-xs font-medium">{asset.title}</h3><div className="mt-1 flex items-center gap-1.5 text-[10px] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[10px] text-foreground/50 hover:bg-foreground/[.055]"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span></div></div><Link2 className="mt-0.5 size-3.5 shrink-0 text-[var(--workspace-accent)]" /></div><div className="mt-2 flex items-center justify-between gap-2 border-t border-border/60 pt-1.5"><span className="text-[9px] text-foreground/38">{formatTime(asset.updatedAt)}</span><div className="flex items-center"><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} /></Popconfirm></div></div></div></article>;
|
||||
return <article className="overflow-hidden rounded-lg border border-border/80 bg-background"><div className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><AssetMediaPreview asset={personalAsset} alt={asset.title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></div><div className="p-2.5"><div className="flex items-start justify-between gap-2"><div className="min-w-0"><h3 className="truncate text-xs font-medium">{asset.title}</h3><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-foreground/[.055]"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span></div></div><Link2 className="mt-0.5 size-3.5 shrink-0 text-[var(--workspace-accent)]" /></div><div className="mt-2 flex items-center justify-between gap-2 border-t border-border/60 pt-1.5"><span className="text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span><div className="flex items-center"><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} /></Popconfirm></div></div></div></article>;
|
||||
}
|
||||
|
||||
function characterDefinition(values: CharacterForm) {
|
||||
|
||||
@@ -53,14 +53,14 @@ export default function ProjectCanvasesView({ detail, refreshProject }: ProjectD
|
||||
<article key={canvas.id} className="min-w-0 overflow-hidden rounded-lg border border-border/80 bg-background">
|
||||
<Link to={`/canvas/${canvas.id}`} className="group relative block h-24 overflow-hidden border-b border-border/70 bg-[#111827] p-3 text-white">
|
||||
<div className="absolute inset-0 opacity-40" style={{ backgroundImage: "linear-gradient(rgba(255,255,255,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.08) 1px, transparent 1px)", backgroundSize: "20px 20px" }} />
|
||||
<div className="relative flex h-full flex-col justify-between"><div className="flex items-center justify-between"><span className="flex items-center gap-1.5 text-[10px] text-white/55"><Film className="size-3" />画布 {String(index + 1).padStart(2, "0")}</span><ArrowUpRight className="size-3.5 text-white/45 group-hover:text-[var(--workspace-accent)]" /></div><div><div className="truncate text-[13px] font-semibold">{canvas.title}</div><div className="mt-0.5 text-[9px] text-white/48">更新于 {formatTime(canvas.updatedAt)}</div></div></div>
|
||||
<div className="relative flex h-full flex-col justify-between"><div className="flex items-center justify-between"><span className="flex items-center gap-1.5 text-[var(--fs-tiny)] text-white/55"><Film className="size-3" />画布 {String(index + 1).padStart(2, "0")}</span><ArrowUpRight className="size-3.5 text-white/45 group-hover:text-[var(--workspace-accent)]" /></div><div><div className="truncate text-[var(--fs-body)] font-semibold">{canvas.title}</div><div className="mt-0.5 text-[var(--fs-micro)] text-white/48">更新于 {formatTime(canvas.updatedAt)}</div></div></div>
|
||||
</Link>
|
||||
<div className="p-2.5">
|
||||
<div className="flex items-center justify-between"><span className="text-[10px] font-medium text-foreground/48">关联章节</span><span className="text-[9px] tabular-nums text-foreground/38">{linkedUnits.length} 个</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-[var(--fs-tiny)] font-medium text-foreground/48">关联章节</span><span className="text-[var(--fs-micro)] tabular-nums text-foreground/38">{linkedUnits.length} 个</span></div>
|
||||
<div className="mt-1.5 flex min-h-6 max-h-12 flex-wrap gap-1 overflow-y-auto">
|
||||
{linkedUnits.length ? linkedUnits.map((unit) => (
|
||||
<span key={unit!.id} className="inline-flex h-5 max-w-full items-center gap-1 rounded bg-[var(--workspace-accent-soft)] pl-1.5 pr-0.5 text-[9px] text-[var(--workspace-accent)]"><span className="truncate">{String(unit!.position + 1).padStart(2, "0")} · {unit!.title}</span><Tooltip title="解除章节关联"><button type="button" className="grid size-4 shrink-0 place-items-center rounded hover:bg-foreground/[.07]" aria-label={`解除${unit!.title}关联`} onClick={() => unlinkUnitMutation.mutate({ canvasId: canvas.id, unitId: unit!.id })}><X className="size-3" /></button></Tooltip></span>
|
||||
)) : <span className="py-0.5 text-[10px] text-foreground/38">尚未关联章节</span>}
|
||||
<span key={unit!.id} className="inline-flex h-5 max-w-full items-center gap-1 rounded bg-[var(--workspace-accent-soft)] pl-1.5 pr-0.5 text-[var(--fs-micro)] text-[var(--workspace-accent)]"><span className="truncate">{String(unit!.position + 1).padStart(2, "0")} · {unit!.title}</span><Tooltip title="解除章节关联"><button type="button" className="grid size-4 shrink-0 place-items-center rounded hover:bg-foreground/[.07]" aria-label={`解除${unit!.title}关联`} onClick={() => unlinkUnitMutation.mutate({ canvasId: canvas.id, unitId: unit!.id })}><X className="size-3" /></button></Tooltip></span>
|
||||
)) : <span className="py-0.5 text-[var(--fs-tiny)] text-foreground/38">尚未关联章节</span>}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5 border-t border-border/60 pt-2">
|
||||
<Select size="small" className="min-w-0 flex-1" placeholder={unlinkedUnits.length ? "关联更多章节" : "全部章节已关联"} disabled={!unlinkedUnits.length} options={unlinkedUnits.map((unit) => ({ label: `${String(unit.position + 1).padStart(2, "0")} · ${unit.title}`, value: unit.id }))} onChange={(unitId) => { setLinkingCanvasId(canvas.id); linkMutation.mutate({ canvasId: canvas.id, unitId }); }} loading={linkMutation.isPending && linkingCanvasId === canvas.id} suffixIcon={<Link2 className="size-3.5" />} />
|
||||
|
||||
@@ -379,7 +379,7 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
|
||||
<input value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && visibleUnits[0]) selectChapter(visibleUnits[0].id); }} className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-foreground/28" placeholder="搜索标题或章节序号" aria-label="搜索章节" />
|
||||
{searchQuery ? <button type="button" onClick={() => setSearchQuery("")} className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-foreground/[.06]" aria-label="清空章节搜索"><X className="size-3" /></button> : null}
|
||||
</label>
|
||||
{deferredSearchQuery ? <div className="mt-1 px-0.5 text-[9px] tabular-nums text-foreground/35">找到 {visibleUnits.length.toLocaleString("zh-CN")} 章 · 搜索时使用“移动到”调整顺序</div> : null}
|
||||
{deferredSearchQuery ? <div className="mt-1 px-0.5 text-[var(--fs-micro)] tabular-nums text-foreground/35">找到 {visibleUnits.length.toLocaleString("zh-CN")} 章 · 搜索时使用“移动到”调整顺序</div> : null}
|
||||
</div>
|
||||
{orderedUnits.length ? (
|
||||
<div ref={listRef} onDragOver={handleListDragOver} className="thin-scrollbar min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain p-1.5">
|
||||
@@ -393,8 +393,8 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
|
||||
<div draggable={!dirty && !reorderMutation.isPending && !deferredSearchQuery} onDragStart={(event) => { setDraggedId(unit.id); event.dataTransfer.effectAllowed = "move"; }} onDragOver={(event) => { if (deferredSearchQuery) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={() => moveChapter(unit.id)} onDragEnd={() => setDraggedId("")} className={`group flex h-[50px] items-start rounded-md transition-colors duration-100 ${unit.id === selectedUnit?.id ? "bg-foreground/[.08]" : "hover:bg-foreground/[.04]"} ${draggedId === unit.id ? "opacity-45" : ""}`}>
|
||||
<button type="button" disabled={Boolean(deferredSearchQuery)} className="mt-2 grid size-6 shrink-0 cursor-grab place-items-center text-foreground/25 active:cursor-grabbing disabled:cursor-default disabled:opacity-35" aria-label={`拖动第 ${chapterNumber} 章排序`}><GripVertical className="size-3.5" /></button>
|
||||
<button type="button" onClick={() => selectChapter(unit.id)} className="flex min-w-0 flex-1 items-start gap-2 px-0 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]">
|
||||
<span className={`w-8 shrink-0 pt-0.5 text-[10px] tabular-nums ${unit.id === selectedUnit?.id ? "font-semibold text-[var(--workspace-accent)]" : "text-foreground/35"}`}>{String(chapterNumber).padStart(Math.max(2, String(orderedUnits.length).length), "0")}</span>
|
||||
<span className="min-w-0 flex-1"><span className={`block truncate text-[13px] ${unit.id === selectedUnit?.id ? "font-medium text-foreground" : "text-foreground/65"}`}>{unit.title}</span><span className="mt-0.5 flex items-center gap-1 text-[10px] text-foreground/38"><span>{statusLabel(unit.status)}</span>{chapterCanvasCount(unit.id) ? <><span>·</span><span>{chapterCanvasCount(unit.id)} 画布</span></> : null}</span></span>
|
||||
<span className={`w-8 shrink-0 pt-0.5 text-[var(--fs-tiny)] tabular-nums ${unit.id === selectedUnit?.id ? "font-semibold text-[var(--workspace-accent)]" : "text-foreground/35"}`}>{String(chapterNumber).padStart(Math.max(2, String(orderedUnits.length).length), "0")}</span>
|
||||
<span className="min-w-0 flex-1"><span className={`block truncate text-[var(--fs-body)] ${unit.id === selectedUnit?.id ? "font-medium text-foreground" : "text-foreground/65"}`}>{unit.title}</span><span className="mt-0.5 flex items-center gap-1 text-[var(--fs-tiny)] text-foreground/38"><span>{statusLabel(unit.status)}</span>{chapterCanvasCount(unit.id) ? <><span>·</span><span>{chapterCanvasCount(unit.id)} 画布</span></> : null}</span></span>
|
||||
</button>
|
||||
<Dropdown trigger={["click"]} placement="bottomRight" menu={{ items: [{ key: "move", icon: <MoveVertical className="size-3.5" />, label: "移动到…" }], onClick: () => { setMoveTargetId(unit.id); setMovePosition(chapterNumber); } }}>
|
||||
<button type="button" className="mt-2 grid size-6 shrink-0 place-items-center rounded text-foreground/28 opacity-0 hover:bg-foreground/[.06] hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100" aria-label={`${unit.title}更多操作`}><MoreHorizontal className="size-3.5" /></button>
|
||||
@@ -416,9 +416,9 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
|
||||
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden rounded-lg border border-border/80 bg-background">
|
||||
<header className="flex shrink-0 flex-wrap items-start gap-3 border-b border-border/70 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 text-[10px] font-medium tabular-nums text-foreground/38">第 {String(orderedUnits.findIndex((unit) => unit.id === selectedUnit.id) + 1).padStart(2, "0")} 章</div>
|
||||
<div className="mb-1 text-[var(--fs-tiny)] font-medium tabular-nums text-foreground/38">第 {String(orderedUnits.findIndex((unit) => unit.id === selectedUnit.id) + 1).padStart(2, "0")} 章</div>
|
||||
<Input variant="borderless" value={draftTitle} disabled={!selectedUnitQuery.data?.unit} onChange={(event) => { setDraftTitle(event.target.value); setDirty(true); }} className="!h-auto !px-0 !py-0 !text-xl !font-semibold !leading-tight disabled:!cursor-wait disabled:!text-foreground" placeholder="章节标题" />
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[10px] text-foreground/38"><span>{dirty ? "有未保存修改" : `保存于 ${formatTime(selectedUnit.updatedAt)}`}</span><span>·</span><span>{formatCount(wordCount)} 字</span><span>·</span><span>{chapterCanvasCount(selectedUnit.id)} 个画布</span></div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/38"><span>{dirty ? "有未保存修改" : `保存于 ${formatTime(selectedUnit.updatedAt)}`}</span><span>·</span><span>{formatCount(wordCount)} 字</span><span>·</span><span>{chapterCanvasCount(selectedUnit.id)} 个画布</span></div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5">
|
||||
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || extractingCharacters} loading={extractingCharacters} onClick={() => void extractCharacters()}>提取角色</Button>
|
||||
@@ -483,7 +483,7 @@ function EditorToolbar({ editor }: { editor: Editor | null }) {
|
||||
<EditorTool editor={editor} label="编号列表" icon={<ListOrdered className="size-3.5" />} onClick={() => editor?.chain().focus().toggleOrderedList().run()} active={Boolean(editor?.isActive("orderedList"))} />
|
||||
<EditorTool editor={editor} label="引用" icon={<Quote className="size-3.5" />} onClick={() => editor?.chain().focus().toggleBlockquote().run()} active={Boolean(editor?.isActive("blockquote"))} />
|
||||
<EditorTool editor={editor} label="链接" icon={<Link2 className="size-3.5" />} onClick={setLink} active={Boolean(editor?.isActive("link"))} />
|
||||
<Dropdown trigger={["click"]} placement="bottomRight" menu={{ items: [{ key: "color", icon: <span className="text-[11px] font-bold text-amber-600">A</span>, label: "文字颜色" }, { key: "highlight", icon: <Highlighter className="size-3.5" />, label: "高亮颜色" }, { type: "divider" }, { key: "code", icon: <Code2 className="size-3.5" />, label: "行内代码" }, { key: "rule", icon: <Minus className="size-3.5" />, label: "分隔线" }, { key: "clear", icon: <Eraser className="size-3.5" />, label: "清除格式" }], onClick: ({ key }) => { if (key === "color") setColor(); else if (key === "highlight") setHighlight(); else if (key === "code") editor?.chain().focus().toggleCode().run(); else if (key === "rule") editor?.chain().focus().setHorizontalRule().run(); else if (key === "clear") editor?.chain().focus().clearNodes().unsetAllMarks().run(); } }}>
|
||||
<Dropdown trigger={["click"]} placement="bottomRight" menu={{ items: [{ key: "color", icon: <span className="text-[var(--fs-label)] font-bold text-amber-600">A</span>, label: "文字颜色" }, { key: "highlight", icon: <Highlighter className="size-3.5" />, label: "高亮颜色" }, { type: "divider" }, { key: "code", icon: <Code2 className="size-3.5" />, label: "行内代码" }, { key: "rule", icon: <Minus className="size-3.5" />, label: "分隔线" }, { key: "clear", icon: <Eraser className="size-3.5" />, label: "清除格式" }], onClick: ({ key }) => { if (key === "color") setColor(); else if (key === "highlight") setHighlight(); else if (key === "code") editor?.chain().focus().toggleCode().run(); else if (key === "rule") editor?.chain().focus().setHorizontalRule().run(); else if (key === "clear") editor?.chain().focus().clearNodes().unsetAllMarks().run(); } }}>
|
||||
<button type="button" className="grid size-7 place-items-center rounded text-foreground/60 hover:bg-foreground/[.06]" aria-label="更多格式"><MoreHorizontal className="size-4" /></button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
@@ -519,17 +519,17 @@ function ImportNovelModal({ open, loading, onClose, onImport }: { open: boolean;
|
||||
return (
|
||||
<Modal title={null} open={open} footer={null} destroyOnHidden onCancel={onClose} width={760} styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}>
|
||||
<div className="flex min-h-[478px] flex-col">
|
||||
<header className="flex h-12 shrink-0 items-center border-b border-border px-4"><div><h2 className="text-sm font-semibold">导入小说</h2><p className="mt-0.5 text-[10px] text-foreground/42">自动识别章节标题,确认后追加到当前项目</p></div></header>
|
||||
<header className="flex h-12 shrink-0 items-center border-b border-border px-4"><div><h2 className="text-sm font-semibold">导入小说</h2><p className="mt-0.5 text-[var(--fs-tiny)] text-foreground/42">自动识别章节标题,确认后追加到当前项目</p></div></header>
|
||||
<div className="grid min-h-[430px] flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_240px]">
|
||||
<div className="border-b border-border p-3 md:border-b-0 md:border-r">
|
||||
<div className="mb-2 flex items-center justify-between gap-2"><div><div className="text-sm font-medium">小说正文</div><div className="mt-0.5 text-[11px] text-foreground/45">识别章节标题后追加到现有章节</div></div><Button size="small" icon={<FileUp className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>{fileName || "选择 TXT"}</Button></div>
|
||||
<div className="mb-2 flex items-center justify-between gap-2"><div><div className="text-sm font-medium">小说正文</div><div className="mt-0.5 text-[var(--fs-label)] text-foreground/45">识别章节标题后追加到现有章节</div></div><Button size="small" icon={<FileUp className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>{fileName || "选择 TXT"}</Button></div>
|
||||
<input ref={fileInputRef} type="file" accept=".txt,.md,text/plain,text/markdown" className="hidden" onChange={(event) => void readFile(event)} />
|
||||
<Input.TextArea value={text} onChange={(event) => setText(event.target.value)} rows={16} placeholder={'也可以直接粘贴小说正文,例如:\n\n第一章 雨夜来信\n正文……\n\n第二章 灯塔以北\n正文……'} className="!resize-none" />
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="flex h-11 shrink-0 items-center justify-between border-b border-border px-3 text-xs"><span className="font-medium">拆分预览</span><span className="tabular-nums text-foreground/45">{chapters.length} 章</span></div>
|
||||
<ImportChapterPreview chapters={chapters} />
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border p-3"><span className={`text-[10px] ${chapters.length > MAX_NOVEL_IMPORT_CHAPTERS ? "text-red-500" : "text-foreground/38"}`}>{chapters.length > MAX_NOVEL_IMPORT_CHAPTERS ? `最多一次导入 ${MAX_NOVEL_IMPORT_CHAPTERS.toLocaleString("zh-CN")} 章` : `支持最多 ${MAX_NOVEL_IMPORT_CHAPTERS.toLocaleString("zh-CN")} 章`}</span><div className="flex gap-2"><Button size="small" onClick={onClose}>取消</Button><Button size="small" type="primary" disabled={!chapters.length || chapters.length > MAX_NOVEL_IMPORT_CHAPTERS} loading={loading} onClick={() => onImport(chapters)}>导入 {chapters.length || ""} 章</Button></div></div>
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border p-3"><span className={`text-[var(--fs-tiny)] ${chapters.length > MAX_NOVEL_IMPORT_CHAPTERS ? "text-red-500" : "text-foreground/38"}`}>{chapters.length > MAX_NOVEL_IMPORT_CHAPTERS ? `最多一次导入 ${MAX_NOVEL_IMPORT_CHAPTERS.toLocaleString("zh-CN")} 章` : `支持最多 ${MAX_NOVEL_IMPORT_CHAPTERS.toLocaleString("zh-CN")} 章`}</span><div className="flex gap-2"><Button size="small" onClick={onClose}>取消</Button><Button size="small" type="primary" disabled={!chapters.length || chapters.length > MAX_NOVEL_IMPORT_CHAPTERS} loading={loading} onClick={() => onImport(chapters)}>导入 {chapters.length || ""} 章</Button></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -550,7 +550,7 @@ function ImportChapterPreview({ chapters }: { chapters: Array<{ title: string; p
|
||||
{chapters.length ? <div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const chapter = chapters[virtualItem.index];
|
||||
return <div key={`${chapter.title}-${virtualItem.index}`} className="absolute left-0 top-0 flex w-full gap-2 border-b border-border/60 px-1.5 py-2" style={{ height: virtualItem.size, transform: `translateY(${virtualItem.start}px)` }}><span className="w-8 shrink-0 pt-0.5 text-[10px] tabular-nums text-foreground/35">{String(virtualItem.index + 1).padStart(Math.max(2, String(chapters.length).length), "0")}</span><div className="min-w-0"><div className="truncate text-xs font-medium">{chapter.title}</div><div className="mt-0.5 text-[10px] text-foreground/40">{formatCount(chapter.plainText.length)} 字</div></div></div>;
|
||||
return <div key={`${chapter.title}-${virtualItem.index}`} className="absolute left-0 top-0 flex w-full gap-2 border-b border-border/60 px-1.5 py-2" style={{ height: virtualItem.size, transform: `translateY(${virtualItem.start}px)` }}><span className="w-8 shrink-0 pt-0.5 text-[var(--fs-tiny)] tabular-nums text-foreground/35">{String(virtualItem.index + 1).padStart(Math.max(2, String(chapters.length).length), "0")}</span><div className="min-w-0"><div className="truncate text-xs font-medium">{chapter.title}</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/40">{formatCount(chapter.plainText.length)} 字</div></div></div>;
|
||||
})}
|
||||
</div> : <div className="grid h-full place-items-center px-4 text-center text-xs leading-5 text-foreground/40">选择 TXT 文件或粘贴正文后,这里会显示拆分结果</div>}
|
||||
</div>
|
||||
|
||||
@@ -23,11 +23,11 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
|
||||
<section className="project-overview-focus overflow-hidden rounded-lg border border-border/75 bg-background/80 shadow-sm">
|
||||
<div className="grid lg:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div className="min-w-0 p-5 sm:p-6 lg:p-7">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[var(--fs-label)]">
|
||||
<span className="font-medium text-[var(--workspace-accent)]">当前任务</span>
|
||||
<span className="text-foreground/20" aria-hidden>/</span>
|
||||
<span className="text-foreground/45">{stage.label}</span>
|
||||
{attentionCount ? <span className="rounded bg-foreground/[.07] px-1.5 py-0.5 text-[10px] font-medium text-foreground/70">{attentionCount} 项待处理</span> : null}
|
||||
{attentionCount ? <span className="rounded bg-foreground/[.07] px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium text-foreground/70">{attentionCount} 项待处理</span> : null}
|
||||
</div>
|
||||
<h2 className="mt-3 max-w-[680px] text-2xl font-semibold leading-8 text-balance">{primaryAction.title}</h2>
|
||||
<p className="mt-2 max-w-[680px] text-sm leading-6 text-foreground/52 text-pretty">{primaryAction.description}</p>
|
||||
@@ -41,7 +41,7 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
|
||||
|
||||
<aside className="project-overview-status border-t border-border/70 bg-foreground/[.018] p-5 lg:border-l lg:border-t-0 lg:p-6" aria-label="项目进度">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div><div className="text-[10px] font-medium text-foreground/38">章节进度</div><div className="mt-1 text-lg font-semibold tabular-nums">{completedUnits}<span className="mx-1 text-sm font-normal text-foreground/28">/</span>{units.length}</div></div>
|
||||
<div><div className="text-[var(--fs-tiny)] font-medium text-foreground/38">章节进度</div><div className="mt-1 text-lg font-semibold tabular-nums">{completedUnits}<span className="mx-1 text-sm font-normal text-foreground/28">/</span>{units.length}</div></div>
|
||||
<span className="text-xs font-medium tabular-nums text-foreground/42">{completion}%</span>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-foreground/[.08]" aria-label={`章节完成度 ${completion}%`}><div className="h-full rounded-full bg-[var(--workspace-accent)] transition-[width]" style={{ width: `${completion}%` }} /></div>
|
||||
@@ -53,7 +53,7 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
|
||||
</dl>
|
||||
{secondaryActions.length ? (
|
||||
<div className="mt-5 border-t border-border/65 pt-4">
|
||||
<div className="text-[10px] font-medium text-foreground/38">随后处理</div>
|
||||
<div className="text-[var(--fs-tiny)] font-medium text-foreground/38">随后处理</div>
|
||||
<div className="mt-2 space-y-1">{secondaryActions.map((action) => <SecondaryAction key={action.id} action={action} />)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -64,7 +64,7 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
|
||||
<section>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-foreground/38">制作流水线</div>
|
||||
<div className="text-[var(--fs-tiny)] font-medium text-foreground/38">制作流水线</div>
|
||||
<h2 className="mt-1 text-lg font-semibold">章节进度</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/46">从内容确认到项目画布,每章只显示当前真实状态。</p>
|
||||
</div>
|
||||
@@ -77,8 +77,8 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
|
||||
{unitStages.map((item) => (
|
||||
<Link key={item.unit.id} to={`/projects/${project.id}/chapters/${item.unit.id}`} className="group grid gap-4 p-4 transition-colors hover:bg-foreground/[.025] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-foreground/20 sm:p-5 lg:grid-cols-[minmax(220px,.75fr)_minmax(480px,1.35fr)_24px] lg:items-center">
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.055] text-[10px] font-semibold tabular-nums text-foreground/45">{String(item.unit.position + 1).padStart(2, "0")}</span>
|
||||
<span className="min-w-0"><span className="block truncate text-sm font-medium">{item.unit.title}</span><span className="mt-1 block text-[10px] text-foreground/36">更新于 {formatTime(item.unit.updatedAt)}</span></span>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.055] text-[var(--fs-tiny)] font-semibold tabular-nums text-foreground/45">{String(item.unit.position + 1).padStart(2, "0")}</span>
|
||||
<span className="min-w-0"><span className="block truncate text-sm font-medium">{item.unit.title}</span><span className="mt-1 block text-[var(--fs-tiny)] text-foreground/36">更新于 {formatTime(item.unit.updatedAt)}</span></span>
|
||||
</span>
|
||||
<StagePipeline content={item.content} assets={item.assets} storyboard={item.storyboard} canvas={item.canvas} />
|
||||
<ArrowRight className="hidden size-4 text-foreground/22 transition group-hover:translate-x-0.5 group-hover:text-foreground/55 lg:block" />
|
||||
@@ -93,12 +93,12 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
|
||||
}
|
||||
|
||||
function ProjectFact({ label, value, attention = false }: { label: string; value: string; attention?: boolean }) {
|
||||
return <div className="min-w-0"><dt className="text-[10px] text-foreground/36">{label}</dt><dd className={`mt-1 truncate font-medium ${attention ? "text-foreground/85" : "text-foreground/72"}`}>{value}</dd></div>;
|
||||
return <div className="min-w-0"><dt className="text-[var(--fs-tiny)] text-foreground/36">{label}</dt><dd className={`mt-1 truncate font-medium ${attention ? "text-foreground/85" : "text-foreground/72"}`}>{value}</dd></div>;
|
||||
}
|
||||
|
||||
function SecondaryAction({ action }: { action: ProjectWorkbenchAction }) {
|
||||
const Icon = action.tone === "danger" ? CircleAlert : action.tone === "attention" ? Clock3 : CheckCircle2;
|
||||
return <Link to={action.href} className="group flex min-w-0 items-center gap-2 rounded px-1 py-1.5 text-[11px] text-foreground/52 hover:bg-foreground/[.04] hover:text-foreground"><Icon className={`size-3.5 shrink-0 ${action.tone === "danger" ? "text-foreground/80" : action.tone === "attention" ? "text-foreground/60" : "text-foreground/30"}`} /><span className="min-w-0 flex-1 truncate">{action.title}</span><ArrowRight className="size-3 shrink-0 text-foreground/25 transition group-hover:text-foreground/55" /></Link>;
|
||||
return <Link to={action.href} className="group flex min-w-0 items-center gap-2 rounded px-1 py-1.5 text-[var(--fs-label)] text-foreground/52 hover:bg-foreground/[.04] hover:text-foreground"><Icon className={`size-3.5 shrink-0 ${action.tone === "danger" ? "text-foreground/80" : action.tone === "attention" ? "text-foreground/60" : "text-foreground/30"}`} /><span className="min-w-0 flex-1 truncate">{action.title}</span><ArrowRight className="size-3 shrink-0 text-foreground/25 transition group-hover:text-foreground/55" /></Link>;
|
||||
}
|
||||
|
||||
function StagePipeline({ content, assets, storyboard, canvas }: { content: ProjectStageCell; assets: ProjectStageCell; storyboard: ProjectStageCell; canvas: ProjectStageCell }) {
|
||||
@@ -114,9 +114,9 @@ function StageStep({ label, cell }: { label: string; cell: ProjectStageCell }) {
|
||||
const bar = cell.state === "completed" ? "bg-foreground/65" : cell.state === "attention" ? "bg-foreground/40" : cell.state === "active" ? "bg-[var(--workspace-accent)]" : "bg-foreground/10";
|
||||
return (
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[10px] font-medium text-foreground/34">{label}</span>
|
||||
<span className="block text-[var(--fs-tiny)] font-medium text-foreground/34">{label}</span>
|
||||
<span className={`mt-2 block h-1 rounded-full ${bar}`} />
|
||||
<span className="mt-1.5 block truncate text-[10px] text-foreground/48">{cell.label}</span>
|
||||
<span className="mt-1.5 block truncate text-[var(--fs-tiny)] text-foreground/48">{cell.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,13 +27,13 @@ export function ProjectCharacterCard({ asset, generating, removing, onEdit, onGe
|
||||
<div className="relative aspect-[3/2] overflow-hidden bg-foreground/[.045]">
|
||||
{cover ? <img src={resourceFileUrl(cover.resourceId)} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" /> : <div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>}
|
||||
<div className="absolute inset-x-2 top-2 flex items-center justify-between gap-2">
|
||||
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[9px] font-medium text-white">角色卡 · v{character?.version || 1}</span>
|
||||
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[9px] text-white">{readinessLabel}</span>
|
||||
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[var(--fs-micro)] font-medium text-white">角色卡 · v{character?.version || 1}</span>
|
||||
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{readinessLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><h3 className="truncate text-sm font-semibold">{asset.title}</h3><p className="mt-0.5 truncate text-[11px] text-foreground/48">{role}</p></div><Button type="text" size="small" className="!h-7 !px-1.5" icon={<Pencil className="size-3.5" />} onClick={onEdit} aria-label={`编辑 ${asset.title}`} /></div>
|
||||
<p className="mt-2 line-clamp-2 min-h-9 text-[11px] leading-[18px] text-foreground/55">{appearance}</p>
|
||||
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><h3 className="truncate text-sm font-semibold">{asset.title}</h3><p className="mt-0.5 truncate text-[var(--fs-label)] text-foreground/48">{role}</p></div><Button type="text" size="small" className="!h-7 !px-1.5" icon={<Pencil className="size-3.5" />} onClick={onEdit} aria-label={`编辑 ${asset.title}`} /></div>
|
||||
<p className="mt-2 line-clamp-2 min-h-9 text-[var(--fs-label)] leading-[18px] text-foreground/55">{appearance}</p>
|
||||
<div className="mt-2 grid gap-1.5">
|
||||
<StatusLine icon={<ImageIcon className="size-3.5" />} ready={character?.visualStatus === "ready"} label={imageStatus} action={character?.visualStatus === "ready" ? "更换" : "初始化"} onClick={character?.visualStatus === "ready" ? onBindImages : onGenerate} />
|
||||
<StatusLine icon={<Volume2 className="size-3.5" />} ready={character?.voiceStatus === "ready"} label={voiceStatus} action={character?.voiceStatus === "ready" ? "调整" : "选择"} onClick={onBindVoice} />
|
||||
@@ -47,7 +47,7 @@ export function ProjectCharacterCard({ asset, generating, removing, onEdit, onGe
|
||||
}
|
||||
|
||||
function StatusLine({ icon, ready, label, action, onClick }: { icon: ReactNode; ready: boolean; label: string; action: string; onClick: () => void }) {
|
||||
return <div className={`flex min-w-0 items-center gap-2 rounded-md border px-2 py-1.5 text-[10px] ${ready ? "border-emerald-500/20 bg-emerald-500/[.06] text-emerald-700 dark:text-emerald-300" : "border-border/70 bg-foreground/[.025] text-foreground/48"}`}><span className="shrink-0">{icon}</span><span className="min-w-0 flex-1 truncate">{label}</span><button type="button" className="shrink-0 font-medium text-[var(--workspace-accent)] hover:underline" onClick={onClick}>{action}</button></div>;
|
||||
return <div className={`flex min-w-0 items-center gap-2 rounded-md border px-2 py-1.5 text-[var(--fs-tiny)] ${ready ? "border-emerald-500/20 bg-emerald-500/[.06] text-emerald-700 dark:text-emerald-300" : "border-border/70 bg-foreground/[.025] text-foreground/48"}`}><span className="shrink-0">{icon}</span><span className="min-w-0 flex-1 truncate">{label}</span><button type="button" className="shrink-0 font-medium text-[var(--workspace-accent)] hover:underline" onClick={onClick}>{action}</button></div>;
|
||||
}
|
||||
|
||||
function textValue(value: unknown) {
|
||||
|
||||
@@ -38,18 +38,18 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
</section>
|
||||
|
||||
<section className="border-b border-border/70 py-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3"><div><h3 className="text-sm font-semibold">项目画风</h3><p className="mt-0.5 text-[11px] text-foreground/45">选择后会同步到项目画布中的画风节点</p></div>{stylePresetId ? <span className="text-[11px] text-[var(--workspace-accent)]">已选择</span> : <span className="text-[11px] text-foreground/40">未设置</span>}</div>
|
||||
<div className="mb-3 flex items-center justify-between gap-3"><div><h3 className="text-sm font-semibold">项目画风</h3><p className="mt-0.5 text-[var(--fs-label)] text-foreground/45">选择后会同步到项目画布中的画风节点</p></div>{stylePresetId ? <span className="text-[var(--fs-label)] text-[var(--workspace-accent)]">已选择</span> : <span className="text-[var(--fs-label)] text-foreground/40">未设置</span>}</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{canvasStylePresets.map((preset) => {
|
||||
const active = preset.id === stylePresetId;
|
||||
return <div key={preset.id} className={`group flex min-w-0 items-center overflow-hidden rounded-lg border transition-colors ${active ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent-soft)]" : "border-border/80 hover:border-foreground/25"}`}><button type="button" onClick={() => setStylePresetId(preset.id)} className="grid min-w-0 flex-1 grid-cols-[72px_minmax(0,1fr)_24px] items-center gap-2 p-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"><img src={preset.imageUrl} width="72" height="44" alt={`${preset.title}画风示意`} loading="lazy" className="h-11 w-[72px] rounded object-cover" /><span className="min-w-0"><span className="block truncate text-xs font-medium">{preset.title}</span><span className="mt-0.5 block truncate text-[10px] text-foreground/42">{preset.description}</span></span><span className={`grid size-5 place-items-center rounded-full ${active ? "bg-[var(--workspace-accent)] text-white" : "border border-border text-transparent"}`}><Check className="size-3" /></span></button><button type="button" className="mr-1 grid size-8 shrink-0 place-items-center rounded-md text-foreground/42 hover:bg-foreground/[.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={() => setStyleDetail(preset)} aria-label={`查看${preset.title}详情`} title="查看画风详情"><Eye className="size-3.5" /></button></div>;
|
||||
return <div key={preset.id} className={`group flex min-w-0 items-center overflow-hidden rounded-lg border transition-colors ${active ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent-soft)]" : "border-border/80 hover:border-foreground/25"}`}><button type="button" onClick={() => setStylePresetId(preset.id)} className="grid min-w-0 flex-1 grid-cols-[72px_minmax(0,1fr)_24px] items-center gap-2 p-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"><img src={preset.imageUrl} width="72" height="44" alt={`${preset.title}画风示意`} loading="lazy" className="h-11 w-[72px] rounded object-cover" /><span className="min-w-0"><span className="block truncate text-xs font-medium">{preset.title}</span><span className="mt-0.5 block truncate text-[var(--fs-tiny)] text-foreground/42">{preset.description}</span></span><span className={`grid size-5 place-items-center rounded-full ${active ? "bg-[var(--workspace-accent)] text-white" : "border border-border text-transparent"}`}><Check className="size-3" /></span></button><button type="button" className="mr-1 grid size-8 shrink-0 place-items-center rounded-md text-foreground/42 hover:bg-foreground/[.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={() => setStyleDetail(preset)} aria-label={`查看${preset.title}详情`} title="查看画风详情"><Eye className="size-3.5" /></button></div>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-4">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-red-500/20 bg-red-500/[.025] px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-2.5"><span className="grid size-7 shrink-0 place-items-center rounded bg-red-500/10 text-red-500"><Archive className="size-3.5" /></span><div className="min-w-0"><h3 className="text-sm font-medium">{project.status === "archived" ? "恢复项目" : "归档项目"}</h3><p className="mt-0.5 text-[11px] text-foreground/48">{project.status === "archived" ? "恢复后可继续创建章节、画布和生成任务" : "保留全部章节、画布和资产,停止项目内新建与生成"}</p></div></div>
|
||||
<div className="flex min-w-0 items-center gap-2.5"><span className="grid size-7 shrink-0 place-items-center rounded bg-red-500/10 text-red-500"><Archive className="size-3.5" /></span><div className="min-w-0"><h3 className="text-sm font-medium">{project.status === "archived" ? "恢复项目" : "归档项目"}</h3><p className="mt-0.5 text-[var(--fs-label)] text-foreground/48">{project.status === "archived" ? "恢复后可继续创建章节、画布和生成任务" : "保留全部章节、画布和资产,停止项目内新建与生成"}</p></div></div>
|
||||
<Button size="small" danger={project.status !== "archived"} icon={project.status === "archived" ? <Check className="size-3.5" /> : <ShieldAlert className="size-3.5" />} onClick={() => setArchiveOpen(true)}>{project.status === "archived" ? "恢复项目" : "归档项目"}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -81,14 +81,14 @@ export function sourceTypeLabel(value: string) {
|
||||
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
const color = status === "completed" || status === "confirmed" || status === "succeeded" ? "success" : status === "failed" ? "error" : status === "running" || status === "active" ? "processing" : status === "review" || status === "pending_confirmation" ? "warning" : "default";
|
||||
return <Tag color={color} className="m-0 !rounded-full !px-2 !text-[11px]">{statusLabel(status)}</Tag>;
|
||||
return <Tag color={color} className="m-0 !rounded-full !px-2 !text-[var(--fs-label)]">{statusLabel(status)}</Tag>;
|
||||
}
|
||||
|
||||
export function SectionTitle({ eyebrow, title, description, action }: { eyebrow?: string; title: string; description?: string; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-b border-border/70 pb-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{eyebrow ? <div className="mb-1 text-[10px] font-semibold text-foreground/40">{eyebrow}</div> : null}
|
||||
{eyebrow ? <div className="mb-1 text-[var(--fs-tiny)] font-semibold text-foreground/40">{eyebrow}</div> : null}
|
||||
<h2 className="text-lg font-semibold tracking-normal">{title}</h2>
|
||||
{description ? <p className="mt-1 text-sm leading-5 text-foreground/55">{description}</p> : null}
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function ProjectsPage() {
|
||||
{query.isLoading ? <WorkspaceLoadingState label="正在整理项目" detail="读取章节、画布与资产进度" /> : null}
|
||||
{!query.isLoading && rows.length ? (
|
||||
<TableSurface>
|
||||
<div className="hidden h-10 grid-cols-[minmax(240px,1.2fr)_minmax(150px,.75fr)_minmax(150px,.7fr)_minmax(180px,.8fr)_120px_28px] items-center gap-4 border-b border-border/70 bg-foreground/[.025] px-4 text-[11px] font-medium text-foreground/42 lg:grid">
|
||||
<div className="hidden h-10 grid-cols-[minmax(240px,1.2fr)_minmax(150px,.75fr)_minmax(150px,.7fr)_minmax(180px,.8fr)_120px_28px] items-center gap-4 border-b border-border/70 bg-foreground/[.025] px-4 text-[var(--fs-label)] font-medium text-foreground/42 lg:grid">
|
||||
<span>项目</span><span>当前阶段</span><span>章节进度</span><span>内容</span><span>最近更新</span><span />
|
||||
</div>
|
||||
<div className="divide-y divide-border/65">
|
||||
@@ -108,25 +108,25 @@ function ProjectRow({ row }: { row: ProjectSummary }) {
|
||||
<Link to={`/projects/${row.project.id}/overview`} className="group block min-w-0 px-3 py-3 transition-colors hover:bg-foreground/[.025] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-foreground/20 sm:px-4 lg:grid lg:min-h-[76px] lg:grid-cols-[minmax(240px,1.2fr)_minmax(150px,.75fr)_minmax(150px,.7fr)_minmax(180px,.8fr)_120px_28px] lg:items-center lg:gap-4 lg:py-2.5">
|
||||
<span className="flex min-w-0 items-start gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.06] text-foreground/50"><FolderKanban className="size-4" /></span>
|
||||
<span className="min-w-0"><span className="flex min-w-0 items-center gap-2"><strong className="truncate text-sm font-semibold">{row.project.name}</strong>{row.project.status === "archived" ? <span className="shrink-0 rounded bg-foreground/[.07] px-1.5 py-0.5 text-[10px] text-foreground/45">已归档</span> : null}</span><span className="mt-1 block truncate text-[11px] text-foreground/42">{row.project.stylePresetId || "未设置画风"} · {row.project.aspectRatio} · {sourceTypeLabel(row.project.sourceType)}</span></span>
|
||||
<span className="min-w-0"><span className="flex min-w-0 items-center gap-2"><strong className="truncate text-sm font-semibold">{row.project.name}</strong>{row.project.status === "archived" ? <span className="shrink-0 rounded bg-foreground/[.07] px-1.5 py-0.5 text-[var(--fs-tiny)] text-foreground/45">已归档</span> : null}</span><span className="mt-1 block truncate text-[var(--fs-label)] text-foreground/42">{row.project.stylePresetId || "未设置画风"} · {row.project.aspectRatio} · {sourceTypeLabel(row.project.sourceType)}</span></span>
|
||||
</span>
|
||||
|
||||
<span className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] items-start gap-2 lg:mt-0 lg:block">
|
||||
<span className="text-[10px] text-foreground/38 lg:hidden">当前阶段</span>
|
||||
<span className="min-w-0"><span className="block text-xs font-medium text-foreground/78">{stage.label}</span><span className="mt-1 line-clamp-1 block text-[10px] text-foreground/40">{stage.detail}</span></span>
|
||||
<span className="text-[var(--fs-tiny)] text-foreground/38 lg:hidden">当前阶段</span>
|
||||
<span className="min-w-0"><span className="block text-xs font-medium text-foreground/78">{stage.label}</span><span className="mt-1 line-clamp-1 block text-[var(--fs-tiny)] text-foreground/40">{stage.detail}</span></span>
|
||||
</span>
|
||||
|
||||
<span className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] items-center gap-2 lg:mt-0 lg:block">
|
||||
<span className="text-[10px] text-foreground/38 lg:hidden">章节进度</span>
|
||||
<span className="min-w-0"><span className="flex items-center justify-between text-[10px] text-foreground/48"><span>{row.completedUnitCount}/{row.unitCount} 章</span><span>{completion}%</span></span><span className="mt-1.5 block h-1.5 overflow-hidden rounded-full bg-foreground/[.08]"><span className="block h-full rounded-full bg-[var(--workspace-accent)] transition-[width]" style={{ width: `${completion}%` }} /></span></span>
|
||||
<span className="text-[var(--fs-tiny)] text-foreground/38 lg:hidden">章节进度</span>
|
||||
<span className="min-w-0"><span className="flex items-center justify-between text-[var(--fs-tiny)] text-foreground/48"><span>{row.completedUnitCount}/{row.unitCount} 章</span><span>{completion}%</span></span><span className="mt-1.5 block h-1.5 overflow-hidden rounded-full bg-foreground/[.08]"><span className="block h-full rounded-full bg-[var(--workspace-accent)] transition-[width]" style={{ width: `${completion}%` }} /></span></span>
|
||||
</span>
|
||||
|
||||
<span className="mt-3 flex items-center gap-4 text-[11px] text-foreground/50 lg:mt-0">
|
||||
<span className="mt-3 flex items-center gap-4 text-[var(--fs-label)] text-foreground/50 lg:mt-0">
|
||||
<ProjectCount icon={<BookOpenText className="size-3.5" />} label="章节" value={row.unitCount} />
|
||||
<ProjectCount icon={<LayoutGrid className="size-3.5" />} label="画布" value={row.canvasCount} />
|
||||
<ProjectCount icon={<Images className="size-3.5" />} label="资产" value={row.assetCount} />
|
||||
</span>
|
||||
<span className="mt-3 block text-[11px] tabular-nums text-foreground/42 lg:mt-0">{formatProjectTime(row.project.updatedAt)}</span>
|
||||
<span className="mt-3 block text-[var(--fs-label)] tabular-nums text-foreground/42 lg:mt-0">{formatProjectTime(row.project.updatedAt)}</span>
|
||||
<ArrowRight className="hidden size-4 text-foreground/25 transition-transform group-hover:translate-x-0.5 group-hover:text-foreground/60 lg:block" />
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -40,8 +40,8 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
return (
|
||||
<div className="mt-3 border-t border-border/70 pt-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div><div className="text-xs font-medium">模型请求协议</div><div className="mt-0.5 text-[10px] text-foreground/42">测试会发起真实请求并可能产生供应商费用;视频价格仅用于成本记录</div></div>
|
||||
<span className="text-[10px] text-foreground/35">{channel.models.length} 个模型</span>
|
||||
<div><div className="text-xs font-medium">模型请求协议</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/42">测试会发起真实请求并可能产生供应商费用;视频价格仅用于成本记录</div></div>
|
||||
<span className="text-[var(--fs-tiny)] text-foreground/35">{channel.models.length} 个模型</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/60 rounded-md border border-border/70">
|
||||
{channel.models.map((rawModel) => {
|
||||
@@ -52,14 +52,14 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
const billingMode = cost?.billingMode || "fixed_request";
|
||||
return (
|
||||
<div key={model} className="grid gap-2 px-2.5 py-2.5 lg:grid-cols-[minmax(140px,1fr)_minmax(280px,1.6fr)_176px_160px_96px] lg:items-center">
|
||||
<div className="min-w-0"><div className="truncate text-xs font-medium" title={model}>{model}</div><div className="mt-0.5 truncate font-mono text-[10px] text-foreground/40" title={modelProtocolDefinition(protocol)?.create}>{modelProtocolDefinition(protocol)?.create}</div></div>
|
||||
<div className="min-w-0"><div className="truncate text-xs font-medium" title={model}>{model}</div><div className="mt-0.5 truncate font-mono text-[var(--fs-tiny)] text-foreground/40" title={modelProtocolDefinition(protocol)?.create}>{modelProtocolDefinition(protocol)?.create}</div></div>
|
||||
<Select<ModelProtocol>
|
||||
size="small"
|
||||
value={protocol}
|
||||
options={MODEL_PROTOCOL_OPTIONS}
|
||||
onChange={(value) => updateCost(model, { protocol: value, capability: modelProtocolCapability(value) || capability, billingMode: modelProtocolCapability(value) === "video" ? billingMode : "fixed_request" })}
|
||||
/>
|
||||
{capability === "video" ? <Segmented size="small" block value={billingMode} options={[{ label: "按次", value: "fixed_request" }, { label: "按秒", value: "per_second" }]} onChange={(value) => updateCost(model, { billingMode: value as ModelCost["billingMode"] })} /> : <span className="text-center text-[10px] text-foreground/35">{modelProtocolLabel(protocol)}</span>}
|
||||
{capability === "video" ? <Segmented size="small" block value={billingMode} options={[{ label: "按次", value: "fixed_request" }, { label: "按秒", value: "per_second" }]} onChange={(value) => updateCost(model, { billingMode: value as ModelCost["billingMode"] })} /> : <span className="text-center text-[var(--fs-tiny)] text-foreground/35">{modelProtocolLabel(protocol)}</span>}
|
||||
{capability === "video" ? <InputNumber size="small" min={0} max={1_000_000} precision={6} step={0.1} className="w-full" placeholder={billingMode === "per_second" ? "每秒价格" : "每次价格"} addonAfter={`积分/${billingMode === "per_second" ? "秒" : "次"}`} value={cost ? cost.unitPriceMicrocredits / 1_000_000 : null} onChange={(value) => updateCost(model, { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })} /> : <span />}
|
||||
<Button size="small" icon={<FlaskConical className="size-3.5" />} loading={testingModel === model} disabled={Boolean(testingModel && testingModel !== model)} onClick={() => void testModel(model, capability, protocol)}>测试</Button>
|
||||
</div>
|
||||
|
||||
@@ -270,7 +270,7 @@ export default function SettingsPage() {
|
||||
aria-current={selected ? "page" : undefined}
|
||||
>
|
||||
<span className={`shrink-0 md:mt-0.5 ${selected ? "text-[var(--workspace-accent)]" : ""}`}>{item.icon}</span>
|
||||
<span className="min-w-0"><span className="block whitespace-nowrap text-sm font-medium">{item.label}</span><span className="mt-1 hidden text-[11px] leading-4 text-current opacity-65 md:block">{item.description}</span></span>
|
||||
<span className="min-w-0"><span className="block whitespace-nowrap text-sm font-medium">{item.label}</span><span className="mt-1 hidden text-[var(--fs-label)] leading-4 text-current opacity-65 md:block">{item.description}</span></span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -69,10 +69,10 @@ export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onCha
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-semibold">{modelDisplayName(config, model)}</span>
|
||||
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-[10px] text-foreground/45">
|
||||
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-[var(--fs-tiny)] text-foreground/45">
|
||||
<span className="max-w-full truncate">{channel.name || "未命名渠道"}</span>
|
||||
<span className="rounded border border-border/70 px-1 py-px text-[9px]">{channel.scope === "system" ? "系统" : "自定义"}</span>
|
||||
{cost ? <Tag color="gold" className="!m-0 !px-1.5 !text-[9px] !font-medium !leading-[18px] tabular-nums">{formatPrice(cost.unitPriceMicrocredits)} 积分/{cost.billingMode === "per_second" ? "秒" : "次"}</Tag> : null}
|
||||
<span className="rounded border border-border/70 px-1 py-px text-[var(--fs-micro)]">{channel.scope === "system" ? "系统" : "自定义"}</span>
|
||||
{cost ? <Tag color="gold" className="!m-0 !px-1.5 !text-[var(--fs-micro)] !font-medium !leading-[18px] tabular-nums">{formatPrice(cost.unitPriceMicrocredits)} 积分/{cost.billingMode === "per_second" ? "秒" : "次"}</Tag> : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className={cn("grid size-5 shrink-0 place-items-center rounded-full border", selected ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent)] text-white" : "border-border text-transparent group-hover:border-foreground/30")}>
|
||||
|
||||
@@ -204,7 +204,7 @@ export default function SkillsPage() {
|
||||
<section key={group.value} aria-labelledby={`skill-category-${group.value}`}>
|
||||
<div className="mb-3 flex items-baseline justify-between px-0.5">
|
||||
<h2 id={`skill-category-${group.value}`} className="text-sm font-medium text-foreground/62">{group.label}</h2>
|
||||
<span className="text-[11px] text-foreground/32">{group.skills.length} 个</span>
|
||||
<span className="text-[var(--fs-label)] text-foreground/32">{group.skills.length} 个</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{group.skills.map((skill) => <SkillCard key={skill.skill_id} skill={skill} loading={mutatingID === skill.skill_id} onOpen={() => void openSkill(skill)} onAdd={() => void toggleAdded(skill)} onLike={() => void toggleLiked(skill)} onEdit={() => void openEditor(skill)} onDelete={() => confirmDelete(skill)} />)}
|
||||
@@ -241,7 +241,7 @@ function SkillCard({ skill, loading, onOpen, onAdd, onLike, onEdit, onDelete }:
|
||||
<article className="flex h-[178px] min-w-0 flex-col rounded-md border border-border/55 bg-[color:var(--workspace-surface-strong)] p-4 transition-[border-color,box-shadow,background-color] duration-200 hover:border-border hover:bg-[color:var(--workspace-surface)] hover:shadow-sm">
|
||||
<div className="flex min-h-8 items-start gap-3">
|
||||
<button type="button" className="min-w-0 flex-1 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={onOpen}>
|
||||
<h3 className="line-clamp-1 text-[15px] font-semibold leading-6">{skill.skill_name}</h3>
|
||||
<h3 className="line-clamp-1 text-[var(--fs-body-lg)] font-semibold leading-6">{skill.skill_name}</h3>
|
||||
</button>
|
||||
{skill.is_owner ? (
|
||||
<Dropdown
|
||||
@@ -269,7 +269,7 @@ function SkillCard({ skill, loading, onOpen, onAdd, onLike, onEdit, onDelete }:
|
||||
<button type="button" className="mt-1 min-h-0 flex-1 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={onOpen}>
|
||||
<p className="line-clamp-3 text-xs leading-5 text-foreground/52">{skill.description || "暂无技能简介"}</p>
|
||||
</button>
|
||||
<div className="mt-3 flex min-w-0 items-center gap-2 border-t border-border/45 pt-3 text-[11px] text-foreground/42">
|
||||
<div className="mt-3 flex min-w-0 items-center gap-2 border-t border-border/45 pt-3 text-[var(--fs-label)] text-foreground/42">
|
||||
<button type="button" disabled={loading} className="inline-flex shrink-0 items-center gap-1 rounded-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait" aria-label={skill.is_like ? "取消收藏" : "收藏"} onClick={onLike}>
|
||||
<Heart className={`size-3.5 ${skill.is_like ? "fill-current text-rose-500" : ""}`} />
|
||||
<span>{formatSkillCount(skill.like_count)}</span>
|
||||
|
||||
@@ -275,7 +275,7 @@ export default function TasksPage() {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-medium text-foreground/82" title={context.canvasName}>{context.canvasName}</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1 text-[10px] text-foreground/42" title={context.projectName || "未加入项目"}>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1 text-[var(--fs-tiny)] text-foreground/42" title={context.projectName || "未加入项目"}>
|
||||
<FolderKanban className="size-3 shrink-0" />
|
||||
<span className="truncate">{context.projectName || "未加入项目"}</span>
|
||||
</div>
|
||||
@@ -287,7 +287,7 @@ export default function TasksPage() {
|
||||
title: "类型|模型",
|
||||
width: 180,
|
||||
responsive: ["md"],
|
||||
render: (_, task) => <div className="min-w-0"><div className="truncate text-xs font-medium text-foreground/78">{formatTaskKind(task)}</div><div className="mt-1 truncate text-[10px] text-foreground/42" title={formatModelName(effectiveConfig, task)}>{formatModelName(effectiveConfig, task)}</div></div>,
|
||||
render: (_, task) => <div className="min-w-0"><div className="truncate text-xs font-medium text-foreground/78">{formatTaskKind(task)}</div><div className="mt-1 truncate text-[var(--fs-tiny)] text-foreground/42" title={formatModelName(effectiveConfig, task)}>{formatModelName(effectiveConfig, task)}</div></div>,
|
||||
},
|
||||
{
|
||||
title: "任务名称",
|
||||
@@ -299,14 +299,14 @@ export default function TasksPage() {
|
||||
<TaskPreviewThumbnail task={task} onOpen={() => task.previewUrl && setMediaPreview({ url: task.previewUrl, kind: task.previewKind === "video" ? "video" : "image", title: prompt || formatTaskKind(task) })} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div title={prompt} className="line-clamp-2 max-w-full break-words text-xs font-medium leading-5 text-foreground/88">{prompt || "未命名任务"}</div>
|
||||
<div className={`mt-1 hidden truncate text-[10px] md:block ${task.status === "failed" ? "text-red-600 dark:text-red-400" : task.status === "cancelled" ? "text-amber-600 dark:text-amber-400" : "text-foreground/38"}`} title={task.error ? generationErrorMessage(task.error) : undefined}>
|
||||
<div className={`mt-1 hidden truncate text-[var(--fs-tiny)] md:block ${task.status === "failed" ? "text-red-600 dark:text-red-400" : task.status === "cancelled" ? "text-amber-600 dark:text-amber-400" : "text-foreground/38"}`} title={task.error ? generationErrorMessage(task.error) : undefined}>
|
||||
{task.status === "failed" || task.status === "cancelled" ? taskAttentionReason(task) : task.stage || statusLabel[task.status]}
|
||||
</div>
|
||||
<div className="mt-2 space-y-2 md:hidden">
|
||||
<div className="truncate text-[10px] text-foreground/45">{formatTaskKind(task)} · {formatModelName(effectiveConfig, task)}</div>
|
||||
<div className="truncate text-[10px] text-foreground/38">{context.canvasName}{context.projectName ? ` · ${context.projectName}` : ""}</div>
|
||||
<div className="truncate text-[var(--fs-tiny)] text-foreground/45">{formatTaskKind(task)} · {formatModelName(effectiveConfig, task)}</div>
|
||||
<div className="truncate text-[var(--fs-tiny)] text-foreground/38">{context.canvasName}{context.projectName ? ` · ${context.projectName}` : ""}</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-[11px]"><span className={`size-1.5 shrink-0 rounded-full ${statusDotClassName(task.status)}`} /><span>{statusLabel[task.status]}</span><TaskBilling billing={task.billing} /></div>
|
||||
<div className="flex min-w-0 items-center gap-2 text-[var(--fs-label)]"><span className={`size-1.5 shrink-0 rounded-full ${statusDotClassName(task.status)}`} /><span>{statusLabel[task.status]}</span><TaskBilling billing={task.billing} /></div>
|
||||
<Space size={0}>
|
||||
<Button type="text" size="small" aria-label="查看详情" icon={<Eye className="size-3.5" />} onClick={() => openTaskDetail(task)} />
|
||||
{task.status === "failed" || task.status === "cancelled" ? <Button type="text" size="small" aria-label="重新生成" icon={<RotateCcw className="size-3.5" />} loading={actingId === task.id} disabled={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)} onClick={() => runAction(task.id, "retry")} /> : null}
|
||||
@@ -327,7 +327,7 @@ export default function TasksPage() {
|
||||
<div className="flex items-center gap-2 text-xs font-medium">
|
||||
<span className={`size-1.5 shrink-0 rounded-full ${statusDotClassName(task.status)}`} />
|
||||
<span>{statusLabel[task.status]}</span>
|
||||
{task.status === "queued" || task.status === "running" ? <span className="ml-auto text-[10px] tabular-nums text-foreground/42">{task.progress || 0}%</span> : null}
|
||||
{task.status === "queued" || task.status === "running" ? <span className="ml-auto text-[var(--fs-tiny)] tabular-nums text-foreground/42">{task.progress || 0}%</span> : null}
|
||||
</div>
|
||||
{task.status === "queued" || task.status === "running" ? <Progress className="!mb-0 !mt-1.5 block" percent={task.progress || 0} showInfo={false} size={[90, 3]} strokeColor="var(--workspace-accent)" /> : null}
|
||||
</div>
|
||||
@@ -591,14 +591,14 @@ function TaskDate({ value }: { value?: string }) {
|
||||
if (!value) return <span className="text-xs text-foreground/38">-</span>;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return <span className="text-xs text-foreground/38">-</span>;
|
||||
return <div className="text-xs tabular-nums text-foreground/62"><div>{date.toLocaleDateString()}</div><div className="mt-1 text-[10px] text-foreground/38">{date.toLocaleTimeString()}</div></div>;
|
||||
return <div className="text-xs tabular-nums text-foreground/62"><div>{date.toLocaleDateString()}</div><div className="mt-1 text-[var(--fs-tiny)] text-foreground/38">{date.toLocaleTimeString()}</div></div>;
|
||||
}
|
||||
|
||||
function TaskBilling({ billing }: { billing?: GenerationTask["billing"] }) {
|
||||
if (!billing) return <span className="text-xs text-foreground/30">-</span>;
|
||||
const amount = formatCredits(billing.amountMicrocredits);
|
||||
const note = billing.status === "settled" ? "已结算" : billing.status === "refunded" ? "已退回" : billing.status === "uncertain" ? "待核对" : "预计";
|
||||
return <div className="text-right"><div className="text-xs font-medium tabular-nums text-foreground/78">{amount}</div><div className={`mt-1 text-[10px] ${billing.status === "uncertain" ? "text-amber-600 dark:text-amber-300" : "text-foreground/38"}`}>{note}</div></div>;
|
||||
return <div className="text-right"><div className="text-xs font-medium tabular-nums text-foreground/78">{amount}</div><div className={`mt-1 text-[var(--fs-tiny)] ${billing.status === "uncertain" ? "text-amber-600 dark:text-amber-300" : "text-foreground/38"}`}>{note}</div></div>;
|
||||
}
|
||||
|
||||
function formatModelName(config: AiConfig, task: GenerationTask) {
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function WalletPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<WorkspaceSignalIcon variant="wallet" />
|
||||
<div>
|
||||
<h1 className="text-[22px] font-semibold leading-7">积分中心</h1>
|
||||
<h1 className="text-[var(--fs-title)] font-semibold leading-7">积分中心</h1>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/58">模型调用、冻结与退款都在同一条可追溯流水中。</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,7 +150,7 @@ export default function WalletPage() {
|
||||
<div className="mt-4 text-5xl font-semibold tabular-nums">{formatCredits(account?.availableMicrocredits || 0, 6)}</div>
|
||||
<div className="mt-2 text-xs opacity-45">最近更新 {formatTime(account?.updatedAt)}</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-current/10 px-2.5 py-1 text-[11px] font-medium opacity-70">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-current/10 px-2.5 py-1 text-[var(--fs-label)] font-medium opacity-70">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
账户正常
|
||||
</span>
|
||||
@@ -230,9 +230,9 @@ function BalanceMetric({ label, description, value, icon }: { label: string; des
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-3 rounded-xl bg-current/[0.055] px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] opacity-48">{label}</div>
|
||||
<div className="text-[var(--fs-tiny)] opacity-48">{label}</div>
|
||||
<div className="mt-0.5 truncate text-base font-semibold tabular-nums">{formatCredits(value, 6)}</div>
|
||||
<div className="truncate text-[10px] opacity-35">{description}</div>
|
||||
<div className="truncate text-[var(--fs-tiny)] opacity-35">{description}</div>
|
||||
</div>
|
||||
<span className="shrink-0 opacity-30">{icon}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,175 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* =========================================================================
|
||||
Design Token System — 三层架构 (Primitive → Semantic → Component)
|
||||
详见 Wayfinder #96: https://github.com/ddcat-ai/open-ai-canvas/issues/96
|
||||
规范:禁止在组件中新增裸 text-[Npx]/rounded-[Npx]/z-[N],必须使用 token
|
||||
========================================================================= */
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
Layer 1: Primitive — 纯值,无语义,不随主题变(所有维度的基础)
|
||||
------------------------------------------------------------------------- */
|
||||
:root {
|
||||
/* --- 1. 色彩(oklch 色板)--- */
|
||||
--color-neutral-0: oklch(1 0 0);
|
||||
--color-neutral-50: oklch(0.985 0 0);
|
||||
--color-neutral-100: oklch(0.97 0 0);
|
||||
--color-neutral-200: oklch(0.922 0 0);
|
||||
--color-neutral-300: oklch(0.87 0 0);
|
||||
--color-neutral-400: oklch(0.708 0 0);
|
||||
--color-neutral-500: oklch(0.556 0 0);
|
||||
--color-neutral-600: oklch(0.439 0 0);
|
||||
--color-neutral-700: oklch(0.371 0 0);
|
||||
--color-neutral-800: oklch(0.269 0 0);
|
||||
--color-neutral-900: oklch(0.205 0 0);
|
||||
--color-neutral-950: oklch(0.145 0 0);
|
||||
|
||||
/* 节点类型语义色板(对应 #97 色带+图标徽章方案)
|
||||
注意:用 --palette- 前缀避免与 @theme 的 --color-* 命名空间冲突导致循环引用 */
|
||||
--palette-node-image: oklch(0.75 0.15 75); /* 琥珀 300 — 图片 */
|
||||
--palette-node-video: oklch(0.65 0.22 350); /* 品红 400 — 视频 */
|
||||
--palette-node-text: oklch(0.45 0.02 250); /* 中性蓝灰 700 — 文本 */
|
||||
--palette-node-audio: oklch(0.7 0.13 220); /* 青蓝 400 — 音频 */
|
||||
--palette-node-config: oklch(0.55 0.04 250); /* 灰蓝 500 — 配置 */
|
||||
--palette-node-script: oklch(0.6 0.18 300); /* 紫色 400 — 脚本 */
|
||||
--palette-node-drawing: oklch(0.65 0.12 55); /* 赭石 400 — 绘图 */
|
||||
--palette-node-frame: oklch(0.7 0.15 155); /* 翠绿 400 — 帧 */
|
||||
--palette-node-character: oklch(0.78 0.13 20); /* 桃红 300 — 角色引用 */
|
||||
--palette-node-skill: oklch(0.7 0.17 55); /* 橙色 400 — 技能 */
|
||||
|
||||
/* 状态色(用 --palette- 前缀避免循环引用)*/
|
||||
--palette-status-success: oklch(0.7 0.17 155);
|
||||
--palette-status-error: oklch(0.65 0.22 25);
|
||||
--palette-status-loading: oklch(0.62 0.18 250);
|
||||
--palette-status-warning: oklch(0.75 0.15 85);
|
||||
|
||||
/* --- 2. 间距(4px 栅格 + 半距)--- */
|
||||
--space-0: 0px;
|
||||
--space-half: 2px;
|
||||
--space-1: 4px;
|
||||
--space-1-half: 6px;
|
||||
--space-2: 8px;
|
||||
--space-2-half: 10px;
|
||||
--space-3: 12px;
|
||||
--space-3-half: 14px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
--space-20: 80px;
|
||||
--space-24: 96px;
|
||||
|
||||
/* --- 3. 字号(10 档)--- */
|
||||
--fs-micro: 9px; /* 极小标注(分镜行号辅助)*/
|
||||
--fs-tiny: 10px; /* 小标注(徽章、tag)*/
|
||||
--fs-label: 11px; /* 标签文字 */
|
||||
--fs-caption: 12px; /* 说明文字 */
|
||||
--fs-body: 14px; /* 正文(基准)*/
|
||||
--fs-body-lg: 15px; /* 大正文 */
|
||||
--fs-heading: 16px; /* 小标题 */
|
||||
--fs-heading-lg: 18px; /* 大标题 */
|
||||
--fs-title: 22px; /* 页面标题 */
|
||||
--fs-display: 36px; /* 展示标题 */
|
||||
|
||||
/* --- 4. 圆角(10 档)--- */
|
||||
--r-xs: 4px;
|
||||
--r-sm: 6px;
|
||||
--r-md: 8px;
|
||||
--r-lg: 12px;
|
||||
--r-xl: 14px;
|
||||
--r-2xl: 16px;
|
||||
--r-3xl: 20px;
|
||||
--r-4xl: 24px;
|
||||
--r-5xl: 32px;
|
||||
--r-full: 9999px;
|
||||
|
||||
/* --- 5. 阴影(含 glow)--- */
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 16px 40px rgba(0, 0, 0, 0.12);
|
||||
--shadow-2xl: 0 24px 60px rgba(0, 0, 0, 0.16);
|
||||
--shadow-glow-sm: 0 0 12px rgba(125, 162, 255, 0.2);
|
||||
--shadow-glow-md: 0 0 24px rgba(125, 162, 255, 0.3);
|
||||
--shadow-glow-lg: 0 0 40px rgba(125, 162, 255, 0.4);
|
||||
|
||||
/* --- 6. Elevation(z-index 12 档,对应 #98 五层体系)--- */
|
||||
--z-canvas: 0; /* L0 画布层 */
|
||||
--z-canvas-overlay: 5; /* L0 画布覆盖(网格、连线)*/
|
||||
--z-toolbar: 10; /* L1 浮动工具层 */
|
||||
--z-toolbar-floating: 20; /* L1 悬停工具栏 */
|
||||
--z-panel: 50; /* L2 侧边面板层 */
|
||||
--z-panel-floating: 80; /* L2 浮动面板 */
|
||||
--z-modal: 100; /* L3 对话框层 */
|
||||
--z-modal-overlay: 110; /* L3 遮罩 */
|
||||
--z-popover: 200; /* L3 popover */
|
||||
--z-toast: 500; /* L4 通知层 */
|
||||
--z-tooltip: 1000; /* L4 tooltip */
|
||||
--z-max: 2000; /* 最高层(紧急通知)*/
|
||||
|
||||
/* --- 7. 动效(对应 #99 动效 token)--- */
|
||||
/* duration */
|
||||
--motion-dur-instant: 80ms;
|
||||
--motion-dur-fast: 150ms;
|
||||
--motion-dur-base: 250ms;
|
||||
--motion-dur-slow: 400ms;
|
||||
--motion-dur-emphasize: 600ms;
|
||||
/* easing */
|
||||
--motion-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--motion-ease-in: cubic-bezier(0.64, 0, 0.78, 0);
|
||||
--motion-ease-in-out: cubic-bezier(0.64, 0, 0.36, 1);
|
||||
--motion-ease-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--motion-ease-linear: linear;
|
||||
/* delay */
|
||||
--motion-delay-none: 0ms;
|
||||
--motion-delay-xs: 30ms;
|
||||
--motion-delay-sm: 60ms;
|
||||
--motion-delay-md: 100ms;
|
||||
--motion-delay-lg: 150ms;
|
||||
/* motion-scale(联动 prefers-reduced-motion)*/
|
||||
--motion-scale: 1;
|
||||
|
||||
/* --- 8. 描边宽度 --- */
|
||||
--stroke-0: 0px;
|
||||
--stroke-1: 1px;
|
||||
--stroke-2: 2px;
|
||||
--stroke-3: 3px;
|
||||
|
||||
/* --- 9. 不透明度(6 档)--- */
|
||||
--opa-hide: 0.08;
|
||||
--opa-faint: 0.2;
|
||||
--opa-muted: 0.45;
|
||||
--opa-subtle: 0.55;
|
||||
--opa-medium: 0.72;
|
||||
--opa-full: 1;
|
||||
}
|
||||
|
||||
/* prefers-reduced-motion 降级(对应 #99 三级降级策略 L1)*/
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--motion-scale: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* 用户在应用内选择"无动效"时,JS 添加 .no-motion 类(L2 禁用)*/
|
||||
.no-motion {
|
||||
--motion-scale: 0;
|
||||
}
|
||||
|
||||
/* 动效计算函数(实际 duration = base * motion-scale)*/
|
||||
:root {
|
||||
--motion-dur-instant-calc: calc(var(--motion-dur-instant) * var(--motion-scale));
|
||||
--motion-dur-fast-calc: calc(var(--motion-dur-fast) * var(--motion-scale));
|
||||
--motion-dur-base-calc: calc(var(--motion-dur-base) * var(--motion-scale));
|
||||
--motion-dur-slow-calc: calc(var(--motion-dur-slow) * var(--motion-scale));
|
||||
--motion-dur-emphasize-calc: calc(var(--motion-dur-emphasize) * var(--motion-scale));
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
@@ -45,6 +214,44 @@
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
|
||||
/* === Design Token System 映射(对应 #96 三层架构)=== */
|
||||
/* 色彩 — 节点类型色(可用 text-node-image / bg-node-video 等)*/
|
||||
--color-node-image: var(--cn-type-image);
|
||||
--color-node-video: var(--cn-type-video);
|
||||
--color-node-text: var(--cn-type-text);
|
||||
--color-node-audio: var(--cn-type-audio);
|
||||
--color-node-config: var(--cn-type-config);
|
||||
--color-node-script: var(--cn-type-script);
|
||||
--color-node-drawing: var(--cn-type-drawing);
|
||||
--color-node-frame: var(--cn-type-frame);
|
||||
--color-node-character: var(--cn-type-character);
|
||||
--color-node-skill: var(--cn-type-skill);
|
||||
|
||||
/* 色彩 — 状态色(可用 text-status-success / bg-status-error 等)*/
|
||||
--color-status-success: var(--status-success);
|
||||
--color-status-error: var(--status-error);
|
||||
--color-status-loading: var(--status-loading);
|
||||
--color-status-warning: var(--status-warning);
|
||||
|
||||
/* 色彩 — Canvas 语义色 */
|
||||
--color-cn-text: var(--cn-text);
|
||||
--color-cn-muted: var(--cn-muted);
|
||||
--color-cn-faint: var(--cn-faint);
|
||||
--color-cn-stroke: var(--cn-stroke);
|
||||
--color-cn-fill: var(--cn-fill);
|
||||
|
||||
/* 字号(可用 text-fs-body / text-fs-caption 等)*/
|
||||
--font-size-micro: var(--fs-micro);
|
||||
--font-size-tiny: var(--fs-tiny);
|
||||
--font-size-label: var(--fs-label);
|
||||
--font-size-caption: var(--fs-caption);
|
||||
--font-size-body: var(--fs-body);
|
||||
--font-size-body-lg: var(--fs-body-lg);
|
||||
--font-size-heading: var(--fs-heading);
|
||||
--font-size-heading-lg: var(--fs-heading-lg);
|
||||
--font-size-title: var(--fs-title);
|
||||
--font-size-display: var(--fs-display);
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -64,6 +271,52 @@
|
||||
--workspace-sidebar: #f5f6f8;
|
||||
--workspace-accent: #2563eb;
|
||||
--workspace-accent-soft: #eaf1ff;
|
||||
|
||||
/* --- Semantic 层(引用 Primitive,随主题切换)--- */
|
||||
/* 通用语义色(引用 Primitive neutral 色板)*/
|
||||
--bg: var(--color-neutral-0);
|
||||
--fg: var(--color-neutral-950);
|
||||
--border-semantic: var(--color-neutral-200);
|
||||
--accent: var(--workspace-accent);
|
||||
--accent-soft: var(--workspace-accent-soft);
|
||||
--muted: var(--color-neutral-100);
|
||||
|
||||
/* Canvas Theme 语义(从 CSS 变量反序列化到 TS 主题对象,对应 #96 决策3)*/
|
||||
--cn-text: var(--color-neutral-950);
|
||||
--cn-muted: var(--color-neutral-500);
|
||||
--cn-faint: var(--color-neutral-400);
|
||||
--cn-stroke: var(--color-neutral-200);
|
||||
--cn-fill: var(--color-neutral-50);
|
||||
--cn-active-stroke: var(--workspace-accent);
|
||||
--cn-active-stroke-soft: var(--workspace-accent-soft);
|
||||
|
||||
/* Aceternity spatial 语义(对应 #96 决策3:CSS→TS 单向同步)*/
|
||||
--cn-spatial-surface: var(--workspace-surface);
|
||||
--cn-spatial-surface-strong: var(--workspace-surface-strong);
|
||||
--cn-spatial-shadow: rgba(0, 0, 0, 0.12);
|
||||
--cn-spatial-glow: rgba(125, 162, 255, 0.2);
|
||||
--cn-spatial-glow-strong: rgba(125, 162, 255, 0.35);
|
||||
--cn-spatial-elevated: var(--color-neutral-0);
|
||||
--cn-spatial-toolbar-border: var(--color-neutral-200);
|
||||
|
||||
/* 节点类型色(引用 Primitive 节点色板,对应 #97 决策1 色带方案)*/
|
||||
--cn-type-image: var(--palette-node-image);
|
||||
--cn-type-video: var(--palette-node-video);
|
||||
--cn-type-text: var(--palette-node-text);
|
||||
--cn-type-audio: var(--palette-node-audio);
|
||||
--cn-type-config: var(--palette-node-config);
|
||||
--cn-type-script: var(--palette-node-script);
|
||||
--cn-type-drawing: var(--palette-node-drawing);
|
||||
--cn-type-frame: var(--palette-node-frame);
|
||||
--cn-type-character: var(--palette-node-character);
|
||||
--cn-type-skill: var(--palette-node-skill);
|
||||
|
||||
/* 状态语义色 */
|
||||
--status-success: var(--palette-status-success);
|
||||
--status-error: var(--palette-status-error);
|
||||
--status-loading: var(--palette-status-loading);
|
||||
--status-warning: var(--palette-status-warning);
|
||||
|
||||
--border: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
@@ -139,6 +392,98 @@
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
|
||||
/* --- Semantic 层 dark 覆盖 --- */
|
||||
--bg: var(--color-neutral-950);
|
||||
--fg: var(--color-neutral-50);
|
||||
--border-semantic: var(--color-neutral-800);
|
||||
--muted: var(--color-neutral-900);
|
||||
|
||||
--cn-text: var(--color-neutral-50);
|
||||
--cn-muted: var(--color-neutral-400);
|
||||
--cn-faint: var(--color-neutral-500);
|
||||
--cn-stroke: rgba(255, 255, 255, 0.1);
|
||||
--cn-fill: rgba(255, 255, 255, 0.04);
|
||||
--cn-active-stroke: var(--workspace-accent);
|
||||
--cn-active-stroke-soft: var(--workspace-accent-soft);
|
||||
|
||||
--cn-spatial-surface: var(--workspace-surface);
|
||||
--cn-spatial-surface-strong: var(--workspace-surface-strong);
|
||||
--cn-spatial-shadow: rgba(0, 0, 0, 0.4);
|
||||
--cn-spatial-glow: rgba(125, 162, 255, 0.15);
|
||||
--cn-spatial-glow-strong: rgba(125, 162, 255, 0.25);
|
||||
--cn-spatial-elevated: var(--color-neutral-900);
|
||||
--cn-spatial-toolbar-border: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
Layer 3: Component — 引用 Semantic,某组件专属
|
||||
------------------------------------------------------------------------- */
|
||||
:root {
|
||||
/* Dock 组件(FloatingDock、悬停工具栏,对应 #95 工具栏方案)*/
|
||||
--dock-radius: var(--r-xl); /* 14px */
|
||||
--dock-padding: var(--space-2); /* 8px */
|
||||
--dock-icon-size: 32px;
|
||||
--dock-icon-size-labeled: 36px;
|
||||
--dock-label-fs: var(--fs-label); /* 11px */
|
||||
--dock-blur: 24px;
|
||||
--dock-surface: var(--cn-spatial-surface);
|
||||
--dock-border: var(--cn-spatial-toolbar-border);
|
||||
--dock-shadow: var(--shadow-lg);
|
||||
--dock-item-radius: var(--r-md); /* 8px */
|
||||
--dock-item-radius-labeled: var(--r-lg); /* 12px */
|
||||
|
||||
/* Node 组件(画布节点,对应 #97 节点视觉规范)*/
|
||||
--node-radius: var(--r-2xl); /* 16px */
|
||||
--node-border-width: var(--stroke-1); /* 1px */
|
||||
--node-border: var(--cn-stroke);
|
||||
--node-active-border-width: var(--stroke-2); /* 2px */
|
||||
--node-active-border: var(--cn-active-stroke);
|
||||
--node-active-glow: 0 0 0 4px var(--cn-active-stroke-soft);
|
||||
--node-active-glow-strong: 0 0 0 6px var(--cn-active-stroke-soft);
|
||||
--node-shadow: var(--shadow-sm);
|
||||
--node-shadow-hover: var(--shadow-md);
|
||||
--node-shadow-selected: var(--shadow-lg);
|
||||
--node-shadow-selected-primary: var(--shadow-xl);
|
||||
--node-shadow-active: var(--shadow-2xl);
|
||||
--node-header-height: 24px;
|
||||
--node-title-fs: var(--fs-label); /* 11px */
|
||||
--node-badge-fs: var(--fs-tiny); /* 10px */
|
||||
--node-type-strip-width: 3px; /* 色带宽度 */
|
||||
--node-type-badge-size: 20px; /* 图标徽章尺寸 */
|
||||
|
||||
/* Modal 组件(对话框,对应 #98 面板层级)*/
|
||||
--modal-radius: var(--r-3xl); /* 20px */
|
||||
--modal-max-width: 480px;
|
||||
--modal-overlay-opacity: 0.5;
|
||||
--modal-shadow: var(--shadow-2xl);
|
||||
--modal-padding: var(--space-5); /* 20px */
|
||||
|
||||
/* Panel 组件(侧边面板,对应 #98 五层体系 L2)*/
|
||||
--panel-radius: var(--r-2xl); /* 16px */
|
||||
--panel-padding: var(--space-4); /* 16px */
|
||||
--panel-shadow: var(--shadow-lg);
|
||||
--panel-section-gap: var(--space-3); /* 12px */
|
||||
|
||||
/* Prompt 面板(对应 #98 决策2 渐进式三区)*/
|
||||
--prompt-panel-max-height: min(70vh, 640px);
|
||||
--prompt-panel-input-min-height: 72px;
|
||||
--prompt-panel-section-fs: var(--fs-body); /* 14px A区 */
|
||||
--prompt-panel-caption-fs: var(--fs-caption); /* 12px B区 */
|
||||
--prompt-panel-label-fs: var(--fs-label); /* 11px 标签 */
|
||||
--prompt-panel-heading-fs: var(--fs-heading); /* 16px 区标题 */
|
||||
|
||||
/* 进度条(对应 #98 决策3 任务进度)*/
|
||||
--progress-bar-height: 6px;
|
||||
--progress-bar-radius: var(--r-xs); /* 3px */
|
||||
--progress-bar-fill: var(--status-loading);
|
||||
--progress-bar-fill-success: var(--status-success);
|
||||
--progress-bar-fill-error: var(--status-error);
|
||||
|
||||
/* 缩放控制(对应 #95 决策5)*/
|
||||
--zoom-control-min: 0.05;
|
||||
--zoom-control-max: 2;
|
||||
--zoom-control-step: 0.1;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -4017,6 +4362,13 @@
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* 节点状态徽章 success 淡出动画(对应 #97 决策2)*/
|
||||
@keyframes canvas-status-success-fade {
|
||||
0% { opacity: 1; transform: scale(1); }
|
||||
70% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(0.9); }
|
||||
}
|
||||
|
||||
.canvas-node-shell-generating::before {
|
||||
position: absolute;
|
||||
z-index: 45;
|
||||
|
||||
Reference in New Issue
Block a user