refactor(*): 代码质量治理 - 收敛接口客户端、模型职责与异步计费巡检
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user