refactor(*): 代码质量治理 - 收敛接口客户端、模型职责与异步计费巡检

This commit is contained in:
ddCat
2026-08-06 16:36:51 +08:00
parent 9ca9dd9df6
commit b3199c5e7e
42 changed files with 3051 additions and 2271 deletions

View File

@@ -3,21 +3,11 @@ import { useEffect, useLayoutEffect, useRef, useState, type PointerEvent as Reac
import { createPortal } from "react-dom";
import { LoaderCircle, ScanFace, SquareDashedMousePointer, X } from "lucide-react";
import {
CanvasNodeEmotionPanel,
type CanvasEmotionCharacter,
type CanvasImageEmotionPayload,
} from "@/components/canvas/canvas-node-emotion-panel";
import { CanvasNodeEmotionPanel, type CanvasEmotionCharacter, type CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasThemes } from "@/lib/canvas-theme";
import {
buildEmotionImageArtifacts,
buildEmotionPrompt,
neutralEmotionPreset,
type CanvasEmotionPreset,
type CanvasFaceBox,
} from "@/lib/canvas/canvas-emotion";
import { buildEmotionImageArtifacts, buildEmotionPrompt, neutralEmotionPreset, type CanvasEmotionPreset, type CanvasFaceBox } from "@/lib/canvas/canvas-emotion";
import { detectCanvasFaces } from "@/lib/canvas/canvas-face-detection";
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -141,16 +131,7 @@ export function CanvasEmotionWorkspace({ node, viewport, containerRef, onClose,
}}
onFaceSelect={selectFace}
/>
<SelectionToolbar
node={node}
viewport={viewport}
containerRef={containerRef}
status={status}
faceCount={faces.length}
error={error}
onManualSelect={beginManualSelection}
onClose={onClose}
/>
<SelectionToolbar node={node} viewport={viewport} containerRef={containerRef} status={status} faceCount={faces.length} error={error} onManualSelect={beginManualSelection} onClose={onClose} />
<AnimatePresence>
{activeCharacter && (status === "editing" || status === "generating") ? (
<EmotionPanelOverlay node={node} viewport={viewport} containerRef={containerRef}>
@@ -163,7 +144,10 @@ export function CanvasEmotionWorkspace({ node, viewport, containerRef, onClose,
preset={preset}
generating={status === "generating"}
error={error}
onSelectCharacter={(id) => { setActiveCharacterId(id); setError(""); }}
onSelectCharacter={(id) => {
setActiveCharacterId(id);
setError("");
}}
onManualSelect={beginManualSelection}
onPresetChange={setPreset}
onClose={onClose}
@@ -177,7 +161,21 @@ export function CanvasEmotionWorkspace({ node, viewport, containerRef, onClose,
);
}
function FaceSelectionOverlay({ node, viewport, containerRef, imageWidth, imageHeight, faces, characters, activeCharacterId, status, manualDraft, onManualDraftChange, onManualComplete, onFaceSelect }: {
function FaceSelectionOverlay({
node,
viewport,
containerRef,
imageWidth,
imageHeight,
faces,
characters,
activeCharacterId,
status,
manualDraft,
onManualDraftChange,
onManualComplete,
onFaceSelect,
}: {
node: CanvasNodeData;
viewport: ViewportTransform;
containerRef: RefObject<HTMLDivElement | null>;
@@ -209,39 +207,71 @@ function FaceSelectionOverlay({ node, viewport, containerRef, imageWidth, imageH
data-canvas-no-zoom
className={`absolute z-[var(--z-modal)] 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();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
const start = pointerPosition(event);
dragStartRef.current = start;
onManualDraftChange({ id: `manual-${Date.now()}`, x: start.x, y: start.y, width: 0, height: 0, source: "manual" });
} : undefined}
onPointerMove={status === "manual" ? (event) => {
const start = dragStartRef.current;
if (!start) return;
const current = pointerPosition(event);
onManualDraftChange({ id: manualDraft?.id || `manual-${Date.now()}`, x: Math.min(start.x, current.x), y: Math.min(start.y, current.y), width: Math.abs(current.x - start.x), height: Math.abs(current.y - start.y), source: "manual" });
} : undefined}
onPointerUp={status === "manual" ? (event) => {
const start = dragStartRef.current;
dragStartRef.current = null;
if (!start) return;
const current = pointerPosition(event);
const box = { id: manualDraft?.id || `manual-${Date.now()}`, x: Math.min(start.x, current.x), y: Math.min(start.y, current.y), width: Math.abs(current.x - start.x), height: Math.abs(current.y - start.y), source: "manual" as const };
if (box.width >= Math.max(18, imageWidth * 0.025) && box.height >= Math.max(18, imageHeight * 0.025)) onManualComplete(box);
else onManualDraftChange(null);
} : undefined}
onPointerCancel={status === "manual" ? () => {
dragStartRef.current = null;
onManualDraftChange(null);
} : undefined}
onPointerDown={
status === "manual"
? (event) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
const start = pointerPosition(event);
dragStartRef.current = start;
onManualDraftChange({ id: `manual-${Date.now()}`, x: start.x, y: start.y, width: 0, height: 0, source: "manual" });
}
: undefined
}
onPointerMove={
status === "manual"
? (event) => {
const start = dragStartRef.current;
if (!start) return;
const current = pointerPosition(event);
onManualDraftChange({
id: manualDraft?.id || `manual-${Date.now()}`,
x: Math.min(start.x, current.x),
y: Math.min(start.y, current.y),
width: Math.abs(current.x - start.x),
height: Math.abs(current.y - start.y),
source: "manual",
});
}
: undefined
}
onPointerUp={
status === "manual"
? (event) => {
const start = dragStartRef.current;
dragStartRef.current = null;
if (!start) return;
const current = pointerPosition(event);
const box = {
id: manualDraft?.id || `manual-${Date.now()}`,
x: Math.min(start.x, current.x),
y: Math.min(start.y, current.y),
width: Math.abs(current.x - start.x),
height: Math.abs(current.y - start.y),
source: "manual" as const,
};
if (box.width >= Math.max(18, imageWidth * 0.025) && box.height >= Math.max(18, imageHeight * 0.025)) onManualComplete(box);
else onManualDraftChange(null);
}
: undefined
}
onPointerCancel={
status === "manual"
? () => {
dragStartRef.current = null;
onManualDraftChange(null);
}
: undefined
}
>
<svg aria-hidden className="pointer-events-none absolute inset-0 size-full" viewBox={`0 0 ${imageWidth} ${imageHeight}`} preserveAspectRatio="none">
<defs>
<mask id={`emotion-face-mask-${node.id}`}>
<rect width={imageWidth} height={imageHeight} fill="white" />
{faces.map((face) => <rect key={face.id} x={face.x} y={face.y} width={face.width} height={face.height} rx={Math.min(face.width, face.height) * 0.16} fill="black" />)}
{faces.map((face) => (
<rect key={face.id} x={face.x} y={face.y} width={face.width} height={face.height} rx={Math.min(face.width, face.height) * 0.16} fill="black" />
))}
{manualDraft ? <rect x={manualDraft.x} y={manualDraft.y} width={manualDraft.width} height={manualDraft.height} rx={Math.min(manualDraft.width, manualDraft.height) * 0.12} fill="black" /> : null}
</mask>
</defs>
@@ -256,23 +286,69 @@ function FaceSelectionOverlay({ node, viewport, containerRef, imageWidth, imageH
type="button"
aria-label={selected ? `选择${selected.name}` : "选择此人脸"}
className={`absolute rounded-[var(--r-md)] border-2 ${interactive ? "pointer-events-auto" : "pointer-events-none"}`}
style={{ left: `${(face.x / imageWidth) * 100}%`, top: `${(face.y / imageHeight) * 100}%`, width: `${(face.width / imageWidth) * 100}%`, height: `${(face.height / imageHeight) * 100}%`, borderColor: active ? theme.accent.primary : "rgba(255,255,255,.94)", boxShadow: active ? `0 0 0 3px ${theme.accent.primarySoft}, 0 8px 24px rgba(0,0,0,.24)` : "0 8px 20px rgba(0,0,0,.18)" }}
style={{
left: `${(face.x / imageWidth) * 100}%`,
top: `${(face.y / imageHeight) * 100}%`,
width: `${(face.width / imageWidth) * 100}%`,
height: `${(face.height / imageHeight) * 100}%`,
borderColor: active ? theme.accent.primary : "rgba(255,255,255,.94)",
boxShadow: active ? `0 0 0 3px ${theme.accent.primarySoft}, 0 8px 24px rgba(0,0,0,.24)` : "0 8px 20px rgba(0,0,0,.18)",
}}
whileHover={{ scale: 1.025 }}
whileTap={{ scale: 0.985 }}
transition={aceternityMotion.spring.dock}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onFaceSelect(face); }}
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-[var(--fs-micro)] 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>
);
})}
{manualDraft ? <div className="pointer-events-none absolute rounded-[var(--r-md)] border-2 border-dashed border-white" style={{ left: `${(manualDraft.x / imageWidth) * 100}%`, top: `${(manualDraft.y / imageHeight) * 100}%`, width: `${(manualDraft.width / imageWidth) * 100}%`, height: `${(manualDraft.height / imageHeight) * 100}%`, boxShadow: "0 0 0 3px rgba(255,255,255,.16)" }} /> : null}
{manualDraft ? (
<div
className="pointer-events-none absolute rounded-[var(--r-md)] border-2 border-dashed border-white"
style={{
left: `${(manualDraft.x / imageWidth) * 100}%`,
top: `${(manualDraft.y / imageHeight) * 100}%`,
width: `${(manualDraft.width / imageWidth) * 100}%`,
height: `${(manualDraft.height / imageHeight) * 100}%`,
boxShadow: "0 0 0 3px rgba(255,255,255,.16)",
}}
/>
) : null}
</div>
);
}
function SelectionToolbar({ node, viewport, containerRef, status, faceCount, error, onManualSelect, onClose }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject<HTMLDivElement | null>; status: WorkspaceStatus; faceCount: number; error: string; onManualSelect: () => void; onClose: () => void }) {
function SelectionToolbar({
node,
viewport,
containerRef,
status,
faceCount,
error,
onManualSelect,
onClose,
}: {
node: CanvasNodeData;
viewport: ViewportTransform;
containerRef: RefObject<HTMLDivElement | null>;
status: WorkspaceStatus;
faceCount: number;
error: string;
onManualSelect: () => void;
onClose: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const toolbarRef = useRef<HTMLDivElement>(null);
@@ -287,16 +363,29 @@ function SelectionToolbar({ node, viewport, containerRef, status, faceCount, err
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -5, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.panel}
className="aceternity-floating-panel absolute z-[var(--z-modal-overlay)] flex h-12 w-[420px] max-w-[calc(100%_-_24px)] items-center rounded-[15px] border px-2 backdrop-blur-2xl"
className="aceternity-floating-panel absolute z-[var(--z-modal-overlay)] flex h-12 w-[420px] max-w-[calc(100%_-_24px)] items-center rounded-[var(--dock-radius-tight)] border px-2 backdrop-blur-2xl"
style={{ left: 12, top: 76, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 22px 60px ${theme.spatial.shadow}` }}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="flex size-full items-center">
<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>
<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="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-[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}
{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>
);
@@ -305,10 +394,20 @@ function SelectionToolbar({ node, viewport, containerRef, status, faceCount, err
function EmotionPanelOverlay({ node, viewport, containerRef, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject<HTMLDivElement | null>; children: ReactNode }) {
const panelRef = useRef<HTMLDivElement>(null);
useScreenAnchor(panelRef, node, viewport, containerRef, (next, container) => panelScreenRect(node, next, container, panelRef.current));
return <div ref={panelRef} data-canvas-no-zoom className="absolute z-[var(--z-modal-overlay)] w-[580px] max-w-[calc(100%_-_24px)]" style={{ left: 12, top: 76 }} onPointerDown={(event) => event.stopPropagation()}>{children}</div>;
return (
<div ref={panelRef} data-canvas-no-zoom className="absolute z-[var(--z-modal-overlay)] w-[580px] max-w-[calc(100%_-_24px)]" style={{ left: 12, top: 76 }} onPointerDown={(event) => event.stopPropagation()}>
{children}
</div>
);
}
function useScreenAnchor(ref: RefObject<HTMLElement | null>, node: CanvasNodeData, viewport: ViewportTransform, containerRef: RefObject<HTMLDivElement | null>, resolve: (viewport: ViewportTransform, container: HTMLDivElement) => { left: number; top: number; width?: number; height?: number }) {
function useScreenAnchor(
ref: RefObject<HTMLElement | null>,
node: CanvasNodeData,
viewport: ViewportTransform,
containerRef: RefObject<HTMLDivElement | null>,
resolve: (viewport: ViewportTransform, container: HTMLDivElement) => { left: number; top: number; width?: number; height?: number },
) {
const resolveRef = useRef(resolve);
resolveRef.current = resolve;
useLayoutEffect(() => {
@@ -327,7 +426,10 @@ function useScreenAnchor(ref: RefObject<HTMLElement | null>, node: CanvasNodeDat
observer.observe(container);
observer.observe(element);
const unsubscribe = subscribeCanvasViewportPreview(container, update);
return () => { observer.disconnect(); unsubscribe(); };
return () => {
observer.disconnect();
unsubscribe();
};
}, [containerRef, node.height, node.id, node.metadata?.freeResize, node.position.x, node.position.y, node.width, ref, viewport]);
}
@@ -351,9 +453,7 @@ function toolbarScreenRect(node: CanvasNodeData, viewport: ViewportTransform, co
const viewportTop = container.scrollTop;
const left = clamp(nodeRect.left + nodeRect.width / 2 - width / 2, viewportLeft + 12, Math.max(viewportLeft + 12, viewportLeft + container.clientWidth - width - 12));
const above = nodeRect.top - height - 10;
const top = above >= viewportTop + 72
? above
: clamp(nodeRect.top + nodeRect.height + 10, viewportTop + 72, Math.max(viewportTop + 72, viewportTop + container.clientHeight - height - 12));
const top = above >= viewportTop + 72 ? above : clamp(nodeRect.top + nodeRect.height + 10, viewportTop + 72, Math.max(viewportTop + 72, viewportTop + container.clientHeight - height - 12));
return { left, top };
}

View File

@@ -55,21 +55,21 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴)
const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const priceChannel = resolveModelChannel(config, config.model);
const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? generationCount : 1, seconds: mode === "video" ? config.videoSeconds : 1 });
const credits = requestCreditCost({
channelMode: priceChannel.scope === "system" ? "remote" : "local",
modelCosts: priceChannel.modelCosts,
model: modelOptionName(config.model),
count: mode === "image" ? generationCount : 1,
seconds: mode === "video" ? config.videoSeconds : 1,
});
const activeReferenceCount = mentionReferences.filter((item) => item.active && item.kind !== "skill").length;
const videoFrameOptions = mentionReferences
.filter((item) => item.active && item.kind === "image")
.map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
const videoFrameOptions = mentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
const darkSurface = themeName === "dark";
const monochromeAccent = theme.node.activeStroke;
const shellBorder = darkSurface ? "rgba(255,255,255,.08)" : "rgba(15,23,42,.08)";
const insetBorder = darkSurface ? "rgba(255,255,255,.06)" : "rgba(15,23,42,.07)";
const shellSurface = darkSurface
? theme.canvas.background
: "rgba(250,251,252,.97)";
const composerSurface = darkSurface
? theme.canvas.background
: "rgba(15,23,42,.025)";
const shellSurface = darkSurface ? theme.canvas.background : "rgba(250,251,252,.97)";
const composerSurface = darkSurface ? theme.canvas.background : "rgba(15,23,42,.025)";
const controlSurface = darkSurface ? theme.canvas.background : "rgba(15,23,42,.045)";
const controlsSurface = darkSurface ? controlSurface : "transparent";
const shellShadow = darkSurface ? `0 22px 60px ${theme.spatial.shadow}, 0 2px 8px rgba(0,0,0,.22)` : "0 12px 32px rgba(15,23,42,.10), 0 1px 2px rgba(15,23,42,.06)";
@@ -82,7 +82,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const canExpandPrompt = mode === "image" || mode === "video";
const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture);
const updatePromptContentHeight = useCallback((height: number) => {
setPromptContentHeight((current) => Math.abs(current - height) < 1 ? current : height);
setPromptContentHeight((current) => (Math.abs(current - height) < 1 ? current : height));
}, []);
useEffect(() => {
@@ -141,11 +141,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const renderComposerHeader = (expanded: boolean) => (
<div className="flex min-w-0 items-center gap-1 px-0.5">
{isPortraitTexture ? (
<CanvasPortraitTexturePopover
value={node.metadata?.portraitTexture}
placement={expanded ? "topRight" : "topLeft"}
onChange={(portraitTexture) => onConfigChange(node.id, { portraitTexture })}
/>
<CanvasPortraitTexturePopover value={node.metadata?.portraitTexture} placement={expanded ? "topRight" : "topLeft"} onChange={(portraitTexture) => onConfigChange(node.id, { portraitTexture })} />
) : (
<div className="flex h-6 min-w-0 items-center gap-1 rounded-md px-1.5 transition-colors hover:bg-white/[.04]" style={{ background: controlSurface }}>
<span className="grid size-3.5 shrink-0 place-items-center" style={{ color: monochromeAccent }}>
@@ -154,16 +150,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<span className="truncate text-[var(--fs-tiny)] font-medium">{modeDisplayName(mode)}创作</span>
</div>
)}
{!simpleMode ? (
<CanvasPresetPicker
mode={mode}
skillReferences={skillReferences}
open={expanded ? expandedPresetOpen : presetOpen}
onOpenChange={expanded ? setExpandedPresetOpen : setPresetOpen}
onSelect={applyPreset}
dense
/>
) : null}
{!simpleMode ? <CanvasPresetPicker mode={mode} skillReferences={skillReferences} open={expanded ? expandedPresetOpen : presetOpen} onOpenChange={expanded ? setExpandedPresetOpen : setPresetOpen} onSelect={applyPreset} dense /> : null}
<div className="ml-auto flex shrink-0 items-center justify-end gap-1">
{activeReferenceCount ? <ComposerPill theme={theme} borderColor={insetBorder} icon={<Boxes className="size-2.5" />} label={`已连接 ${activeReferenceCount} 个`} /> : null}
{!expanded && canExpandPrompt ? (
@@ -183,59 +170,86 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
</div>
);
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-[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-[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)` }}
onClick={() => (isRunning ? onStop(node.id) : expanded ? submitExpandedPrompt() : submit())}
aria-label={isRunning ? "停止生成" : "生成"}
>
{isRunning ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}
{isRunning ? "停止" : "生成"}
</Button>
</div>
) : (
<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-[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-[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-[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-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
) : null}
{creditsEnabled ? <GenerationCostBadge credits={credits} theme={theme} /> : null}
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-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
{activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"}
</span>
<Button
type="text"
className="!inline-flex !h-8 !w-8 shrink-0 !items-center !justify-center !rounded-full !border !p-0 transition hover:!-translate-y-px hover:!brightness-110 motion-reduce:hover:!translate-y-0"
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, borderColor: isSubmitDisabled ? insetBorder : monochromeAccent, color: isSubmitDisabled ? theme.node.faint : theme.canvas.background, boxShadow: isSubmitDisabled ? "none" : `0 8px 20px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.18)` }}
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)`,
}}
onClick={() => (isRunning ? onStop(node.id) : expanded ? submitExpandedPrompt() : submit())}
aria-label={isRunning ? "停止生成" : "生成"}
>
{isRunning ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}
{isRunning ? "停止" : "生成"}
</Button>
</div>
</div>
);
) : (
<div className="flex min-w-0 items-center justify-between gap-0.5 p-1" style={{ background: controlsSurface }}>
<div className={`${expanded ? "max-w-[var(--panel-width-compact)]" : 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-[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-[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-[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-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3"
onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))}
/>
) : null}
{creditsEnabled ? <GenerationCostBadge credits={credits} theme={theme} /> : null}
<Button
type="text"
className="!inline-flex !h-8 !w-8 shrink-0 !items-center !justify-center !rounded-full !border !p-0 transition hover:!-translate-y-px hover:!brightness-110 motion-reduce:hover:!translate-y-0"
danger={isRunning}
disabled={isSubmitDisabled}
style={{
background: isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : monochromeAccent,
borderColor: isSubmitDisabled ? insetBorder : monochromeAccent,
color: isSubmitDisabled ? theme.node.faint : theme.canvas.background,
boxShadow: isSubmitDisabled ? "none" : `0 8px 20px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.18)`,
}}
onClick={() => (isRunning ? onStop(node.id) : expanded ? submitExpandedPrompt() : submit())}
aria-label={isRunning ? "停止生成" : "生成"}
>
{isRunning ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}
</Button>
</div>
</div>
);
return (
<div
@@ -297,15 +311,18 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
centered
width={760}
destroyOnHidden
onCancel={() => { setExpandedPresetOpen(false); setExpandedPromptOpen(false); }}
styles={{ container: { display: "flex", height: "min(440px, calc(100vh - 40px))", flexDirection: "column", borderRadius: 12, border: `1px solid ${shellBorder}`, padding: 0, overflow: "hidden", background: shellSurface, boxShadow: modalShadow }, body: { minHeight: 0, flex: 1, padding: 0 } }}
onCancel={() => {
setExpandedPresetOpen(false);
setExpandedPromptOpen(false);
}}
styles={{
container: { display: "flex", height: "min(440px, calc(100vh - 40px))", flexDirection: "column", borderRadius: 12, border: `1px solid ${shellBorder}`, padding: 0, overflow: "hidden", background: shellSurface, boxShadow: modalShadow },
body: { minHeight: 0, flex: 1, padding: 0 },
}}
>
<div className="flex h-full min-h-0 flex-col gap-2.5 p-3" style={{ color: theme.node.text }}>
<div className="shrink-0 pr-8">{renderComposerHeader(true)}</div>
<div
className="flex min-h-[240px] flex-1 flex-col overflow-hidden rounded-xl outline-none ring-0 focus-within:outline-none focus-within:ring-0"
style={{ background: composerSurface, boxShadow: composerShadow }}
>
<div className="flex min-h-[240px] flex-1 flex-col overflow-hidden rounded-xl outline-none ring-0 focus-within:outline-none focus-within:ring-0" style={{ background: composerSurface, boxShadow: composerShadow }}>
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} />
<CanvasResourceMentionTextarea
value={prompt}
@@ -332,10 +349,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-[var(--fs-micro)] font-medium transition hover:brightness-125"
style={{ background: theme.canvas.background, borderColor, color: theme.node.activeStroke }}
>
<span 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}
{label}
</span>
@@ -376,7 +390,9 @@ function ConnectedReferenceShelf({ references, theme, onInsert }: { references:
<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-[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>
<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>
))}
</div>
@@ -399,7 +415,12 @@ 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-[var(--fs-micro)] font-semibold tabular-nums" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }} title={`预计消耗 ${credits.toLocaleString()} 积分`} aria-label={`预计消耗 ${credits.toLocaleString()} 积分`}>
<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>

View File

@@ -67,7 +67,23 @@ const BUILTIN_PRESETS: CanvasPromptPreset[] = [
},
];
export function CanvasPresetPicker({ mode, skillReferences = [], open, onOpenChange, onSelect, compact = false, dense = false }: { mode: CanvasGenerationMode; skillReferences?: CanvasResourceReference[]; open?: boolean; onOpenChange?: (open: boolean) => void; onSelect: (preset: CanvasPromptPreset) => void; compact?: boolean; dense?: boolean }) {
export function CanvasPresetPicker({
mode,
skillReferences = [],
open,
onOpenChange,
onSelect,
compact = false,
dense = false,
}: {
mode: CanvasGenerationMode;
skillReferences?: CanvasResourceReference[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSelect: (preset: CanvasPromptPreset) => void;
compact?: boolean;
dense?: boolean;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [internalOpen, setInternalOpen] = useState(false);
const [query, setQuery] = useState("");
@@ -80,43 +96,89 @@ export function CanvasPresetPicker({ mode, skillReferences = [], open, onOpenCha
const presets = useMemo(() => {
const skills = skillReferences.flatMap((reference): CanvasPromptPreset[] => {
if (!reference.skill) return [];
return [{ id: `skill:${reference.skill.skill_id}`, name: reference.skill.skill_name, description: reference.skill.description || reference.skill.instruction || "已加入技能", prompt: `@${reference.skill.skill_name} `, modes: ["text", "image", "video", "audio"], source: "skill" }];
return [
{
id: `skill:${reference.skill.skill_id}`,
name: reference.skill.skill_name,
description: reference.skill.description || reference.skill.instruction || "已加入技能",
prompt: `@${reference.skill.skill_name} `,
modes: ["text", "image", "video", "audio"],
source: "skill",
},
];
});
const normalized = query.trim().toLowerCase();
return [...BUILTIN_PRESETS.filter((preset) => preset.modes.includes(mode)), ...skills].filter((preset) => !normalized || `${preset.name} ${preset.description}`.toLowerCase().includes(normalized));
}, [mode, query, skillReferences]);
const content = (
<div data-canvas-no-zoom className="canvas-preset-picker-menu w-[320px] max-w-[calc(100vw-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<Input className="canvas-preset-picker-search" variant="borderless" autoFocus allowClear size="small" prefix={<Search className="size-3.5" />} placeholder="搜索预设或已加入技能" value={query} onChange={(event) => setQuery(event.target.value)} />
<div data-canvas-no-zoom className="canvas-preset-picker-menu w-[var(--panel-width-compact)] max-w-[calc(100vw-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<Input
className="canvas-preset-picker-search"
variant="borderless"
autoFocus
allowClear
size="small"
prefix={<Search className="size-3.5" />}
placeholder="搜索预设或已加入技能"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div className="thin-scrollbar mt-1 max-h-72 space-y-0.5 overflow-y-auto">
{presets.length ? presets.map((preset) => (
<button
key={preset.id}
type="button"
className="canvas-preset-picker-option"
onClick={() => {
onSelect(preset);
setOpen(false);
}}
>
<span className="canvas-preset-picker-option-icon" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}><WandSparkles className="size-3.5" /></span>
<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-[var(--fs-micro)] font-medium" style={{ color: theme.accent.primary }}>{preset.source === "skill" ? "技能" : "预设"}</span>
{presets.length ? (
presets.map((preset) => (
<button
key={preset.id}
type="button"
className="canvas-preset-picker-option"
onClick={() => {
onSelect(preset);
setOpen(false);
}}
>
<span className="canvas-preset-picker-option-icon" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}>
<WandSparkles className="size-3.5" />
</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>}
<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-[var(--fs-micro)] font-medium" style={{ color: theme.accent.primary }}>
{preset.source === "skill" ? "技能" : "预设"}
</span>
</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>
)}
</div>
</div>
);
return (
<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}>
<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-[var(--fs-tiny)] font-semibold">预设</span>}
</button>

View File

@@ -1,23 +1,7 @@
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
ArrowUpRight,
BookOpenText,
ChevronLeft,
ChevronRight,
Crosshair,
FolderKanban,
GripVertical,
Images,
LocateFixed,
LoaderCircle,
Palette,
Plus,
Search,
Settings2,
X,
} from "lucide-react";
import { ArrowUpRight, BookOpenText, ChevronLeft, ChevronRight, Crosshair, FolderKanban, GripVertical, Images, LocateFixed, LoaderCircle, Palette, Plus, Search, Settings2, X } from "lucide-react";
import { Link } from "react-router";
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
@@ -119,8 +103,12 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
if (collapsed) {
return (
<aside className="relative z-[var(--z-panel)] hidden w-11 shrink-0 flex-col items-center border-r border-border bg-background/94 py-2 backdrop-blur-xl lg:flex">
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-foreground/[.06]" title="展开项目侧栏" aria-label="展开项目侧栏" onClick={() => setCollapsed(false)}><ChevronRight className="size-4" /></button>
<Link to={`/projects/${projectId}/canvases`} className="mt-2 grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-foreground/[.06]" title="返回项目画布列表"><FolderKanban className="size-4" /></Link>
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-foreground/[.06]" title="展开项目侧栏" aria-label="展开项目侧栏" onClick={() => setCollapsed(false)}>
<ChevronRight className="size-4" />
</button>
<Link to={`/projects/${projectId}/canvases`} className="mt-2 grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-foreground/[.06]" title="返回项目画布列表">
<FolderKanban className="size-4" />
</Link>
</aside>
);
}
@@ -128,29 +116,77 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
return (
<aside className="relative z-[var(--z-panel)] hidden w-[var(--canvas-sidebar-width)] shrink-0 flex-col border-r border-border bg-background/94 backdrop-blur-xl lg:flex">
<header className="flex h-12 shrink-0 items-center justify-between gap-2 border-b border-border px-2.5">
<Link to={`/projects/${projectId}/canvases`} className="flex min-w-0 items-center gap-2 text-xs font-semibold" title="返回项目画布列表"><FolderKanban className="size-3.5 shrink-0" /><span className="truncate">{projectDetail?.project.name || "项目空间"}</span></Link>
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-md text-foreground/45 hover:bg-foreground/[.06]" title="收起项目侧栏" aria-label="收起项目侧栏" onClick={() => setCollapsed(true)}><ChevronLeft className="size-4" /></button>
<Link to={`/projects/${projectId}/canvases`} className="flex min-w-0 items-center gap-2 text-xs font-semibold" title="返回项目画布列表">
<FolderKanban className="size-3.5 shrink-0" />
<span className="truncate">{projectDetail?.project.name || "项目空间"}</span>
</Link>
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-md text-foreground/45 hover:bg-foreground/[.06]" title="收起项目侧栏" aria-label="收起项目侧栏" onClick={() => setCollapsed(true)}>
<ChevronLeft className="size-4" />
</button>
</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-[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-[var(--fs-micro)] text-foreground/38">{style ? "随项目设置自动同步" : "前往项目设置选择"}</span></span>
<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-[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-[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}
<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">
<label className="flex h-8 items-center gap-1.5 rounded-md border border-border/75 bg-foreground/[.025] px-2 focus-within:border-[var(--workspace-accent)] focus-within:ring-2 focus-within:ring-[var(--workspace-accent-soft)]">
<Search className="size-3.5 shrink-0 text-foreground/32" />
<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}
<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-[var(--fs-micro)] tabular-nums text-foreground/35">找到 {visibleUnits.length.toLocaleString("zh-CN")} 章</div> : null}
</div>
@@ -165,38 +201,122 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
const active = unit.id === selectedId;
return (
<div key={unit.id} className="absolute left-0 top-0 w-full" style={{ height: virtualItem.size, transform: `translateY(${virtualItem.start}px)` }}>
<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]"}`}>
<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}`}>
<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-[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>
<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>
</div>
</div>
);
})}
</div>
) : <div className="px-2 py-8 text-center text-xs text-foreground/40">{orderedUnits.length ? "没有匹配的章节" : "暂无章节"}</div>}
) : (
<div className="px-2 py-8 text-center text-xs text-foreground/40">{orderedUnits.length ? "没有匹配的章节" : "暂无章节"}</div>
)}
</div>
</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-[var(--fs-micro)] 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}
{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}
</aside>
);
}
function ChapterPreview({ projectId, unit, chapterNumber, loading, loadError, adding, onAdd, onClose }: { projectId: string; unit: ProjectUnit; chapterNumber: number; loading: boolean; loadError: boolean; adding: boolean; onAdd: () => void | Promise<void>; onClose: () => void }) {
function ChapterPreview({
projectId,
unit,
chapterNumber,
loading,
loadError,
adding,
onAdd,
onClose,
}: {
projectId: string;
unit: ProjectUnit;
chapterNumber: number;
loading: boolean;
loadError: boolean;
adding: boolean;
onAdd: () => void | Promise<void>;
onClose: () => void;
}) {
const text = htmlToPlainText(unit.sourceText);
return (
<section className="absolute bottom-20 left-[calc(100%+8px)] top-14 z-[var(--z-panel-floating)] 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-[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
className="absolute bottom-20 left-[calc(100%+8px)] top-14 z-[var(--z-panel-floating)] flex w-[var(--panel-width-compact)] 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-[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>
);
}