feat: add style pro canvas workspace
This commit is contained in:
parent
e51de15aac
commit
5387d6f1d2
@ -928,6 +928,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
|
||||
markerPanel: Tag,
|
||||
workflowConnection: GitBranch,
|
||||
styleMode: Wand2,
|
||||
stylePro: Sparkles,
|
||||
batchProcessing: Layers,
|
||||
addImage: ImagePlus,
|
||||
addImageInput: ImagePlus,
|
||||
@ -11356,6 +11357,11 @@ export function AdminSettingsClient() {
|
||||
管理PSD模板
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/settings/style-pro">
|
||||
风格化PRO设置
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
16
app/(admin)/settings/style-pro/page.tsx
Normal file
16
app/(admin)/settings/style-pro/page.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import { StyleProSettingsClient } from "./style-pro-settings-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function StyleProSettingsPage() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
} catch {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return <StyleProSettingsClient />;
|
||||
}
|
||||
|
||||
651
app/(admin)/settings/style-pro/style-pro-settings-client.tsx
Normal file
651
app/(admin)/settings/style-pro/style-pro-settings-client.tsx
Normal file
@ -0,0 +1,651 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Plus, Save, Trash2, Wand2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import {
|
||||
STYLE_PRO_CONFIG_KEY,
|
||||
defaultStyleProConfig,
|
||||
normalizeStyleProConfig,
|
||||
type StyleProCategory,
|
||||
type StyleProConfig,
|
||||
type StyleProOption,
|
||||
} from "@/lib/style-pro/config";
|
||||
|
||||
type SettingsResponse = {
|
||||
setting?: {
|
||||
value?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type AiModelOption = {
|
||||
id: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
supportsImageGen?: boolean;
|
||||
isEnabled?: boolean;
|
||||
isHidden?: boolean;
|
||||
};
|
||||
|
||||
type AiModelsResponse = {
|
||||
models?: AiModelOption[];
|
||||
};
|
||||
|
||||
const AUTO_MODEL_VALUE = "__auto__";
|
||||
|
||||
function getId(prefix: string) {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
return `${prefix}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
function reindexCategories(categories: StyleProCategory[]) {
|
||||
return categories.map((category, index) => ({
|
||||
...category,
|
||||
order: (index + 1) * 10,
|
||||
options: category.options.map((option, optionIndex) => ({
|
||||
...option,
|
||||
order: (optionIndex + 1) * 10,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function createCategory(): StyleProCategory {
|
||||
const id = getId("category");
|
||||
return {
|
||||
id,
|
||||
title: "新分类",
|
||||
description: "",
|
||||
required: true,
|
||||
minSelections: 1,
|
||||
maxSelections: 1,
|
||||
enabled: true,
|
||||
order: 999,
|
||||
options: [createOption(id)],
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(categoryId: string): StyleProOption {
|
||||
return {
|
||||
id: getId("option"),
|
||||
label: "新按钮",
|
||||
description: "",
|
||||
prompt: "请填写这个按钮对应的隐藏提示词。",
|
||||
jsonKey: categoryId,
|
||||
enabled: true,
|
||||
order: 999,
|
||||
};
|
||||
}
|
||||
|
||||
function modelLabel(model: AiModelOption) {
|
||||
const name = model.displayName || model.modelId || model.id;
|
||||
const id = model.modelId && model.modelId !== name ? ` · ${model.modelId}` : "";
|
||||
return `${name}${id}`;
|
||||
}
|
||||
|
||||
export function StyleProSettingsClient() {
|
||||
const { data, mutate } = useSWR<SettingsResponse>(
|
||||
`/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`,
|
||||
fetcher
|
||||
);
|
||||
const { data: modelsData } = useSWR<AiModelsResponse>(
|
||||
"/api/admin/ai-models",
|
||||
fetcher
|
||||
);
|
||||
const [config, setConfig] = useState<StyleProConfig>(defaultStyleProConfig);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setConfig(normalizeStyleProConfig(data.setting?.value));
|
||||
}, [data]);
|
||||
|
||||
const imageModels = useMemo(
|
||||
() =>
|
||||
(modelsData?.models || []).filter(
|
||||
(model) =>
|
||||
model.isEnabled !== false &&
|
||||
model.isHidden !== true &&
|
||||
model.supportsImageGen
|
||||
),
|
||||
[modelsData?.models]
|
||||
);
|
||||
|
||||
const updateConfig = (updates: Partial<StyleProConfig>) => {
|
||||
setConfig((current) => ({ ...current, ...updates }));
|
||||
};
|
||||
|
||||
const updateCategory = (
|
||||
categoryId: string,
|
||||
updates: Partial<StyleProCategory>
|
||||
) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
categories: current.categories.map((category) =>
|
||||
category.id === categoryId ? { ...category, ...updates } : category
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateOption = (
|
||||
categoryId: string,
|
||||
optionId: string,
|
||||
updates: Partial<StyleProOption>
|
||||
) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
categories: current.categories.map((category) =>
|
||||
category.id === categoryId
|
||||
? {
|
||||
...category,
|
||||
options: category.options.map((option) =>
|
||||
option.id === optionId ? { ...option, ...updates } : option
|
||||
),
|
||||
}
|
||||
: category
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addCategory = () => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
categories: reindexCategories([...current.categories, createCategory()]),
|
||||
}));
|
||||
};
|
||||
|
||||
const removeCategory = (categoryId: string) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
categories: reindexCategories(
|
||||
current.categories.filter((category) => category.id !== categoryId)
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const moveCategory = (categoryId: string, direction: -1 | 1) => {
|
||||
setConfig((current) => {
|
||||
const categories = [...current.categories];
|
||||
const index = categories.findIndex((category) => category.id === categoryId);
|
||||
const nextIndex = index + direction;
|
||||
if (index < 0 || nextIndex < 0 || nextIndex >= categories.length) {
|
||||
return current;
|
||||
}
|
||||
const [category] = categories.splice(index, 1);
|
||||
categories.splice(nextIndex, 0, category);
|
||||
return { ...current, categories: reindexCategories(categories) };
|
||||
});
|
||||
};
|
||||
|
||||
const addOption = (categoryId: string) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
categories: current.categories.map((category) =>
|
||||
category.id === categoryId
|
||||
? {
|
||||
...category,
|
||||
options: [
|
||||
...category.options,
|
||||
{
|
||||
...createOption(category.id),
|
||||
order: (category.options.length + 1) * 10,
|
||||
},
|
||||
],
|
||||
}
|
||||
: category
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const removeOption = (categoryId: string, optionId: string) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
categories: current.categories.map((category) =>
|
||||
category.id === categoryId
|
||||
? {
|
||||
...category,
|
||||
options: category.options.filter((option) => option.id !== optionId),
|
||||
}
|
||||
: category
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const normalized = normalizeStyleProConfig(config);
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: STYLE_PRO_CONFIG_KEY,
|
||||
value: normalized,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || "保存失败");
|
||||
}
|
||||
setConfig(normalized);
|
||||
await mutate();
|
||||
toast.success("风格化 PRO 配置已保存");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/settings">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回设置
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="font-semibold text-3xl tracking-tight">风格化 PRO 设置</h1>
|
||||
<p className="mt-2 max-w-2xl text-muted-foreground">
|
||||
配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setConfig(defaultStyleProConfig);
|
||||
toast.info("已恢复默认配置,保存后生效");
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
恢复默认
|
||||
</Button>
|
||||
<Button disabled={saving} onClick={save}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saving ? "保存中" : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>基础设置</CardTitle>
|
||||
<CardDescription>
|
||||
控制功能开关、最终生图模型、默认提示词和生成规格。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4 md:col-span-2">
|
||||
<div>
|
||||
<Label>启用风格化 PRO</Label>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
关闭后前台页面会提示管理员暂未启用,生成接口也会拒绝执行。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(enabled) => updateConfig({ enabled })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>最终生图模型</Label>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
modelId: value === AUTO_MODEL_VALUE ? "" : value,
|
||||
})
|
||||
}
|
||||
value={config.modelId || AUTO_MODEL_VALUE}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AUTO_MODEL_VALUE}>自动选择可用图片模型</SelectItem>
|
||||
{config.modelId &&
|
||||
!imageModels.some((model) => model.modelId === config.modelId || model.id === config.modelId) ? (
|
||||
<SelectItem value={config.modelId}>
|
||||
当前保存值:{config.modelId}
|
||||
</SelectItem>
|
||||
) : null}
|
||||
{imageModels.map((model) => {
|
||||
const value = model.modelId || model.id;
|
||||
return (
|
||||
<SelectItem key={model.id} value={value}>
|
||||
{modelLabel(model)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>图片比例</Label>
|
||||
<Select
|
||||
onValueChange={(aspectRatio) => updateConfig({ aspectRatio })}
|
||||
value={config.aspectRatio || "auto"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">自动延续原图</SelectItem>
|
||||
<SelectItem value="1:1">1:1</SelectItem>
|
||||
<SelectItem value="4:3">4:3</SelectItem>
|
||||
<SelectItem value="3:4">3:4</SelectItem>
|
||||
<SelectItem value="16:9">16:9</SelectItem>
|
||||
<SelectItem value="9:16">9:16</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>图片尺寸</Label>
|
||||
<Select
|
||||
onValueChange={(imageSize) => updateConfig({ imageSize })}
|
||||
value={config.imageSize || "2K"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1K">1K</SelectItem>
|
||||
<SelectItem value="2K">2K</SelectItem>
|
||||
<SelectItem value="4K">4K</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>默认系统提示词</Label>
|
||||
<Textarea
|
||||
className="min-h-[140px]"
|
||||
onChange={(event) =>
|
||||
updateConfig({ defaultPrompt: event.target.value })
|
||||
}
|
||||
value={config.defaultPrompt}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>负向提示词</Label>
|
||||
<Textarea
|
||||
className="min-h-[90px]"
|
||||
onChange={(event) =>
|
||||
updateConfig({ negativePrompt: event.target.value })
|
||||
}
|
||||
value={config.negativePrompt}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle>分类与按钮</CardTitle>
|
||||
<CardDescription>
|
||||
分类和按钮会按顺序显示在前台,按钮提示词只在服务端参与生成。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={addCategory}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加分类
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{config.categories.map((category, categoryIndex) => (
|
||||
<div className="rounded-xl border p-4" key={category.id}>
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 font-semibold text-primary">
|
||||
{categoryIndex + 1}
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-medium">{category.title || "未命名分类"}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{category.required ? "必填分类" : "选填分类"} · {category.options.length} 个按钮
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={categoryIndex === 0}
|
||||
onClick={() => moveCategory(category.id, -1)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
上移
|
||||
</Button>
|
||||
<Button
|
||||
disabled={categoryIndex === config.categories.length - 1}
|
||||
onClick={() => moveCategory(category.id, 1)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
下移
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => removeCategory(category.id)}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除分类
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>分类名称</Label>
|
||||
<Input
|
||||
onChange={(event) =>
|
||||
updateCategory(category.id, { title: event.target.value })
|
||||
}
|
||||
value={category.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>分类 ID</Label>
|
||||
<Input
|
||||
onChange={(event) =>
|
||||
updateCategory(category.id, { id: event.target.value })
|
||||
}
|
||||
value={category.id}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>分类说明</Label>
|
||||
<Input
|
||||
onChange={(event) =>
|
||||
updateCategory(category.id, {
|
||||
description: event.target.value,
|
||||
})
|
||||
}
|
||||
value={category.description || ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>最少选择</Label>
|
||||
<Input
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
updateCategory(category.id, {
|
||||
minSelections: Number(event.target.value) || 0,
|
||||
})
|
||||
}
|
||||
type="number"
|
||||
value={category.minSelections}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>最多选择</Label>
|
||||
<Input
|
||||
min={1}
|
||||
onChange={(event) =>
|
||||
updateCategory(category.id, {
|
||||
maxSelections: Number(event.target.value) || 1,
|
||||
})
|
||||
}
|
||||
type="number"
|
||||
value={category.maxSelections}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="flex items-center justify-between rounded-lg border p-3">
|
||||
<span className="text-sm">必填</span>
|
||||
<Switch
|
||||
checked={category.required}
|
||||
onCheckedChange={(required) =>
|
||||
updateCategory(category.id, { required })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between rounded-lg border p-3">
|
||||
<span className="text-sm">启用</span>
|
||||
<Switch
|
||||
checked={category.enabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
updateCategory(category.id, { enabled })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm">按钮配置</h3>
|
||||
<Button onClick={() => addOption(category.id)} size="sm" variant="outline">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加按钮
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{category.options.map((option) => (
|
||||
<div className="rounded-lg border bg-muted/20 p-4" key={option.id}>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wand2 className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium text-sm">
|
||||
{option.label || "未命名按钮"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={option.enabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
updateOption(category.id, option.id, { enabled })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeOption(category.id, option.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>前台按钮名</Label>
|
||||
<Input
|
||||
onChange={(event) =>
|
||||
updateOption(category.id, option.id, {
|
||||
label: event.target.value,
|
||||
})
|
||||
}
|
||||
value={option.label}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>按钮 ID</Label>
|
||||
<Input
|
||||
onChange={(event) =>
|
||||
updateOption(category.id, option.id, {
|
||||
id: event.target.value,
|
||||
})
|
||||
}
|
||||
value={option.id}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>JSON Key</Label>
|
||||
<Input
|
||||
onChange={(event) =>
|
||||
updateOption(category.id, option.id, {
|
||||
jsonKey: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={category.id}
|
||||
value={option.jsonKey || ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>说明</Label>
|
||||
<Input
|
||||
onChange={(event) =>
|
||||
updateOption(category.id, option.id, {
|
||||
description: event.target.value,
|
||||
})
|
||||
}
|
||||
value={option.description || ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>隐藏提示词</Label>
|
||||
<Textarea
|
||||
className="min-h-[110px]"
|
||||
onChange={(event) =>
|
||||
updateOption(category.id, option.id, {
|
||||
prompt: event.target.value,
|
||||
})
|
||||
}
|
||||
value={option.prompt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@ -59,6 +59,10 @@ import {
|
||||
DINGTALK_NOTIFICATION_DRAFT_CONFIG_KEY,
|
||||
normalizeDingTalkNotificationDraftConfig,
|
||||
} from "@/lib/notifications/dingtalk-notification-draft-config";
|
||||
import {
|
||||
STYLE_PRO_CONFIG_KEY,
|
||||
normalizeStyleProConfig,
|
||||
} from "@/lib/style-pro/config";
|
||||
|
||||
const settingSchema = z.object({
|
||||
key: z.string(),
|
||||
@ -118,6 +122,12 @@ export async function GET(request: Request) {
|
||||
setting: setting ? { ...setting, value } : { key, value },
|
||||
});
|
||||
}
|
||||
if (key === STYLE_PRO_CONFIG_KEY) {
|
||||
const value = normalizeStyleProConfig(setting?.value);
|
||||
return Response.json({
|
||||
setting: setting ? { ...setting, value } : { key, value },
|
||||
});
|
||||
}
|
||||
return Response.json({ setting });
|
||||
}
|
||||
|
||||
@ -260,6 +270,10 @@ export async function POST(request: Request) {
|
||||
nextValue = normalizeDingTalkNotificationDraftConfig(value);
|
||||
}
|
||||
|
||||
if (key === STYLE_PRO_CONFIG_KEY) {
|
||||
nextValue = normalizeStyleProConfig(value);
|
||||
}
|
||||
|
||||
if (key === THIRD_PARTY_SQLSERVER_CONFIG_KEY) {
|
||||
const existingSetting = await getSystemSetting({
|
||||
key: THIRD_PARTY_SQLSERVER_CONFIG_KEY,
|
||||
|
||||
@ -10,6 +10,9 @@ type StitchReferenceInput = {
|
||||
id: string;
|
||||
label?: string | null;
|
||||
imageUrl: string;
|
||||
previewUrl?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
candidateId?: string | null;
|
||||
sortOrder?: number | null;
|
||||
passthrough?: boolean | null;
|
||||
@ -98,10 +101,16 @@ function sanitizeReferenceInput(input: unknown, origin: string): StitchReference
|
||||
const raw = (item || {}) as Record<string, unknown>;
|
||||
const imageUrl = resolveHttpUrl(raw.imageUrl, origin);
|
||||
if (!imageUrl) return null;
|
||||
const previewUrl = resolveHttpUrl(raw.previewUrl, origin);
|
||||
const width = Number(raw.width);
|
||||
const height = Number(raw.height);
|
||||
return {
|
||||
id: String(raw.id || `reference-${index + 1}`).trim() || `reference-${index + 1}`,
|
||||
label: String(raw.label || "").trim() || `参考图 ${index + 1}`,
|
||||
imageUrl,
|
||||
previewUrl: previewUrl || null,
|
||||
width: Number.isFinite(width) && width > 0 ? Math.round(width) : null,
|
||||
height: Number.isFinite(height) && height > 0 ? Math.round(height) : null,
|
||||
candidateId: String(raw.candidateId || "").trim() || null,
|
||||
sortOrder: Number.isFinite(Number(raw.sortOrder)) ? Number(raw.sortOrder) : index,
|
||||
passthrough: raw.passthrough === true,
|
||||
@ -217,24 +226,44 @@ async function prepareStitch(params: {
|
||||
requestOrigin: string;
|
||||
cookie: string;
|
||||
}) {
|
||||
const sharp = (await import("sharp")).default;
|
||||
let sharp: ((input: Buffer, options?: import("sharp").SharpOptions) => import("sharp").Sharp) | null = null;
|
||||
const fetched = await runBoundedParallel(
|
||||
params.references,
|
||||
FETCH_CONCURRENCY,
|
||||
async (card, index) => {
|
||||
try {
|
||||
if (card.width && card.height) {
|
||||
return {
|
||||
ok: true as const,
|
||||
card,
|
||||
index,
|
||||
width: card.width,
|
||||
height: card.height,
|
||||
};
|
||||
}
|
||||
const buffer = await fetchImageBuffer(
|
||||
buildReferenceFetchUrl(card.imageUrl),
|
||||
params.requestOrigin,
|
||||
params.cookie,
|
||||
);
|
||||
sharp = sharp || (await import("sharp")).default;
|
||||
const metadata = await sharp(buffer, { failOn: "none" }).rotate().metadata();
|
||||
const width = metadata.width || 0;
|
||||
const height = metadata.height || 0;
|
||||
if (!width || !height) {
|
||||
throw new Error("无法读取图片尺寸");
|
||||
}
|
||||
return { ok: true as const, card, index, buffer, width, height };
|
||||
return {
|
||||
ok: true as const,
|
||||
card: {
|
||||
...card,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
index,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
@ -432,6 +461,9 @@ async function applyStitch(params: {
|
||||
id: `detail-stitch-slice-${Date.now()}-${segment.index + 1}`,
|
||||
label: `拼合切片 ${segment.index + 1}`,
|
||||
imageUrl: upload.url,
|
||||
previewUrl: upload.url,
|
||||
width: params.width,
|
||||
height: segmentHeight,
|
||||
candidateId: sourceSegment?.card.candidateId || null,
|
||||
sortOrder: segment.index,
|
||||
passthrough: passthroughSet.has(segment.index),
|
||||
@ -474,10 +506,13 @@ export async function POST(request: NextRequest) {
|
||||
const raw = (item || {}) as Record<string, unknown>;
|
||||
const rawCard = (raw.card || {}) as Record<string, unknown>;
|
||||
const imageUrl = resolveHttpUrl(rawCard.imageUrl, requestOrigin);
|
||||
const previewUrl = resolveHttpUrl(rawCard.previewUrl, requestOrigin);
|
||||
const top = Math.round(Number(raw.top));
|
||||
const segmentHeight = Math.round(Number(raw.height));
|
||||
const sourceWidth = Math.round(Number(raw.sourceWidth));
|
||||
const sourceHeight = Math.round(Number(raw.sourceHeight));
|
||||
const cardWidth = Math.round(Number(rawCard.width));
|
||||
const cardHeight = Math.round(Number(rawCard.height));
|
||||
if (!imageUrl || !Number.isFinite(top) || !Number.isFinite(segmentHeight)) {
|
||||
return null;
|
||||
}
|
||||
@ -490,6 +525,9 @@ export async function POST(request: NextRequest) {
|
||||
id: String(rawCard.id || "").trim() || `source-${top}`,
|
||||
label: String(rawCard.label || "").trim() || "参考图",
|
||||
imageUrl,
|
||||
previewUrl: previewUrl || null,
|
||||
width: Number.isFinite(cardWidth) && cardWidth > 0 ? cardWidth : null,
|
||||
height: Number.isFinite(cardHeight) && cardHeight > 0 ? cardHeight : null,
|
||||
candidateId: String(rawCard.candidateId || "").trim() || null,
|
||||
sortOrder: Number.isFinite(Number(rawCard.sortOrder)) ? Number(rawCard.sortOrder) : null,
|
||||
passthrough: rawCard.passthrough === true,
|
||||
|
||||
@ -369,6 +369,7 @@ function buildReferenceItems(params: {
|
||||
id: `${params.candidate.id}-${params.target}-${index + 1}`,
|
||||
label: `${prefix} ${targetLabel} ${String(index + 1).padStart(2, "0")}`,
|
||||
imageUrl: url,
|
||||
previewUrl: url,
|
||||
candidateId: params.candidate.id,
|
||||
sortOrder: index,
|
||||
passthrough: false,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import {
|
||||
DEFAULT_BFL_MODEL,
|
||||
DEFAULT_REPLICATE_BASE_URL,
|
||||
@ -27,6 +28,8 @@ type GeneratePayload = {
|
||||
prompt?: string;
|
||||
seed?: number;
|
||||
imageSize?: "square" | "square_hd";
|
||||
aspectRatio?: "1:1" | "4:3" | "2:3";
|
||||
generationMode?: "manual" | "auto";
|
||||
steps?: number;
|
||||
cfgScale?: number;
|
||||
strength?: number;
|
||||
@ -65,6 +68,10 @@ type FalSubmitResult = {
|
||||
|
||||
const FALLBACK_REALTIME_PROMPT =
|
||||
"严格按照输入草图生成:保持主体轮廓、位置、比例和数量一致,只做材质与光影细化;禁止改变构图,禁止新增无关背景纹理。";
|
||||
const REALTIME_GENERATION_COSTS = {
|
||||
manual: 1,
|
||||
auto: 1,
|
||||
} as const;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
@ -79,6 +86,82 @@ function resolveSquareSize(
|
||||
return clamp(Math.round(capped / 64) * 64, 512, 1536);
|
||||
}
|
||||
|
||||
function normalizeRealtimeGenerationMode(
|
||||
raw: GeneratePayload["generationMode"]
|
||||
): keyof typeof REALTIME_GENERATION_COSTS {
|
||||
return raw === "auto" ? "auto" : "manual";
|
||||
}
|
||||
|
||||
function normalizeRealtimeAspectRatio(
|
||||
raw: GeneratePayload["aspectRatio"]
|
||||
): NonNullable<GeneratePayload["aspectRatio"]> {
|
||||
if (raw === "4:3" || raw === "2:3") return raw;
|
||||
return "1:1";
|
||||
}
|
||||
|
||||
function resolveRatioDimensions(
|
||||
widthUnits: number,
|
||||
heightUnits: number,
|
||||
maxEdge: number
|
||||
) {
|
||||
const dominantUnits = Math.max(widthUnits, heightUnits);
|
||||
const unit = Math.max(
|
||||
128,
|
||||
Math.floor(maxEdge / dominantUnits / 64) * 64
|
||||
);
|
||||
return {
|
||||
width: clamp(widthUnits * unit, 256, 1536),
|
||||
height: clamp(heightUnits * unit, 256, 1536),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRealtimeDimensions(
|
||||
imageSize: "square" | "square_hd" | undefined,
|
||||
aspectRatio: GeneratePayload["aspectRatio"],
|
||||
maxInputResolution: number
|
||||
) {
|
||||
const baseSize = resolveSquareSize(imageSize, maxInputResolution);
|
||||
const normalizedRatio = normalizeRealtimeAspectRatio(aspectRatio);
|
||||
|
||||
if (normalizedRatio === "4:3") {
|
||||
const dimensions = resolveRatioDimensions(4, 3, baseSize);
|
||||
return {
|
||||
...dimensions,
|
||||
aspectRatio: normalizedRatio,
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedRatio === "2:3") {
|
||||
const dimensions = resolveRatioDimensions(2, 3, baseSize);
|
||||
return {
|
||||
...dimensions,
|
||||
aspectRatio: normalizedRatio,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: baseSize,
|
||||
height: baseSize,
|
||||
aspectRatio: normalizedRatio,
|
||||
};
|
||||
}
|
||||
|
||||
function getRealtimePointsChargeNote(params: {
|
||||
mode: keyof typeof REALTIME_GENERATION_COSTS;
|
||||
requestId?: string;
|
||||
model?: string;
|
||||
}) {
|
||||
const modeLabel = params.mode === "auto" ? "自动" : "手动";
|
||||
const requestLabel = String(params.requestId || "").trim();
|
||||
const modelLabel = String(params.model || "").trim();
|
||||
return [
|
||||
`实时绘画${modeLabel}生成消耗积分`,
|
||||
requestLabel || modelLabel
|
||||
? `(${[requestLabel, modelLabel].filter(Boolean).join(" / ")})`
|
||||
: "",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function resolveSeed(seed: unknown) {
|
||||
const parsed = Number(seed);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
@ -531,7 +614,11 @@ async function generateWithProvider(params: {
|
||||
provider: RealtimeProviderConfig;
|
||||
imagePrompt: string;
|
||||
prompt: string;
|
||||
size: number;
|
||||
dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio: "1:1" | "4:3" | "2:3";
|
||||
};
|
||||
seed?: number;
|
||||
body: GeneratePayload;
|
||||
config: RealtimeCanvasConfig;
|
||||
@ -578,8 +665,8 @@ async function generateWithProvider(params: {
|
||||
selectedImageField = imageFieldCandidates[index] as "input_image" | "image_prompt";
|
||||
const requestBody: Record<string, unknown> = {
|
||||
prompt: params.prompt,
|
||||
width: params.size,
|
||||
height: params.size,
|
||||
width: params.dimensions.width,
|
||||
height: params.dimensions.height,
|
||||
output_format: "jpeg",
|
||||
safety_tolerance: 2,
|
||||
[selectedImageField]: params.imagePrompt,
|
||||
@ -724,8 +811,27 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const maxInputResolution = clamp(Number(config.maxInputResolution || 1024), 512, 1536);
|
||||
const size = resolveSquareSize(body.imageSize, maxInputResolution);
|
||||
const dimensions = resolveRealtimeDimensions(
|
||||
body.imageSize,
|
||||
body.aspectRatio,
|
||||
maxInputResolution
|
||||
);
|
||||
const seed = resolveSeed(body.seed);
|
||||
const generationMode = normalizeRealtimeGenerationMode(body.generationMode);
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: REALTIME_GENERATION_COSTS[generationMode],
|
||||
reason: "realtime_canvas_generation",
|
||||
modelId: providers[0]?.model || null,
|
||||
note: getRealtimePointsChargeNote({
|
||||
mode: generationMode,
|
||||
requestId: body.requestId,
|
||||
model: providers[0]?.model,
|
||||
}),
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return Response.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const rawPrompt = sanitizeText(String(body.prompt || "").trim()) || FALLBACK_REALTIME_PROMPT;
|
||||
const negativePrompt =
|
||||
@ -783,7 +889,7 @@ export async function POST(request: Request) {
|
||||
provider,
|
||||
imagePrompt: providerImageInput,
|
||||
prompt: positivePrompt,
|
||||
size,
|
||||
dimensions,
|
||||
seed,
|
||||
body,
|
||||
config,
|
||||
@ -799,6 +905,27 @@ export async function POST(request: Request) {
|
||||
warningParts.push("主实时服务异常,已自动切换到备用服务。");
|
||||
}
|
||||
const warning = warningParts.filter(Boolean).join(" ");
|
||||
const chargeResult = await pointsCharge
|
||||
.consume({
|
||||
modelId: result.provider.model,
|
||||
note: getRealtimePointsChargeNote({
|
||||
mode: generationMode,
|
||||
requestId: body.requestId,
|
||||
model: result.provider.model,
|
||||
}),
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (pointsCharge.chargeable && !chargeResult?.ok) {
|
||||
return Response.json(
|
||||
{
|
||||
code: "INSUFFICIENT_POINTS",
|
||||
message: `积分扣减失败,本次实时绘画需要 ${pointsCharge.unitCost} 积分,请确认余额后重试。`,
|
||||
expectedCost: pointsCharge.unitCost,
|
||||
},
|
||||
{ status: 402 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[RealtimeBFL] frame success", {
|
||||
userId: session.user.id,
|
||||
@ -810,7 +937,12 @@ export async function POST(request: Request) {
|
||||
hasPrompt: Boolean(rawPrompt && rawPrompt !== FALLBACK_REALTIME_PROMPT),
|
||||
imageField: result.imageField,
|
||||
qualityProfile: body.qualityProfile || "refine",
|
||||
size,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
aspectRatio: dimensions.aspectRatio,
|
||||
generationMode,
|
||||
pointsCost: pointsCharge.unitCost,
|
||||
pointsCharged: Boolean(chargeResult && !chargeResult.skipped),
|
||||
roi: body.roi || null,
|
||||
hasMask: Boolean(body.maskImageUrl),
|
||||
warning: warning || null,
|
||||
@ -829,6 +961,12 @@ export async function POST(request: Request) {
|
||||
? `data:image/jpeg;base64,${result.imageUrl}`
|
||||
: undefined,
|
||||
imageUrl: /^https?:\/\//i.test(result.imageUrl) ? result.imageUrl : undefined,
|
||||
cost: pointsCharge.unitCost,
|
||||
pointsCost: pointsCharge.unitCost,
|
||||
pointsCharged: Boolean(chargeResult && !chargeResult.skipped),
|
||||
aspectRatio: dimensions.aspectRatio,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
warning: warning || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
30
app/api/style-pro/config/route.ts
Normal file
30
app/api/style-pro/config/route.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
STYLE_PRO_CONFIG_KEY,
|
||||
normalizeStyleProConfig,
|
||||
toPublicStyleProConfig,
|
||||
} from "@/lib/style-pro/config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const setting = await getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(
|
||||
() => null
|
||||
);
|
||||
const config = normalizeStyleProConfig(setting?.value);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
config: toPublicStyleProConfig(config),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Style Pro Config] Failed:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "获取风格化 PRO 配置失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
238
app/api/style-pro/generate/route.ts
Normal file
238
app/api/style-pro/generate/route.ts
Normal file
@ -0,0 +1,238 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
STYLE_PRO_CONFIG_KEY,
|
||||
type StyleProCategory,
|
||||
type StyleProOption,
|
||||
normalizeStyleProConfig,
|
||||
} from "@/lib/style-pro/config";
|
||||
|
||||
export const maxDuration = 120;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const generateRequestSchema = z.object({
|
||||
canvasId: z.string().trim().min(1).max(160).optional(),
|
||||
sourceImageUrl: z.string().trim().min(1).max(4000),
|
||||
selections: z.record(z.array(z.string().trim().min(1).max(120)).max(20)),
|
||||
});
|
||||
|
||||
type SelectedOption = {
|
||||
category: StyleProCategory;
|
||||
option: StyleProOption;
|
||||
};
|
||||
|
||||
function isDisallowedImageUrl(value: string) {
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
return trimmed.startsWith("data:") || trimmed.startsWith("javascript:");
|
||||
}
|
||||
|
||||
function buildSelectionPayload(selectedOptions: SelectedOption[]) {
|
||||
return selectedOptions.map(({ category, option }) => ({
|
||||
categoryId: category.id,
|
||||
categoryTitle: category.title,
|
||||
jsonKey: option.jsonKey || category.id,
|
||||
optionId: option.id,
|
||||
optionLabel: option.label,
|
||||
prompt: option.prompt,
|
||||
}));
|
||||
}
|
||||
|
||||
function composePrompt({
|
||||
defaultPrompt,
|
||||
selectedOptions,
|
||||
}: {
|
||||
defaultPrompt: string;
|
||||
selectedOptions: SelectedOption[];
|
||||
}) {
|
||||
const selectionPayload = buildSelectionPayload(selectedOptions);
|
||||
const groupedPrompt = selectionPayload.reduce<Record<string, string[]>>(
|
||||
(acc, item) => {
|
||||
const key = item.jsonKey || item.categoryId;
|
||||
acc[key] = acc[key] || [];
|
||||
acc[key].push(item.prompt);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return [
|
||||
defaultPrompt,
|
||||
"请严格依据以下风格化 PRO 选择执行最终图片生成。用户不可见配置 JSON:",
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: "style-pro-final",
|
||||
selections: selectionPayload,
|
||||
promptByKey: groupedPrompt,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function validateSelections(
|
||||
categories: StyleProCategory[],
|
||||
rawSelections: Record<string, string[]>
|
||||
) {
|
||||
const categoryById = new Map(categories.map((category) => [category.id, category]));
|
||||
const selectedOptions: SelectedOption[] = [];
|
||||
const normalizedSelections: Record<string, string[]> = {};
|
||||
|
||||
for (const [categoryId, optionIds] of Object.entries(rawSelections)) {
|
||||
const category = categoryById.get(categoryId);
|
||||
if (!category) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `无效分类:${categoryId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const enabledOptions = new Map(
|
||||
category.options
|
||||
.filter((option) => option.enabled)
|
||||
.map((option) => [option.id, option])
|
||||
);
|
||||
const uniqueOptionIds = Array.from(new Set(optionIds));
|
||||
if (uniqueOptionIds.length > category.maxSelections) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `${category.title} 最多选择 ${category.maxSelections} 项`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const optionId of uniqueOptionIds) {
|
||||
const option = enabledOptions.get(optionId);
|
||||
if (!option) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `无效选项:${category.title} / ${optionId}`,
|
||||
};
|
||||
}
|
||||
selectedOptions.push({ category, option });
|
||||
}
|
||||
normalizedSelections[category.id] = uniqueOptionIds;
|
||||
}
|
||||
|
||||
const missingCategory = categories.find((category) => {
|
||||
if (!category.required && category.minSelections <= 0) return false;
|
||||
const selectedCount = normalizedSelections[category.id]?.length || 0;
|
||||
return selectedCount < Math.max(category.required ? 1 : 0, category.minSelections);
|
||||
});
|
||||
if (missingCategory) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `请完成必选项:${missingCategory.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
selectedOptions,
|
||||
normalizedSelections,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = generateRequestSchema.parse(await request.json());
|
||||
if (isDisallowedImageUrl(body.sourceImageUrl)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请使用已上传的图片链接生成" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const setting = await getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(
|
||||
() => null
|
||||
);
|
||||
const config = normalizeStyleProConfig(setting?.value);
|
||||
if (!config.enabled) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "风格化 PRO 暂未启用" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const enabledCategories = config.categories
|
||||
.filter((category) => category.enabled)
|
||||
.map((category) => ({
|
||||
...category,
|
||||
options: category.options.filter((option) => option.enabled),
|
||||
}))
|
||||
.filter((category) => category.options.length > 0);
|
||||
const selectionResult = validateSelections(enabledCategories, body.selections);
|
||||
if (!selectionResult.ok) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: selectionResult.error },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const prompt = composePrompt({
|
||||
defaultPrompt: config.defaultPrompt,
|
||||
selectedOptions: selectionResult.selectedOptions,
|
||||
});
|
||||
const result = await generateCanvasImage({
|
||||
prompt,
|
||||
negativePrompt: config.negativePrompt || undefined,
|
||||
model: config.modelId || undefined,
|
||||
aspectRatio: config.aspectRatio || "auto",
|
||||
imageSize: config.imageSize || "2K",
|
||||
imageCount: 1,
|
||||
referenceImage: body.sourceImageUrl,
|
||||
ownerKey: session.user.id,
|
||||
debugLabel: "style-pro",
|
||||
debugMeta: {
|
||||
canvasId: body.canvasId || null,
|
||||
selectedOptionCount: selectionResult.selectedOptions.length,
|
||||
},
|
||||
});
|
||||
const image = result.images[0];
|
||||
if (!image?.originalUrl) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "生成结果为空" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
imageUrl: image.originalUrl,
|
||||
thumbnailUrl: image.thumbnailUrl || image.originalUrl,
|
||||
model: result.model,
|
||||
modelRef: result.modelRef,
|
||||
selections: selectionResult.normalizedSelections,
|
||||
optionLabels: selectionResult.selectedOptions.map(
|
||||
({ category, option }) => `${category.title}:${option.label}`
|
||||
),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Style Pro Generate] Failed:", error);
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请求参数不完整" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "风格化 PRO 生成失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,6 +151,7 @@ import {
|
||||
import {
|
||||
getCanvasToolbarItem,
|
||||
getDefaultCanvasToolbarItem,
|
||||
isCanvasToolbarItemEnabled,
|
||||
normalizeCanvasToolbarConfig,
|
||||
type CanvasToolbarConfig,
|
||||
type CanvasToolbarItemConfig,
|
||||
@ -159,6 +160,7 @@ import {
|
||||
import {
|
||||
buildAgentWorkflowComposerContext,
|
||||
} from "@/lib/agents/workflow-composer";
|
||||
import { normalizeStyleProPersistedState } from "@/lib/style-pro/config";
|
||||
import {
|
||||
createEmptyAgentWorkflowMaterialSelections,
|
||||
normalizeAgentWorkflowMaterialSelections,
|
||||
@ -952,6 +954,7 @@ function normalizeImportedCanvasState(payload: unknown): {
|
||||
workflowState: isPlainObject(rawCanvasData.workflowState)
|
||||
? (rawCanvasData.workflowState as unknown as CanvasState["workflowState"])
|
||||
: undefined,
|
||||
styleProState: normalizeStyleProPersistedState(rawCanvasData.styleProState),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1968,6 +1971,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
const isBatchProcessingEnabled =
|
||||
workflowPageFeatureSettings.batchProcessingEnabled !== false &&
|
||||
sceneFusionConfigData?.enabled !== false;
|
||||
const isStyleProToolbarEnabled = isCanvasToolbarItemEnabled(
|
||||
canvasToolbarConfig,
|
||||
"stylePro"
|
||||
);
|
||||
const sanitizedWorkflowSelections = useMemo(
|
||||
() =>
|
||||
sanitizeAgentWorkflowSelections({
|
||||
@ -10207,6 +10214,35 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{isStyleProToolbarEnabled && !isVideoMode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
isVideoMode
|
||||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||||
: "h-9 w-9 rounded-xl"
|
||||
)}
|
||||
onClick={() => router.push(`/canvas/${id}/style-pro`)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Sparkles}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"stylePro"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{getCanvasToolbarItem(canvasToolbarConfig, "stylePro").label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{isBatchProcessingEnabled ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
128
app/canvas/[id]/style-pro/page.tsx
Normal file
128
app/canvas/[id]/style-pro/page.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { StyleProWorkspace } from "@/components/style-pro/StyleProWorkspace";
|
||||
import { canvasService } from "@/services/canvas";
|
||||
import { canvasSelectors, useCanvasStore } from "@/store/canvas";
|
||||
import type {
|
||||
StyleProHistoryItem,
|
||||
StyleProPersistedState,
|
||||
} from "@/lib/style-pro/config";
|
||||
|
||||
export default function CanvasStyleProPage() {
|
||||
const router = useRouter();
|
||||
const routeParams = useParams();
|
||||
const id = String(routeParams?.id || "");
|
||||
|
||||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||||
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
|
||||
const nodes = useCanvasStore(canvasSelectors.nodes);
|
||||
const styleProState = useCanvasStore((state) => state.styleProState);
|
||||
const addNode = useCanvasStore((state) => state.addNode);
|
||||
const selectNodes = useCanvasStore((state) => state.selectNodes);
|
||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||
|
||||
const selectedImageNode =
|
||||
selectedNodeIds.length === 1
|
||||
? nodes.find(
|
||||
(node) => node.id === selectedNodeIds[0] && node.type === "image"
|
||||
)
|
||||
: null;
|
||||
const selectedImageUrl = selectedImageNode
|
||||
? String((selectedImageNode.data as any)?.src || "").trim()
|
||||
: "";
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
router.push(`/canvas/${id}`);
|
||||
}, [id, router]);
|
||||
|
||||
const handlePersistState = useCallback(
|
||||
async (nextState: StyleProPersistedState) => {
|
||||
useCanvasStore.getState().setStyleProState(nextState);
|
||||
const result = await canvasService.saveCanvasState(
|
||||
id,
|
||||
useCanvasStore.getState().getCanvasState()
|
||||
);
|
||||
if (!result.success) {
|
||||
console.error("[CanvasStyleProPage] 保存风格化 PRO 状态失败:", result.error);
|
||||
}
|
||||
},
|
||||
[id]
|
||||
);
|
||||
|
||||
const handleAddToCanvas = useCallback(
|
||||
async (item: StyleProHistoryItem) => {
|
||||
if (!item.resultImageUrl) {
|
||||
toast.error("没有可添加到画布的结果图");
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportX = viewport.x || 0;
|
||||
const viewportY = viewport.y || 0;
|
||||
const viewportZoom = viewport.zoom || 1;
|
||||
const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280;
|
||||
const stageHeight = typeof window !== "undefined" ? window.innerHeight : 720;
|
||||
const naturalWidth = Number(item.width || 0);
|
||||
const naturalHeight = Number(item.height || 0);
|
||||
const hasNaturalSize = naturalWidth > 0 && naturalHeight > 0;
|
||||
const maxPreviewEdge = 460;
|
||||
const fallbackSize = 360;
|
||||
const scale = hasNaturalSize
|
||||
? Math.min(1, maxPreviewEdge / Math.max(naturalWidth, naturalHeight))
|
||||
: 1;
|
||||
const targetWidth = hasNaturalSize
|
||||
? Math.max(160, Math.round(naturalWidth * scale))
|
||||
: fallbackSize;
|
||||
const targetHeight = hasNaturalSize
|
||||
? Math.max(160, Math.round(naturalHeight * scale))
|
||||
: fallbackSize;
|
||||
const x =
|
||||
(stageWidth * 0.46 - viewportX) / viewportZoom - targetWidth / 2;
|
||||
const y =
|
||||
(stageHeight * 0.46 - viewportY) / viewportZoom - targetHeight / 2;
|
||||
|
||||
const nodeId = addNode({
|
||||
type: "image",
|
||||
position: { x, y },
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
src: item.resultImageUrl,
|
||||
originalSrc: item.sourceImageUrl,
|
||||
status: "completed",
|
||||
title: "风格化 PRO 结果",
|
||||
prompt: item.optionLabels.join(" / ") || "风格化 PRO",
|
||||
model: item.model || "Style Pro",
|
||||
metadata: {
|
||||
compareSourceUrl: item.sourceImageUrl,
|
||||
styleProHistoryId: item.id,
|
||||
styleProSelections: item.selections,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
});
|
||||
|
||||
selectNodes([nodeId]);
|
||||
const result = await canvasService.saveCanvasState(id, getCanvasState());
|
||||
if (!result.success) {
|
||||
toast.error(result.error || "保存画布失败");
|
||||
return;
|
||||
}
|
||||
toast.success("风格化 PRO 结果已添加到画布");
|
||||
},
|
||||
[addNode, getCanvasState, id, selectNodes, viewport.x, viewport.y, viewport.zoom]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyleProWorkspace
|
||||
canvasId={id}
|
||||
initialSourceImageUrl={selectedImageUrl}
|
||||
onAddToCanvas={handleAddToCanvas}
|
||||
onBack={handleBack}
|
||||
onPersistState={handlePersistState}
|
||||
persistedState={styleProState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -24,6 +24,9 @@ import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
|
||||
|
||||
const MIN_SLICE_HEIGHT = 120;
|
||||
const FAST_PREVIEW_WIDTH = 720;
|
||||
const ESTIMATED_REFERENCE_HEIGHT = 960;
|
||||
const SEGMENT_PRELOAD_CONCURRENCY = 4;
|
||||
|
||||
type StitchSourceSegment = {
|
||||
card: AgentDetailReplacementReferenceItem;
|
||||
@ -77,11 +80,97 @@ function buildStitchPreviewImageUrl(url: string, width: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildStitchSegmentDisplayImageUrl(url: string, width: number) {
|
||||
const normalizedUrl = String(url || "").trim();
|
||||
if (!normalizedUrl) return "";
|
||||
return buildImageProxyUrl(normalizedUrl, {
|
||||
width: Math.min(Math.max(Math.round(width || FAST_PREVIEW_WIDTH), 96), 960),
|
||||
quality: 78,
|
||||
format: "jpeg",
|
||||
delivery: "display",
|
||||
});
|
||||
}
|
||||
|
||||
function getReferenceDimension(value: unknown) {
|
||||
const normalized = Math.round(Number(value));
|
||||
return Number.isFinite(normalized) && normalized > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function resolveReferencePreviewHeight(card: AgentDetailReplacementReferenceItem) {
|
||||
const width = getReferenceDimension(card.width);
|
||||
const height = getReferenceDimension(card.height);
|
||||
if (width && height) {
|
||||
return Math.max(MIN_SLICE_HEIGHT, Math.round((FAST_PREVIEW_WIDTH / width) * height));
|
||||
}
|
||||
return ESTIMATED_REFERENCE_HEIGHT;
|
||||
}
|
||||
|
||||
function buildOptimisticSnapshot(
|
||||
cards: Array<AgentDetailReplacementReferenceItem & { imageUrl: string }>
|
||||
): MergedSnapshot {
|
||||
let top = 0;
|
||||
const defaultCutLines: number[] = [];
|
||||
const sourceSegments = cards.map((card, index) => {
|
||||
const height = resolveReferencePreviewHeight(card);
|
||||
const currentTop = top;
|
||||
top += height;
|
||||
if (index < cards.length - 1) {
|
||||
defaultCutLines.push(top);
|
||||
}
|
||||
return {
|
||||
card,
|
||||
top: currentTop,
|
||||
height,
|
||||
sourceWidth: getReferenceDimension(card.width) || undefined,
|
||||
sourceHeight: getReferenceDimension(card.height) || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
previewUrl: "",
|
||||
width: FAST_PREVIEW_WIDTH,
|
||||
height: Math.max(1, top),
|
||||
defaultCutLines,
|
||||
sourceSegments,
|
||||
};
|
||||
}
|
||||
|
||||
function areCutLinesEqual(left: number[], right: number[]) {
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((value, index) => Math.round(value) === Math.round(right[index] || 0));
|
||||
}
|
||||
|
||||
function scaleCutLines(lines: number[], fromHeight: number, toHeight: number) {
|
||||
const ratio = toHeight / Math.max(1, fromHeight);
|
||||
return Array.from(
|
||||
new Set(
|
||||
lines
|
||||
.map((line) => Math.round(line * ratio))
|
||||
.filter((line) => Number.isFinite(line) && line > 0 && line < toHeight)
|
||||
)
|
||||
).sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function preloadBrowserImage(url: string) {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (typeof window === "undefined" || !url) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () => resolve();
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
interface AgentDetailReferenceStitchDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
cards: AgentDetailReplacementReferenceItem[];
|
||||
sourceLabel?: string;
|
||||
onPrepared?: (nextCards: AgentDetailReplacementReferenceItem[]) => void;
|
||||
onApply: (nextCards: AgentDetailReplacementReferenceItem[]) => void;
|
||||
}
|
||||
|
||||
@ -90,6 +179,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
onOpenChange,
|
||||
cards,
|
||||
sourceLabel = "详情参考图",
|
||||
onPrepared,
|
||||
onApply,
|
||||
}: AgentDetailReferenceStitchDialogProps) {
|
||||
const previewContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -99,6 +189,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
const [isAddingCutLine, setIsAddingCutLine] = useState(false);
|
||||
const [previewCutLineY, setPreviewCutLineY] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isPreparing, setIsPreparing] = useState(false);
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [applyProgressText, setApplyProgressText] = useState("");
|
||||
const [loadingProgressText, setLoadingProgressText] = useState("");
|
||||
@ -118,7 +209,10 @@ export function AgentDetailReferenceStitchDialog({
|
||||
const validCardLoadKey = useMemo(
|
||||
() =>
|
||||
validCards
|
||||
.map((card) => `${card.id}::${String(card.imageUrl || "").trim()}`)
|
||||
.map(
|
||||
(card) =>
|
||||
`${card.id}::${String(card.imageUrl || "").trim()}::${String(card.previewUrl || "").trim()}::${card.width || ""}x${card.height || ""}`
|
||||
)
|
||||
.join("|"),
|
||||
[validCards]
|
||||
);
|
||||
@ -166,6 +260,8 @@ export function AgentDetailReferenceStitchDialog({
|
||||
setIsAddingCutLine(false);
|
||||
setPreviewCutLineY(null);
|
||||
setLoadingProgressText("");
|
||||
setIsLoading(false);
|
||||
setIsPreparing(false);
|
||||
setWarningText(null);
|
||||
setPreviewImageLoadFailed(false);
|
||||
setPassthroughSegmentIndexes([]);
|
||||
@ -175,20 +271,27 @@ export function AgentDetailReferenceStitchDialog({
|
||||
if (setupCards.length === 0) {
|
||||
setSnapshot(null);
|
||||
setCutLines([]);
|
||||
setIsLoading(false);
|
||||
setIsPreparing(false);
|
||||
setErrorText(`当前没有可拼合的${sourceLabel}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const optimisticSnapshot = buildOptimisticSnapshot(setupCards);
|
||||
|
||||
setSnapshot(optimisticSnapshot);
|
||||
setCutLines(optimisticSnapshot.defaultCutLines);
|
||||
setPassthroughSegmentIndexes([]);
|
||||
setErrorText(null);
|
||||
setWarningText(null);
|
||||
setPreviewImageLoadFailed(true);
|
||||
setIsLoading(false);
|
||||
setIsPreparing(true);
|
||||
setLoadingProgressText(`正在后台校准 ${setupCards.length} 张参考图尺寸`);
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setErrorText(null);
|
||||
setWarningText(null);
|
||||
setPreviewImageLoadFailed(false);
|
||||
setLoadingProgressText(`正在读取 ${setupCards.length} 张参考图尺寸`);
|
||||
|
||||
const response = await fetch("/api/agent-workflow/detail-stitch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@ -229,16 +332,44 @@ export function AgentDetailReferenceStitchDialog({
|
||||
throw new Error("拼合结果无效,请稍后重试");
|
||||
}
|
||||
|
||||
setSnapshot({
|
||||
const preciseSnapshot = {
|
||||
width,
|
||||
height,
|
||||
previewUrl,
|
||||
defaultCutLines,
|
||||
sourceSegments,
|
||||
});
|
||||
};
|
||||
setSnapshot(preciseSnapshot);
|
||||
setPreviewImageLoadFailed(!previewUrl);
|
||||
setCutLines(defaultCutLines);
|
||||
setPassthroughSegmentIndexes([]);
|
||||
setCutLines((current) =>
|
||||
areCutLinesEqual(current, optimisticSnapshot.defaultCutLines)
|
||||
? defaultCutLines
|
||||
: scaleCutLines(current, optimisticSnapshot.height, height)
|
||||
);
|
||||
if (onPrepared) {
|
||||
const dimensionByCardId = new Map(
|
||||
sourceSegments.map((segment) => [
|
||||
segment.card.id,
|
||||
{
|
||||
previewUrl: segment.card.previewUrl || segment.card.imageUrl || null,
|
||||
width: segment.sourceWidth || segment.card.width || null,
|
||||
height: segment.sourceHeight || segment.card.height || null,
|
||||
},
|
||||
])
|
||||
);
|
||||
onPrepared(
|
||||
setupCards.map((card, index) => {
|
||||
const dimensions = dimensionByCardId.get(card.id);
|
||||
return {
|
||||
...card,
|
||||
sortOrder: card.sortOrder ?? index,
|
||||
previewUrl: dimensions?.previewUrl || card.previewUrl || card.imageUrl || null,
|
||||
width: dimensions?.width || card.width || null,
|
||||
height: dimensions?.height || card.height || null,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
setWarningText(
|
||||
(result.data.failed?.length || 0) > 0
|
||||
? `有 ${result.data.failed?.length || 0} 张参考图加载失败,已自动跳过异常图片。`
|
||||
@ -247,12 +378,12 @@ export function AgentDetailReferenceStitchDialog({
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
console.error("[AgentDetailReferenceStitchDialog] Failed to prepare merged image:", error);
|
||||
setSnapshot(null);
|
||||
setCutLines([]);
|
||||
setErrorText(error instanceof Error ? error.message : "拼合参考图失败");
|
||||
setWarningText(
|
||||
`精准尺寸校准失败,已先使用快速预览:${error instanceof Error ? error.message : "拼合参考图失败"}`
|
||||
);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
setIsPreparing(false);
|
||||
setLoadingProgressText("");
|
||||
}
|
||||
}
|
||||
@ -263,7 +394,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, setupCards, sourceLabel]);
|
||||
}, [onPrepared, open, setupCards, sourceLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
const validIndexes = new Set(sliceSegments.map((segment) => segment.index));
|
||||
@ -272,6 +403,46 @@ export function AgentDetailReferenceStitchDialog({
|
||||
);
|
||||
}, [sliceSegments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !snapshot || !useVirtualPreview) return;
|
||||
const urls = Array.from(
|
||||
new Set(
|
||||
snapshot.sourceSegments
|
||||
.map((segment) => {
|
||||
const originalImageUrl = String(segment.card.imageUrl || "").trim();
|
||||
const segmentImageUrl =
|
||||
String(segment.card.previewUrl || "").trim() || originalImageUrl;
|
||||
return buildStitchSegmentDisplayImageUrl(segmentImageUrl, snapshot.width);
|
||||
})
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
if (urls.length <= 2) return;
|
||||
|
||||
let cancelled = false;
|
||||
let cursor = 2;
|
||||
const timer = window.setTimeout(() => {
|
||||
const worker = async () => {
|
||||
while (!cancelled && cursor < urls.length) {
|
||||
const currentIndex = cursor;
|
||||
cursor += 1;
|
||||
await preloadBrowserImage(urls[currentIndex] || "");
|
||||
}
|
||||
};
|
||||
void Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(SEGMENT_PRELOAD_CONCURRENCY, Math.max(urls.length - 2, 0)) },
|
||||
() => worker()
|
||||
)
|
||||
);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [open, snapshot, useVirtualPreview]);
|
||||
|
||||
const resolveInsertCutLineY = useCallback(
|
||||
(rawY: number) => {
|
||||
if (!snapshot) return null;
|
||||
@ -590,7 +761,9 @@ export function AgentDetailReferenceStitchDialog({
|
||||
清空切线
|
||||
</Button>
|
||||
<div className="text-xs text-white/42">
|
||||
默认切线已按原始详情页边界生成,可直接拖动横线微调。
|
||||
{isPreparing
|
||||
? loadingProgressText || "正在后台校准参考图尺寸,完成后会自动修正切线。"
|
||||
: "默认切线已按原始详情页边界生成,可直接拖动横线微调。"}
|
||||
{isAddingCutLine ? " 当前已进入加线模式,移动鼠标预览位置后点击长图即可落线。" : ""}
|
||||
</div>
|
||||
</div>
|
||||
@ -629,29 +802,35 @@ export function AgentDetailReferenceStitchDialog({
|
||||
{useVirtualPreview ? (
|
||||
<div className="w-full bg-white">
|
||||
{snapshot.sourceSegments.map((segment, index) => {
|
||||
const segmentImageUrl = String(segment.card.imageUrl || "").trim();
|
||||
const originalImageUrl = String(segment.card.imageUrl || "").trim();
|
||||
const segmentImageUrl =
|
||||
String(segment.card.previewUrl || "").trim() || originalImageUrl;
|
||||
return (
|
||||
<div
|
||||
key={`${segment.card.id || "segment"}-${index}-${segment.top}`}
|
||||
className="relative overflow-hidden bg-white"
|
||||
style={{
|
||||
aspectRatio: `${snapshot.width} / ${Math.max(segment.height, 1)}`,
|
||||
}}
|
||||
>
|
||||
style={{
|
||||
aspectRatio: `${snapshot.width} / ${Math.max(segment.height, 1)}`,
|
||||
contentVisibility: index > 4 ? "auto" : undefined,
|
||||
containIntrinsicSize:
|
||||
index > 4 ? `${Math.max(segment.height, 1)}px` : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-neutral-400">
|
||||
参考图 {index + 1} 加载失败
|
||||
</div>
|
||||
{segmentImageUrl ? (
|
||||
<img
|
||||
src={buildStitchPreviewImageUrl(segmentImageUrl, snapshot.width)}
|
||||
alt={`拼合分段预览 ${index + 1}`}
|
||||
className="relative z-10 block h-full w-full select-none object-fill"
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading={index > 2 ? "lazy" : "eager"}
|
||||
onError={handleSegmentPreviewImageError(segmentImageUrl)}
|
||||
/>
|
||||
) : null}
|
||||
{segmentImageUrl ? (
|
||||
<img
|
||||
src={buildStitchSegmentDisplayImageUrl(segmentImageUrl, snapshot.width)}
|
||||
alt={`拼合分段预览 ${index + 1}`}
|
||||
className="relative z-10 block h-full w-full select-none object-fill"
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading={index > 2 ? "lazy" : "eager"}
|
||||
fetchPriority={index < 2 ? "high" : "low"}
|
||||
onError={handleSegmentPreviewImageError(originalImageUrl)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -800,7 +979,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
disabled={isLoading || isApplying || !snapshot || sliceSegments.length === 0}
|
||||
disabled={isLoading || isPreparing || isApplying || !snapshot || sliceSegments.length === 0}
|
||||
className="h-11 w-full rounded-[18px] bg-emerald-500 text-black hover:bg-emerald-400 disabled:opacity-50"
|
||||
>
|
||||
{isApplying ? (
|
||||
@ -808,6 +987,11 @@ export function AgentDetailReferenceStitchDialog({
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{applyProgressText || "正在应用切割"}
|
||||
</>
|
||||
) : isPreparing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
正在校准尺寸
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Scissors className="mr-2 h-4 w-4" />
|
||||
|
||||
@ -744,6 +744,7 @@ function buildReferenceCardsFromCandidate(params: {
|
||||
id: `${candidate.id}-${params.target}-${index + 1}`,
|
||||
label: `${prefix} ${targetLabel} ${String(index + 1).padStart(2, "0")}`,
|
||||
imageUrl: url,
|
||||
previewUrl: url,
|
||||
candidateId: candidate.id,
|
||||
sortOrder: index,
|
||||
passthrough: false,
|
||||
@ -754,6 +755,28 @@ function buildReferenceCardsFromCandidate(params: {
|
||||
}));
|
||||
}
|
||||
|
||||
function readLocalImageDimensions(file: File): Promise<{ width: number | null; height: number | null }> {
|
||||
if (typeof window === "undefined") {
|
||||
return Promise.resolve({ width: null, height: null });
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
const finish = (width: number | null, height: number | null) => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
resolve({ width, height });
|
||||
};
|
||||
image.onload = () => {
|
||||
finish(image.naturalWidth || null, image.naturalHeight || null);
|
||||
};
|
||||
image.onerror = () => {
|
||||
finish(null, null);
|
||||
};
|
||||
image.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function formatCandidateMeta(candidate: AgentModeReferenceCandidate, target: AgentTaskDetailReferenceSource) {
|
||||
const rawImageCount =
|
||||
target === "main"
|
||||
@ -4839,6 +4862,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
try {
|
||||
const uploadedItems = await Promise.all(
|
||||
uploadFiles.map(async (file, index) => {
|
||||
const dimensions = await readLocalImageDimensions(file);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await fetchUploadWithRetry(formData);
|
||||
@ -4850,6 +4874,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
index,
|
||||
file,
|
||||
url: String(data.url),
|
||||
previewUrl: String(data.previewUrl || data.thumbnailUrl || data.url || ""),
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
};
|
||||
})
|
||||
);
|
||||
@ -4866,6 +4893,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
? `上传详情图 ${baseLength + index + 1} · ${item.file.name}`
|
||||
: `上传详情图 ${baseLength + index + 1}`,
|
||||
imageUrl: item.url,
|
||||
previewUrl: item.previewUrl || item.url,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
candidateId: null,
|
||||
sortOrder: baseLength + index,
|
||||
passthrough: false,
|
||||
@ -5019,6 +5049,25 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
references: [],
|
||||
}));
|
||||
}, [updateDetailReplacementState]);
|
||||
const handlePreparedDetailReferenceCards = useCallback(
|
||||
(preparedCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => {
|
||||
const preparedById = new Map(preparedCards.map((card) => [card.id, card]));
|
||||
updateDetailReplacementState((current) => ({
|
||||
...current,
|
||||
references: current.references.map((card) => {
|
||||
const prepared = preparedById.get(card.id);
|
||||
if (!prepared) return card;
|
||||
return {
|
||||
...card,
|
||||
previewUrl: prepared.previewUrl || card.previewUrl || card.imageUrl || null,
|
||||
width: prepared.width || card.width || null,
|
||||
height: prepared.height || card.height || null,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[updateDetailReplacementState]
|
||||
);
|
||||
const handleApplyStitchedDetailReferenceCards = useCallback(
|
||||
(nextCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => {
|
||||
updateDetailReplacementState((current) => ({
|
||||
@ -5641,8 +5690,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
) : null}
|
||||
|
||||
{hasWorkflowStyleStep ? (
|
||||
<section id="agent-anchor-style" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="mb-4 space-y-1 border-b border-white/10 pb-4">
|
||||
<section id="agent-anchor-style" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-5 md:p-6">
|
||||
<div className="mb-5 space-y-1 border-b border-white/10 pb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-emerald-500/18 px-2.5 py-1 text-[11px] font-semibold text-emerald-200">
|
||||
{styleStepBadge}
|
||||
@ -5654,7 +5703,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-7">
|
||||
{shouldShowDirectUploadInStyleStep ? attachmentSlotPanel : null}
|
||||
{workflowStylePanel}
|
||||
{hasBrandModelStep ? brandModelPanel : null}
|
||||
@ -6396,11 +6445,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
{isDetailReplacementPreset && !isDetailAiGenerationMethod ? (
|
||||
<AgentDetailReferenceStitchDialog
|
||||
open={detailReferenceStitchDialogOpen}
|
||||
onOpenChange={setDetailReferenceStitchDialogOpen}
|
||||
cards={detailReplacementReferenceCards}
|
||||
sourceLabel={`${detailReferenceSourceLabel}参考图`}
|
||||
onApply={handleApplyStitchedDetailReferenceCards}
|
||||
/>
|
||||
onOpenChange={setDetailReferenceStitchDialogOpen}
|
||||
cards={detailReplacementReferenceCards}
|
||||
sourceLabel={`${detailReferenceSourceLabel}参考图`}
|
||||
onPrepared={handlePreparedDetailReferenceCards}
|
||||
onApply={handleApplyStitchedDetailReferenceCards}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<motion.section
|
||||
@ -6556,7 +6606,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openPsdTemplateDialog({
|
||||
sourceImageUrl: displayedResultUrl,
|
||||
mode: getPsdTemplateModeByResultMode(activeResultModeType),
|
||||
resultModeType: activeResultModeType,
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/72 transition-colors hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||||
|
||||
@ -850,6 +850,23 @@ function clampImageScale(value: number) {
|
||||
return Math.max(MIN_IMAGE_SCALE, Math.min(MAX_IMAGE_SCALE, value));
|
||||
}
|
||||
|
||||
function getDefaultSourceImageLayer(
|
||||
layers: AgentPsdTemplateLayerSlot[]
|
||||
): AgentPsdTemplateImageLayerSlot | null {
|
||||
const editableImageLayers = layers.filter(
|
||||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||||
layer.isEditable &&
|
||||
!isAgentPsdLayerSuperLocked(layer) &&
|
||||
isAgentPsdImageLayerSlot(layer)
|
||||
);
|
||||
|
||||
return (
|
||||
editableImageLayers.find((layer) => layer.isDefaultResultSource) ||
|
||||
editableImageLayers[0] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function createInitialState(
|
||||
template: AgentPsdTemplate | null,
|
||||
sourceImageUrl: string
|
||||
@ -872,6 +889,10 @@ function createInitialState(
|
||||
};
|
||||
}
|
||||
|
||||
const defaultSourceImageLayerId = sourceImageUrl
|
||||
? getDefaultSourceImageLayer(template.layers)?.id || ""
|
||||
: "";
|
||||
|
||||
for (const layer of template.layers) {
|
||||
layerOrderIds.push(layer.id);
|
||||
if (isAgentPsdLayerSuperLocked(layer)) {
|
||||
@ -879,7 +900,7 @@ function createInitialState(
|
||||
}
|
||||
if (
|
||||
isAgentPsdImageLayerSlot(layer) &&
|
||||
layer.isDefaultResultSource &&
|
||||
layer.id === defaultSourceImageLayerId &&
|
||||
sourceImageUrl
|
||||
) {
|
||||
imageAssignments[layer.id] = sourceImageUrl;
|
||||
@ -2022,6 +2043,10 @@ export function AgentPsdTemplateEditorDialog({
|
||||
return nextAssignments;
|
||||
}
|
||||
|
||||
const defaultSourceImageLayerId = sourceImageUrl?.trim()
|
||||
? getDefaultSourceImageLayer(editableImageLayers)?.id || ""
|
||||
: "";
|
||||
|
||||
for (const layer of editableImageLayers) {
|
||||
const explicit = String(imageAssignments[layer.id] || "").trim();
|
||||
if (explicit) {
|
||||
@ -2029,7 +2054,7 @@ export function AgentPsdTemplateEditorDialog({
|
||||
continue;
|
||||
}
|
||||
|
||||
if (layer.isDefaultResultSource && sourceImageUrl?.trim()) {
|
||||
if (layer.id === defaultSourceImageLayerId && sourceImageUrl?.trim()) {
|
||||
nextAssignments[layer.id] = sourceImageUrl.trim();
|
||||
}
|
||||
}
|
||||
@ -2733,7 +2758,7 @@ export function AgentPsdTemplateEditorDialog({
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [open, selectedTemplate?.id]);
|
||||
}, [open, selectedTemplate?.id, sourceImageUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hiddenLayerIds.length === 0) {
|
||||
@ -3458,9 +3483,7 @@ export function AgentPsdTemplateEditorDialog({
|
||||
setRenderedResultUrl("");
|
||||
const normalizedRenderInputs = await normalizeRenderImageInputs();
|
||||
if (normalizedRenderInputs.sourceImageUrl !== (sourceImageUrl || "")) {
|
||||
const defaultSourceLayer = editableImageLayers.find(
|
||||
(layer) => layer.isDefaultResultSource
|
||||
);
|
||||
const defaultSourceLayer = getDefaultSourceImageLayer(editableImageLayers);
|
||||
if (defaultSourceLayer) {
|
||||
setImageAssignments((current) => ({
|
||||
...current,
|
||||
|
||||
@ -1681,17 +1681,17 @@ function AgentWorkflowPanelDarkV2Component({
|
||||
activeEmbeddedStepEntry ? (
|
||||
<motion.section
|
||||
key={activeEmbeddedStepEntry.step.key}
|
||||
className={cn(
|
||||
"rounded-2xl border border-white/12 bg-white/[0.04] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]",
|
||||
className={cn(
|
||||
"rounded-[24px] border border-white/10 bg-white/[0.035] p-6 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] md:p-7",
|
||||
activeEmbeddedStepEntry.depth > 1 &&
|
||||
"border-l-4 border-l-emerald-500/30"
|
||||
)}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="mt-2">
|
||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
<div className="mb-7 space-y-3">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{flattenedResolvedSteps.map(({ step }, stepIndex) => {
|
||||
const completed = isAgentWorkflowStepSelectionComplete({
|
||||
step,
|
||||
@ -1715,15 +1715,15 @@ function AgentWorkflowPanelDarkV2Component({
|
||||
<div
|
||||
key={step.key}
|
||||
className={cn(
|
||||
"group flex min-h-[84px] flex-col justify-between rounded-[18px] border px-3 py-2.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]",
|
||||
active
|
||||
? "border-emerald-300/28 bg-emerald-400/[0.08]"
|
||||
: completed
|
||||
? "border-white/14 bg-white/[0.05]"
|
||||
: "border-white/10 bg-white/[0.025]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5 text-left">
|
||||
"group flex min-h-[64px] rounded-[14px] border px-3 py-2.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]",
|
||||
active
|
||||
? "border-emerald-300/34 bg-emerald-400/[0.075]"
|
||||
: completed
|
||||
? "border-white/14 bg-white/[0.045]"
|
||||
: "border-white/10 bg-white/[0.022]"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2.5 text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@ -1732,11 +1732,11 @@ function AgentWorkflowPanelDarkV2Component({
|
||||
setActiveEmbeddedStepKey(step.key);
|
||||
setActiveEmbeddedOptionDepth(1);
|
||||
}}
|
||||
className="flex min-w-0 flex-1 items-start gap-2.5 text-left transition-transform duration-200 hover:-translate-y-[1px]"
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 text-left transition-transform duration-200 hover:-translate-y-[1px]"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold transition-colors",
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold transition-colors",
|
||||
active
|
||||
? "border-emerald-300 bg-emerald-300 text-black shadow-[0_0_18px_rgba(52,211,153,0.28)]"
|
||||
: completed
|
||||
@ -1746,11 +1746,11 @@ function AgentWorkflowPanelDarkV2Component({
|
||||
>
|
||||
{stepIndex + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-[12px] font-semibold leading-5 transition-colors",
|
||||
"truncate text-[13px] font-semibold leading-5 transition-colors",
|
||||
active
|
||||
? "text-white"
|
||||
: completed
|
||||
@ -1761,12 +1761,12 @@ function AgentWorkflowPanelDarkV2Component({
|
||||
{step.title}
|
||||
</div>
|
||||
{active ? (
|
||||
<span className="inline-flex h-5 items-center rounded-full border border-emerald-300/30 bg-emerald-400/10 px-2 py-0 text-[9px] font-medium leading-none text-emerald-200">
|
||||
<span className="inline-flex h-5 items-center rounded-full border border-emerald-300/28 bg-emerald-400/10 px-2 py-0 text-[9px] font-medium leading-none text-emerald-200">
|
||||
当前步骤
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5 pt-0.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-[11px] leading-4",
|
||||
@ -1797,17 +1797,17 @@ function AgentWorkflowPanelDarkV2Component({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[16px] border border-white/10 bg-white/[0.025]">
|
||||
<div className="overflow-hidden rounded-[14px] border border-white/8 bg-black/20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setIsEmbeddedPathExpanded((current) => !current)
|
||||
}
|
||||
aria-expanded={isEmbeddedPathExpanded}
|
||||
className="flex w-full items-center justify-between gap-3 px-3.5 py-2.5 text-left transition-colors hover:bg-white/[0.03]"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
className="flex min-h-[44px] w-full items-center justify-between gap-3 px-3.5 py-2 text-left transition-colors hover:bg-white/[0.035]"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
|
||||
<div className="flex min-w-0 shrink-0 flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-white/12 px-2 py-0.5 text-[10px] font-medium text-white/55">
|
||||
当前步骤
|
||||
</span>
|
||||
@ -1815,8 +1815,8 @@ function AgentWorkflowPanelDarkV2Component({
|
||||
{activeEmbeddedStepEntry.step.title}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 truncate text-xs text-white/68"
|
||||
<div
|
||||
className="min-w-0 truncate text-xs text-white/68"
|
||||
title={activeEmbeddedPathSummary}
|
||||
>
|
||||
{activeEmbeddedPrimaryOption?.buttonName ||
|
||||
@ -1892,8 +1892,8 @@ function AgentWorkflowPanelDarkV2Component({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeEmbeddedOptionLevel ? (
|
||||
<div className="space-y-4">
|
||||
{activeEmbeddedOptionLevel ? (
|
||||
<div className="space-y-5">
|
||||
{autoAdvanceNotice ? (
|
||||
<motion.div
|
||||
key={`${autoAdvanceNotice.completedStepTitle}-${autoAdvanceNotice.nextStepTitle}`}
|
||||
@ -1920,13 +1920,13 @@ function AgentWorkflowPanelDarkV2Component({
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-white/12 px-2.5 py-1 text-[11px] font-medium text-white/45">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="rounded-full border border-white/12 bg-white/[0.03] px-3 py-1 text-[11px] font-medium text-white/52">
|
||||
{activeEmbeddedOptionLevel.depth === 1
|
||||
? "主卡片"
|
||||
: `${activeEmbeddedOptionLevel.depth}级子卡片`}
|
||||
</span>
|
||||
<span className="text-xs text-white/35">
|
||||
<span className="text-xs leading-5 text-white/35">
|
||||
{activeEmbeddedOptionLevel.depth === 1
|
||||
? "先选择当前步骤的主卡片"
|
||||
: "当前层级只展示这一层的子节点卡片,选完再进入下一层"}
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@ -780,6 +781,13 @@ export function EnhanceProWorkspace({
|
||||
);
|
||||
};
|
||||
|
||||
const handleRequestChangeImage = useCallback(() => {
|
||||
requestIdRef.current = null;
|
||||
requestSignatureRef.current = null;
|
||||
setLastErrorMessage("");
|
||||
onRequestAddImage?.();
|
||||
}, [onRequestAddImage]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!config.enabled) {
|
||||
toast.error("图像增强 Pro 当前未开启,请先在系统设置中启用");
|
||||
@ -1071,8 +1079,18 @@ export function EnhanceProWorkspace({
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
<span>{activePreviewLabel}</span>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute top-6 right-8 hidden items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/96 px-3 py-1.5 text-[#666666] text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_10px_24px_rgba(0,0,0,0.04)] lg:inline-flex">
|
||||
图片固定显示
|
||||
<div className="pointer-events-auto absolute top-6 right-8">
|
||||
<Button
|
||||
className="h-9 rounded-full border-[#e5e5e5] bg-white/96 px-3.5 font-medium text-[#111111] text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_10px_24px_rgba(0,0,0,0.04)] backdrop-blur-sm hover:bg-white"
|
||||
disabled={!onRequestAddImage}
|
||||
onClick={handleRequestChangeImage}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
更换图片
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@ -1083,7 +1101,7 @@ export function EnhanceProWorkspace({
|
||||
PRIMARY_ACTION_BUTTON_CLASS
|
||||
)}
|
||||
disabled={!onRequestAddImage}
|
||||
onClick={onRequestAddImage}
|
||||
onClick={handleRequestChangeImage}
|
||||
type="button"
|
||||
>
|
||||
<ImageIcon className="mr-2 h-5 w-5 sm:h-6 sm:w-6" />
|
||||
|
||||
@ -92,6 +92,8 @@ type RealtimeConfig = {
|
||||
|
||||
type RealtimeStatus = "idle" | "connecting" | "live" | "error";
|
||||
type RealtimeWorkspaceMode = "realtime" | "enhance-pro";
|
||||
type RealtimeGenerationMode = "manual" | "auto";
|
||||
type RealtimeAspectRatio = "1:1" | "4:3" | "2:3";
|
||||
type RealtimeCanvasModeIconConfig = {
|
||||
realtimeIconUrl: string;
|
||||
enhanceIconUrl: string;
|
||||
@ -184,6 +186,26 @@ const CANVAS_BACKGROUND = "#ffffff";
|
||||
const DEFAULT_BRUSH_COLOR = "#18181b";
|
||||
const DRAW_LAYER_ID = "__draw_layer__";
|
||||
const REALTIME_PROJECTS_STORAGE_KEY = "ai-chatbot:realtime-canvas-projects";
|
||||
const REALTIME_GENERATION_MODE_OPTIONS: Array<{
|
||||
value: RealtimeGenerationMode;
|
||||
label: string;
|
||||
description: string;
|
||||
points: number;
|
||||
}> = [
|
||||
{
|
||||
value: "manual",
|
||||
label: "手动",
|
||||
description: "点击生成时扣除",
|
||||
points: 1,
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
label: "自动",
|
||||
description: "绘制停顿后自动扣除",
|
||||
points: 1,
|
||||
},
|
||||
];
|
||||
const REALTIME_ASPECT_RATIO_OPTIONS: RealtimeAspectRatio[] = ["1:1", "4:3", "2:3"];
|
||||
|
||||
function cloneImageLayers(layers: ImageLayer[]) {
|
||||
return layers.map((layer) => ({ ...layer }));
|
||||
@ -808,6 +830,10 @@ export function RealtimeCanvasDialog({
|
||||
defaultRealtimeConfig.transport.throttleInterval
|
||||
);
|
||||
const pumpFlushRef = useRef<(() => void) | null>(null);
|
||||
const realtimeGenerationModeRef =
|
||||
useRef<RealtimeGenerationMode>("manual");
|
||||
const manualGenerateRequestRef = useRef(false);
|
||||
const realtimeAspectRatioRef = useRef<RealtimeAspectRatio>("1:1");
|
||||
const warningMessageRef = useRef("");
|
||||
const seqRef = useRef(0);
|
||||
const requestStartMapRef = useRef<Map<string, number>>(new Map());
|
||||
@ -844,6 +870,10 @@ export function RealtimeCanvasDialog({
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [workspaceMode, setWorkspaceMode] =
|
||||
useState<RealtimeWorkspaceMode>(initialMode);
|
||||
const [realtimeGenerationMode, setRealtimeGenerationMode] =
|
||||
useState<RealtimeGenerationMode>("manual");
|
||||
const [realtimeAspectRatio, setRealtimeAspectRatio] =
|
||||
useState<RealtimeAspectRatio>("1:1");
|
||||
const [enhanceUploadedSourceImageUrl, setEnhanceUploadedSourceImageUrl] =
|
||||
useState("");
|
||||
const [brushMode, setBrushMode] = useState<RealtimeBrushMode>("select");
|
||||
@ -908,6 +938,18 @@ export function RealtimeCanvasDialog({
|
||||
"border border-[#e5e5e5] bg-white text-[#666666] dark:border-white/12 dark:bg-white/8 dark:text-white/58",
|
||||
};
|
||||
}, [status]);
|
||||
const currentRealtimeModeOption =
|
||||
REALTIME_GENERATION_MODE_OPTIONS.find(
|
||||
(option) => option.value === realtimeGenerationMode
|
||||
) || REALTIME_GENERATION_MODE_OPTIONS[0];
|
||||
|
||||
useEffect(() => {
|
||||
realtimeGenerationModeRef.current = realtimeGenerationMode;
|
||||
}, [realtimeGenerationMode]);
|
||||
|
||||
useEffect(() => {
|
||||
realtimeAspectRatioRef.current = realtimeAspectRatio;
|
||||
}, [realtimeAspectRatio]);
|
||||
|
||||
const selectedImageLayer = useMemo(
|
||||
() => imageLayers.find((layer) => layer.id === selectedLayerId) || null,
|
||||
@ -915,10 +957,11 @@ export function RealtimeCanvasDialog({
|
||||
);
|
||||
const enhanceHistoryScopeKey = useMemo(() => {
|
||||
return (
|
||||
initialImageUrl ||
|
||||
enhanceUploadedSourceImageUrl ||
|
||||
selectedImageLayer?.src ||
|
||||
imageLayers[0]?.src ||
|
||||
previewUrl ||
|
||||
initialImageUrl ||
|
||||
"realtime-canvas-enhance"
|
||||
);
|
||||
}, [
|
||||
@ -926,12 +969,13 @@ export function RealtimeCanvasDialog({
|
||||
imageLayers,
|
||||
initialImageUrl,
|
||||
previewUrl,
|
||||
selectedImageLayer?.src,
|
||||
]);
|
||||
const enhanceSourceImageUrl = useMemo(() => {
|
||||
return (
|
||||
enhanceUploadedSourceImageUrl ||
|
||||
selectedImageLayer?.src ||
|
||||
imageLayers[0]?.src ||
|
||||
enhanceUploadedSourceImageUrl ||
|
||||
previewUrl ||
|
||||
initialImageUrl ||
|
||||
""
|
||||
@ -1662,6 +1706,8 @@ export function RealtimeCanvasDialog({
|
||||
seed: seedRef.current,
|
||||
strictSketch: config.behavior?.strictSketch !== false,
|
||||
imageSize: config.model.defaults.imageSize,
|
||||
aspectRatio: realtimeAspectRatioRef.current,
|
||||
generationMode: realtimeGenerationModeRef.current,
|
||||
steps: config.model.defaults.steps,
|
||||
cfgScale: config.model.defaults.cfgScale,
|
||||
strength: config.model.defaults.strength,
|
||||
@ -1795,6 +1841,19 @@ export function RealtimeCanvasDialog({
|
||||
if (markDirty) {
|
||||
markCanvasDirty(null);
|
||||
}
|
||||
if (
|
||||
realtimeGenerationModeRef.current === "manual" &&
|
||||
!manualGenerateRequestRef.current
|
||||
) {
|
||||
if (pendingFlushTimerRef.current) {
|
||||
window.clearTimeout(pendingFlushTimerRef.current);
|
||||
pendingFlushTimerRef.current = null;
|
||||
}
|
||||
pendingFlushModeRef.current = null;
|
||||
pendingFlushForceRef.current = false;
|
||||
return;
|
||||
}
|
||||
manualGenerateRequestRef.current = false;
|
||||
const nextMode: FlushMode = mode || (force ? "refine" : "preview");
|
||||
if (pendingFlushModeRef.current === "refine" || nextMode === "refine") {
|
||||
pendingFlushModeRef.current = "refine";
|
||||
@ -1839,6 +1898,36 @@ export function RealtimeCanvasDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRealtimeGenerationModeChange = useCallback(
|
||||
(nextMode: RealtimeGenerationMode) => {
|
||||
if (nextMode === realtimeGenerationModeRef.current) return;
|
||||
realtimeGenerationModeRef.current = nextMode;
|
||||
manualGenerateRequestRef.current = false;
|
||||
setRealtimeGenerationMode(nextMode);
|
||||
|
||||
if (nextMode === "manual") {
|
||||
cancelPendingRealtimeFlush(false);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleFlush(false, false, "preview");
|
||||
},
|
||||
[cancelPendingRealtimeFlush, scheduleFlush]
|
||||
);
|
||||
|
||||
const handleRealtimeAspectRatioChange = useCallback(
|
||||
(nextRatio: RealtimeAspectRatio) => {
|
||||
if (nextRatio === realtimeAspectRatioRef.current) return;
|
||||
realtimeAspectRatioRef.current = nextRatio;
|
||||
setRealtimeAspectRatio(nextRatio);
|
||||
|
||||
if (realtimeGenerationModeRef.current === "auto") {
|
||||
scheduleFlush(true, false, "refine");
|
||||
}
|
||||
},
|
||||
[scheduleFlush]
|
||||
);
|
||||
|
||||
const reorderLayersFromSidebar = useCallback(
|
||||
(draggedLayerId: string, targetLayerId: string) => {
|
||||
if (!draggedLayerId || !targetLayerId || draggedLayerId === targetLayerId)
|
||||
@ -1976,7 +2065,8 @@ export function RealtimeCanvasDialog({
|
||||
toast.error("请先上传图片或绘制内容");
|
||||
return;
|
||||
}
|
||||
scheduleFlush(true);
|
||||
manualGenerateRequestRef.current = true;
|
||||
scheduleFlush(true, true, "refine");
|
||||
}, [hasRenderableInput, scheduleFlush]);
|
||||
|
||||
const refreshSavedProjects = useCallback(() => {
|
||||
@ -4279,10 +4369,56 @@ export function RealtimeCanvasDialog({
|
||||
value={prompt}
|
||||
/>
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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;
|
||||
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={option.value}
|
||||
onClick={() =>
|
||||
handleRealtimeGenerationModeChange(option.value)
|
||||
}
|
||||
title={option.description}
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
{currentRealtimeModeOption.points} 积分
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-6 items-center gap-1.5 rounded-full px-2.5 font-medium text-xs",
|
||||
"hidden h-7 items-center gap-1.5 rounded-full px-2.5 font-medium text-xs sm:flex",
|
||||
statusBadge.className
|
||||
)}
|
||||
>
|
||||
@ -4296,9 +4432,6 @@ export function RealtimeCanvasDialog({
|
||||
/>
|
||||
{statusBadge.label}
|
||||
</div>
|
||||
<div className="flex h-6 items-center rounded-full border border-[#e5e5e5] bg-white px-2.5 font-medium text-[#666666] text-xs dark:border-white/12 dark:bg-white/8 dark:text-white/58">
|
||||
延迟 {adaptiveThrottleMs}ms
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
886
components/style-pro/StyleProWorkspace.tsx
Normal file
886
components/style-pro/StyleProWorkspace.tsx
Normal file
@ -0,0 +1,886 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Clock3,
|
||||
Home,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
Upload,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { imageGenerationService } from "@/services/canvas";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import type {
|
||||
PublicStyleProCategory,
|
||||
PublicStyleProConfig,
|
||||
StyleProHistoryItem,
|
||||
StyleProPersistedState,
|
||||
StyleProSelectionMap,
|
||||
} from "@/lib/style-pro/config";
|
||||
|
||||
type BrandingSettings = {
|
||||
siteName?: string;
|
||||
siteLogo?: string | null;
|
||||
siteLogoDark?: string | null;
|
||||
};
|
||||
|
||||
type StyleProConfigResponse = {
|
||||
success: boolean;
|
||||
config?: PublicStyleProConfig;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type StyleProGenerateResponse = {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: {
|
||||
imageUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
model?: string;
|
||||
modelRef?: string;
|
||||
selections: StyleProSelectionMap;
|
||||
optionLabels: string[];
|
||||
};
|
||||
};
|
||||
|
||||
type StyleProWorkspaceProps = {
|
||||
canvasId: string;
|
||||
initialSourceImageUrl?: string | null;
|
||||
persistedState?: StyleProPersistedState | null;
|
||||
onAddToCanvas?: (item: StyleProHistoryItem) => void | Promise<void>;
|
||||
onBack?: () => void;
|
||||
onPersistState?: (state: StyleProPersistedState) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function getHistoryId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `style-pro-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function firstEnabledOptionId(category: PublicStyleProCategory) {
|
||||
return category.options.find((option) => option.enabled)?.id || "";
|
||||
}
|
||||
|
||||
function loadImageDimensions(url: string) {
|
||||
return new Promise<{ width?: number; height?: number }>((resolve) => {
|
||||
if (typeof window === "undefined" || !url) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
const image = new window.Image();
|
||||
image.onload = () => {
|
||||
resolve({
|
||||
width: image.naturalWidth || undefined,
|
||||
height: image.naturalHeight || undefined,
|
||||
});
|
||||
};
|
||||
image.onerror = () => resolve({});
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeHistory(history: StyleProHistoryItem[] | undefined) {
|
||||
return Array.isArray(history) ? history.slice(0, 4) : [];
|
||||
}
|
||||
|
||||
function ImagePreview({
|
||||
alt,
|
||||
className,
|
||||
imageUrl,
|
||||
}: {
|
||||
alt: string;
|
||||
className?: string;
|
||||
imageUrl?: string | null;
|
||||
}) {
|
||||
if (!imageUrl) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full min-h-[220px] items-center justify-center rounded-lg border border-dashed border-white/15 bg-white/[0.03] text-sm text-white/45",
|
||||
className
|
||||
)}
|
||||
>
|
||||
等待图片
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
alt={alt}
|
||||
className={cn("h-full w-full rounded-lg object-contain", className)}
|
||||
draggable={false}
|
||||
src={imageUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratingImagePlaceholder() {
|
||||
return (
|
||||
<div className="min-h-[460px] rounded-lg border border-white/10 bg-white p-5 text-neutral-800">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-[15px] text-neutral-700">
|
||||
正在创建图片
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-neutral-400">
|
||||
正在理解图片与提示词组合
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 rounded-lg bg-neutral-100 px-3 py-1.5 text-xs font-medium text-neutral-500">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
生成中
|
||||
</span>
|
||||
</div>
|
||||
<div className="style-pro-generating-frame relative mx-auto h-[390px] max-h-[58vh] w-full max-w-[340px] overflow-hidden rounded-lg bg-[#f4f4f4]">
|
||||
<div className="style-pro-dot-field absolute inset-0" />
|
||||
<div className="style-pro-light-field absolute inset-0" />
|
||||
<div className="style-pro-scan-field absolute inset-0" />
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes styleProDotDrift {
|
||||
0% { transform: translate3d(-10px, -8px, 0); opacity: 0.35; }
|
||||
45% { transform: translate3d(8px, 18px, 0); opacity: 0.78; }
|
||||
100% { transform: translate3d(18px, -4px, 0); opacity: 0.42; }
|
||||
}
|
||||
@keyframes styleProLightFloat {
|
||||
0% { transform: translate3d(-18%, -10%, 0) scale(1); opacity: 0.72; }
|
||||
50% { transform: translate3d(18%, 16%, 0) scale(1.06); opacity: 0.98; }
|
||||
100% { transform: translate3d(34%, -4%, 0) scale(1); opacity: 0.74; }
|
||||
}
|
||||
@keyframes styleProScanMove {
|
||||
0% { transform: translateX(-80%); opacity: 0; }
|
||||
18% { opacity: 0.65; }
|
||||
54% { opacity: 0.82; }
|
||||
100% { transform: translateX(80%); opacity: 0; }
|
||||
}
|
||||
.style-pro-generating-frame {
|
||||
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.025), 0 18px 50px rgba(0,0,0,0.08);
|
||||
}
|
||||
.style-pro-dot-field {
|
||||
background-image: radial-gradient(circle, rgba(116,116,116,0.32) 1.05px, transparent 1.05px);
|
||||
background-size: 16px 16px;
|
||||
mask-image: radial-gradient(ellipse 88% 72% at 50% 44%, #000 0%, rgba(0,0,0,0.78) 48%, transparent 82%);
|
||||
animation: styleProDotDrift 3.8s ease-in-out infinite alternate;
|
||||
}
|
||||
.style-pro-light-field {
|
||||
background:
|
||||
radial-gradient(circle at 24% 24%, rgba(255,255,255,0.96) 0%, rgba(255,255,255,0.82) 22%, transparent 45%),
|
||||
radial-gradient(circle at 70% 72%, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 20%, transparent 44%);
|
||||
animation: styleProLightFloat 4.4s ease-in-out infinite alternate;
|
||||
}
|
||||
.style-pro-scan-field {
|
||||
width: 150%;
|
||||
left: -25%;
|
||||
background: linear-gradient(105deg, transparent 20%, rgba(255,255,255,0.92) 48%, transparent 74%);
|
||||
animation: styleProScanMove 3.4s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BeforeAfterSlider({
|
||||
afterUrl,
|
||||
beforeUrl,
|
||||
generating = false,
|
||||
}: {
|
||||
afterUrl?: string | null;
|
||||
beforeUrl?: string | null;
|
||||
generating?: boolean;
|
||||
}) {
|
||||
const [position, setPosition] = useState(50);
|
||||
|
||||
if (generating) {
|
||||
return <GeneratingImagePlaceholder />;
|
||||
}
|
||||
|
||||
if (!beforeUrl && !afterUrl) {
|
||||
return (
|
||||
<div className="flex min-h-[460px] items-center justify-center rounded-lg border border-dashed border-white/15 bg-white/[0.03] text-white/45">
|
||||
上传图片后开始生成
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!afterUrl) {
|
||||
return (
|
||||
<div className="min-h-[460px] rounded-lg border border-white/10 bg-[#111] p-3">
|
||||
<ImagePreview alt="上传图片" imageUrl={beforeUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[460px] overflow-hidden rounded-lg border border-white/10 bg-[#111]">
|
||||
<ImagePreview
|
||||
alt="生成后"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
imageUrl={afterUrl}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ clipPath: `inset(0 ${100 - position}% 0 0)` }}
|
||||
>
|
||||
<ImagePreview
|
||||
alt="生成前"
|
||||
className="h-full w-full"
|
||||
imageUrl={beforeUrl}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-white"
|
||||
style={{ left: `${position}%` }}
|
||||
>
|
||||
<span className="-translate-x-1/2 absolute top-1/2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/70 bg-black/70 text-[11px] font-semibold text-white shadow-lg">
|
||||
对比
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
aria-label="图像对比滑块"
|
||||
className="absolute inset-x-4 bottom-5 z-10 accent-emerald-400"
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(event) => setPosition(Number(event.target.value))}
|
||||
type="range"
|
||||
value={position}
|
||||
/>
|
||||
<div className="absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1 text-xs text-white/80">
|
||||
原图
|
||||
</div>
|
||||
<div className="absolute right-4 top-4 rounded-full bg-emerald-500/90 px-3 py-1 text-xs font-semibold text-black">
|
||||
结果
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StyleProWorkspace({
|
||||
canvasId,
|
||||
initialSourceImageUrl,
|
||||
onAddToCanvas,
|
||||
onBack,
|
||||
onPersistState,
|
||||
persistedState,
|
||||
}: StyleProWorkspaceProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { data: brandingData } = useSWR<BrandingSettings>(
|
||||
"/api/site-settings?scope=branding",
|
||||
fetcher
|
||||
);
|
||||
const { data: configData, isLoading: configLoading } =
|
||||
useSWR<StyleProConfigResponse>("/api/style-pro/config", fetcher);
|
||||
const config = configData?.config;
|
||||
const enabledCategories = useMemo(
|
||||
() =>
|
||||
(config?.categories || [])
|
||||
.filter((category) => category.enabled)
|
||||
.map((category) => ({
|
||||
...category,
|
||||
options: category.options.filter((option) => option.enabled),
|
||||
}))
|
||||
.filter((category) => category.options.length > 0),
|
||||
[config?.categories]
|
||||
);
|
||||
|
||||
const [sourceImageUrl, setSourceImageUrl] = useState(
|
||||
persistedState?.sourceImageUrl || initialSourceImageUrl || ""
|
||||
);
|
||||
const [currentResult, setCurrentResult] =
|
||||
useState<StyleProHistoryItem | null>(
|
||||
normalizeHistory(persistedState?.history)[0] || null
|
||||
);
|
||||
const [history, setHistory] = useState<StyleProHistoryItem[]>(
|
||||
normalizeHistory(persistedState?.history)
|
||||
);
|
||||
const [selections, setSelections] = useState<StyleProSelectionMap>({});
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const nextSource =
|
||||
persistedState?.sourceImageUrl || initialSourceImageUrl || "";
|
||||
setSourceImageUrl((current) => current || nextSource);
|
||||
const nextHistory = normalizeHistory(persistedState?.history);
|
||||
setHistory(nextHistory);
|
||||
setCurrentResult((current) => current || nextHistory[0] || null);
|
||||
}, [initialSourceImageUrl, persistedState?.history, persistedState?.sourceImageUrl]);
|
||||
|
||||
const siteName = brandingData?.siteName || "由壹点AI";
|
||||
const logo = brandingData?.siteLogoDark || brandingData?.siteLogo || "";
|
||||
|
||||
const selectedOptionLabels = useMemo(() => {
|
||||
const labels: string[] = [];
|
||||
for (const category of enabledCategories) {
|
||||
const selected = selections[category.id] || [];
|
||||
for (const optionId of selected) {
|
||||
const option = category.options.find((item) => item.id === optionId);
|
||||
if (option) {
|
||||
labels.push(`${category.title}:${option.label}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}, [enabledCategories, selections]);
|
||||
|
||||
const missingRequiredCategories = useMemo(
|
||||
() =>
|
||||
enabledCategories.filter((category) => {
|
||||
if (!category.required && category.minSelections <= 0) return false;
|
||||
const selectedCount = selections[category.id]?.length || 0;
|
||||
return (
|
||||
selectedCount <
|
||||
Math.max(category.required ? 1 : 0, category.minSelections)
|
||||
);
|
||||
}),
|
||||
[enabledCategories, selections]
|
||||
);
|
||||
|
||||
const persistState = useCallback(
|
||||
async (nextState: StyleProPersistedState) => {
|
||||
await onPersistState?.({
|
||||
sourceImageUrl: nextState.sourceImageUrl,
|
||||
history: normalizeHistory(nextState.history),
|
||||
});
|
||||
},
|
||||
[onPersistState]
|
||||
);
|
||||
|
||||
const updateSourceImage = useCallback(
|
||||
async (url: string) => {
|
||||
const nextUrl = String(url || "").trim();
|
||||
setSourceImageUrl(nextUrl);
|
||||
setCurrentResult(null);
|
||||
await persistState({ sourceImageUrl: nextUrl, history });
|
||||
},
|
||||
[history, persistState]
|
||||
);
|
||||
|
||||
const handleFile = useCallback(
|
||||
async (file: File | null | undefined) => {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("请上传图片文件");
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await imageGenerationService.uploadImage(file);
|
||||
if (!result.success || !result.data?.url) {
|
||||
throw new Error(result.error || "上传失败");
|
||||
}
|
||||
await updateSourceImage(result.data.url);
|
||||
toast.success("图片已上传");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "上传失败");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[updateSourceImage]
|
||||
);
|
||||
|
||||
const toggleOption = useCallback(
|
||||
(category: PublicStyleProCategory, optionId: string) => {
|
||||
setSelections((current) => {
|
||||
const previous = current[category.id] || [];
|
||||
const selected = previous.includes(optionId);
|
||||
let nextSelected = selected
|
||||
? previous.filter((id) => id !== optionId)
|
||||
: [...previous, optionId];
|
||||
if (!selected && category.maxSelections <= 1) {
|
||||
nextSelected = [optionId];
|
||||
}
|
||||
if (nextSelected.length > category.maxSelections) {
|
||||
nextSelected = nextSelected.slice(-category.maxSelections);
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[category.id]: nextSelected,
|
||||
};
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const quickFillRequired = useCallback(() => {
|
||||
setSelections((current) => {
|
||||
const next = { ...current };
|
||||
for (const category of enabledCategories) {
|
||||
if (!category.required || next[category.id]?.length) continue;
|
||||
const firstId = firstEnabledOptionId(category);
|
||||
if (firstId) next[category.id] = [firstId];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [enabledCategories]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!sourceImageUrl) {
|
||||
toast.error("请先上传一张图片");
|
||||
return;
|
||||
}
|
||||
if (!config?.enabled) {
|
||||
toast.error("风格化 PRO 暂未启用");
|
||||
return;
|
||||
}
|
||||
if (missingRequiredCategories.length > 0) {
|
||||
toast.error(`请完成必选项:${missingRequiredCategories[0].title}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setGenerating(true);
|
||||
try {
|
||||
const response = await fetch("/api/style-pro/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
canvasId,
|
||||
sourceImageUrl,
|
||||
selections,
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as
|
||||
| StyleProGenerateResponse
|
||||
| undefined;
|
||||
if (!response.ok || !payload?.success || !payload.data?.imageUrl) {
|
||||
throw new Error(payload?.error || "生成失败");
|
||||
}
|
||||
const dimensions = await loadImageDimensions(payload.data.imageUrl);
|
||||
const item: StyleProHistoryItem = {
|
||||
id: getHistoryId(),
|
||||
sourceImageUrl,
|
||||
resultImageUrl: payload.data.imageUrl,
|
||||
selections: payload.data.selections || selections,
|
||||
optionLabels: payload.data.optionLabels || selectedOptionLabels,
|
||||
createdAt: new Date().toISOString(),
|
||||
model: payload.data.model,
|
||||
modelRef: payload.data.modelRef,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
};
|
||||
const nextHistory = [item, ...history.filter((entry) => entry.id !== item.id)].slice(
|
||||
0,
|
||||
4
|
||||
);
|
||||
setHistory(nextHistory);
|
||||
setCurrentResult(item);
|
||||
await persistState({ sourceImageUrl, history: nextHistory });
|
||||
toast.success("风格化 PRO 生成完成");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "生成失败");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [
|
||||
canvasId,
|
||||
config?.enabled,
|
||||
history,
|
||||
missingRequiredCategories,
|
||||
persistState,
|
||||
selectedOptionLabels,
|
||||
selections,
|
||||
sourceImageUrl,
|
||||
]);
|
||||
|
||||
const restoreHistoryItem = useCallback((item: StyleProHistoryItem) => {
|
||||
setSourceImageUrl(item.sourceImageUrl);
|
||||
setSelections(item.selections || {});
|
||||
setCurrentResult(item);
|
||||
}, []);
|
||||
|
||||
const disabledReason = !sourceImageUrl
|
||||
? "请先上传图片"
|
||||
: missingRequiredCategories.length > 0
|
||||
? `还差:${missingRequiredCategories[0].title}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full overflow-hidden bg-[#050606] text-white">
|
||||
<aside className="flex w-[76px] shrink-0 flex-col items-center border-white/10 border-r bg-black">
|
||||
<button
|
||||
className="mt-5 flex h-11 w-11 items-center justify-center rounded-lg bg-white/[0.06] text-white"
|
||||
onClick={onBack}
|
||||
title="返回画布"
|
||||
type="button"
|
||||
>
|
||||
{logo ? (
|
||||
<img alt={siteName} className="h-6 w-6 object-contain" src={logo} />
|
||||
) : (
|
||||
<Sparkles className="h-5 w-5 text-emerald-300" />
|
||||
)}
|
||||
</button>
|
||||
<div className="mt-8 flex flex-col gap-3">
|
||||
<button
|
||||
className="flex h-11 w-11 items-center justify-center rounded-lg bg-emerald-400 text-black"
|
||||
onClick={onBack}
|
||||
title="返回画布"
|
||||
type="button"
|
||||
>
|
||||
<Home className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-11 w-11 items-center justify-center rounded-lg bg-white/[0.05] text-white/75 hover:bg-white/[0.09] hover:text-white"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传图片"
|
||||
type="button"
|
||||
>
|
||||
<ImagePlus className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-white/10 border-b bg-black/85 px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
className="rounded-lg border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
|
||||
onClick={onBack}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回画布
|
||||
</Button>
|
||||
<div>
|
||||
<div className="text-sm text-white/45">{siteName}</div>
|
||||
<h1 className="font-semibold text-xl">风格化 PRO</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={() => toast.info("个人中心入口已保留,可在首页打开完整账户面板")}
|
||||
type="button"
|
||||
>
|
||||
<UserRound className="h-4 w-4" />
|
||||
个人中心
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[360px_minmax(0,1fr)] overflow-hidden">
|
||||
<section className="flex min-h-0 flex-col border-white/10 border-r bg-[#080909]">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-5 p-5">
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base">上传图片</h2>
|
||||
<p className="mt-1 text-sm text-white/45">
|
||||
上传后选择按钮组合生成最终图。
|
||||
</p>
|
||||
</div>
|
||||
<Badge className="border-emerald-400/30 bg-emerald-400/10 text-emerald-200">
|
||||
Final
|
||||
</Badge>
|
||||
</div>
|
||||
<button
|
||||
className="group relative flex min-h-[230px] w-full items-center justify-center overflow-hidden rounded-lg border border-dashed border-white/15 bg-white/[0.03] transition hover:border-emerald-300/60 hover:bg-white/[0.05]"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
void handleFile(event.dataTransfer.files?.[0]);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{sourceImageUrl ? (
|
||||
<img
|
||||
alt="上传图片"
|
||||
className="h-full max-h-[300px] w-full object-contain"
|
||||
src={sourceImageUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex flex-col items-center gap-3 text-white/55">
|
||||
{uploading ? (
|
||||
<Loader2 className="h-7 w-7 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-7 w-7" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
点击上传,或拖入图片
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFile(event.target.files?.[0]);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-base">历史记录</h2>
|
||||
<span className="text-xs text-white/40">最近 4 条</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{history.length > 0 ? (
|
||||
history.map((item) => (
|
||||
<button
|
||||
className={cn(
|
||||
"group overflow-hidden rounded-lg border bg-white/[0.03] text-left transition hover:border-emerald-300/70",
|
||||
currentResult?.id === item.id
|
||||
? "border-emerald-300/80"
|
||||
: "border-white/10"
|
||||
)}
|
||||
key={item.id}
|
||||
onClick={() => restoreHistoryItem(item)}
|
||||
type="button"
|
||||
>
|
||||
<div className="aspect-square bg-black">
|
||||
<img
|
||||
alt="风格化历史"
|
||||
className="h-full w-full object-cover"
|
||||
src={item.resultImageUrl}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 p-2">
|
||||
<div className="flex items-center gap-1 text-[11px] text-white/50">
|
||||
<Clock3 className="h-3 w-3" />
|
||||
{new Date(item.createdAt).toLocaleTimeString(
|
||||
"zh-CN",
|
||||
{
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div className="line-clamp-1 text-xs text-white/80">
|
||||
{item.optionLabels[0] || "风格化结果"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-2 rounded-lg border border-dashed border-white/12 p-5 text-center text-sm text-white/40">
|
||||
生成后会在这里保存历史。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
|
||||
<section className="flex min-w-0 flex-col overflow-hidden bg-[#0b0c0c]">
|
||||
<div className="border-white/10 border-b p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-emerald-300" />
|
||||
<h2 className="font-semibold text-lg">提示词按钮组合</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-white/45">
|
||||
完成必选项后执行,后台隐藏提示词会参与最终生成。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="rounded-lg border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
|
||||
onClick={quickFillRequired}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
自动补全必选
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-lg border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
|
||||
onClick={() => setSelections({})}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
清空选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-6 p-5">
|
||||
{configLoading ? (
|
||||
<div className="flex min-h-[180px] items-center justify-center rounded-lg border border-white/10 bg-white/[0.03] text-white/55">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
正在加载配置
|
||||
</div>
|
||||
) : !config?.enabled ? (
|
||||
<div className="rounded-lg border border-amber-400/30 bg-amber-400/10 p-5 text-amber-100">
|
||||
风格化 PRO 暂未启用,请联系管理员开启。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{enabledCategories.map((category, index) => {
|
||||
const selectedIds = selections[category.id] || [];
|
||||
const complete =
|
||||
selectedIds.length >=
|
||||
Math.max(
|
||||
category.required ? 1 : 0,
|
||||
category.minSelections
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-white/10 bg-white/[0.035] p-4"
|
||||
key={category.id}
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold",
|
||||
complete
|
||||
? "bg-emerald-300 text-black"
|
||||
: "bg-white/[0.08] text-white/65"
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-white">
|
||||
{category.title}
|
||||
</h3>
|
||||
{category.required ? (
|
||||
<Badge className="border-emerald-400/30 bg-emerald-400/10 text-emerald-200">
|
||||
必选
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
className="border-white/10 text-white/55"
|
||||
variant="outline"
|
||||
>
|
||||
可选
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{category.description ? (
|
||||
<p className="mt-1 text-sm text-white/45">
|
||||
{category.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 px-3 py-1 text-xs text-white/45">
|
||||
已选 {selectedIds.length}/{category.maxSelections}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{category.options.map((option) => {
|
||||
const selected = selectedIds.includes(option.id);
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-[96px] rounded-lg border p-4 text-left transition",
|
||||
selected
|
||||
? "border-emerald-300/80 bg-emerald-300/12 shadow-[0_0_0_1px_rgba(110,231,183,0.25)]"
|
||||
: "border-white/10 bg-black/25 hover:border-white/25 hover:bg-white/[0.05]"
|
||||
)}
|
||||
key={option.id}
|
||||
onClick={() => toggleOption(category, option.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="font-semibold text-sm text-white">
|
||||
{option.label}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="rounded-full bg-emerald-300 px-2 py-0.5 text-[11px] font-semibold text-black">
|
||||
已选
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{option.description ? (
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-white/45">
|
||||
{option.description}
|
||||
</p>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-white/10 bg-white/[0.035] p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">图像对比</h2>
|
||||
<p className="mt-1 text-sm text-white/45">
|
||||
生成完成后拖动滑块查看前后差异。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
className="rounded-lg bg-emerald-400 text-black hover:bg-emerald-300"
|
||||
disabled={generating || Boolean(disabledReason)}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
{generating ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{generating ? "生成中" : "开始生成"}
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-lg border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
|
||||
disabled={!currentResult}
|
||||
onClick={() => currentResult && onAddToCanvas?.(currentResult)}
|
||||
variant="outline"
|
||||
>
|
||||
添加到画布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{disabledReason ? (
|
||||
<div className="mb-3 rounded-lg border border-white/10 bg-black/35 px-3 py-2 text-sm text-white/45">
|
||||
{disabledReason}
|
||||
</div>
|
||||
) : null}
|
||||
<BeforeAfterSlider
|
||||
afterUrl={currentResult?.resultImageUrl}
|
||||
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
|
||||
generating={generating}
|
||||
/>
|
||||
{selectedOptionLabels.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{selectedOptionLabels.map((label) => (
|
||||
<Badge
|
||||
className="border-white/10 bg-white/[0.04] text-white/70"
|
||||
key={label}
|
||||
variant="outline"
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
docs/更新日志-2026-05-07至2026-05-12.md
Normal file
73
docs/更新日志-2026-05-07至2026-05-12.md
Normal file
@ -0,0 +1,73 @@
|
||||
# 更新日志(2026 年 5 月 7 日 - 2026 年 5 月 12 日)
|
||||
|
||||
本次更新重点围绕出图稳定性、无限画布效率、PSD 模板编辑能力和任务调度能力展开,整体目标是让高频创作场景更稳、更快、更顺手。
|
||||
|
||||
## 本次重点
|
||||
|
||||
- 生图并发与调度能力升级,高峰期多人同时使用时更稳定。
|
||||
- 无限画布继续增强,新增画布内图片裁剪,并优化多角度预览、缩放和批量工作流体验。
|
||||
- PSD 模板编辑器能力升级,图层编辑、素材回填和预览链路更完整。
|
||||
- 多个生图模型的尺寸、比例和参考图适配进一步修正,结果更贴近预期。
|
||||
- 3D 和增强类功能补齐显隐、计费与失败处理逻辑,使用更稳妥。
|
||||
- 钉钉登录、商品详情图、场景融合等链路稳定性持续提升。
|
||||
|
||||
## 详细更新
|
||||
|
||||
### 1. AI 生图与任务调度
|
||||
|
||||
- 提升了全站生图任务的并发处理能力,减少高峰时段的等待感。
|
||||
- 新增模型健康切换与恢复机制,当个别模型异常时,系统可更平滑地切换与恢复。
|
||||
- 优化图片意图识别与尺寸映射,减少提示词理解偏差和尺寸不匹配问题。
|
||||
- 修复多个图像模型在参考图比例保留上的问题,生成结果更贴近原图构图和画幅。
|
||||
|
||||
### 2. 无限画布与图片工作流
|
||||
|
||||
- 新增画布内图片裁剪能力,常用裁切操作无需离开当前工作区。
|
||||
- 多角度预览面板持续优化,界面层次、预览结构和交互手感更清晰。
|
||||
- 扩展批量处理与图片工作流入口,连续处理多张素材时更高效。
|
||||
- 修复画布缩放手势、工具栏开关同步和侧边工具弹层等交互问题。
|
||||
- 增强图片下载兜底逻辑,降低下载失败概率。
|
||||
|
||||
### 3. PSD 模板编辑器
|
||||
|
||||
- 强化 PSD 模板图层编辑能力,可编辑内容展示更完整。
|
||||
- 图层列表补充滚动能力,长列表场景下浏览和定位更顺畅。
|
||||
- 修复侧边栏溢出与局部重叠问题,编辑区域布局更稳定。
|
||||
- 优化 PSD 模板素材回填、解析、超时处理和渲染链路,复杂模板的可用性进一步提升。
|
||||
|
||||
### 4. 3D、增强与图片延展
|
||||
|
||||
- 3D 模型相关功能支持更细的后台显隐控制,前台展示更可控。
|
||||
- 补齐 3D 任务失败后的积分退回处理,扣费逻辑更稳妥。
|
||||
- Enhance Pro 配置完成新一轮升级,增强类能力的展示和配置更清晰。
|
||||
- 优化延展图和部分模型的尺寸参数传递,减少因参数不一致导致的异常。
|
||||
|
||||
### 5. 商品详情图与场景融合
|
||||
|
||||
- 商品详情图工作流已支持更稳定的参考图接入,细节生成更容易对齐素材。
|
||||
- 修复商品详情 API 兼容和渠道透传问题,减少链路丢参。
|
||||
- 优化场景融合的素材上传和替换流程,批量上传场景下更稳定。
|
||||
|
||||
### 6. 登录与基础稳定性
|
||||
|
||||
- 钉钉登录链路稳定性提升,登录与回调流程更可靠。
|
||||
- 调整部分 CSP 与中间件代理细节,提升页面脚本与请求链路兼容性。
|
||||
|
||||
## 可直接发给用户的简版
|
||||
|
||||
```md
|
||||
【本周更新】
|
||||
|
||||
过去一周,我们重点升级了出图稳定性、无限画布效率和 PSD 模板编辑能力。
|
||||
|
||||
1. 生图任务调度升级,多人同时使用时更稳定,等待感更低。
|
||||
2. 无限画布新增图片裁剪,并持续优化多角度预览、缩放和批量处理体验。
|
||||
3. PSD 模板编辑器进一步增强,图层编辑、素材回填和预览链路更完整。
|
||||
4. 多个生图模型的尺寸与参考图比例适配已修复,结果更贴近预期。
|
||||
5. 3D 与增强类功能补齐失败处理和配置能力,整体使用更稳妥。
|
||||
6. 钉钉登录、商品详情图、场景融合等链路稳定性继续提升。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
整理说明:本文根据 2026 年 5 月 7 日至 2026 年 5 月 12 日的 Git 提交记录整理,已按用户可感知体验做口径归纳。
|
||||
237
docs/更新日志-2026-05-10至2026-05-14.md
Normal file
237
docs/更新日志-2026-05-10至2026-05-14.md
Normal file
@ -0,0 +1,237 @@
|
||||
# Git 更新日志(2026-05-10 至 2026-05-14)
|
||||
|
||||
生成日期:2026-05-14
|
||||
统计分支:`main`
|
||||
统计范围:`2026-05-10 00:00:00` 至当前 `HEAD`
|
||||
基准提交:`510e3dc323f9cb501f390cb909941ec14d0b5c9d`
|
||||
当前提交:`769862c`(`fix: improve detail stitch and model recovery`)
|
||||
|
||||
## 总览
|
||||
|
||||
最近 4 天内,当前仓库共包含 `29` 个提交,主要集中在风格化详情替换、PSD 模板编辑、3D/世界查看器加载、AI 生图模型适配、画布生成结果同步等方向。
|
||||
|
||||
整体变更规模:
|
||||
|
||||
- 涉及文件:`72` 个
|
||||
- 代码增量:`5458 insertions`
|
||||
- 代码删减:`1440 deletions`
|
||||
- 主要目录分布:`lib` 30 个文件、`app` 19 个文件、`components` 16 个文件、`public` 3 个文件
|
||||
|
||||
按日期提交数量:
|
||||
|
||||
- `2026-05-14`:15 个提交
|
||||
- `2026-05-13`:5 个提交
|
||||
- `2026-05-12`:4 个提交
|
||||
- `2026-05-11`:5 个提交
|
||||
|
||||
## 核心更新摘要
|
||||
|
||||
### 1. 风格化详情替换与拼合切割流程增强
|
||||
|
||||
本轮更新对风格化页面的详情图替换能力做了持续强化,重点在“参考图拼合切割”“长图预览格式”“产品-only 替换”“下载速度”“模型恢复”几个方面。
|
||||
|
||||
主要变化:
|
||||
|
||||
- 新增/强化详情参考图拼合切割接口与前端弹窗交互。
|
||||
- 支持将详情参考图按切片结果进入替换流程,并保留当前工作台预览。
|
||||
- 优化详情替换下载链路,减少等待时间。
|
||||
- 增加产品-only 详情替换能力,适配只替换主体产品、不重绘整张详情图的场景。
|
||||
- 修复长图预览格式问题,降低拼合后预览比例异常的概率。
|
||||
- 优化详情拼合与模型恢复逻辑,提升失败后恢复和继续处理的稳定性。
|
||||
|
||||
重点相关提交:
|
||||
|
||||
- `769862c`:优化详情拼合与模型恢复
|
||||
- `25d331f`:优化风格化图片拼合与预览
|
||||
- `147f939`:支持 product-only 详情替换
|
||||
- `000ac13`:加速详情替换下载
|
||||
- `5f90dbd`:修复详情拼合长预览格式
|
||||
- `d0bb8b7`:支持批量商品 ID 填充参考图
|
||||
- `da6cae9`:收紧风格化详情 UI
|
||||
|
||||
### 2. PSD 模板编辑器持续修复
|
||||
|
||||
PSD 模板编辑器在这几天修复了图层交互、命中测试、预览层级、图层滚动等问题,整体目标是让模板编辑更稳定,减少误点、错层、无法滚动和预览不一致。
|
||||
|
||||
主要变化:
|
||||
|
||||
- 增强 PSD 图层编辑能力。
|
||||
- 修复图层列表无滚动条问题。
|
||||
- 改善图层交互命中区域,减少点不中或点错层。
|
||||
- 修复 PSD 预览层级堆叠问题。
|
||||
- 移除不合适的 PSD 图层位置控制,避免用户误操作或造成层级错乱。
|
||||
|
||||
重点相关提交:
|
||||
|
||||
- `4db458a`:改进 PSD 模板图层编辑
|
||||
- `1ed3184`:增加 PSD 图层列表滚动条
|
||||
- `5e78096`:优化 PSD 模板图层交互
|
||||
- `a3a41ef`:对齐 PSD 图层命中测试
|
||||
- `96d9551`:恢复 PSD 预览图层堆叠
|
||||
- `359b964`:移除 PSD 图层位置控制
|
||||
|
||||
### 3. 3D / 世界查看器加载与纹理修复
|
||||
|
||||
3D 相关模块本轮改动较多,集中解决浏览器加载远程模型、纹理、PBR 贴图、Tripo 资源代理、Basis 转码资源等问题,同时升级世界查看器体验。
|
||||
|
||||
主要变化:
|
||||
|
||||
- 恢复画布 3D 查看器的 PBR 纹理。
|
||||
- 加快世界查看器加载速度。
|
||||
- 增加 3D 归档资源代理,解决浏览器跨域/私有资源加载问题。
|
||||
- 代理 Tripo 贴图资源,保证模型贴图能在前端稳定加载。
|
||||
- 加入 Basis transcoder 资源,支持压缩纹理转码。
|
||||
- 世界查看器升级到 Spark 2。
|
||||
|
||||
重点相关提交:
|
||||
|
||||
- `89ec01c`:恢复 3D PBR 纹理
|
||||
- `dee8019`:加速 world viewer 加载
|
||||
- `2f5c2fc`:代理归档 3D 资产供浏览器加载
|
||||
- `5864e9f`:代理 Tripo 纹理资源
|
||||
- `95572bd`:允许 Basis transcoder 资产用于 3D 纹理
|
||||
- `1c935bf`:升级 world viewer 到 Spark 2
|
||||
|
||||
### 4. AI 生图模型、价格与故障恢复
|
||||
|
||||
AI 模型层面,本轮主要围绕多服务商模型适配、图像比例参数、GPT Image 尺寸映射、模型健康恢复池和价格配置展开。
|
||||
|
||||
主要变化:
|
||||
|
||||
- 增加 AI 模型健康恢复池,便于模型失败后进行自动恢复或备用调度。
|
||||
- 修复/适配 GRSai GPT Image 尺寸映射。
|
||||
- 保留 GRSai GPT Image 参考图比例。
|
||||
- 保留 Volcano 图片比例。
|
||||
- 修复 BLTCY 图片比例参数传递。
|
||||
- 适配 Ruxa Nano Banana 2 运行时。
|
||||
- 增加折扣模型价格配置。
|
||||
|
||||
重点相关提交:
|
||||
|
||||
- `3ebe7db`:增加 AI 模型健康恢复池
|
||||
- `2cf2162`:映射 GRSai GPT Image 尺寸
|
||||
- `44d4a80`:保留 GRSai GPT Image 参考图比例
|
||||
- `a0e303a`:保留 Volcano 图片比例
|
||||
- `40e7ecc`:传递 BLTCY 图片比例参数
|
||||
- `f0cb1a4`:适配 Ruxa Nano Banana 2 运行时
|
||||
- `1b1ff65`:增加折扣模型价格并修复实时 3D 媒体处理
|
||||
|
||||
### 5. 画布生成结果同步与输入体验
|
||||
|
||||
画布侧主要改动是让生成结果能更快同步到前端,减少“后端已完成但前端不显示”的体验问题;同时对图片意图判断、输入图尺寸和部分实时媒体处理做了修复。
|
||||
|
||||
主要变化:
|
||||
|
||||
- 画布生成结果立即同步。
|
||||
- 改善智能体图片意图识别,减少模型选错工作流。
|
||||
- 修复实时 3D 媒体处理。
|
||||
- 修复登录与图片尺寸相关问题。
|
||||
|
||||
重点相关提交:
|
||||
|
||||
- `86b1ee9`:立即同步画布生成结果
|
||||
- `dd4426d`:改进智能体图片意图处理
|
||||
- `1b1ff65`:修复实时 3D 媒体处理
|
||||
- `40848ff`:稳定钉钉登录和图片尺寸
|
||||
|
||||
## 按日期提交明细
|
||||
|
||||
### 2026-05-14
|
||||
|
||||
| 提交 | 时间 | 说明 | 规模 |
|
||||
| --- | --- | --- | --- |
|
||||
| `769862c` | 16:35:16 | 修复详情拼合与模型恢复 | 7 files, +273/-152 |
|
||||
| `359b964` | 16:22:59 | 移除 PSD 图层位置控制 | 1 file, +4/-107 |
|
||||
| `96d9551` | 16:15:32 | 恢复 PSD 预览图层堆叠 | 1 file, +1/-2 |
|
||||
| `5f90dbd` | 15:48:48 | 修复详情拼合长图预览格式 | 1 file, +5/-4 |
|
||||
| `a3a41ef` | 15:48:13 | 修复 PSD 图层命中测试 | 1 file, +23/-2 |
|
||||
| `5e78096` | 15:35:54 | 优化 PSD 模板图层交互 | 1 file, +78/-46 |
|
||||
| `25d331f` | 15:28:09 | 优化风格化图片拼合与预览 | 4 files, +782/-387 |
|
||||
| `95572bd` | 15:08:33 | 允许 Basis transcoder 资源加载 3D 纹理 | 1 file, +8/-1 |
|
||||
| `1c935bf` | 14:57:56 | 世界查看器升级到 Spark 2 | 4 files, +96/-21 |
|
||||
| `147f939` | 14:52:01 | 支持 product-only 详情替换 | 1 file, +78/-77 |
|
||||
| `000ac13` | 14:36:37 | 加速详情替换下载 | 7 files, +658/-258 |
|
||||
| `5864e9f` | 14:34:29 | 代理 Tripo 纹理资源 | 2 files, +3 |
|
||||
| `2f5c2fc` | 12:04:45 | 代理归档 3D 资产供浏览器加载 | 2 files, +3 |
|
||||
| `dee8019` | 11:46:16 | 加快世界查看器加载 | 6 files, +105/-15 |
|
||||
| `89ec01c` | 10:48:39 | 恢复画布 3D PBR 纹理 | 4 files, +93/-14 |
|
||||
|
||||
### 2026-05-13
|
||||
|
||||
| 提交 | 时间 | 说明 | 规模 |
|
||||
| --- | --- | --- | --- |
|
||||
| `86b1ee9` | 20:36:08 | 立即同步画布生成结果 | 1 file, +202/-2 |
|
||||
| `1b1ff65` | 20:02:52 | 增加折扣模型价格并修复实时 3D 媒体处理 | 20 files, +583/-145 |
|
||||
| `d0bb8b7` | 18:37:03 | 支持批量商品 ID 参考图填充 | 2 files, +293/-6 |
|
||||
| `da6cae9` | 18:33:43 | 收紧风格化详情 UI | 2 files, +575/-397 |
|
||||
| `f0cb1a4` | 12:25:16 | 适配 Ruxa Nano Banana 2 运行时 | 1 file, +39/-4 |
|
||||
|
||||
### 2026-05-12
|
||||
|
||||
| 提交 | 时间 | 说明 | 规模 |
|
||||
| --- | --- | --- | --- |
|
||||
| `40e7ecc` | 17:18:26 | 传递 BLTCY 图片比例参数 | 1 file, +7/-8 |
|
||||
| `a0e303a` | 16:40:21 | 保留 Volcano 图片比例 | 1 file, +264/-17 |
|
||||
| `4db458a` | 14:44:29 | 改进 PSD 模板图层编辑 | 5 files, +693/-60 |
|
||||
| `44d4a80` | 14:36:56 | 保留 GRSai GPT Image 参考图比例 | 1 file, +52/-2 |
|
||||
|
||||
### 2026-05-11
|
||||
|
||||
| 提交 | 时间 | 说明 | 规模 |
|
||||
| --- | --- | --- | --- |
|
||||
| `2cf2162` | 21:03:53 | 映射 GRSai GPT Image 尺寸 | 4 files, +123/-6 |
|
||||
| `dd4426d` | 20:31:17 | 改进智能体图片意图处理 | 9 files, +413/-60 |
|
||||
| `3ebe7db` | 20:27:21 | 增加 AI 模型健康恢复池 | 7 files, +274/-17 |
|
||||
| `1ed3184` | 15:01:27 | 增加 PSD 图层列表滚动条 | 1 file, +29/-8 |
|
||||
| `40848ff` | 14:24:34 | 稳定钉钉登录与图片尺寸 | 5 files, +157/-78 |
|
||||
|
||||
## 高频改动文件
|
||||
|
||||
以下文件在最近 4 天内被多次修改,属于本轮迭代的重点区域:
|
||||
|
||||
- `components/agents/AgentPsdTemplateEditorDialog.tsx`:7 次,PSD 模板编辑器核心交互。
|
||||
- `app/(chat)/api/chat/route.ts`:4 次,聊天/生图请求主链路。
|
||||
- `app/api/canvas/3d/model-proxy/route.ts`:4 次,3D 模型与纹理资源代理。
|
||||
- `components/agents/AgentDetailReferenceStitchDialog.tsx`:3 次,详情参考图拼合切割弹窗。
|
||||
- `app/api/agent-workflow/detail-stitch/route.ts`:3 次,详情拼合切割后端接口。
|
||||
- `components/canvas/CanvasChatPanel.tsx`:3 次,画布聊天与生成结果同步。
|
||||
- `lib/db/queries.ts`:3 次,数据库查询与任务/模型状态相关逻辑。
|
||||
|
||||
## 重点影响面
|
||||
|
||||
### 用户侧
|
||||
|
||||
- 风格化详情替换流程的可用性明显增强,尤其是拼合切割、参考图替换、长图预览和产品-only 替换。
|
||||
- PSD 模板编辑体验更稳定,图层滚动、命中、预览层级等问题被集中修复。
|
||||
- 3D/世界查看器加载更稳定,贴图丢失和远程资源加载失败问题被重点处理。
|
||||
- 画布生图结果同步更及时,减少等待或刷新才能看到结果的情况。
|
||||
|
||||
### 管理侧
|
||||
|
||||
- AI 模型配置能力增强,包括健康恢复池、价格配置和多服务商比例参数适配。
|
||||
- PSD 模板和详情替换相关后台接口更完整。
|
||||
- 钉钉登录回调和图片尺寸处理更稳。
|
||||
|
||||
### 技术侧
|
||||
|
||||
- 新增 `app/api/agent-workflow/detail-stitch/route.ts`,说明详情拼合切割进入了独立 API 化阶段。
|
||||
- 新增 `lib/ai/image-model-pricing.ts` 和模型健康恢复相关逻辑,说明模型调度正在从“单次调用”走向“可配置、可恢复、可计费”的系统能力。
|
||||
- 新增 `public/three/basis/*`,说明 3D 纹理加载开始引入浏览器侧转码资产。
|
||||
- `lib/ai/providers/*` 多个供应商适配被修改,近期如果新增模型,需要重点检查比例、尺寸和参考图参数是否一致。
|
||||
|
||||
## 建议验证清单
|
||||
|
||||
上线或回归时建议重点验证以下路径:
|
||||
|
||||
- 风格化详情模式:搜索详情图、拼合切割、标记不处理、生成替换、结果预览、下载。
|
||||
- PSD 模板:图层点击、文字编辑、预览层级、图层列表滚动、导出结果一致性。
|
||||
- 画布生图:生成完成后是否自动显示结果,不需要刷新页面。
|
||||
- 3D 查看器:不同来源的模型、PBR 纹理、Tripo 贴图、归档资源是否能正常加载。
|
||||
- AI 模型:GRSai GPT Image、Volcano、BLTCY、Ruxa Nano Banana 2 的比例与尺寸参数是否符合预期。
|
||||
- 登录:钉钉登录、回调页、图片尺寸处理是否正常。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 这份日志基于本地 `main` 分支 Git 历史生成,没有包含未提交文件。
|
||||
- 当前统计范围从 `2026-05-10 00:00:00` 开始,但该日没有查询到提交,实际提交集中在 `2026-05-11` 至 `2026-05-14`。
|
||||
- 当前工作区存在构建缓存/未跟踪文档类文件时,不影响本日志对已提交历史的统计。
|
||||
@ -58,10 +58,16 @@ function normalizeReferenceItems(value: unknown): AgentDetailReplacementReferenc
|
||||
const id = String(raw.id || "").trim() || `reference-${index + 1}`;
|
||||
const label = String(raw.label || "").trim() || `参考图 ${index + 1}`;
|
||||
const imageUrl = String(raw.imageUrl || "").trim() || null;
|
||||
const previewUrl = String(raw.previewUrl || "").trim() || null;
|
||||
const width = Number(raw.width);
|
||||
const height = Number(raw.height);
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
imageUrl,
|
||||
previewUrl,
|
||||
width: Number.isFinite(width) && width > 0 ? Math.round(width) : null,
|
||||
height: Number.isFinite(height) && height > 0 ? Math.round(height) : null,
|
||||
candidateId: String(raw.candidateId || "").trim() || null,
|
||||
sortOrder: Number.isFinite(Number(raw.sortOrder))
|
||||
? Math.max(0, Math.min(999, Math.round(Number(raw.sortOrder))))
|
||||
|
||||
@ -43,6 +43,12 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
|
||||
label: "风格化模式",
|
||||
description: "进入无限画布风格化工作区",
|
||||
},
|
||||
{
|
||||
key: "stylePro",
|
||||
group: "画布主工具栏",
|
||||
label: "风格化 PRO",
|
||||
description: "进入风格化 PRO 子页面",
|
||||
},
|
||||
{
|
||||
key: "batchProcessing",
|
||||
group: "画布主工具栏",
|
||||
|
||||
417
lib/style-pro/config.ts
Normal file
417
lib/style-pro/config.ts
Normal file
@ -0,0 +1,417 @@
|
||||
export const STYLE_PRO_CONFIG_KEY = "style_pro_config";
|
||||
|
||||
export type StyleProOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
prompt: string;
|
||||
jsonKey?: string;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type StyleProCategory = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
minSelections: number;
|
||||
maxSelections: number;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
options: StyleProOption[];
|
||||
};
|
||||
|
||||
export type StyleProConfig = {
|
||||
enabled: boolean;
|
||||
modelId: string;
|
||||
defaultPrompt: string;
|
||||
negativePrompt: string;
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
categories: StyleProCategory[];
|
||||
};
|
||||
|
||||
export type PublicStyleProOption = Omit<StyleProOption, "prompt">;
|
||||
|
||||
export type PublicStyleProCategory = Omit<StyleProCategory, "options"> & {
|
||||
options: PublicStyleProOption[];
|
||||
};
|
||||
|
||||
export type PublicStyleProConfig = Omit<
|
||||
StyleProConfig,
|
||||
"modelId" | "defaultPrompt" | "negativePrompt" | "categories"
|
||||
> & {
|
||||
categories: PublicStyleProCategory[];
|
||||
};
|
||||
|
||||
export type StyleProSelectionMap = Record<string, string[]>;
|
||||
|
||||
export type StyleProHistoryItem = {
|
||||
id: string;
|
||||
sourceImageUrl: string;
|
||||
resultImageUrl: string;
|
||||
selections: StyleProSelectionMap;
|
||||
optionLabels: string[];
|
||||
createdAt: string;
|
||||
model?: string;
|
||||
modelRef?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export type StyleProPersistedState = {
|
||||
sourceImageUrl?: string;
|
||||
history: StyleProHistoryItem[];
|
||||
};
|
||||
|
||||
const DEFAULT_PROMPT = [
|
||||
"你是专业商业视觉风格化摄影师。",
|
||||
"请以用户上传的图片作为主体参考,保持主体结构、数量、相对位置和核心识别特征一致。",
|
||||
"可以根据用户选择调整光线、材质、氛围、机位与画面质感。",
|
||||
"不要新增无关主体,不要改变产品类别,不要破坏原始构图逻辑。",
|
||||
].join("\n");
|
||||
|
||||
export const defaultStyleProConfig: StyleProConfig = {
|
||||
enabled: true,
|
||||
modelId: "",
|
||||
defaultPrompt: DEFAULT_PROMPT,
|
||||
negativePrompt: "低清晰度,畸变,文字乱码,主体变形,产品结构错误,多余物体,水印",
|
||||
aspectRatio: "auto",
|
||||
imageSize: "2K",
|
||||
categories: [
|
||||
{
|
||||
id: "audience",
|
||||
title: "人群选择",
|
||||
description: "定义画面服务的人群与生活方式基调。",
|
||||
required: true,
|
||||
minSelections: 1,
|
||||
maxSelections: 1,
|
||||
enabled: true,
|
||||
order: 10,
|
||||
options: [
|
||||
{
|
||||
id: "young-family",
|
||||
label: "有孩家庭",
|
||||
description: "温暖、亲近、真实生活气息。",
|
||||
prompt:
|
||||
"目标人群为有孩家庭,画面需要温暖、亲近、可信,呈现真实生活场景中的舒适使用感。",
|
||||
jsonKey: "audience",
|
||||
enabled: true,
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
id: "young-couple",
|
||||
label: "年轻情侣 / 夫妻",
|
||||
description: "轻松、共享空间、现代审美。",
|
||||
prompt:
|
||||
"目标人群为年轻情侣或年轻夫妻,画面强调共享空间、轻松陪伴和现代审美。",
|
||||
jsonKey: "audience",
|
||||
enabled: true,
|
||||
order: 20,
|
||||
},
|
||||
{
|
||||
id: "solo-youth",
|
||||
label: "独居青年",
|
||||
description: "自我表达、松弛、精致小空间。",
|
||||
prompt:
|
||||
"目标人群为独居青年,画面强调自我表达、松弛感、精致小空间和个人生活方式。",
|
||||
jsonKey: "audience",
|
||||
enabled: true,
|
||||
order: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "style",
|
||||
title: "主题风格",
|
||||
description: "确定整体视觉方向。",
|
||||
required: true,
|
||||
minSelections: 1,
|
||||
maxSelections: 1,
|
||||
enabled: true,
|
||||
order: 20,
|
||||
options: [
|
||||
{
|
||||
id: "modern-natural",
|
||||
label: "自然生活",
|
||||
description: "自然光、柔和材质、干净舒服。",
|
||||
prompt:
|
||||
"主题风格为自然生活方式,使用自然光、柔和材质、干净舒适的空间层次,避免过度商业棚拍感。",
|
||||
jsonKey: "style",
|
||||
enabled: true,
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
id: "commercial-premium",
|
||||
label: "商业高级",
|
||||
description: "精致布光、高质感、成片感强。",
|
||||
prompt:
|
||||
"主题风格为商业高级视觉,画面需要精致布光、高质感材质、清晰主体和成熟广告成片感。",
|
||||
jsonKey: "style",
|
||||
enabled: true,
|
||||
order: 20,
|
||||
},
|
||||
{
|
||||
id: "retro-warm",
|
||||
label: "复古暖调",
|
||||
description: "胶片感、温暖、怀旧但干净。",
|
||||
prompt:
|
||||
"主题风格为复古暖调,保留干净商业画质,同时加入温暖胶片质感和轻微怀旧氛围。",
|
||||
jsonKey: "style",
|
||||
enabled: true,
|
||||
order: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "light",
|
||||
title: "光氛选择",
|
||||
description: "定义光线时间和明暗关系。",
|
||||
required: true,
|
||||
minSelections: 1,
|
||||
maxSelections: 1,
|
||||
enabled: true,
|
||||
order: 30,
|
||||
options: [
|
||||
{
|
||||
id: "sunrise-edge",
|
||||
label: "日出边缘光",
|
||||
description: "短暂临界时段,明暗对比强。",
|
||||
prompt:
|
||||
"时间效果:太阳上边缘接触地平线至完全跃出地平线的 1-3 分钟;光线特征:直射光强但不刺眼,红橙光占比高,色温约 1800K-2500K,带有明显体积感;视觉特征:以主体为核心视点,地面形成长阴影,画面暖调饱满,明暗对比强烈。",
|
||||
jsonKey: "light",
|
||||
enabled: true,
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
id: "soft-window",
|
||||
label: "自然窗光",
|
||||
description: "柔和、真实、生活感。",
|
||||
prompt:
|
||||
"光线为自然窗光,柔和漫射,保留真实阴影层次,画面舒适通透,避免硬阴影和过曝。",
|
||||
jsonKey: "light",
|
||||
enabled: true,
|
||||
order: 20,
|
||||
},
|
||||
{
|
||||
id: "cinematic",
|
||||
label: "电影氛围光",
|
||||
description: "方向明确,层次和空间感强。",
|
||||
prompt:
|
||||
"光线为电影氛围光,方向明确,明暗层次清晰,空间纵深强,主体边缘有细腻轮廓光。",
|
||||
jsonKey: "light",
|
||||
enabled: true,
|
||||
order: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "camera",
|
||||
title: "机位选择",
|
||||
description: "控制视角和镜头语言。",
|
||||
required: false,
|
||||
minSelections: 0,
|
||||
maxSelections: 1,
|
||||
enabled: true,
|
||||
order: 40,
|
||||
options: [
|
||||
{
|
||||
id: "wide-perspective",
|
||||
label: "广角透视",
|
||||
description: "空间开阔,纵深明显。",
|
||||
prompt:
|
||||
"机位使用广角透视,空间开阔,纵深明显,但主体比例保持自然,不产生夸张畸变。",
|
||||
jsonKey: "camera",
|
||||
enabled: true,
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
id: "eye-level",
|
||||
label: "平视机位",
|
||||
description: "稳定真实,适合商品主体。",
|
||||
prompt:
|
||||
"机位使用平视视角,画面稳定真实,主体呈现清晰完整,适合商业商品展示。",
|
||||
jsonKey: "camera",
|
||||
enabled: true,
|
||||
order: 20,
|
||||
},
|
||||
{
|
||||
id: "close-detail",
|
||||
label: "近景细节",
|
||||
description: "突出材质和光影细节。",
|
||||
prompt:
|
||||
"机位偏近景细节,突出材质、纹理、边缘光和主体工艺感,同时保留可识别的整体结构。",
|
||||
jsonKey: "camera",
|
||||
enabled: true,
|
||||
order: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function cleanString(value: unknown, fallback = "", maxLength = 4000) {
|
||||
const text = String(value ?? "").trim();
|
||||
return (text || fallback).slice(0, maxLength);
|
||||
}
|
||||
|
||||
function cleanId(value: unknown, fallback: string) {
|
||||
const text = cleanString(value, fallback, 80)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
function cleanNumber(value: unknown, fallback: number, min: number, max: number) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.round(numeric)));
|
||||
}
|
||||
|
||||
function sortByOrder<T extends { order: number; id: string }>(items: T[]) {
|
||||
return [...items].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
export function normalizeStyleProConfig(value: unknown): StyleProConfig {
|
||||
const raw = asRecord(value);
|
||||
const fallback = defaultStyleProConfig;
|
||||
const rawCategories = Array.isArray(raw.categories)
|
||||
? raw.categories
|
||||
: fallback.categories;
|
||||
|
||||
const categories = rawCategories
|
||||
.map((categoryValue, categoryIndex) => {
|
||||
const categoryRaw = asRecord(categoryValue);
|
||||
const id = cleanId(categoryRaw.id, `category-${categoryIndex + 1}`);
|
||||
const rawOptions = Array.isArray(categoryRaw.options)
|
||||
? categoryRaw.options
|
||||
: [];
|
||||
const maxSelections = cleanNumber(categoryRaw.maxSelections, 1, 1, 20);
|
||||
const minSelections = cleanNumber(
|
||||
categoryRaw.minSelections,
|
||||
categoryRaw.required === false ? 0 : 1,
|
||||
0,
|
||||
maxSelections
|
||||
);
|
||||
const options = rawOptions
|
||||
.map((optionValue, optionIndex) => {
|
||||
const optionRaw = asRecord(optionValue);
|
||||
const optionId = cleanId(optionRaw.id, `${id}-option-${optionIndex + 1}`);
|
||||
return {
|
||||
id: optionId,
|
||||
label: cleanString(optionRaw.label, `选项 ${optionIndex + 1}`, 80),
|
||||
description: cleanString(optionRaw.description, "", 240) || undefined,
|
||||
prompt: cleanString(optionRaw.prompt, "", 4000),
|
||||
jsonKey: cleanString(optionRaw.jsonKey, "", 80) || undefined,
|
||||
enabled:
|
||||
typeof optionRaw.enabled === "boolean" ? optionRaw.enabled : true,
|
||||
order: cleanNumber(optionRaw.order, (optionIndex + 1) * 10, -9999, 9999),
|
||||
} satisfies StyleProOption;
|
||||
})
|
||||
.filter((option) => option.label && option.prompt);
|
||||
|
||||
return {
|
||||
id,
|
||||
title: cleanString(categoryRaw.title, `分类 ${categoryIndex + 1}`, 80),
|
||||
description: cleanString(categoryRaw.description, "", 240) || undefined,
|
||||
required:
|
||||
typeof categoryRaw.required === "boolean" ? categoryRaw.required : true,
|
||||
minSelections,
|
||||
maxSelections,
|
||||
enabled:
|
||||
typeof categoryRaw.enabled === "boolean" ? categoryRaw.enabled : true,
|
||||
order: cleanNumber(categoryRaw.order, (categoryIndex + 1) * 10, -9999, 9999),
|
||||
options: sortByOrder(options),
|
||||
} satisfies StyleProCategory;
|
||||
})
|
||||
.filter((category) => category.title && category.options.length > 0);
|
||||
|
||||
return {
|
||||
enabled: typeof raw.enabled === "boolean" ? raw.enabled : fallback.enabled,
|
||||
modelId: cleanString(raw.modelId, fallback.modelId, 160),
|
||||
defaultPrompt: cleanString(raw.defaultPrompt, fallback.defaultPrompt, 8000),
|
||||
negativePrompt: cleanString(raw.negativePrompt, fallback.negativePrompt, 4000),
|
||||
aspectRatio: cleanString(raw.aspectRatio, fallback.aspectRatio, 32),
|
||||
imageSize: cleanString(raw.imageSize, fallback.imageSize, 32),
|
||||
categories: sortByOrder(categories.length ? categories : fallback.categories),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeStyleProPersistedState(
|
||||
value: unknown
|
||||
): StyleProPersistedState {
|
||||
const raw = asRecord(value);
|
||||
const historyRaw = Array.isArray(raw.history) ? raw.history : [];
|
||||
const history = historyRaw
|
||||
.map((historyValue, index) => {
|
||||
const itemRaw = asRecord(historyValue);
|
||||
const sourceImageUrl = cleanString(itemRaw.sourceImageUrl, "", 2000);
|
||||
const resultImageUrl = cleanString(itemRaw.resultImageUrl, "", 2000);
|
||||
if (!sourceImageUrl || !resultImageUrl) return null;
|
||||
const selectionsRaw = asRecord(itemRaw.selections);
|
||||
const selections = Object.fromEntries(
|
||||
Object.entries(selectionsRaw).map(([key, value]) => [
|
||||
cleanId(key, key),
|
||||
Array.isArray(value)
|
||||
? value.map((entry) => cleanId(entry, "")).filter(Boolean)
|
||||
: [],
|
||||
])
|
||||
);
|
||||
const normalizedItem: StyleProHistoryItem = {
|
||||
id: cleanString(itemRaw.id, `style-pro-history-${index + 1}`, 120),
|
||||
sourceImageUrl,
|
||||
resultImageUrl,
|
||||
selections,
|
||||
optionLabels: Array.isArray(itemRaw.optionLabels)
|
||||
? itemRaw.optionLabels
|
||||
.map((entry) => cleanString(entry, "", 80))
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
createdAt:
|
||||
cleanString(itemRaw.createdAt, "", 80) || new Date(0).toISOString(),
|
||||
};
|
||||
const model = cleanString(itemRaw.model, "", 120);
|
||||
const modelRef = cleanString(itemRaw.modelRef, "", 160);
|
||||
const width = cleanNumber(itemRaw.width, 0, 0, 20000);
|
||||
const height = cleanNumber(itemRaw.height, 0, 0, 20000);
|
||||
if (model) normalizedItem.model = model;
|
||||
if (modelRef) normalizedItem.modelRef = modelRef;
|
||||
if (width) normalizedItem.width = width;
|
||||
if (height) normalizedItem.height = height;
|
||||
return normalizedItem;
|
||||
})
|
||||
.filter((item): item is StyleProHistoryItem => Boolean(item))
|
||||
.slice(0, 4);
|
||||
|
||||
return {
|
||||
sourceImageUrl: cleanString(raw.sourceImageUrl, "", 2000) || undefined,
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPublicStyleProConfig(
|
||||
config: StyleProConfig
|
||||
): PublicStyleProConfig {
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
aspectRatio: config.aspectRatio,
|
||||
imageSize: config.imageSize,
|
||||
categories: config.categories
|
||||
.filter((category) => category.enabled)
|
||||
.map((category) => ({
|
||||
...category,
|
||||
options: category.options
|
||||
.filter((option) => option.enabled)
|
||||
.map(({ prompt: _prompt, ...option }) => option),
|
||||
}))
|
||||
.filter((category) => category.options.length > 0),
|
||||
};
|
||||
}
|
||||
@ -11,6 +11,10 @@ import type {
|
||||
Size,
|
||||
NodeType,
|
||||
} from '@/types/canvas';
|
||||
import {
|
||||
normalizeStyleProPersistedState,
|
||||
type StyleProPersistedState,
|
||||
} from '@/lib/style-pro/config';
|
||||
|
||||
// 生成唯一 ID
|
||||
const generateId = () => crypto.randomUUID();
|
||||
@ -88,6 +92,7 @@ interface CanvasStore {
|
||||
viewport: Viewport;
|
||||
tool: ToolType;
|
||||
suppressedMediaKeys: string[];
|
||||
styleProState: StyleProPersistedState;
|
||||
|
||||
// 历史记录(用于撤销/重做)
|
||||
history: CanvasState[];
|
||||
@ -151,6 +156,7 @@ interface CanvasStore {
|
||||
// UI 操作
|
||||
togglePanel: () => void;
|
||||
setGenerating: (isGenerating: boolean) => void;
|
||||
setStyleProState: (styleProState: StyleProPersistedState) => void;
|
||||
|
||||
// 批量操作
|
||||
loadCanvas: (state: Partial<CanvasState>) => void;
|
||||
@ -196,6 +202,7 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
viewport: defaultViewport,
|
||||
tool: 'select',
|
||||
suppressedMediaKeys: [],
|
||||
styleProState: { history: [] },
|
||||
history: [],
|
||||
historyIndex: -1,
|
||||
isPanelOpen: true,
|
||||
@ -508,6 +515,8 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
selectedNodeIds: [...state.selectedNodeIds],
|
||||
viewport: { ...state.viewport },
|
||||
tool: state.tool,
|
||||
suppressedMediaKeys: [...state.suppressedMediaKeys],
|
||||
styleProState: normalizeStyleProPersistedState(state.styleProState),
|
||||
};
|
||||
|
||||
// 移除当前位置之后的历史
|
||||
@ -531,6 +540,10 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
state.isGenerating = isGenerating;
|
||||
}),
|
||||
|
||||
setStyleProState: (styleProState) => set((state) => {
|
||||
state.styleProState = normalizeStyleProPersistedState(styleProState);
|
||||
}),
|
||||
|
||||
// 批量操作
|
||||
loadCanvas: (canvasState) => set((state) => {
|
||||
if (canvasState.nodes) state.nodes = canvasState.nodes;
|
||||
@ -542,6 +555,7 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
state.suppressedMediaKeys = Array.isArray(canvasState.suppressedMediaKeys)
|
||||
? canvasState.suppressedMediaKeys.map((item) => String(item || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
state.styleProState = normalizeStyleProPersistedState(canvasState.styleProState);
|
||||
}),
|
||||
|
||||
clearCanvas: () => set((state) => {
|
||||
@ -550,6 +564,7 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
state.selectedNodeIds = [];
|
||||
state.viewport = defaultViewport;
|
||||
state.suppressedMediaKeys = [];
|
||||
state.styleProState = { history: [] };
|
||||
state.history = [];
|
||||
state.historyIndex = -1;
|
||||
}),
|
||||
@ -563,6 +578,7 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
viewport: state.viewport,
|
||||
tool: state.tool,
|
||||
suppressedMediaKeys: state.suppressedMediaKeys,
|
||||
styleProState: normalizeStyleProPersistedState(state.styleProState),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@ -402,6 +402,9 @@ export interface AgentDetailReplacementReferenceItem {
|
||||
id: string;
|
||||
label: string;
|
||||
imageUrl?: string | null;
|
||||
previewUrl?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
candidateId?: string | null;
|
||||
sortOrder?: number;
|
||||
assignedProductIndex?: number | null;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { AgentWorkflowPersistedState } from "@/types/agent";
|
||||
import type { UiScreenDocument } from "@/lib/ui-screen/schema";
|
||||
import type { StyleProPersistedState } from "@/lib/style-pro/config";
|
||||
|
||||
export type CanvasWorkspaceType = 'canvas' | 'agent' | 'video';
|
||||
|
||||
@ -239,6 +240,7 @@ export interface CanvasState {
|
||||
tool: ToolType;
|
||||
suppressedMediaKeys?: string[];
|
||||
workflowState?: AgentWorkflowPersistedState;
|
||||
styleProState?: StyleProPersistedState;
|
||||
}
|
||||
|
||||
// 画布项目
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user