feat: 完成短剧项目空间与结构化资产创作闭环 (#38)

* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP

* feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环

Refs #30

* fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置

* feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环

Refs #30

---------

Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
ddcat666
2026-07-27 10:45:11 +08:00
committed by GitHub
parent 431b9e3294
commit d2fbc413b3
208 changed files with 22655 additions and 4234 deletions

View File

@@ -1,7 +1,10 @@
import { useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Segmented, Slider, Tooltip } from "antd";
import { Segmented, Slider, Tooltip } from "antd";
import { motion, useReducedMotion } from "motion/react";
import { Camera, RotateCcw, Send, X } from "lucide-react";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -24,6 +27,7 @@ const presets = [
export function CanvasNodeAnglePanel({ dataUrl, onClose, onConfirm }: { dataUrl: string; onClose: () => void; onConfirm: (params: CanvasImageAngleParams) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const [params, setParams] = useState(defaultParams);
const dragRef = useRef<{ x: number; y: number; horizontal: number; pitch: number } | null>(null);
const update = <Key extends keyof CanvasImageAngleParams>(key: Key, value: CanvasImageAngleParams[Key]) => setParams((current) => ({ ...current, [key]: value }));
@@ -41,46 +45,65 @@ export function CanvasNodeAnglePanel({ dataUrl, onClose, onConfirm }: { dataUrl:
update("pitchAngle", clamp(Math.round(drag.pitch - (event.clientY - drag.y) * 0.55), -75, 75));
};
const marker = cameraMarker(params.horizontalAngle, params.pitchAngle);
const activePreset = presets.find((preset) => preset.horizontalAngle === params.horizontalAngle && preset.pitchAngle === params.pitchAngle);
return (
<div data-canvas-no-zoom className="overflow-hidden rounded-lg border" style={{ background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text, boxShadow: "0 18px 44px rgba(0,0,0,.2)" }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<div className="flex h-11 items-center gap-2 border-b px-4" style={{ borderColor: theme.node.stroke }}>
<Camera className="size-4" />
<span className="text-sm font-semibold">多角度编辑器</span>
<SpotlightSurface
data-canvas-no-zoom
spotlightColor={theme.toolbar.itemHover}
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"
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="text-xs font-semibold">多角度编辑器</span>
<span className="min-w-0 flex-1" />
<Tooltip title="关闭"><button type="button" className="grid size-8 place-items-center rounded-md transition hover:bg-black/5 dark:hover:bg-white/10" onClick={onClose}><X className="size-4" /></button></Tooltip>
<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>
</div>
<div className="flex flex-wrap gap-1.5 border-b px-4 py-2.5" style={{ borderColor: theme.node.stroke }}>
<button type="button" className="h-8 rounded-md px-3 text-xs font-medium" style={{ background: theme.toolbar.itemHover }}>自定义</button>
{presets.map((preset) => <button key={preset.label} type="button" className="h-8 rounded-md border px-3 text-xs transition hover:bg-black/5 dark:hover:bg-white/10" style={{ borderColor: theme.node.stroke }} onClick={() => setParams((current) => ({ ...current, horizontalAngle: preset.horizontalAngle, pitchAngle: preset.pitchAngle }))}>{preset.label}</button>)}
<div className="flex h-10 items-center gap-1 overflow-x-auto border-b px-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" style={{ borderColor: theme.toolbar.border }}>
<AnglePresetButton active={!activePreset} label="自定义" theme={theme} />
{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 gap-5 p-4 md:grid-cols-[280px_1fr]">
<div className="relative grid aspect-square cursor-grab place-items-center overflow-hidden rounded-lg border active:cursor-grabbing" style={{ borderColor: theme.node.stroke, background: theme.node.fill }} onPointerDown={startCameraDrag} onPointerMove={moveCamera} onPointerUp={() => { dragRef.current = null; }} onPointerCancel={() => { dragRef.current = null; }}>
<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; }}>
<GlobeGrid color={theme.node.muted} />
<img src={dataUrl} alt="角度参考" className="relative z-10 size-24 rounded-md object-cover shadow-lg" draggable={false} style={{ transform: previewTransform(params) }} />
<div className="pointer-events-none absolute z-20 grid size-8 -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-4" /></div>
<span className="pointer-events-none absolute bottom-3 text-[11px]" style={{ color: theme.node.muted }}>拖动调整摄影机位置</span>
<img src={dataUrl} alt="角度参考" className="relative z-10 size-[72px] rounded-[8px] 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>
</div>
<div className="flex flex-col gap-4">
<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 }}>
<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 grid-cols-[76px_1fr] items-center gap-3">
<span className="text-xs font-medium" style={{ color: theme.node.muted }}>镜头</span>
<Segmented size="small" value={params.wideAngle ? "wide" : "standard"} options={[{ label: "标准", value: "standard" }, { label: "广角", value: "wide" }]} onChange={(value) => update("wideAngle", value === "wide")} />
</div>
<div className="mt-auto flex items-center justify-between pt-2">
<Button type="text" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}>重置参数</Button>
<Button type="primary" icon={<Send className="size-4" />} onClick={() => onConfirm(params)}>生成新角度</Button>
<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>
<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>
<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-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="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>
</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 grid-cols-[76px_1fr_62px] items-center gap-3"><span className="text-xs font-medium opacity-60">{label}</span><Slider min={min} max={max} step={step} value={value} onChange={onChange} /><span className="text-right text-xs 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-[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>;
}
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>;
}
function GlobeGrid({ color }: { color: string }) {