diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index dcc5c12db..803dae960 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -727,9 +727,11 @@ type StorageConfig = { type RealtimeCanvasModeIconConfig = { realtimeIconUrl: string; enhanceIconUrl: string; + realtimeLabel: string; + enhanceLabel: string; }; -type RealtimeCanvasModeIconKey = keyof RealtimeCanvasModeIconConfig; +type RealtimeCanvasModeIconKey = "realtimeIconUrl" | "enhanceIconUrl"; type SiteSettings = { siteName: string; @@ -849,6 +851,8 @@ const defaultContactConfig: ContactConfig = { const defaultRealtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig = { realtimeIconUrl: "", enhanceIconUrl: "", + realtimeLabel: "实时画布模式", + enhanceLabel: "图像增强模式", }; function normalizeRealtimeCanvasModeIconConfig( @@ -858,10 +862,16 @@ function normalizeRealtimeCanvasModeIconConfig( value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; + const normalizeLabel = (input: unknown, fallback: string) => { + const label = String(input || "").trim().slice(0, 32); + return label || fallback; + }; return { realtimeIconUrl: String(raw.realtimeIconUrl || "").trim(), enhanceIconUrl: String(raw.enhanceIconUrl || "").trim(), + realtimeLabel: normalizeLabel(raw.realtimeLabel, "实时画布模式"), + enhanceLabel: normalizeLabel(raw.enhanceLabel, "图像增强模式"), }; } @@ -10308,10 +10318,11 @@ export function AdminSettingsClient() {
- 实时画布模式图标 + 实时画布模式名称与图标
- 上传 SVG 后会替换前端“实时画布模式”和“图像增强模式”的按钮图标;前端会按选中状态自动切换黑白色。 + 名称会同步到前端模式切换按钮;上传 SVG + 后会替换前端按钮图标,并按选中状态自动切换黑白色。
@@ -10320,6 +10331,7 @@ export function AdminSettingsClient() { [ { field: "realtimeIconUrl", + labelField: "realtimeLabel", title: "实时画布模式", description: "左侧模式切换按钮的画笔图标", uploadId: "realtime-mode-icon-upload", @@ -10327,6 +10339,7 @@ export function AdminSettingsClient() { }, { field: "enhanceIconUrl", + labelField: "enhanceLabel", title: "图像增强模式", description: "右侧模式切换按钮的魔法棒图标", uploadId: "enhance-mode-icon-upload", @@ -10338,6 +10351,11 @@ export function AdminSettingsClient() { siteSettings.realtimeCanvasModeIconConfig[ item.field ]; + const labelValue = + siteSettings.realtimeCanvasModeIconConfig[ + item.labelField + ]; + const displayName = labelValue || item.title; const FallbackIcon = item.fallback; const isUploading = realtimeModeIconUploading === item.field; @@ -10350,7 +10368,7 @@ export function AdminSettingsClient() {
- {item.title} + {displayName}
{item.description} @@ -10415,11 +10433,35 @@ export function AdminSettingsClient() { }} />
+
+ + + setSiteSettings((prev) => ({ + ...prev, + realtimeCanvasModeIconConfig: { + ...prev.realtimeCanvasModeIconConfig, + [item.labelField]: + event.target.value.slice(0, 32), + }, + })) + } + placeholder={item.title} + value={labelValue} + /> +
{iconUrl ? ( {`${item.title}选中预览`} @@ -10431,7 +10473,7 @@ export function AdminSettingsClient() {
{iconUrl ? ( {`${item.title}未选中预览`} diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index c94f605e1..1ff6fc316 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -138,11 +138,18 @@ type AgentWorkflowSettingsField = keyof typeof AGENT_WORKFLOW_SETTINGS_FIELD_KEY type RealtimeCanvasModeIconConfig = { realtimeIconUrl: string; enhanceIconUrl: string; + realtimeLabel: string; + enhanceLabel: string; }; +const DEFAULT_REALTIME_CANVAS_MODE_LABEL = "实时画布模式"; +const DEFAULT_ENHANCE_CANVAS_MODE_LABEL = "图像增强模式"; + const defaultRealtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig = { realtimeIconUrl: "", enhanceIconUrl: "", + realtimeLabel: DEFAULT_REALTIME_CANVAS_MODE_LABEL, + enhanceLabel: DEFAULT_ENHANCE_CANVAS_MODE_LABEL, }; function normalizeRealtimeCanvasModeIconConfig( @@ -154,10 +161,22 @@ function normalizeRealtimeCanvasModeIconConfig( : {}; const normalizeUrl = (input: unknown) => String(input || "").trim().slice(0, 2000); + const normalizeLabel = (input: unknown, fallback: string) => { + const label = String(input || "").trim().slice(0, 32); + return label || fallback; + }; return { realtimeIconUrl: normalizeUrl(raw.realtimeIconUrl), enhanceIconUrl: normalizeUrl(raw.enhanceIconUrl), + realtimeLabel: normalizeLabel( + raw.realtimeLabel, + DEFAULT_REALTIME_CANVAS_MODE_LABEL + ), + enhanceLabel: normalizeLabel( + raw.enhanceLabel, + DEFAULT_ENHANCE_CANVAS_MODE_LABEL + ), }; } @@ -1192,6 +1211,8 @@ const updateSchema = z.object({ .object({ realtimeIconUrl: z.string().trim().max(2000).optional(), enhanceIconUrl: z.string().trim().max(2000).optional(), + realtimeLabel: z.string().trim().max(32).optional(), + enhanceLabel: z.string().trim().max(32).optional(), }) .optional(), aiDetailGenerationEnabled: z.boolean().optional(), diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 090bdd21b..d164c1d50 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -473,6 +473,69 @@ function getVideoWorkflowQuickConnectIcon(type: VideoWorkflowTemplateType) { return VIDEO_WORKFLOW_QUICK_CONNECT_ICON_MAP[type] || Plus; } +type RealtimeCanvasModeIconConfig = { + realtimeIconUrl: string; + enhanceIconUrl: string; + realtimeLabel: string; + enhanceLabel: string; +}; + +const DEFAULT_REALTIME_CANVAS_MODE_LABEL = "实时画布模式"; +const DEFAULT_ENHANCE_CANVAS_MODE_LABEL = "图像增强模式"; + +function normalizeRealtimeCanvasModeDisplayConfig( + value: unknown +): RealtimeCanvasModeIconConfig { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + const normalizeUrl = (input: unknown) => String(input || "").trim(); + const normalizeLabel = (input: unknown, fallback: string) => { + const label = String(input || "").trim().slice(0, 32); + return label || fallback; + }; + + return { + realtimeIconUrl: normalizeUrl(raw.realtimeIconUrl), + enhanceIconUrl: normalizeUrl(raw.enhanceIconUrl), + realtimeLabel: normalizeLabel( + raw.realtimeLabel, + DEFAULT_REALTIME_CANVAS_MODE_LABEL + ), + enhanceLabel: normalizeLabel( + raw.enhanceLabel, + DEFAULT_ENHANCE_CANVAS_MODE_LABEL + ), + }; +} + +function RealtimeCanvasToolbarIcon({ + iconUrl, + fallback: FallbackIcon, + className, +}: { + iconUrl?: string; + fallback: LucideIcon; + className?: string; +}) { + if (iconUrl) { + return ( + + ); + } + + return ; +} + const VIDEO_CANVAS_SIDEBAR_GROUPS: VideoCanvasSidebarGroup[] = [ { id: "input", @@ -1549,6 +1612,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { layerSplitEnabled?: boolean; realtimeCanvasEnabled?: boolean; realtimeCanvasServiceEnabled?: boolean; + realtimeCanvasModeIconConfig?: Partial; agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig; agentWorkflowBrandModelConfig?: unknown; agentPsdTemplateCategoryTagIndex?: AgentPsdTemplateCategoryTagIndex; @@ -1600,6 +1664,13 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { siteSettingsData?.canvasAgentModeEnabled !== false; const isVideoModuleSidebarEnabled = siteSettingsData?.videoCanvasSidebarEnabled !== false; + const realtimeCanvasModeDisplayConfig = useMemo( + () => + normalizeRealtimeCanvasModeDisplayConfig( + siteSettingsData?.realtimeCanvasModeIconConfig + ), + [siteSettingsData?.realtimeCanvasModeIconConfig] + ); const isAgentMode = isAgentWorkspaceEnabled && requestedAgentMode; const hasLoadedCurrentAgentProject = !requestedAgentMode || @@ -9801,10 +9872,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { size="icon" variant="ghost" > - + - 实时画布 + + {realtimeCanvasModeDisplayConfig.realtimeLabel} + ) : null} @@ -9818,17 +9895,23 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { ? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS : "h-9 w-9 rounded-xl" )} - onClick={() => router.push(`/canvas/${id}/realtime?mode=enhance-pro`)} + onClick={() => + router.push(`/canvas/${id}/realtime?mode=enhance-pro`) + } size="icon" variant="ghost" > - + {selectedImageNode - ? "图像增强 Pro" - : "进入图像增强 Pro 后上传图片"} + ? realtimeCanvasModeDisplayConfig.enhanceLabel + : `进入${realtimeCanvasModeDisplayConfig.enhanceLabel}后上传图片`} ) : null} diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 4a94ebd85..2907ca422 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -850,38 +850,6 @@ export default function CanvasModel3DPage() { : isHighVip ? "VIP 用户本次不扣积分" : "按当前生成质量扣除"; - const getModelModeCostLabel = useCallback( - (targetMode: GenerationMode) => { - const unitCost = get3DTaskCreditCost(pointsSystemConfig, { - taskType: modelModeToTaskType(targetMode), - pbr, - quad, - smartLowPoly, - }); - if (targetMode === "batch") { - return `每张 ${formatFeatureCostLabel({ - cost: unitCost, - isHighVip, - pointsEnabled, - })}`; - } - return formatFeatureCostLabel({ - cost: unitCost, - isHighVip, - pointsEnabled, - }); - }, - [isHighVip, pbr, pointsEnabled, pointsSystemConfig, quad, smartLowPoly] - ); - const getWorldModeCostLabel = useCallback( - () => - formatFeatureCostLabel({ - cost: worldFeatureCost, - isHighVip, - pointsEnabled, - }), - [isHighVip, pointsEnabled, worldFeatureCost] - ); const getWorldModelCostLabel = useCallback( (targetWorldModel: string) => formatFeatureCostLabel({ @@ -2788,7 +2756,7 @@ export default function CanvasModel3DPage() {

@@ -2833,7 +2793,7 @@ export default function CanvasModel3DPage() { {activeTab === "create" && ( workspaceMode === "model" ? ( <> -
+
{[ { id: "text", label: "文生", icon: PenLine }, { id: "image", label: "图生", icon: ImagePlus }, @@ -2842,7 +2802,7 @@ export default function CanvasModel3DPage() { ].map((m) => ( ))}
@@ -3058,7 +3013,7 @@ export default function CanvasModel3DPage() { ) : ( <> -
+
{[ { id: "text", label: "文生", icon: PenLine }, { id: "image", label: "图生", icon: ImagePlus }, @@ -3067,7 +3022,7 @@ export default function CanvasModel3DPage() { ].map((item) => ( ))}
diff --git a/components/canvas/EnhanceProWorkspace.tsx b/components/canvas/EnhanceProWorkspace.tsx index 2226195c1..c3b1afb09 100644 --- a/components/canvas/EnhanceProWorkspace.tsx +++ b/components/canvas/EnhanceProWorkspace.tsx @@ -711,9 +711,20 @@ export function EnhanceProWorkspace({ return; } + const maxOffsetX = Math.max(48, event.currentTarget.clientWidth * 0.18); + const maxOffsetY = Math.max(48, event.currentTarget.clientHeight * 0.18); + setPreviewOffset({ - x: dragState.originX + event.clientX - dragState.startX, - y: dragState.originY + event.clientY - dragState.startY, + x: clampNumber( + dragState.originX + event.clientX - dragState.startX, + -maxOffsetX, + maxOffsetX + ), + y: clampNumber( + dragState.originY + event.clientY - dragState.startY, + -maxOffsetY, + maxOffsetY + ), }); }; @@ -742,7 +753,7 @@ export function EnhanceProWorkspace({ {activePreviewUrl ? (
setPreviewOffset({ x: 0, y: 0 })} @@ -751,40 +762,38 @@ export function EnhanceProWorkspace({ onPointerMove={handlePreviewPointerMove} onPointerUp={finishPreviewDrag} > -
-
-
- {activePreviewLabel} - {showComparisonPreview && sourceImageUrl ? ( - <> -
- 原图对比 -
-
+
+ {activePreviewLabel} + {showComparisonPreview && sourceImageUrl ? ( + <> +
+ 原图对比 - - ) : null} -
+
+
+ + ) : null} {showComparisonPreview ? ( <>
diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx index 2bc560b47..31b24ee66 100644 --- a/components/canvas/RealtimeCanvasDialog.tsx +++ b/components/canvas/RealtimeCanvasDialog.tsx @@ -86,7 +86,13 @@ type RealtimeWorkspaceMode = "realtime" | "enhance-pro"; type RealtimeCanvasModeIconConfig = { realtimeIconUrl: string; enhanceIconUrl: string; + realtimeLabel: string; + enhanceLabel: string; }; + +const DEFAULT_REALTIME_CANVAS_MODE_LABEL = "实时画布模式"; +const DEFAULT_ENHANCE_CANVAS_MODE_LABEL = "图像增强模式"; + type RealtimeBrushMode = | "select" | "brush" @@ -858,6 +864,8 @@ export function RealtimeCanvasDialog({ useState({ realtimeIconUrl: "", enhanceIconUrl: "", + realtimeLabel: DEFAULT_REALTIME_CANVAS_MODE_LABEL, + enhanceLabel: DEFAULT_ENHANCE_CANVAS_MODE_LABEL, }); const statusBadge = useMemo(() => { @@ -923,6 +931,10 @@ export function RealtimeCanvasDialog({ typeof data.realtimeCanvasModeIconConfig === "object" ? data.realtimeCanvasModeIconConfig : {}; + const normalizeModeLabel = (value: unknown, fallback: string) => { + const label = String(value || "").trim().slice(0, 32); + return label || fallback; + }; setModeIconConfig({ realtimeIconUrl: typeof iconConfig.realtimeIconUrl === "string" @@ -932,6 +944,14 @@ export function RealtimeCanvasDialog({ typeof iconConfig.enhanceIconUrl === "string" ? iconConfig.enhanceIconUrl.trim() : "", + realtimeLabel: normalizeModeLabel( + iconConfig.realtimeLabel, + DEFAULT_REALTIME_CANVAS_MODE_LABEL + ), + enhanceLabel: normalizeModeLabel( + iconConfig.enhanceLabel, + DEFAULT_ENHANCE_CANVAS_MODE_LABEL + ), }); }) .catch((error) => { @@ -3168,7 +3188,7 @@ export function RealtimeCanvasDialog({
diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 7a17a802b..f9968adc3 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -660,6 +660,18 @@ export function ImageNode({ height: activeBoxCutoutRect.height * boxCutoutMetrics.renderedHeight, }; }, [activeBoxCutoutRect, boxCutoutMetrics]); + const currentPickerClickStyle = useMemo(() => { + if (!boxCutoutMetrics || !currentClick) return null; + + return { + left: + boxCutoutMetrics.offsetX + + currentClick.x * boxCutoutMetrics.renderedWidth, + top: + boxCutoutMetrics.offsetY + + currentClick.y * boxCutoutMetrics.renderedHeight, + }; + }, [boxCutoutMetrics, currentClick]); const resetBoxCutoutState = useCallback(() => { setBoxCutoutMode(false); @@ -759,8 +771,18 @@ export function ImageNode({ if (!currentMetrics) return null; const rect = imageElement.getBoundingClientRect(); - const rawX = clientX - rect.left - currentMetrics.offsetX; - const rawY = clientY - rect.top - currentMetrics.offsetY; + if (rect.width <= 0 || rect.height <= 0) return null; + + // The image node content may be counter-scaled with CSS transforms. + // getBoundingClientRect() returns the transformed size, while + // object-contain offsets are calculated in the element's local box. + // Convert the pointer back into that local coordinate space first. + const localX = + ((clientX - rect.left) / rect.width) * imageElement.clientWidth; + const localY = + ((clientY - rect.top) / rect.height) * imageElement.clientHeight; + const rawX = localX - currentMetrics.offsetX; + const rawY = localY - currentMetrics.offsetY; const clampedX = clampNumber(rawX, 0, currentMetrics.renderedWidth); const clampedY = clampNumber(rawY, 0, currentMetrics.renderedHeight); @@ -1085,12 +1107,14 @@ export function ImageNode({ e.stopPropagation(); e.preventDefault(); - const img = e.currentTarget; - const rect = img.getBoundingClientRect(); + const pointerResult = getBoxCutoutPointerPoint(e.clientX, e.clientY); + if (!pointerResult?.insideImage) { + toast.info("请点击图片内容区域"); + return; + } - // 计算相对坐标 (0-1) - const clickX = (e.clientX - rect.left) / rect.width; - const clickY = (e.clientY - rect.top) / rect.height; + const clickX = pointerResult.point.x; + const clickY = pointerResult.point.y; setCurrentClick({ x: clickX, y: clickY }); setPickerLoading(true); @@ -1151,6 +1175,7 @@ export function ImageNode({ isThisNodePicking, isPickerLoading, data.src, + getBoxCutoutPointerPoint, node.id, setPickerLoading, addPendingPickedObject, @@ -3100,12 +3125,12 @@ export function ImageNode({ )} {/* 🆕 拾取模式:当前点击位置(正在识别) */} - {isThisNodePicking && currentClick && ( + {isThisNodePicking && currentPickerClickStyle && (