From f726c14397900360eeb3aa45e1cc68668779faf1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 16 May 2026 22:36:05 +0800 Subject: [PATCH] fix: sync style pro toolbar and realtime controls --- app/api/site-settings/route.ts | 5 + .../[id]/CanvasEditorLegacyPageClient.tsx | 8 +- components/canvas/RealtimeCanvasDialog.tsx | 686 ++++++++++-------- components/style-pro/StyleProWorkspace.tsx | 246 ++++++- 4 files changed, 621 insertions(+), 324 deletions(-) diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 5f750b71f..0764c3671 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -109,6 +109,7 @@ import { ENHANCE_PRO_KREA_SETTING_KEY, normalizeEnhanceProKreaConfig, } from "@/lib/enhance-pro/config"; +import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config"; import type { AgentCategoryTreeConfig } from "@/types/agent"; const HOME_VARIANT_KEY = "home_variant"; @@ -613,6 +614,7 @@ async function getScopedPublicSiteSettings(scope: PublicSiteSettingsScope) { VIDEO_CANVAS_ENABLED_KEY, VIDEO_CANVAS_SIDEBAR_ENABLED_KEY, ENHANCE_PRO_KREA_SETTING_KEY, + STYLE_PRO_CONFIG_KEY, AGENT_WORKFLOW_UI_CONFIG_KEY, AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY, AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY, @@ -654,6 +656,7 @@ async function getScopedPublicSiteSettings(scope: PublicSiteSettingsScope) { canvasToolbarConfig: normalizeCanvasToolbarConfig( valueByKey.get(CANVAS_TOOLBAR_CONFIG_SETTING_KEY) ), + styleProEnabled: normalizeStyleProConfig(valueByKey.get(STYLE_PRO_CONFIG_KEY)).enabled, layerSplitEnabled: normalizeLayerSplitEnabled( valueByKey.get(LAYER_SPLIT_CONFIG_KEY) ), @@ -710,6 +713,7 @@ async function getScopedPublicSiteSettings(scope: PublicSiteSettingsScope) { VIDEO_CANVAS_ENABLED_KEY, VIDEO_CANVAS_SIDEBAR_ENABLED_KEY, ENHANCE_PRO_KREA_SETTING_KEY, + STYLE_PRO_CONFIG_KEY, ]); const canvasFeatureAvailability = await readCanvasFeatureAvailability(valueByKey); const typedSettings = settings as Record | undefined; @@ -739,6 +743,7 @@ async function getScopedPublicSiteSettings(scope: PublicSiteSettingsScope) { canvasToolbarConfig: normalizeCanvasToolbarConfig( valueByKey.get(CANVAS_TOOLBAR_CONFIG_SETTING_KEY) ), + styleProEnabled: normalizeStyleProConfig(valueByKey.get(STYLE_PRO_CONFIG_KEY)).enabled, layerSplitEnabled: normalizeLayerSplitEnabled( valueByKey.get(LAYER_SPLIT_CONFIG_KEY) ), diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 293a47e74..523e50bac 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -1862,6 +1862,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { angleEditEnabled?: boolean; realtimeCanvasEnabled?: boolean; realtimeCanvasServiceEnabled?: boolean; + styleProEnabled?: boolean; enhanceProEnabled?: boolean; tripo3dEnabled?: boolean; worldlabs3dEnabled?: boolean; @@ -1971,10 +1972,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { const isBatchProcessingEnabled = workflowPageFeatureSettings.batchProcessingEnabled !== false && sceneFusionConfigData?.enabled !== false; - const isStyleProToolbarEnabled = isCanvasToolbarItemEnabled( - canvasToolbarConfig, - "stylePro" - ); + const isStyleProToolbarEnabled = + isCanvasToolbarItemEnabled(canvasToolbarConfig, "stylePro") && + siteSettingsData?.styleProEnabled !== false; const sanitizedWorkflowSelections = useMemo( () => sanitizeAgentWorkflowSelections({ diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx index 824d33d6c..51b48cbab 100644 --- a/components/canvas/RealtimeCanvasDialog.tsx +++ b/components/canvas/RealtimeCanvasDialog.tsx @@ -2,6 +2,7 @@ import { ArrowLeft, + ChevronDown, Circle as CircleIcon, Download, Eraser, @@ -205,7 +206,11 @@ const REALTIME_GENERATION_MODE_OPTIONS: Array<{ points: 1, }, ]; -const REALTIME_ASPECT_RATIO_OPTIONS: RealtimeAspectRatio[] = ["1:1", "4:3", "2:3"]; +const REALTIME_ASPECT_RATIO_OPTIONS: RealtimeAspectRatio[] = [ + "1:1", + "4:3", + "2:3", +]; function cloneImageLayers(layers: ImageLayer[]) { return layers.map((layer) => ({ ...layer })); @@ -830,8 +835,7 @@ export function RealtimeCanvasDialog({ defaultRealtimeConfig.transport.throttleInterval ); const pumpFlushRef = useRef<(() => void) | null>(null); - const realtimeGenerationModeRef = - useRef("manual"); + const realtimeGenerationModeRef = useRef("manual"); const manualGenerateRequestRef = useRef(false); const realtimeAspectRatioRef = useRef("1:1"); const warningMessageRef = useRef(""); @@ -893,6 +897,7 @@ export function RealtimeCanvasDialog({ const [pendingProjectName, setPendingProjectName] = useState(""); const [brushPopoverOpen, setBrushPopoverOpen] = useState(false); const [eraserPopoverOpen, setEraserPopoverOpen] = useState(false); + const [aspectRatioPopoverOpen, setAspectRatioPopoverOpen] = useState(false); const [isDragImportActive, setIsDragImportActive] = useState(false); const [adaptiveThrottleMs, setAdaptiveThrottleMs] = useState( defaultRealtimeConfig.transport.throttleInterval @@ -1015,10 +1020,13 @@ export function RealtimeCanvasDialog({ iconUrl: modeIconConfig.realtimeIconUrl, } ); - const realtimeModeEnhanceItem = resolveRealtimeModeItem("realtimeModeEnhance", { - label: modeIconConfig.enhanceLabel, - iconUrl: modeIconConfig.enhanceIconUrl, - }); + const realtimeModeEnhanceItem = resolveRealtimeModeItem( + "realtimeModeEnhance", + { + label: modeIconConfig.enhanceLabel, + iconUrl: modeIconConfig.enhanceIconUrl, + } + ); const realtimeReturnCanvasItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeReturnCanvas" @@ -1095,14 +1103,18 @@ export function RealtimeCanvasDialog({ if (!data || cancelled) return; setIsLayerSplitEnabled(data.layerSplitEnabled === true); setIsEnhanceProEnabled(data.enhanceProEnabled === true); - setCanvasToolbarConfig(normalizeCanvasToolbarConfig(data.canvasToolbarConfig)); + setCanvasToolbarConfig( + normalizeCanvasToolbarConfig(data.canvasToolbarConfig) + ); const iconConfig = data.realtimeCanvasModeIconConfig && typeof data.realtimeCanvasModeIconConfig === "object" ? data.realtimeCanvasModeIconConfig : {}; const normalizeModeLabel = (value: unknown, fallback: string) => { - const label = String(value || "").trim().slice(0, 32); + const label = String(value || "") + .trim() + .slice(0, 32); return label || fallback; }; setModeIconConfig({ @@ -1917,6 +1929,7 @@ export function RealtimeCanvasDialog({ const handleRealtimeAspectRatioChange = useCallback( (nextRatio: RealtimeAspectRatio) => { + setAspectRatioPopoverOpen(false); if (nextRatio === realtimeAspectRatioRef.current) return; realtimeAspectRatioRef.current = nextRatio; setRealtimeAspectRatio(nextRatio); @@ -3253,19 +3266,44 @@ export function RealtimeCanvasDialog({ [onApplyResult, onOpenChange] ); - const handleDownloadPreview = useCallback(() => { + const handleDownloadPreview = useCallback(async () => { if (!previewUrl) { toast.error("请先生成预览结果"); return; } - const anchor = document.createElement("a"); - anchor.href = /^https?:\/\//i.test(previewUrl) - ? buildImageProxyUrl(previewUrl) - : previewUrl; - anchor.download = `realtime-preview-${Date.now()}.png`; - anchor.rel = "noopener"; - anchor.click(); + try { + toast.info("正在准备下载..."); + const downloadUrl = /^https?:\/\//i.test(previewUrl) + ? buildImageProxyUrl(previewUrl, { delivery: "proxy" }) + : previewUrl; + const response = await fetch(downloadUrl); + if (!response.ok) { + throw new Error(`下载失败(${response.status})`); + } + const blob = await response.blob(); + const mimeType = blob.type.toLowerCase(); + const extension = mimeType.includes("jpeg") + ? "jpg" + : mimeType.includes("webp") + ? "webp" + : mimeType.includes("png") + ? "png" + : "png"; + const blobUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = blobUrl; + anchor.download = `realtime-preview-${Date.now()}.${extension}`; + anchor.rel = "noopener"; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); + toast.success("下载成功"); + } catch (error) { + console.error("[RealtimeCanvasDialog] 下载预览失败:", error); + toast.error("下载失败,请稍后重试"); + } }, [previewUrl]); const handleCloseRealtime = useCallback(() => { @@ -3406,23 +3444,23 @@ export function RealtimeCanvasDialog({ floatingSurfaceClass )} > - +
{isEnhanceProEnabled ? ( ) : null}
@@ -3519,7 +3557,10 @@ export function RealtimeCanvasDialog({ 取消 + title={realtimeLayerSplitImportItem.label} + type="button" + variant="outline" + > + {isLayerSplitImporting ? ( + + ) : ( + + )} + ) : null} + title={realtimeAddImageLayerItem.label} + type="button" + variant="outline" + > + + @@ -3906,25 +3950,25 @@ export function RealtimeCanvasDialog({ "h-10 rounded-full px-4 font-medium text-sm", softActionButtonClass )} - onClick={handleDownloadPreview} - size="sm" - type="button" - > - - {realtimeDownloadPreviewItem.label} - + onClick={handleDownloadPreview} + size="sm" + type="button" + > + + {realtimeDownloadPreviewItem.label} + ) : null} -
+
- - + onClick={activateSelectMode} + size="icon" + title={realtimeSelectToolItem.label} + type="button" + variant="ghost" + > + + -
- {realtimeSelectToolItem.label} -
+
+ {realtimeSelectToolItem.label} +
选择图层并拖动位置
@@ -3975,19 +4019,19 @@ export function RealtimeCanvasDialog({ brushMode === "brush" && compactToolButtonActiveClass )} - onClick={activateBrushMode} - size="icon" - title={realtimeBrushToolItem.label} - type="button" - variant="ghost" - > - - + onClick={activateBrushMode} + size="icon" + title={realtimeBrushToolItem.label} + type="button" + variant="ghost" + > + + -
- {realtimeBrushToolItem.label} -
+
+ {realtimeBrushToolItem.label} +
点击后直接调整颜色和粗细
@@ -4070,19 +4114,19 @@ export function RealtimeCanvasDialog({ brushMode === "eraser" && compactToolButtonActiveClass )} - onClick={activateEraserMode} - size="icon" - title={realtimeEraserToolItem.label} - type="button" - variant="ghost" - > - - + onClick={activateEraserMode} + size="icon" + title={realtimeEraserToolItem.label} + type="button" + variant="ghost" + > + + -
- {realtimeEraserToolItem.label} -
+
+ {realtimeEraserToolItem.label} +
点击后直接调整橡皮粗细
@@ -4126,23 +4170,23 @@ export function RealtimeCanvasDialog({ + className={cn( + compactToolButtonClass, + isShapeMode(brushMode) && + compactToolButtonActiveClass + )} + size="icon" + title={realtimeShapeToolItem.label} + type="button" + variant="ghost" + > + + -
- {realtimeShapeToolItem.label} -
+
+ {realtimeShapeToolItem.label} +
矩形、圆形与直线
@@ -4209,25 +4253,25 @@ export function RealtimeCanvasDialog({ - + -
- {realtimeSaveProjectItem.label} -
+
+ {realtimeSaveProjectItem.label} +
保存当前画布状态
@@ -4241,48 +4285,46 @@ export function RealtimeCanvasDialog({ refreshSavedProjects(); setProjectPanelOpen((openState) => !openState); }} - size="icon" - title={realtimeProjectListItem.label} - type="button" - variant="ghost" - > - - + size="icon" + title={realtimeProjectListItem.label} + type="button" + variant="ghost" + > + + -
- {realtimeProjectListItem.label} -
-
- 打开已保存项目 +
+ {realtimeProjectListItem.label}
+
打开已保存项目
+ onClick={startBlankProject} + size="icon" + title={realtimeNewBlankItem.label} + type="button" + variant="ghost" + > + + -
- {realtimeNewBlankItem.label} -
+
+ {realtimeNewBlankItem.label} +
清空并开始新项目
@@ -4293,54 +4335,50 @@ export function RealtimeCanvasDialog({ + disabled={!canUndo} + onClick={undo} + size="icon" + title={realtimeUndoItem.label} + type="button" + variant="ghost" + > + + -
- {realtimeUndoItem.label} -
-
- 回退上一步编辑 +
+ {realtimeUndoItem.label}
+
回退上一步编辑
+ disabled={!canRedo} + onClick={redo} + size="icon" + title={realtimeRedoItem.label} + type="button" + variant="ghost" + > + + -
- {realtimeRedoItem.label} -
-
- 恢复已撤销操作 +
+ {realtimeRedoItem.label}
+
恢复已撤销操作
@@ -4368,8 +4406,8 @@ export function RealtimeCanvasDialog({ placeholder={FALLBACK_PROMPT} value={prompt} /> -
-
+
+
{REALTIME_GENERATION_MODE_OPTIONS.map((option) => { const active = option.value === realtimeGenerationMode; @@ -4393,27 +4431,51 @@ export function RealtimeCanvasDialog({ ); })}
-
- {REALTIME_ASPECT_RATIO_OPTIONS.map((ratio) => { - const active = ratio === realtimeAspectRatio; - return ( - - ); - })} -
-
+ + + + + +
+ {REALTIME_ASPECT_RATIO_OPTIONS.map((ratio) => { + const active = ratio === realtimeAspectRatio; + return ( + + ); + })} +
+
+
+
{currentRealtimeModeOption.points} 积分
-
+
+ disabled={status === "connecting"} + onClick={triggerGenerate} + size="sm" + title={realtimeGenerateItem.label} + > + {status === "connecting" ? ( + + ) : ( + <> + + {realtimeGenerateItem.label} + + )} + + disabled={!previewUrl} + onClick={handleApplyToCanvas} + size="sm" + title={realtimeSendToCanvasItem.label} + variant="outline" + > + + {realtimeSendToCanvasItem.label} +
diff --git a/components/style-pro/StyleProWorkspace.tsx b/components/style-pro/StyleProWorkspace.tsx index c09db3312..16dd92fd5 100644 --- a/components/style-pro/StyleProWorkspace.tsx +++ b/components/style-pro/StyleProWorkspace.tsx @@ -1,6 +1,7 @@ "use client"; -import { Clock3, Loader2, Sparkles, Upload } from "lucide-react"; +import { Clock3, Loader2, LogOut, Sparkles, Upload } from "lucide-react"; +import { signOut, useSession } from "next-auth/react"; import { type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, @@ -14,6 +15,12 @@ import useSWR from "swr"; import { toast } from "sonner"; import { BrandTopBar } from "@/components/layout/brand-top-bar"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { imageGenerationService } from "@/services/canvas"; import { cn, fetcher } from "@/lib/utils"; import type { @@ -55,6 +62,29 @@ type StyleProModelsResponse = { error?: string; }; +type StyleProMembershipSummaryResponse = { + currentPlan?: { + name?: string; + monthlyCredits?: number; + dailyFreeCredits?: number; + }; + summary?: { + currentCredits?: number; + dailyFreeCredits?: number; + }; +}; + +type StyleProAccountCenterResponse = { + profile?: { + name?: string; + email?: string; + currentPlanName?: string; + availablePoints?: number; + monthlyCredits?: number; + dailyFreeCredits?: number; + }; +}; + type StyleProGenerateResponse = { success: boolean; error?: string; @@ -194,6 +224,211 @@ function mergeStyleProState( }; } +function getStyleProUserInitials(name?: string | null, email?: string | null) { + const source = String(name || email || "mu").trim(); + return (source || "mu").slice(0, 2).toLowerCase(); +} + +function formatStyleProCredits(value?: number | null) { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric)) return "0"; + return Math.max(0, Math.round(numeric)).toLocaleString("zh-CN"); +} + +function formatStyleProHistoryDateTime(value?: string | null) { + const date = new Date(value || ""); + if (Number.isNaN(date.getTime())) return "时间未知"; + const dateText = date + .toLocaleDateString("zh-CN", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }) + .replace(/\//g, "-"); + const timeText = date.toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }); + return `${dateText} ${timeText}`; +} + +function StyleProAccountActions() { + const { data: session, status } = useSession(); + const [accountCenterOpen, setAccountCenterOpen] = useState(false); + const isAuthenticated = status === "authenticated" && Boolean(session?.user); + const { data: membershipData } = useSWR( + isAuthenticated ? "/api/user/membership-summary" : null, + fetcher + ); + const { data: accountCenterData, isLoading: accountCenterLoading } = + useSWR( + isAuthenticated && accountCenterOpen ? "/api/user/account-center" : null, + fetcher + ); + + const accountProfile = useMemo(() => { + const profile = accountCenterData?.profile; + return { + name: profile?.name || session?.user?.name || "用户", + email: profile?.email || session?.user?.email || "", + planName: + profile?.currentPlanName || membershipData?.currentPlan?.name || "Free", + availablePoints: + profile?.availablePoints ?? + membershipData?.summary?.currentCredits ?? + 0, + monthlyCredits: + profile?.monthlyCredits ?? + membershipData?.currentPlan?.monthlyCredits ?? + 0, + dailyFreeCredits: + profile?.dailyFreeCredits ?? + membershipData?.summary?.dailyFreeCredits ?? + membershipData?.currentPlan?.dailyFreeCredits ?? + 0, + }; + }, [ + accountCenterData?.profile, + membershipData?.currentPlan?.dailyFreeCredits, + membershipData?.currentPlan?.monthlyCredits, + membershipData?.currentPlan?.name, + membershipData?.summary?.currentCredits, + membershipData?.summary?.dailyFreeCredits, + session?.user?.email, + session?.user?.name, + ]); + + const userInitials = getStyleProUserInitials( + session?.user?.name, + session?.user?.email + ); + + const openLogin = useCallback(() => { + if (typeof window === "undefined") { + return; + } + const callbackUrl = `${window.location.pathname}${window.location.search}`; + window.location.href = `/login?callbackUrl=${encodeURIComponent(callbackUrl)}`; + }, []); + + const handleLogout = useCallback(async () => { + try { + await fetch("/api/auth/logout", { method: "POST" }); + await signOut({ callbackUrl: "/login", redirect: false }); + } finally { + window.location.replace("/login"); + } + }, []); + + if (status === "loading") { + return ( +
+ ); + } + + if (!isAuthenticated) { + return ( + + ); + } + + return ( + <> + + + + + + 个人中心 + +
+
+
+
+ {userInitials} +
+
+
+ {accountProfile.name} +
+
+ {accountProfile.email || "登录后查看账户邮箱"} +
+
+ +
+
+ +
+
+
+ 积分余额 +
+
+ {accountCenterLoading ? ( + + ) : ( + formatStyleProCredits(accountProfile.availablePoints) + )} +
+
+
+
+ 当前套餐 +
+
+ {accountProfile.planName} +
+
+
+
+ 每月更新 +
+
+ {formatStyleProCredits(accountProfile.monthlyCredits)} +
+
+
+
+ 每日赠送 +
+
+ {formatStyleProCredits(accountProfile.dailyFreeCredits)} +
+
+
+
+
+
+ + ); +} + function ImagePreview({ alt, className, @@ -844,6 +1079,7 @@ export function StyleProWorkspace({
} logo={logo} onLogoClick={() => { window.location.href = "/"; @@ -1044,13 +1280,7 @@ export function StyleProWorkspace({
- {new Date(item.createdAt).toLocaleTimeString( - "zh-CN", - { - hour: "2-digit", - minute: "2-digit", - } - )} + {formatStyleProHistoryDateTime(item.createdAt)}
{item.optionLabels[0] || "风格化结果"}