fix: sync style pro toolbar and realtime controls
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-16 22:36:05 +08:00
parent e8a3212b35
commit f726c14397
4 changed files with 621 additions and 324 deletions

View File

@ -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<string, unknown> | 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)
),

View File

@ -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({

View File

@ -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<RealtimeGenerationMode>("manual");
const realtimeGenerationModeRef = useRef<RealtimeGenerationMode>("manual");
const manualGenerateRequestRef = useRef(false);
const realtimeAspectRatioRef = useRef<RealtimeAspectRatio>("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<number>(
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
)}
>
<button
className="inline-flex h-12 shrink-0 items-center gap-3 rounded-[22px] border border-white/90 bg-white/80 px-4 text-left text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95)] transition hover:bg-white dark:border-white/12 dark:bg-white/8 dark:text-white dark:shadow-none dark:hover:bg-white/12"
onClick={handleCloseRealtime}
title={realtimeReturnCanvasItem.label}
type="button"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white shadow-[0_8px_18px_rgba(0,0,0,0.06)] dark:border-white/12 dark:bg-white/10 dark:shadow-none">
<CanvasToolbarIcon
className="h-4 w-4 text-[#111111] dark:text-white"
fallback={ArrowLeft}
item={realtimeReturnCanvasItem}
/>
</span>
<span className="font-semibold text-[14px] tracking-[-0.02em]">
{realtimeReturnCanvasItem.label}
</span>
</button>
<button
className="inline-flex h-12 shrink-0 items-center gap-3 rounded-[22px] border border-white/90 bg-white/80 px-4 text-left text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95)] transition hover:bg-white dark:border-white/12 dark:bg-white/8 dark:text-white dark:shadow-none dark:hover:bg-white/12"
onClick={handleCloseRealtime}
title={realtimeReturnCanvasItem.label}
type="button"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white shadow-[0_8px_18px_rgba(0,0,0,0.06)] dark:border-white/12 dark:bg-white/10 dark:shadow-none">
<CanvasToolbarIcon
className="h-4 w-4 text-[#111111] dark:text-white"
fallback={ArrowLeft}
item={realtimeReturnCanvasItem}
/>
</span>
<span className="font-semibold text-[14px] tracking-[-0.02em]">
{realtimeReturnCanvasItem.label}
</span>
</button>
<div className="inline-flex h-12 min-w-0 items-center gap-1 rounded-[22px] border border-[#ebebeb] bg-[#fafafa] p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)] dark:border-white/12 dark:bg-white/8 dark:shadow-none">
<button
@ -3435,15 +3473,15 @@ export function RealtimeCanvasDialog({
onClick={() => handleWorkspaceModeChange("realtime")}
type="button"
>
<CanvasToolbarIcon
active={workspaceMode === "realtime"}
className="h-4 w-4 transition group-hover:opacity-100"
fallback={Paintbrush}
item={realtimeModeRealtimeItem}
/>
<span className="max-w-[9rem] truncate">
{realtimeModeRealtimeItem.label}
</span>
<CanvasToolbarIcon
active={workspaceMode === "realtime"}
className="h-4 w-4 transition group-hover:opacity-100"
fallback={Paintbrush}
item={realtimeModeRealtimeItem}
/>
<span className="max-w-[9rem] truncate">
{realtimeModeRealtimeItem.label}
</span>
</button>
{isEnhanceProEnabled ? (
<button
@ -3456,15 +3494,15 @@ export function RealtimeCanvasDialog({
onClick={() => handleWorkspaceModeChange("enhance-pro")}
type="button"
>
<CanvasToolbarIcon
active={workspaceMode === "enhance-pro"}
className="h-4 w-4 transition group-hover:opacity-100"
fallback={Wand2}
item={realtimeModeEnhanceItem}
/>
<span className="max-w-[9rem] truncate">
{realtimeModeEnhanceItem.label}
</span>
<CanvasToolbarIcon
active={workspaceMode === "enhance-pro"}
className="h-4 w-4 transition group-hover:opacity-100"
fallback={Wand2}
item={realtimeModeEnhanceItem}
/>
<span className="max-w-[9rem] truncate">
{realtimeModeEnhanceItem.label}
</span>
</button>
) : null}
</div>
@ -3519,7 +3557,10 @@ export function RealtimeCanvasDialog({
取消
</Button>
<Button
className={cn("h-10 flex-1 rounded-2xl", primaryActionButtonClass)}
className={cn(
"h-10 flex-1 rounded-2xl",
primaryActionButtonClass
)}
onClick={() => void confirmSaveCurrentProject()}
type="button"
>
@ -3608,7 +3649,10 @@ export function RealtimeCanvasDialog({
打开
</Button>
<Button
className={cn("h-8 rounded-xl px-4", controlSurfaceClass)}
className={cn(
"h-8 rounded-xl px-4",
controlSurfaceClass
)}
onClick={() => deleteSavedProject(project.id)}
size="sm"
type="button"
@ -3768,18 +3812,18 @@ export function RealtimeCanvasDialog({
floatingSurfaceClass
)}
>
<div className="flex items-center justify-center border-[#e5e5e5] border-b pb-3 dark:border-white/12">
<div
className="flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white text-[#111111] dark:border-white/12 dark:bg-white/10 dark:text-white"
title={realtimeLayerPanelItem.label}
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Layers3}
item={realtimeLayerPanelItem}
/>
</div>
</div>
<div className="flex items-center justify-center border-[#e5e5e5] border-b pb-3 dark:border-white/12">
<div
className="flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white text-[#111111] dark:border-white/12 dark:bg-white/10 dark:text-white"
title={realtimeLayerPanelItem.label}
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Layers3}
item={realtimeLayerPanelItem}
/>
</div>
</div>
<div className="mt-2 flex flex-1 flex-col items-center space-y-2 overflow-y-auto py-1">
{imageLayers.length === 0 ? (
<div className="rounded-2xl border border-[#e5e5e5] border-dashed px-1 py-4 text-center text-[10px] text-[#666666] leading-4 dark:border-white/12 dark:text-white/55">
@ -3840,20 +3884,20 @@ export function RealtimeCanvasDialog({
)}
disabled={isLayerSplitImporting}
onClick={() => splitUploadInputRef.current?.click()}
title={realtimeLayerSplitImportItem.label}
type="button"
variant="outline"
>
{isLayerSplitImporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Layers3}
item={realtimeLayerSplitImportItem}
/>
)}
</Button>
title={realtimeLayerSplitImportItem.label}
type="button"
variant="outline"
>
{isLayerSplitImporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Layers3}
item={realtimeLayerSplitImportItem}
/>
)}
</Button>
) : null}
<Button
className={cn(
@ -3862,16 +3906,16 @@ export function RealtimeCanvasDialog({
)}
disabled={isLayerSplitImporting}
onClick={() => uploadInputRef.current?.click()}
title={realtimeAddImageLayerItem.label}
type="button"
variant="outline"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Upload}
item={realtimeAddImageLayerItem}
/>
</Button>
title={realtimeAddImageLayerItem.label}
type="button"
variant="outline"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Upload}
item={realtimeAddImageLayerItem}
/>
</Button>
</div>
</div>
</div>
@ -3906,25 +3950,25 @@ export function RealtimeCanvasDialog({
"h-10 rounded-full px-4 font-medium text-sm",
softActionButtonClass
)}
onClick={handleDownloadPreview}
size="sm"
type="button"
>
<CanvasToolbarIcon
active
className="mr-1.5 h-4 w-4"
fallback={Download}
item={realtimeDownloadPreviewItem}
/>
{realtimeDownloadPreviewItem.label}
</Button>
onClick={handleDownloadPreview}
size="sm"
type="button"
>
<CanvasToolbarIcon
active
className="mr-1.5 h-4 w-4"
fallback={Download}
item={realtimeDownloadPreviewItem}
/>
{realtimeDownloadPreviewItem.label}
</Button>
</div>
) : null}
</div>
</div>
</div>
<div className="-translate-x-1/2 pointer-events-none absolute bottom-4 left-1/2 z-30 flex w-[min(600px,calc(100vw-2rem))] flex-col items-center gap-2.5">
<div className="-translate-x-1/2 pointer-events-none absolute bottom-4 left-1/2 z-30 flex w-[min(760px,calc(100vw-2rem))] flex-col items-center gap-2.5">
<TooltipProvider delayDuration={120}>
<div
className={cn(
@ -3939,24 +3983,24 @@ export function RealtimeCanvasDialog({
compactToolButtonClass,
brushMode === "select" && compactToolButtonActiveClass
)}
onClick={activateSelectMode}
size="icon"
title={realtimeSelectToolItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
active={brushMode === "select"}
className="h-4.5 w-4.5"
fallback={MousePointer2}
item={realtimeSelectToolItem}
/>
</Button>
onClick={activateSelectMode}
size="icon"
title={realtimeSelectToolItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
active={brushMode === "select"}
className="h-4.5 w-4.5"
fallback={MousePointer2}
item={realtimeSelectToolItem}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">
{realtimeSelectToolItem.label}
</div>
<div className="font-medium text-sm">
{realtimeSelectToolItem.label}
</div>
<div className="text-[#666666] text-xs">
选择图层并拖动位置
</div>
@ -3975,19 +4019,19 @@ export function RealtimeCanvasDialog({
brushMode === "brush" &&
compactToolButtonActiveClass
)}
onClick={activateBrushMode}
size="icon"
title={realtimeBrushToolItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
active={brushMode === "brush"}
className="h-4.5 w-4.5"
fallback={Paintbrush}
item={realtimeBrushToolItem}
/>
</Button>
onClick={activateBrushMode}
size="icon"
title={realtimeBrushToolItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
active={brushMode === "brush"}
className="h-4.5 w-4.5"
fallback={Paintbrush}
item={realtimeBrushToolItem}
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<PopoverContent
@ -4049,9 +4093,9 @@ export function RealtimeCanvasDialog({
</PopoverContent>
</Popover>
<TooltipContent>
<div className="font-medium text-sm">
{realtimeBrushToolItem.label}
</div>
<div className="font-medium text-sm">
{realtimeBrushToolItem.label}
</div>
<div className="text-[#666666] text-xs">
点击后直接调整颜色和粗细
</div>
@ -4070,19 +4114,19 @@ export function RealtimeCanvasDialog({
brushMode === "eraser" &&
compactToolButtonActiveClass
)}
onClick={activateEraserMode}
size="icon"
title={realtimeEraserToolItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
active={brushMode === "eraser"}
className="h-4.5 w-4.5"
fallback={Eraser}
item={realtimeEraserToolItem}
/>
</Button>
onClick={activateEraserMode}
size="icon"
title={realtimeEraserToolItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
active={brushMode === "eraser"}
className="h-4.5 w-4.5"
fallback={Eraser}
item={realtimeEraserToolItem}
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<PopoverContent
@ -4113,9 +4157,9 @@ export function RealtimeCanvasDialog({
</PopoverContent>
</Popover>
<TooltipContent>
<div className="font-medium text-sm">
{realtimeEraserToolItem.label}
</div>
<div className="font-medium text-sm">
{realtimeEraserToolItem.label}
</div>
<div className="text-[#666666] text-xs">
点击后直接调整橡皮粗细
</div>
@ -4126,23 +4170,23 @@ export function RealtimeCanvasDialog({
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
className={cn(
compactToolButtonClass,
isShapeMode(brushMode) &&
compactToolButtonActiveClass
)}
size="icon"
title={realtimeShapeToolItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
active={isShapeMode(brushMode)}
className="h-4.5 w-4.5"
fallback={Square}
item={realtimeShapeToolItem}
/>
</Button>
className={cn(
compactToolButtonClass,
isShapeMode(brushMode) &&
compactToolButtonActiveClass
)}
size="icon"
title={realtimeShapeToolItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
active={isShapeMode(brushMode)}
className="h-4.5 w-4.5"
fallback={Square}
item={realtimeShapeToolItem}
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<PopoverContent
@ -4198,9 +4242,9 @@ export function RealtimeCanvasDialog({
</PopoverContent>
</Popover>
<TooltipContent>
<div className="font-medium text-sm">
{realtimeShapeToolItem.label}
</div>
<div className="font-medium text-sm">
{realtimeShapeToolItem.label}
</div>
<div className="text-[#666666] text-xs">
矩形、圆形与直线
</div>
@ -4209,25 +4253,25 @@ export function RealtimeCanvasDialog({
<Tooltip>
<TooltipTrigger asChild>
<Button
className={compactToolButtonClass}
onClick={() => void saveCurrentProject()}
size="icon"
title={realtimeSaveProjectItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Save}
item={realtimeSaveProjectItem}
/>
</Button>
<Button
className={compactToolButtonClass}
onClick={() => void saveCurrentProject()}
size="icon"
title={realtimeSaveProjectItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Save}
item={realtimeSaveProjectItem}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">
{realtimeSaveProjectItem.label}
</div>
<div className="font-medium text-sm">
{realtimeSaveProjectItem.label}
</div>
<div className="text-[#666666] text-xs">
保存当前画布状态
</div>
@ -4241,48 +4285,46 @@ export function RealtimeCanvasDialog({
refreshSavedProjects();
setProjectPanelOpen((openState) => !openState);
}}
size="icon"
title={realtimeProjectListItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={FolderOpen}
item={realtimeProjectListItem}
/>
</Button>
size="icon"
title={realtimeProjectListItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={FolderOpen}
item={realtimeProjectListItem}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">
{realtimeProjectListItem.label}
</div>
<div className="text-[#666666] text-xs">
打开已保存项目
<div className="font-medium text-sm">
{realtimeProjectListItem.label}
</div>
<div className="text-[#666666] text-xs">打开已保存项目</div>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={compactToolButtonClass}
onClick={startBlankProject}
size="icon"
title={realtimeNewBlankItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Plus}
item={realtimeNewBlankItem}
/>
</Button>
onClick={startBlankProject}
size="icon"
title={realtimeNewBlankItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Plus}
item={realtimeNewBlankItem}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">
{realtimeNewBlankItem.label}
</div>
<div className="font-medium text-sm">
{realtimeNewBlankItem.label}
</div>
<div className="text-[#666666] text-xs">
清空并开始新项目
</div>
@ -4293,54 +4335,50 @@ export function RealtimeCanvasDialog({
<TooltipTrigger asChild>
<Button
className={compactToolButtonClass}
disabled={!canUndo}
onClick={undo}
size="icon"
title={realtimeUndoItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Undo2}
item={realtimeUndoItem}
/>
</Button>
disabled={!canUndo}
onClick={undo}
size="icon"
title={realtimeUndoItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Undo2}
item={realtimeUndoItem}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">
{realtimeUndoItem.label}
</div>
<div className="text-[#666666] text-xs">
回退上一步编辑
<div className="font-medium text-sm">
{realtimeUndoItem.label}
</div>
<div className="text-[#666666] text-xs">回退上一步编辑</div>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={compactToolButtonClass}
disabled={!canRedo}
onClick={redo}
size="icon"
title={realtimeRedoItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Redo2}
item={realtimeRedoItem}
/>
</Button>
disabled={!canRedo}
onClick={redo}
size="icon"
title={realtimeRedoItem.label}
type="button"
variant="ghost"
>
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Redo2}
item={realtimeRedoItem}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">
{realtimeRedoItem.label}
</div>
<div className="text-[#666666] text-xs">
恢复已撤销操作
<div className="font-medium text-sm">
{realtimeRedoItem.label}
</div>
<div className="text-[#666666] text-xs">恢复已撤销操作</div>
</TooltipContent>
</Tooltip>
</div>
@ -4368,8 +4406,8 @@ export function RealtimeCanvasDialog({
placeholder={FALLBACK_PROMPT}
value={prompt}
/>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<div className="mt-3 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2 overflow-x-auto">
<div className="inline-flex h-9 items-center gap-1 rounded-full border border-[#e5e5e5] bg-white p-1 shadow-[0_6px_16px_rgba(0,0,0,0.04)] dark:border-white/12 dark:bg-white/8">
{REALTIME_GENERATION_MODE_OPTIONS.map((option) => {
const active = option.value === realtimeGenerationMode;
@ -4393,27 +4431,51 @@ export function RealtimeCanvasDialog({
);
})}
</div>
<div className="inline-flex h-9 items-center gap-1 rounded-full border border-[#e5e5e5] bg-white p-1 shadow-[0_6px_16px_rgba(0,0,0,0.04)] dark:border-white/12 dark:bg-white/8">
{REALTIME_ASPECT_RATIO_OPTIONS.map((ratio) => {
const active = ratio === realtimeAspectRatio;
return (
<button
className={cn(
"h-7 rounded-full px-3 font-medium text-xs transition",
active
? "bg-[#111111] text-white dark:bg-white dark:text-[#111111]"
: "text-[#666666] hover:bg-[#f4f4f4] dark:text-white/58 dark:hover:bg-white/10"
)}
key={ratio}
onClick={() => handleRealtimeAspectRatioChange(ratio)}
type="button"
>
{ratio}
</button>
);
})}
</div>
<div className="flex h-9 items-center rounded-full border border-[#e5e5e5] bg-white px-3 font-semibold text-[#111111] text-xs shadow-[0_6px_16px_rgba(0,0,0,0.04)] dark:border-white/12 dark:bg-white/8 dark:text-white">
<Popover
onOpenChange={setAspectRatioPopoverOpen}
open={aspectRatioPopoverOpen}
>
<PopoverTrigger asChild>
<button
className="inline-flex h-9 shrink-0 items-center gap-1 rounded-full border border-[#e5e5e5] bg-white px-3 font-semibold text-[#111111] text-xs shadow-[0_6px_16px_rgba(0,0,0,0.04)] transition hover:bg-[#f4f4f4] dark:border-white/12 dark:bg-white/8 dark:text-white dark:hover:bg-white/10"
title="切换图像比例"
type="button"
>
{realtimeAspectRatio}
<ChevronDown className="h-3.5 w-3.5 opacity-55" />
</button>
</PopoverTrigger>
<PopoverContent
align="center"
className="w-auto rounded-2xl border border-[#e5e5e5] bg-white p-1 shadow-[0_16px_36px_rgba(0,0,0,0.12)] dark:border-white/12 dark:bg-[#171717]"
side="top"
sideOffset={8}
>
<div className="flex items-center gap-1">
{REALTIME_ASPECT_RATIO_OPTIONS.map((ratio) => {
const active = ratio === realtimeAspectRatio;
return (
<button
className={cn(
"h-8 rounded-full px-3 font-medium text-xs transition",
active
? "bg-[#111111] text-white dark:bg-white dark:text-[#111111]"
: "text-[#666666] hover:bg-[#f4f4f4] dark:text-white/58 dark:hover:bg-white/10"
)}
key={ratio}
onClick={() =>
handleRealtimeAspectRatioChange(ratio)
}
type="button"
>
{ratio}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
<div className="flex h-9 shrink-0 items-center rounded-full border border-[#e5e5e5] bg-white px-3 font-semibold text-[#111111] text-xs shadow-[0_6px_16px_rgba(0,0,0,0.04)] dark:border-white/12 dark:bg-white/8 dark:text-white">
{currentRealtimeModeOption.points} 积分
</div>
<div
@ -4433,50 +4495,50 @@ export function RealtimeCanvasDialog({
{statusBadge.label}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
<Button
className={cn(
"h-9 shrink-0 rounded-full px-4 font-medium",
primaryActionButtonClass
)}
disabled={status === "connecting"}
onClick={triggerGenerate}
size="sm"
title={realtimeGenerateItem.label}
>
{status === "connecting" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<CanvasToolbarIcon
active
className="mr-1.5 h-4 w-4"
fallback={Sparkles}
item={realtimeGenerateItem}
/>
{realtimeGenerateItem.label}
</>
)}
</Button>
disabled={status === "connecting"}
onClick={triggerGenerate}
size="sm"
title={realtimeGenerateItem.label}
>
{status === "connecting" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<CanvasToolbarIcon
active
className="mr-1.5 h-4 w-4"
fallback={Sparkles}
item={realtimeGenerateItem}
/>
{realtimeGenerateItem.label}
</>
)}
</Button>
<Button
className={cn(
"h-9 shrink-0 rounded-full px-4 font-medium",
softActionButtonClass
)}
disabled={!previewUrl}
onClick={handleApplyToCanvas}
size="sm"
title={realtimeSendToCanvasItem.label}
variant="outline"
>
<CanvasToolbarIcon
active
className="mr-1.5 h-4 w-4"
fallback={Send}
item={realtimeSendToCanvasItem}
/>
{realtimeSendToCanvasItem.label}
</Button>
disabled={!previewUrl}
onClick={handleApplyToCanvas}
size="sm"
title={realtimeSendToCanvasItem.label}
variant="outline"
>
<CanvasToolbarIcon
active
className="mr-1.5 h-4 w-4"
fallback={Send}
item={realtimeSendToCanvasItem}
/>
{realtimeSendToCanvasItem.label}
</Button>
</div>
</div>
</div>

View File

@ -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<StyleProMembershipSummaryResponse>(
isAuthenticated ? "/api/user/membership-summary" : null,
fetcher
);
const { data: accountCenterData, isLoading: accountCenterLoading } =
useSWR<StyleProAccountCenterResponse>(
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 (
<div className="h-8 w-[96px] animate-pulse rounded-[14px] bg-black/8 dark:bg-white/8" />
);
}
if (!isAuthenticated) {
return (
<button
className="inline-flex h-8 items-center justify-center rounded-full border border-black/15 bg-white px-3 text-xs font-medium text-black/80 transition hover:bg-black/5 dark:border-white/12 dark:bg-[#161618] dark:text-white/80 dark:hover:bg-white/8"
onClick={openLogin}
type="button"
>
登录
</button>
);
}
return (
<>
<button
aria-label="个人中心"
className="inline-flex h-8 items-center gap-2 rounded-[14px] border border-black/10 bg-white px-2.5 text-[12px] font-medium text-black/72 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
onClick={() => setAccountCenterOpen(true)}
type="button"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#d4551f] text-[11px] font-semibold text-white">
{userInitials}
</span>
<span className="hidden sm:inline">个人中心</span>
</button>
<Dialog open={accountCenterOpen} onOpenChange={setAccountCenterOpen}>
<DialogContent className="w-[92vw] max-w-[520px] rounded-[22px] border-black/10 bg-[#f6f6f7]/95 p-0 shadow-2xl dark:border-white/10 dark:bg-[#101014]/95 dark:text-white">
<DialogHeader className="sr-only">
<DialogTitle>个人中心</DialogTitle>
</DialogHeader>
<div className="p-4">
<div className="rounded-[18px] border border-black/10 bg-white p-4 dark:border-white/10 dark:bg-[#17171b]">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
{userInitials}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[15px] font-semibold text-black dark:text-white">
{accountProfile.name}
</div>
<div className="truncate text-[12px] text-black/45 dark:text-white/45">
{accountProfile.email || "登录后查看账户邮箱"}
</div>
</div>
<Button
className="h-8 rounded-xl border-black/10 bg-white px-3 text-[12px] text-black/75 hover:bg-black/5 dark:border-white/12 dark:bg-[#111115] dark:text-white/75 dark:hover:bg-white/6"
onClick={() => {
void handleLogout();
}}
variant="outline"
>
<LogOut className="mr-1.5 h-3.5 w-3.5" />
退出
</Button>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div className="rounded-[16px] border border-black/10 bg-white p-3 dark:border-white/10 dark:bg-[#17171b]">
<div className="text-[12px] text-black/45 dark:text-white/45">
积分余额
</div>
<div className="mt-1 text-[20px] font-semibold text-black dark:text-white">
{accountCenterLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
formatStyleProCredits(accountProfile.availablePoints)
)}
</div>
</div>
<div className="rounded-[16px] border border-black/10 bg-white p-3 dark:border-white/10 dark:bg-[#17171b]">
<div className="text-[12px] text-black/45 dark:text-white/45">
当前套餐
</div>
<div className="mt-1 truncate text-[20px] font-semibold text-black dark:text-white">
{accountProfile.planName}
</div>
</div>
<div className="rounded-[16px] border border-black/10 bg-white p-3 dark:border-white/10 dark:bg-[#17171b]">
<div className="text-[12px] text-black/45 dark:text-white/45">
每月更新
</div>
<div className="mt-1 text-[20px] font-semibold text-black dark:text-white">
{formatStyleProCredits(accountProfile.monthlyCredits)}
</div>
</div>
<div className="rounded-[16px] border border-black/10 bg-white p-3 dark:border-white/10 dark:bg-[#17171b]">
<div className="text-[12px] text-black/45 dark:text-white/45">
每日赠送
</div>
<div className="mt-1 text-[20px] font-semibold text-black dark:text-white">
{formatStyleProCredits(accountProfile.dailyFreeCredits)}
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
function ImagePreview({
alt,
className,
@ -844,6 +1079,7 @@ export function StyleProWorkspace({
<main className="min-w-0 flex-1 overflow-y-auto">
<BrandTopBar
actions={<StyleProAccountActions />}
logo={logo}
onLogoClick={() => {
window.location.href = "/";
@ -1044,13 +1280,7 @@ export function StyleProWorkspace({
<div className="space-y-1 p-2">
<div className="flex items-center gap-1 text-[11px] text-gray-400">
<Clock3 className="h-3 w-3" />
{new Date(item.createdAt).toLocaleTimeString(
"zh-CN",
{
hour: "2-digit",
minute: "2-digit",
}
)}
{formatStyleProHistoryDateTime(item.createdAt)}
</div>
<div className="line-clamp-1 text-[12px] text-black/70">
{item.optionLabels[0] || "风格化结果"}