fix(canvas): 图片生成 - 合并局部重绘、规格校验与模型导入修复

This commit is contained in:
ddCat
2026-09-09 23:10:35 +08:00
19 changed files with 162 additions and 59 deletions

View File

@@ -49,16 +49,16 @@ export const CanvasDrawingExcalidrawEditor = forwardRef<CanvasDrawingEditorHandl
return (
<Excalidraw
initialData={initialData}
langCode="zh-CN"
theme={colorScheme}
autoFocus
excalidrawAPI={(api) => {
apiRef.current = api;
api.setActiveTool({ type: "freedraw" });
onReady();
}}
UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false } }}
initialData={initialData}
langCode="zh-CN"
theme={colorScheme}
autoFocus
excalidrawAPI={(api) => {
apiRef.current = api;
api.setActiveTool({ type: "freedraw" });
onReady();
}}
UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false } }}
/>
);
});

View File

@@ -1,10 +1,11 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Camera, Settings2 } from "lucide-react";
import { Button, Modal } from "antd";
import { Button } from "antd";
import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel";
import { CanvasNodeCameraPanel } from "@/components/canvas/canvas-node-camera-dialog";
import { AppModal } from "@/components/ui/product/app-modal/app-modal";
import { canvasThemes } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library";
@@ -103,7 +104,7 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
)}
{panel}
{cameraOpen && onCameraControlChange && (
<Modal title="摄像机控制" open centered footer={null} width={780} destroyOnHidden onCancel={() => setCameraOpen(false)} styles={{ body: { padding: 0 } }}>
<AppModal title="摄像机控制" open centered footer={null} width={780} flush onCancel={() => setCameraOpen(false)}>
<CanvasNodeCameraPanel
cameraControl={cameraControl}
onClose={() => setCameraOpen(false)}
@@ -112,7 +113,7 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
setCameraOpen(false);
}}
/>
</Modal>
</AppModal>
)}
</>
);

View File

@@ -41,10 +41,10 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon
setBrushSize(defaultBrushSize);
setMode("paint");
setError("");
setAdvancedOpen(false);
setAdvancedOpen(true);
setGenerationConfig(config);
void readImageMeta(dataUrl).then(setImage);
}, [config, dataUrl, open]);
}, [dataUrl, open]);
useEffect(() => {
clearCanvas(maskCanvasRef.current);
@@ -115,7 +115,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_340px]">
<div className="flex min-h-[360px] items-center justify-center rounded-lg bg-surface-active p-0">
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none">
<img src={dataUrl} alt="" className="block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
<img src={dataUrl} alt="" className="relative z-0 block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
{image ? (
<>
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
@@ -123,7 +123,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon
ref={previewCanvasRef}
width={image.width}
height={image.height}
className="absolute inset-0 h-full w-full cursor-crosshair touch-none"
className="absolute inset-0 z-10 h-full w-full cursor-crosshair touch-none"
onPointerDown={startDraw}
onPointerMove={moveDraw}
onPointerUp={stopDraw}
@@ -173,17 +173,6 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon
{error ? <div className="text-xs font-medium text-destructive">{error}</div> : null}
</div>
<ImageSettingsPanel
config={generationConfig}
showTitle={false}
showQuality={false}
showTransparent={false}
showSize={false}
className="space-y-2"
theme={theme}
onConfigChange={(key, value) => setGenerationConfig((current) => ({ ...current, [key]: value }))}
/>
<div className="rounded-xl border border-border/60">
<button
type="button"
@@ -211,6 +200,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon
config={generationConfig}
showTitle={false}
showCount={false}
bypassPriceGuard
className="space-y-3"
theme={theme}
onConfigChange={(key, value) => setGenerationConfig((current) => ({ ...current, [key]: value }))}

View File

@@ -53,9 +53,11 @@ type ImageSettingsPanelProps = {
className?: string;
maxCount?: number;
quickCount?: number;
/** 局部编辑等场景需要先允许选择参数,由后端负责最终计费校验。 */
bypassPriceGuard?: boolean;
};
export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, showQuality = true, showTransparent = true, showSize = true, showCount = true, className = "w-[304px] space-y-3 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 3 }: ImageSettingsPanelProps) {
export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, showQuality = true, showTransparent = true, showSize = true, showCount = true, className = "w-[304px] space-y-3 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 3, bypassPriceGuard = false }: ImageSettingsPanelProps) {
const profile = mergedImageCapabilityConfig(config, config.model || config.imageModel);
const normalized = normalizeImageValue(profile, config);
const quality = normalized.quality;
@@ -82,7 +84,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
<SettingTitle color={theme.node.muted}>{isGrokResolutionQuality(profile) ? "分辨率" : "质量"}</SettingTitle>
<div className={`grid gap-1.5 ${activeQualityOptions.length <= 2 ? "grid-cols-2" : "grid-cols-4"}`}>
{activeQualityOptions.map((item) => (
<OptionPill key={item.value} selected={quality === item.value} disabled={!hasPriceTierForImageSelection(priceTiers, item.value, activeSize)} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
<OptionPill key={item.value} selected={quality === item.value} disabled={!bypassPriceGuard && !hasPriceTierForImageSelection(priceTiers, item.value, activeSize)} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
{item.label}
</OptionPill>
))}
@@ -103,7 +105,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
/>
</span>
</div> : null}
{showSize ? <ImageSizePicker profile={profile} size={activeSize} quality={quality} onChange={(size, nextQuality) => { onConfigChange("size", size); if (nextQuality) onConfigChange("quality", nextQuality); }} /> : null}
{showSize ? <ImageSizePicker profile={profile} size={activeSize} quality={quality} onChange={(size) => onConfigChange("size", size)} /> : null}
{showCount && effectiveMaxCount > 1 ? (
<div className="space-y-2">
<SettingTitle color={theme.node.muted}>生成张数</SettingTitle>

View File

@@ -1,6 +1,7 @@
import { useId, useState } from "react";
import { Button, Input } from "antd";
import { Check, Plus } from "lucide-react";
import { Switch } from "@/components/ui/base/switch";
import "./image-size-picker.css";
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
@@ -11,6 +12,10 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
const id = useId();
const [drafts, setDrafts] = useState<Partial<Record<ImageResolutionTier, string>>>({});
const [errors, setErrors] = useState<Partial<Record<ImageResolutionTier, string>>>({});
const toggleTier = (tier: ImageResolutionTier, ratios: string[], enabled: boolean) => {
if (disabled) return;
update(tier, enabled ? ratios : []);
};
const update = (tier: ImageResolutionTier, ratios: string[]) => {
if (disabled) return false;
try {
@@ -50,13 +55,10 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
return (
<section key={tier} className="image-size-preset-group" aria-labelledby={`${id}-${tier}-title`}>
<header className="image-size-preset-heading">
<div>
<div className="image-size-preset-heading-label">
<strong id={`${id}-${tier}-title`}>{tier.toUpperCase()}</strong>
<span>{items.length ? `${items.length} 个比例` : "未启用"}</span>
</div>
<div className="image-size-preset-bulk-actions">
<Button type="link" size="small" disabled={disabled || !items.length} onClick={() => update(tier, [])}>全不选</Button>
<Button type="link" size="small" disabled={disabled || items.length === ratios.length} onClick={() => update(tier, ratios)}>全选</Button>
<Switch size="sm" checked={items.length === ratios.length} disabled={disabled} aria-label={`启用 ${tier.toUpperCase()} 规格`} onChange={(enabled) => toggleTier(tier, ratios, enabled)} />
</div>
</header>
<div className="image-size-preset-body">

View File

@@ -12,6 +12,7 @@ const AnnouncementTimelineModal = lazy(() => import("@/components/ui/aceternity/
const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 5 * 60_000;
const ANNOUNCEMENT_CACHE_TTL_MS = 60_000;
const ANNOUNCEMENT_DISMISS_TODAY_PREFIX = "yingce.announcements.dismiss-today";
const ANNOUNCEMENT_DISMISS_SESSION_PREFIX = "yingce.announcements.dismiss-session";
type AnnouncementFeed = Awaited<ReturnType<typeof getAnnouncementFeed>>;
@@ -46,9 +47,9 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
const error = feedQuery.error instanceof Error ? feedQuery.error.message : feedQuery.error ? "读取公告失败" : "";
useEffect(() => {
if (!autoOpen || open || !feedQuery.isSuccess || unreadCount <= 0 || announcements.length === 0) return;
if (!autoOpen || open || !feedQuery.isSuccess || announcements.length === 0) return;
const fingerprint = announcementFeedFingerprint(announcements);
if (!fingerprint || fingerprint === dismissedFingerprint || announcementAutoPromptSuppressed(userId)) return;
if (!fingerprint || fingerprint === dismissedFingerprint || announcementAutoPromptSuppressed(userId, fingerprint)) return;
setAutomaticPrompt(true);
setOpen(true);
}, [announcements, autoOpen, dismissedFingerprint, feedQuery.isSuccess, open, unreadCount, userId]);
@@ -71,7 +72,8 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
const dismissAutomaticPrompt = (duration: "once" | "today") => {
const fingerprint = announcementFeedFingerprint(announcements);
if (fingerprint) setDismissedFingerprint(fingerprint);
if (duration === "today") rememberAnnouncementDismissalToday(userId);
if (fingerprint) rememberAnnouncementDismissalSession(userId, fingerprint);
if (duration === "today") rememberAnnouncementDismissalToday(userId, fingerprint);
setAutomaticPrompt(false);
setOpen(false);
};
@@ -138,17 +140,36 @@ function announcementFeedFingerprint(announcements: AnnouncementFeed["announceme
.join("|");
}
function announcementAutoPromptSuppressed(userId: string) {
function announcementAutoPromptSuppressed(userId: string, fingerprint: string) {
if (hasAnnouncementDismissalSession(userId, fingerprint)) return true;
try {
return localStorage.getItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`) === localDateKey();
const value = localStorage.getItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`);
const parsed = value ? JSON.parse(value) as { date?: string; fingerprint?: string } : null;
return parsed?.date === localDateKey() && parsed.fingerprint === fingerprint;
} catch {
return false;
}
}
function rememberAnnouncementDismissalToday(userId: string) {
function hasAnnouncementDismissalSession(userId: string, fingerprint: string) {
try {
localStorage.setItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`, localDateKey());
return sessionStorage.getItem(`${ANNOUNCEMENT_DISMISS_SESSION_PREFIX}.${userId}`) === fingerprint;
} catch {
return false;
}
}
function rememberAnnouncementDismissalSession(userId: string, fingerprint: string) {
try {
sessionStorage.setItem(`${ANNOUNCEMENT_DISMISS_SESSION_PREFIX}.${userId}`, fingerprint);
} catch {
// sessionStorage 不可用时保留页内 dismissedFingerprint 作为降级。
}
}
function rememberAnnouncementDismissalToday(userId: string, fingerprint: string) {
try {
localStorage.setItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`, JSON.stringify({ date: localDateKey(), fingerprint }));
} catch {
// 浏览器禁用存储时仍允许关闭弹窗,本次组件生命周期内不会重复打开。
}