feat: polish agent workflow showcase and layout
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-18 01:18:58 +08:00
parent f764767ba4
commit 1f078d5b37
14 changed files with 1472 additions and 424 deletions

View File

@ -22,6 +22,10 @@ import {
defaultAgentWorkflowBrandModelConfig, defaultAgentWorkflowBrandModelConfig,
normalizeAgentWorkflowBrandModelConfig, normalizeAgentWorkflowBrandModelConfig,
} from "@/lib/agents/workflow-brand-model-config"; } from "@/lib/agents/workflow-brand-model-config";
import {
defaultAgentWorkflowShowcaseConfig,
normalizeAgentWorkflowShowcaseConfig,
} from "@/lib/agents/workflow-showcase-config";
import { import {
defaultAgentWorkflowMaterialApiConfig, defaultAgentWorkflowMaterialApiConfig,
normalizeAgentWorkflowMaterialApiConfig, normalizeAgentWorkflowMaterialApiConfig,
@ -37,6 +41,7 @@ import type {
AgentWorkflowBrandModelConfig, AgentWorkflowBrandModelConfig,
AgentWorkflowMaterialApiConfig, AgentWorkflowMaterialApiConfig,
AgentWorkflowOptionUiConfig, AgentWorkflowOptionUiConfig,
AgentWorkflowShowcaseConfig,
AgentWorkflowStepUiConfig, AgentWorkflowStepUiConfig,
AgentWorkflowUiConfig, AgentWorkflowUiConfig,
} from "@/types/agent"; } from "@/types/agent";
@ -132,11 +137,15 @@ export function AgentWorkflowSettingsClient({
agentWorkflowUiConfig?: AgentWorkflowUiConfig; agentWorkflowUiConfig?: AgentWorkflowUiConfig;
agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig; agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig;
agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig; agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig;
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
}>("/api/site-settings", fetcher); }>("/api/site-settings", fetcher);
const [config, setConfig] = useState<AgentWorkflowUiConfig>(defaultAgentWorkflowUiConfig); const [config, setConfig] = useState<AgentWorkflowUiConfig>(defaultAgentWorkflowUiConfig);
const [materialApiConfig, setMaterialApiConfig] = useState<AgentWorkflowMaterialApiConfig>( const [materialApiConfig, setMaterialApiConfig] = useState<AgentWorkflowMaterialApiConfig>(
defaultAgentWorkflowMaterialApiConfig defaultAgentWorkflowMaterialApiConfig
); );
const [showcaseConfig, setShowcaseConfig] = useState<AgentWorkflowShowcaseConfig>(
defaultAgentWorkflowShowcaseConfig
);
const [brandModelConfig, setBrandModelConfig] = useState<AgentWorkflowBrandModelConfig>( const [brandModelConfig, setBrandModelConfig] = useState<AgentWorkflowBrandModelConfig>(
defaultAgentWorkflowBrandModelConfig defaultAgentWorkflowBrandModelConfig
); );
@ -150,6 +159,9 @@ export function AgentWorkflowSettingsClient({
setMaterialApiConfig( setMaterialApiConfig(
normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig) normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig)
); );
setShowcaseConfig(
normalizeAgentWorkflowShowcaseConfig(data.agentWorkflowShowcaseConfig)
);
setBrandModelConfig( setBrandModelConfig(
normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig) normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig)
); );
@ -279,6 +291,7 @@ export function AgentWorkflowSettingsClient({
agentWorkflowUiConfig: config, agentWorkflowUiConfig: config,
agentWorkflowMaterialApiConfig: materialApiConfig, agentWorkflowMaterialApiConfig: materialApiConfig,
agentWorkflowBrandModelConfig: brandModelConfig, agentWorkflowBrandModelConfig: brandModelConfig,
agentWorkflowShowcaseConfig: showcaseConfig,
}), }),
}); });
@ -395,6 +408,43 @@ export function AgentWorkflowSettingsClient({
})); }));
}; };
const handleUploadShowcaseAsset = async (
target: "video" | "poster",
file: File
) => {
const isVideo = target === "video";
const expectedPrefix = isVideo ? "video/" : "image/";
if (!file.type.startsWith(expectedPrefix)) {
toast.error(isVideo ? "请上传视频文件" : "请上传图片文件");
return;
}
const refKey = `showcase:${target}`;
try {
setUploadingKey(refKey);
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传失败");
}
setShowcaseConfig((prev) => ({
...prev,
[target === "video" ? "videoUrl" : "posterUrl"]: result.url,
}));
toast.success(isVideo ? "视频海报上传成功" : "视频封面上传成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setUploadingKey(null);
}
};
return ( return (
<div className="container mx-auto space-y-6 p-6"> <div className="container mx-auto space-y-6 p-6">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@ -440,6 +490,251 @@ export function AgentWorkflowSettingsClient({
</Card> </Card>
) : ( ) : (
<div className="space-y-5"> <div className="space-y-5">
<Card>
<CardHeader>
<CardTitle>顶部视频海报</CardTitle>
<CardDescription>
配置第一步顶部展示区的视频海报。前台会按固定 21:9 比例自动循环播放,并同步显示可自定义标题、按钮文案和海报叠加文字。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-xl border bg-muted/20 px-4 py-3">
<div className="space-y-1">
<div className="text-sm font-medium">启用顶部视频海报</div>
<div className="text-xs text-muted-foreground">
关闭后前台会隐藏视频海报展示区,但保留下方工作流内容。
</div>
</div>
<Switch
checked={showcaseConfig.enabled}
onCheckedChange={(checked) =>
setShowcaseConfig((prev) => ({
...prev,
enabled: checked,
}))
}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>顶部主标题</Label>
<Input
value={showcaseConfig.title}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
title: event.target.value,
}))
}
placeholder="三倍速出片,电影级质感"
/>
</div>
<div className="space-y-2">
<Label>顶部副标题</Label>
<Input
value={showcaseConfig.subtitle}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
subtitle: event.target.value,
}))
}
placeholder="第一步:风格选择 第二步:生图模式"
/>
</div>
<div className="space-y-2">
<Label>按钮文案</Label>
<Input
value={showcaseConfig.actionText}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
actionText: event.target.value,
}))
}
placeholder="我要创作"
/>
</div>
<div className="space-y-2">
<Label>海报底部说明</Label>
<Input
value={showcaseConfig.footerText || ""}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
footerText: event.target.value,
}))
}
placeholder="用户作品"
/>
</div>
</div>
<div className="space-y-2">
<Label>海报叠加文字</Label>
<Textarea
value={showcaseConfig.overlayText || ""}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
overlayText: event.target.value,
}))
}
rows={2}
placeholder="例如:让每一步选择,都能直达高质感成片"
/>
</div>
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-4">
<div className="space-y-2">
<Label>视频链接</Label>
<Input
value={showcaseConfig.videoUrl}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
videoUrl: event.target.value,
}))
}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label>视频封面链接</Label>
<Input
value={showcaseConfig.posterUrl || ""}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
posterUrl: event.target.value,
}))
}
placeholder="https://..."
/>
</div>
<div className="flex flex-wrap gap-2">
<input
ref={(element) => {
fileInputRefs.current["showcase:video"] = element;
}}
type="file"
accept="video/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
void handleUploadShowcaseAsset("video", file);
event.target.value = "";
}}
/>
<input
ref={(element) => {
fileInputRefs.current["showcase:poster"] = element;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
void handleUploadShowcaseAsset("poster", file);
event.target.value = "";
}}
/>
<Button
type="button"
variant="outline"
disabled={uploadingKey === "showcase:video"}
onClick={() => fileInputRefs.current["showcase:video"]?.click()}
>
{uploadingKey === "showcase:video" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传视频
</Button>
<Button
type="button"
variant="outline"
disabled={uploadingKey === "showcase:poster"}
onClick={() => fileInputRefs.current["showcase:poster"]?.click()}
>
{uploadingKey === "showcase:poster" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传封面图
</Button>
<Button
type="button"
variant="ghost"
onClick={() =>
setShowcaseConfig((prev) => ({
...prev,
videoUrl: "",
posterUrl: "",
}))
}
>
清空媒体
</Button>
</div>
<div className="text-xs text-muted-foreground">
建议优先上传静音短视频,前台会自动以 `muted + loop + playsInline` 形式循环播放;未配置视频时会回退到封面图展示。
</div>
</div>
<div className="space-y-2">
<Label>前台预览</Label>
<div className="overflow-hidden rounded-2xl border bg-slate-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]">
<div className="relative aspect-[21/9] w-full overflow-hidden bg-black">
{showcaseConfig.videoUrl ? (
<video
key={showcaseConfig.videoUrl}
src={showcaseConfig.videoUrl}
poster={showcaseConfig.posterUrl || undefined}
className="h-full w-full object-cover"
autoPlay
muted
loop
playsInline
/>
) : showcaseConfig.posterUrl ? (
<img
src={showcaseConfig.posterUrl}
alt={showcaseConfig.overlayText || showcaseConfig.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-white/45">
暂无视频海报
</div>
)}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/65 via-black/10 to-black/25" />
{showcaseConfig.overlayText ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-5 text-center text-lg font-semibold tracking-[0.18em] text-white/22">
{showcaseConfig.overlayText}
</div>
) : null}
{showcaseConfig.footerText ? (
<div className="pointer-events-none absolute bottom-3 left-3 rounded-full border border-white/12 bg-black/35 px-2.5 py-1 text-[11px] text-white/75">
{showcaseConfig.footerText}
</div>
) : null}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>素材搜索接口配置</CardTitle> <CardTitle>素材搜索接口配置</CardTitle>

View File

@ -46,6 +46,11 @@ import {
normalizeAgentWorkflowMaterialApiConfig, normalizeAgentWorkflowMaterialApiConfig,
normalizeAgentWorkflowMaterialApiConfigForPublic, normalizeAgentWorkflowMaterialApiConfigForPublic,
} from "@/lib/agents/workflow-material-config"; } from "@/lib/agents/workflow-material-config";
import {
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
defaultAgentWorkflowShowcaseConfig,
normalizeAgentWorkflowShowcaseConfig,
} from "@/lib/agents/workflow-showcase-config";
import { import {
defaultOssImageMigrationConfig, defaultOssImageMigrationConfig,
normalizeOssImageMigrationConfig, normalizeOssImageMigrationConfig,
@ -235,6 +240,7 @@ export async function GET() {
agentWorkflowUiConfigSetting, agentWorkflowUiConfigSetting,
agentWorkflowMaterialApiConfigSetting, agentWorkflowMaterialApiConfigSetting,
agentWorkflowBrandModelConfigSetting, agentWorkflowBrandModelConfigSetting,
agentWorkflowShowcaseConfigSetting,
] = await Promise.all([ ] = await Promise.all([
db.select().from(siteSettings).limit(1).then(rows => rows[0]), db.select().from(siteSettings).limit(1).then(rows => rows[0]),
db db
@ -363,6 +369,12 @@ export async function GET() {
.where(eq(systemSettings.key, AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY)) .where(eq(systemSettings.key, AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY))
.limit(1) .limit(1)
.then(rows => rows[0]), .then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
]); ]);
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" }; const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
@ -415,6 +427,9 @@ export async function GET() {
const agentWorkflowBrandModelConfig = normalizeAgentWorkflowBrandModelConfig( const agentWorkflowBrandModelConfig = normalizeAgentWorkflowBrandModelConfig(
agentWorkflowBrandModelConfigSetting?.value agentWorkflowBrandModelConfigSetting?.value
); );
const agentWorkflowShowcaseConfig = normalizeAgentWorkflowShowcaseConfig(
agentWorkflowShowcaseConfigSetting?.value
);
// 如果没有设置记录,返回默认值 // 如果没有设置记录,返回默认值
const baseSettings = settings || { const baseSettings = settings || {
@ -445,6 +460,7 @@ export async function GET() {
agentWorkflowUiConfig: defaultAgentWorkflowUiConfig, agentWorkflowUiConfig: defaultAgentWorkflowUiConfig,
agentWorkflowMaterialApiConfig: defaultAgentWorkflowMaterialApiConfig, agentWorkflowMaterialApiConfig: defaultAgentWorkflowMaterialApiConfig,
agentWorkflowBrandModelConfig: defaultAgentWorkflowBrandModelConfig, agentWorkflowBrandModelConfig: defaultAgentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig: defaultAgentWorkflowShowcaseConfig,
realtimeCanvasEnabled: true, realtimeCanvasEnabled: true,
realtimeCanvasServiceEnabled: true, realtimeCanvasServiceEnabled: true,
storageFailoverConfig, storageFailoverConfig,
@ -491,6 +507,7 @@ export async function GET() {
agentWorkflowUiConfig, agentWorkflowUiConfig,
agentWorkflowMaterialApiConfig, agentWorkflowMaterialApiConfig,
agentWorkflowBrandModelConfig, agentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig,
membershipConfig, membershipConfig,
membershipUserOverrides, membershipUserOverrides,
pointsSystemConfig, pointsSystemConfig,
@ -618,6 +635,18 @@ const updateSchema = z.object({
.max(300), .max(300),
}) })
.optional(), .optional(),
agentWorkflowShowcaseConfig: z
.object({
enabled: z.boolean().optional(),
title: z.string().max(200).optional(),
subtitle: z.string().max(300).optional(),
actionText: z.string().max(48).optional(),
videoUrl: z.string().max(2000).optional(),
posterUrl: z.string().max(2000).optional(),
overlayText: z.string().max(200).optional(),
footerText: z.string().max(64).optional(),
})
.optional(),
realtimeCanvasEnabled: z.boolean().optional(), realtimeCanvasEnabled: z.boolean().optional(),
aiDetailGenerationEnabled: z.boolean().optional(), aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(), canvasDefaultModelId: z.string().nullable().optional(),
@ -693,6 +722,7 @@ export async function PUT(request: NextRequest) {
"agentWorkflowUiConfig", "agentWorkflowUiConfig",
"agentWorkflowMaterialApiConfig", "agentWorkflowMaterialApiConfig",
"agentWorkflowBrandModelConfig", "agentWorkflowBrandModelConfig",
"agentWorkflowShowcaseConfig",
]); ]);
const hasRestrictedKeys = Object.keys(body || {}).some((key) => !allowedKeys.has(key)); const hasRestrictedKeys = Object.keys(body || {}).some((key) => !allowedKeys.has(key));
if (hasRestrictedKeys) { if (hasRestrictedKeys) {
@ -713,6 +743,7 @@ export async function PUT(request: NextRequest) {
agentWorkflowUiConfig, agentWorkflowUiConfig,
agentWorkflowMaterialApiConfig, agentWorkflowMaterialApiConfig,
agentWorkflowBrandModelConfig, agentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig,
membershipConfig, membershipConfig,
membershipUserOverrides, membershipUserOverrides,
pointsSystemConfig, pointsSystemConfig,
@ -981,6 +1012,26 @@ export async function PUT(request: NextRequest) {
}); });
} }
if (agentWorkflowShowcaseConfig !== undefined) {
const normalized = normalizeAgentWorkflowShowcaseConfig(agentWorkflowShowcaseConfig);
await db
.insert(systemSettings)
.values({
key: AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipConfig !== undefined) { if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig); const normalized = normalizeMembershipConfig(membershipConfig);
await db await db
@ -1165,6 +1216,10 @@ export async function PUT(request: NextRequest) {
agentWorkflowBrandModelConfig !== undefined agentWorkflowBrandModelConfig !== undefined
? normalizeAgentWorkflowBrandModelConfig(agentWorkflowBrandModelConfig) ? normalizeAgentWorkflowBrandModelConfig(agentWorkflowBrandModelConfig)
: defaultAgentWorkflowBrandModelConfig, : defaultAgentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig:
agentWorkflowShowcaseConfig !== undefined
? normalizeAgentWorkflowShowcaseConfig(agentWorkflowShowcaseConfig)
: defaultAgentWorkflowShowcaseConfig,
membershipConfig: membershipConfig:
membershipConfig !== undefined membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig) ? normalizeMembershipConfig(membershipConfig)
@ -1477,6 +1532,26 @@ export async function PUT(request: NextRequest) {
}); });
} }
if (agentWorkflowShowcaseConfig !== undefined) {
const normalized = normalizeAgentWorkflowShowcaseConfig(agentWorkflowShowcaseConfig);
await db
.insert(systemSettings)
.values({
key: AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipConfig !== undefined) { if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig); const normalized = normalizeMembershipConfig(membershipConfig);
await db await db
@ -1661,6 +1736,10 @@ export async function PUT(request: NextRequest) {
agentWorkflowBrandModelConfig !== undefined agentWorkflowBrandModelConfig !== undefined
? normalizeAgentWorkflowBrandModelConfig(agentWorkflowBrandModelConfig) ? normalizeAgentWorkflowBrandModelConfig(agentWorkflowBrandModelConfig)
: defaultAgentWorkflowBrandModelConfig, : defaultAgentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig:
agentWorkflowShowcaseConfig !== undefined
? normalizeAgentWorkflowShowcaseConfig(agentWorkflowShowcaseConfig)
: defaultAgentWorkflowShowcaseConfig,
membershipConfig: membershipConfig:
membershipConfig !== undefined membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig) ? normalizeMembershipConfig(membershipConfig)

View File

@ -95,6 +95,7 @@ import type {
AgentWorkflowMaterialSelections, AgentWorkflowMaterialSelections,
AgentWorkflowPersistedState, AgentWorkflowPersistedState,
AgentWorkflowSelectedBrandModel, AgentWorkflowSelectedBrandModel,
AgentWorkflowShowcaseConfig,
AgentWorkflowStepKey, AgentWorkflowStepKey,
} from "@/types/agent"; } from "@/types/agent";
@ -196,6 +197,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
agentEnabled?: boolean; agentEnabled?: boolean;
realtimeCanvasEnabled?: boolean; realtimeCanvasEnabled?: boolean;
realtimeCanvasServiceEnabled?: boolean; realtimeCanvasServiceEnabled?: boolean;
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
agentWorkflowUiConfig?: { agentWorkflowUiConfig?: {
steps?: Array<{ steps?: Array<{
key: AgentWorkflowStepKey; key: AgentWorkflowStepKey;
@ -1009,6 +1011,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
onMaterialsChange={setWorkflowMaterials} onMaterialsChange={setWorkflowMaterials}
selections={workflowSelections} selections={workflowSelections}
onSelectionsChange={setWorkflowSelections} onSelectionsChange={setWorkflowSelections}
siteName={siteSettingsData?.siteName}
siteLogo={siteSettingsData?.siteLogo}
siteLogoDark={siteSettingsData?.siteLogoDark}
showcaseConfig={siteSettingsData?.agentWorkflowShowcaseConfig}
onNextStep={() => setCurrentAgentStage("mode")} onNextStep={() => setCurrentAgentStage("mode")}
onSkipStep={() => setCurrentAgentStage("mode")} onSkipStep={() => setCurrentAgentStage("mode")}
onReset={() => { onReset={() => {
@ -1031,16 +1037,38 @@ export default function CanvasEditorPage({ params }: PageProps) {
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null} selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
selectedBrandModel={selectedBrandModel} selectedBrandModel={selectedBrandModel}
workflowContext={workflowContext} workflowContext={workflowContext}
siteName={siteSettingsData?.siteName}
siteLogo={siteSettingsData?.siteLogo}
siteLogoDark={siteSettingsData?.siteLogoDark}
onBack={() => setCurrentAgentStage("workflow")} onBack={() => setCurrentAgentStage("workflow")}
onClearPreset={() => setSelectedAgentPresetId(null)} onClearPreset={() => setSelectedAgentPresetId(null)}
// 嵌入的智能体选择面板 // 嵌入的智能体选择面板
presetPanel={ presetPanel={
<AgentPresetPanelDarkV2 <AgentPresetPanelDarkV2
embedded embedded
embeddedEntityLabel="生图模式"
selectedPresetId={selectedAgentPresetId} selectedPresetId={selectedAgentPresetId}
onSelectPreset={setSelectedAgentPresetId} onSelectPreset={setSelectedAgentPresetId}
/> />
} }
brandModelPanel={
<AgentWorkflowBrandModelPicker
brand={workflowInputs.brand}
selectedBrandModel={selectedBrandModel}
onSelectBrandModel={setSelectedBrandModel}
theme="dark"
/>
}
materialSelectionPanel={
<AgentWorkflowMaterialSelectionPanel
inputs={workflowInputs}
onInputsChange={setWorkflowInputs}
materials={workflowMaterials}
onMaterialsChange={setWorkflowMaterials}
disabled={!selectedAgentPresetId}
theme="dark"
/>
}
/> />
); );
} }

View File

@ -0,0 +1,123 @@
"use client";
import type { ReactNode } from "react";
import { ArrowLeft, Images, LayoutGrid, Sparkles, Wand2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface AgentCanvasTopNavDarkProps {
siteName?: string | null;
siteLogo?: string | null;
siteLogoDark?: string | null;
onBack?: () => void;
rightSlot?: ReactNode;
activeKey?: "featured" | "agent" | "canvas" | "gallery";
}
const NAV_ITEMS = [
{
key: "featured" as const,
label: "AI精选",
icon: Sparkles,
onClick: () => window.location.assign("/featured"),
},
{
key: "agent" as const,
label: "智能体",
icon: Wand2,
onClick: () => window.location.assign("/canvas?mode=agent"),
},
{
key: "canvas" as const,
label: "无限画布",
icon: LayoutGrid,
onClick: () => window.location.assign(`/canvas/${crypto.randomUUID()}`),
},
{
key: "gallery" as const,
label: "我的图册",
icon: Images,
onClick: () => window.location.assign("/gallery"),
},
];
export function AgentCanvasTopNavDark({
siteName = "AI Chatbot",
siteLogo,
siteLogoDark,
onBack,
rightSlot = null,
activeKey = "agent",
}: AgentCanvasTopNavDarkProps) {
const resolvedSiteName = String(siteName || "AI Chatbot").trim() || "AI Chatbot";
const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || "";
const shouldInvertLogo = !siteLogoDark?.trim() && Boolean(siteLogo?.trim());
return (
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
<div className="flex h-16 items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
{onBack ? (
<button
type="button"
onClick={onBack}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/60 transition-colors hover:bg-white/10 hover:text-white"
aria-label="返回上一步"
>
<ArrowLeft className="h-5 w-5" />
</button>
) : null}
<button
type="button"
onClick={() => window.location.assign("/")}
className="flex min-w-0 items-center gap-2.5 transition-opacity hover:opacity-80"
>
{displayLogo ? (
<img
src={displayLogo}
alt={resolvedSiteName}
className={cn(
"h-8 w-8 shrink-0 object-contain",
shouldInvertLogo && "brightness-0 invert"
)}
/>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/10">
<Wand2 className="h-4 w-4 text-white" />
</div>
)}
<span className="truncate text-base font-semibold text-white">{resolvedSiteName}</span>
</button>
</div>
<nav className="hidden items-center gap-2 md:flex">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = item.key === activeKey;
return (
<button
key={item.key}
type="button"
onClick={item.onClick}
className={cn(
"flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all duration-300",
isActive
? "bg-white text-black"
: "bg-white/5 text-white/60 hover:bg-white/10 hover:text-white"
)}
>
<Icon className="h-4 w-4" />
{item.label}
</button>
);
})}
</nav>
<div className="flex shrink-0 items-center justify-end">{rightSlot}</div>
</div>
</div>
</header>
);
}

View File

@ -2,7 +2,7 @@
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { import {
@ -15,12 +15,6 @@ import {
X, X,
Wand2, Wand2,
Settings2, Settings2,
ArrowLeft,
LayoutGrid,
FolderOpen,
User,
Building2,
Menu
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -40,15 +34,13 @@ import { buildImageProxyUrl } from "@/lib/image-delivery";
import { extractImageUrlsFromText } from "@/lib/image-url"; import { extractImageUrlsFromText } from "@/lib/image-url";
import type { Attachment, ChatMessage } from "@/lib/types"; import type { Attachment, ChatMessage } from "@/lib/types";
import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils"; import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
import type { import type {
AgentPreset, AgentPreset,
AgentWorkflowComposerContext, AgentWorkflowComposerContext,
AgentWorkflowSelectedBrandModel, AgentWorkflowSelectedBrandModel,
} from "@/types/agent"; } from "@/types/agent";
// 导航标签类型
type NavTab = "cases" | "my-works" | "personal-assets" | "company-assets";
interface AgentGenerationWorkspaceDarkV2Props { interface AgentGenerationWorkspaceDarkV2Props {
chatId: string; chatId: string;
projectId: string; projectId: string;
@ -56,16 +48,87 @@ interface AgentGenerationWorkspaceDarkV2Props {
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null; selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
workflowContext?: AgentWorkflowComposerContext | null; workflowContext?: AgentWorkflowComposerContext | null;
onBack?: () => void; onBack?: () => void;
presetPanel?: React.ReactNode; presetPanel?: ReactNode;
materialSelectionPanel?: ReactNode;
brandModelPanel?: ReactNode;
onClearPreset?: () => void; onClearPreset?: () => void;
siteName?: string | null;
siteLogo?: string | null;
siteLogoDark?: string | null;
} }
interface PickedAttachment extends Attachment {} interface PickedAttachment extends Attachment {}
interface GeneratedImageGroup {
messageId: string;
imageUrls: string[];
text: string;
}
function buildPreviewImageUrl(url: string, width: number) { function buildPreviewImageUrl(url: string, width: number) {
return buildImageProxyUrl(url, { width, quality: 86, format: "webp" }); return buildImageProxyUrl(url, { width, quality: 86, format: "webp" });
} }
function buildDownloadFilename(url: string): string {
const fallback = `canvas-image-${Date.now()}.png`;
const raw = String(url || "").trim();
if (!raw) return fallback;
try {
const pathname = new URL(raw).pathname || "";
const candidate = pathname.split("/").pop() || "";
return candidate || fallback;
} catch {
const candidate = raw.split("/").pop()?.split("?")[0] || "";
return candidate || fallback;
}
}
function normalizeAssistantText(value: string) {
return String(value || "")
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
.replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "")
.trim();
}
function getImageDimensions(url: string): Promise<{
width: number;
height: number;
originalWidth: number;
originalHeight: number;
}> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const maxSize = 500;
const originalWidth = img.naturalWidth;
const originalHeight = img.naturalHeight;
let width = originalWidth;
let height = originalHeight;
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const minSize = 150;
if (width < minSize && height < minSize) {
const scale = minSize / Math.min(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
resolve({ width, height, originalWidth, originalHeight });
};
img.onerror = () => {
resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 });
};
img.src = url;
});
}
export function AgentGenerationWorkspaceDarkV2({ export function AgentGenerationWorkspaceDarkV2({
chatId, chatId,
projectId, projectId,
@ -74,7 +137,12 @@ export function AgentGenerationWorkspaceDarkV2({
workflowContext = null, workflowContext = null,
onBack, onBack,
presetPanel, presetPanel,
materialSelectionPanel,
brandModelPanel,
onClearPreset, onClearPreset,
siteName,
siteLogo,
siteLogoDark,
}: AgentGenerationWorkspaceDarkV2Props) { }: AgentGenerationWorkspaceDarkV2Props) {
const rawChatId = useMemo( const rawChatId = useMemo(
() => (chatId.startsWith("canvas-") ? chatId.replace("canvas-", "") : chatId), () => (chatId.startsWith("canvas-") ? chatId.replace("canvas-", "") : chatId),
@ -85,6 +153,7 @@ export function AgentGenerationWorkspaceDarkV2({
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]); const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const hiddenPromptRef = useRef<string | null>(null); const hiddenPromptRef = useRef<string | null>(null);
const generatedImageKeysRef = useRef<Set<string>>(new Set()); const generatedImageKeysRef = useRef<Set<string>>(new Set());
const lastSyncedBrandModelUrlRef = useRef<string | null>(null);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<Array<PickedAttachment | null>>([]); const [attachments, setAttachments] = useState<Array<PickedAttachment | null>>([]);
@ -184,6 +253,22 @@ export function AgentGenerationWorkspaceDarkV2({
}), }),
}); });
useEffect(() => {
if (!messages.length && historyMessages.length) {
setMessages(historyMessages);
}
}, [historyMessages, messages.length, setMessages]);
useEffect(() => {
if (selectedAgentPreset?.defaultModelId) {
setSelectedModelId(selectedAgentPreset.defaultModelId);
return;
}
if (modelsData?.defaultModelId) {
setSelectedModelId(modelsData.defaultModelId);
}
}, [modelsData?.defaultModelId, selectedAgentPreset?.defaultModelId]);
// 初始化附件 // 初始化附件
useEffect(() => { useEffect(() => {
if (!hasAgentUploadSlots) { if (!hasAgentUploadSlots) {
@ -220,7 +305,51 @@ export function AgentGenerationWorkspaceDarkV2({
}); });
}, [agentUploadSlots.length, hasAgentUploadSlots, referenceImages]); }, [agentUploadSlots.length, hasAgentUploadSlots, referenceImages]);
const generatedGroups = useMemo(() => { useEffect(() => {
if (!hasAgentUploadSlots) {
lastSyncedBrandModelUrlRef.current = null;
return;
}
const nextAttachment = selectedBrandModel?.imageUrl
? {
url: selectedBrandModel.imageUrl,
name: selectedBrandModel.displayName || `${selectedBrandModel.brandName} 模特图`,
contentType: selectedBrandModel.mimeType || "image/png",
assetId: selectedBrandModel.assetId || undefined,
}
: null;
setAttachments((current) => {
const slots: Array<PickedAttachment | null> = Array.from(
{ length: Math.max(agentUploadSlots.length, 3) },
(_, index) => current[index] ?? null
);
const currentSlot = slots[2] ?? null;
const previousSyncedUrl = lastSyncedBrandModelUrlRef.current;
if (nextAttachment) {
slots[2] = nextAttachment;
} else if (previousSyncedUrl && currentSlot?.url === previousSyncedUrl) {
slots[2] = null;
}
return slots;
});
lastSyncedBrandModelUrlRef.current = nextAttachment?.url || null;
}, [
agentUploadSlots.length,
hasAgentUploadSlots,
selectedBrandModel?.assetId,
selectedBrandModel?.brandName,
selectedBrandModel?.displayName,
selectedBrandModel?.id,
selectedBrandModel?.imageUrl,
selectedBrandModel?.mimeType,
]);
const generatedGroups = useMemo<GeneratedImageGroup[]>(() => {
return messages return messages
.filter((message) => message.role === "assistant") .filter((message) => message.role === "assistant")
.map((message) => { .map((message) => {
@ -230,7 +359,11 @@ export function AgentGenerationWorkspaceDarkV2({
const imageUrls = Array.from( const imageUrls = Array.from(
new Set(textParts.flatMap((text) => extractImageUrlsFromText(text))) new Set(textParts.flatMap((text) => extractImageUrlsFromText(text)))
); );
return { messageId: message.id, imageUrls, text: textParts.join("\n\n").replace(/!\[[^\]]*\]\([^)]+\)/g, "").trim() }; return {
messageId: message.id,
imageUrls,
text: normalizeAssistantText(textParts.join("\n\n")),
};
}) })
.filter((group) => group.imageUrls.length > 0); .filter((group) => group.imageUrls.length > 0);
}, [messages]); }, [messages]);
@ -249,6 +382,43 @@ export function AgentGenerationWorkspaceDarkV2({
} }
}, [latestGeneratedGroup?.imageUrls, selectedResultUrl]); }, [latestGeneratedGroup?.imageUrls, selectedResultUrl]);
useEffect(() => {
const latestUrls = latestGeneratedGroup?.imageUrls || [];
if (latestUrls.length === 0) return;
latestUrls.forEach(async (url, index) => {
if (!url || generatedImageKeysRef.current.has(url)) return;
generatedImageKeysRef.current.add(url);
try {
const dimensions = await getImageDimensions(url);
const nodeIndex = nodes.length + index;
const spacing = 20;
const x = 120 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 120 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
addNode({
type: "image",
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: url,
status: "completed",
title: "生成结果",
prompt: latestGeneratedGroup.text || "生成结果",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
} catch (error) {
console.warn("[AgentGenerationWorkspaceDarkV2] Failed to add generated image to canvas:", error);
}
});
}, [addNode, latestGeneratedGroup, nodes.length]);
const updateAttachmentAtSlot = useCallback((slotIndex: number, attachment: PickedAttachment | null) => { const updateAttachmentAtSlot = useCallback((slotIndex: number, attachment: PickedAttachment | null) => {
const slotCount = Math.max(agentUploadSlots.length, 1); const slotCount = Math.max(agentUploadSlots.length, 1);
setAttachments((current) => { setAttachments((current) => {
@ -332,61 +502,54 @@ export function AgentGenerationWorkspaceDarkV2({
setInput(""); setInput("");
}, [attachments, hasAgentUploadSlots, input, requiredUploadCount, selectedAgentPreset, selectedAgentPresetId, sendMessage, workflowContext?.promptText, workflowContext?.summaryText]); }, [attachments, hasAgentUploadSlots, input, requiredUploadCount, selectedAgentPreset, selectedAgentPresetId, sendMessage, workflowContext?.promptText, workflowContext?.summaryText]);
// 导航标签配置 const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
const navTabs = [ const raw = String(imageUrl || "").trim();
{ id: "cases" as NavTab, label: "案例广场", icon: LayoutGrid }, if (!raw) {
{ id: "my-works" as NavTab, label: "我的作品", icon: FolderOpen }, toast.error("图片地址无效");
{ id: "personal-assets" as NavTab, label: "个人资产库", icon: User }, return;
{ id: "company-assets" as NavTab, label: "公司资产库", icon: Building2 }, }
];
const filename = buildDownloadFilename(raw);
try {
const response = await fetch(raw);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
}
}, []);
return ( return (
<div className="min-h-screen bg-black"> <div className="min-h-screen bg-black">
{/* 顶部固定导航栏 - 与第一步保持一致 */} <AgentCanvasTopNavDark
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl"> siteName={siteName}
<div className="mx-auto max-w-[1800px] px-6"> siteLogo={siteLogo}
<div className="flex h-16 items-center justify-between"> siteLogoDark={siteLogoDark}
{/* Logo & Site Name & Back */} onBack={onBack}
<div className="flex items-center gap-3"> activeKey="agent"
<button rightSlot={<div className="w-10" />}
onClick={onBack} />
className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/5 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-purple-600">
<Wand2 className="h-4 w-4 text-white" />
</div>
<span className="text-base font-semibold text-white">AI创作平台</span>
</div>
</div>
{/* 导航标签 */}
<nav className="flex items-center gap-1 rounded-full bg-white/5 p-1">
{navTabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
className="flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium text-white/60 transition-all duration-300 hover:text-white hover:bg-white/5"
>
<Icon className="h-4 w-4" />
{tab.label}
</button>
);
})}
</nav>
{/* 右侧占位 */}
<div className="w-20" />
</div>
</div>
</header>
{/* Main Content */} {/* Main Content */}
<main className="pt-16"> <main className="pt-16">
<div className="mx-auto max-w-[1800px] px-6 py-6"> <div className="mx-auto flex min-h-[calc(100vh-64px)] max-w-[1480px] flex-col px-6 py-6 lg:px-8 xl:px-10">
{/* 步骤指示器 */} {/* 步骤指示器 */}
<motion.div <motion.div
className="mb-6 flex items-center justify-center gap-2 text-sm" className="mb-6 flex items-center justify-center gap-2 text-sm"
@ -395,32 +558,34 @@ export function AgentGenerationWorkspaceDarkV2({
> >
<span className="text-white/40">第一步:风格选择</span> <span className="text-white/40">第一步:风格选择</span>
<span className="text-white/20">→</span> <span className="text-white/20">→</span>
<span className="text-emerald-400 font-medium">第二步:智能体生图</span> <span className="text-emerald-400 font-medium">第二步:生图模式</span>
</motion.div> </motion.div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[360px_1fr]"> <div className="grid min-h-0 flex-1 grid-cols-1 gap-6 lg:h-[calc(100vh-136px)] lg:grid-cols-[400px_minmax(0,1fr)]">
{/* Left Panel - Controls */} {/* Left Panel - Controls */}
<div className="space-y-4"> <div className="min-h-0 space-y-4 lg:overflow-y-auto lg:pr-2">
{/* 嵌入的智能体选择面板 */} {/* 嵌入的智能体选择面板 */}
{!selectedAgentPreset && presetPanel && ( {!selectedAgentPreset && presetPanel && (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="rounded-xl border border-white/5 bg-white/[0.02] p-4 max-h-[400px] overflow-y-auto" className="rounded-2xl border border-white/12 bg-white/[0.04] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
> >
<h3 className="mb-3 text-sm font-semibold text-white">选择智能体</h3> <h3 className="mb-3 text-sm font-semibold text-white">选择生图模式</h3>
{presetPanel} <div className="max-h-[480px] overflow-y-auto pr-1">
{presetPanel}
</div>
</motion.div> </motion.div>
)} )}
{/* 已选智能体信息 */} {/* 已选智能体信息 */}
{selectedAgentPreset && ( {selectedAgentPreset && (
<motion.div <motion.div
className="rounded-xl border border-white/10 bg-white/[0.03] p-4" className="rounded-2xl border border-white/12 bg-white/[0.05] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
> >
<div className="mb-2 text-xs text-white/40">当前智能体</div> <div className="mb-2 text-xs text-white/40">当前生图模式</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{selectedAgentPreset.avatarUrl ? ( {selectedAgentPreset.avatarUrl ? (
<img <img
@ -443,15 +608,35 @@ export function AgentGenerationWorkspaceDarkV2({
onClick={onClearPreset} onClick={onClearPreset}
className="mt-3 text-xs text-violet-400 hover:text-violet-300" className="mt-3 text-xs text-violet-400 hover:text-violet-300"
> >
更换智能体 → 更换生图模式 →
</button> </button>
)} )}
</motion.div> </motion.div>
)} )}
{selectedAgentPresetId && brandModelPanel ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
>
{brandModelPanel}
</motion.div>
) : null}
{materialSelectionPanel ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.08 }}
>
{materialSelectionPanel}
</motion.div>
) : null}
{/* Image Slots */} {/* Image Slots */}
<motion.section <motion.section
className="rounded-xl border border-white/5 bg-white/[0.02] p-4" className="rounded-2xl border border-white/12 bg-white/[0.04] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
@ -460,69 +645,78 @@ export function AgentGenerationWorkspaceDarkV2({
<ImagePlus className="h-4 w-4 text-violet-400" /> <ImagePlus className="h-4 w-4 text-violet-400" />
图片槽位 图片槽位
</h3> </h3>
<div className="grid grid-cols-3 gap-2"> {agentUploadSlots.length > 0 ? (
{agentUploadSlots.map((slot, index) => ( <div className="grid grid-cols-3 gap-2">
<motion.button {agentUploadSlots.map((slot, index) => (
key={slot.label} <motion.button
type="button" key={slot.label}
onClick={() => fileInputRefs.current[index]?.click()} type="button"
whileHover={{ scale: 1.05 }} onClick={() => fileInputRefs.current[index]?.click()}
whileTap={{ scale: 0.95 }} whileHover={{ scale: 1.03 }}
className={cn( whileTap={{ scale: 0.97 }}
"group relative aspect-square overflow-hidden rounded-lg transition-all", className={cn(
attachments[index] "group relative aspect-square overflow-hidden rounded-xl transition-all",
? "ring-2 ring-violet-500/30" attachments[index]
: "border-2 border-dashed border-white/10 hover:border-violet-500/30" ? "ring-2 ring-violet-500/40"
)} : "border-2 border-dashed border-white/15 bg-black/25 hover:border-violet-500/35"
> )}
<input >
ref={(el) => (fileInputRefs.current[index] = el)} <input
type="file" ref={(el) => {
accept="image/*" fileInputRefs.current[index] = el;
className="hidden" }}
onChange={(e) => handleSlotFileChange(index, e)} type="file"
/> accept="image/*"
{attachments[index] ? ( className="hidden"
<> onChange={(e) => handleSlotFileChange(index, e)}
<img />
src={buildPreviewImageUrl(attachments[index]!.url, 256)} {attachments[index] ? (
alt={attachments[index]!.name || slot.label} <>
className="h-full w-full object-cover" <img
/> src={buildPreviewImageUrl(attachments[index]!.url, 256)}
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1 text-[10px] text-white"> alt={attachments[index]!.name || slot.label}
{slot.label} className="h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1 text-[10px] text-white">
{slot.label}
</div>
<button
type="button"
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
updateAttachmentAtSlot(index, null);
}}
>
<X className="h-3 w-3" />
</button>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 text-white/30">
{slotUploading === index ? (
<Loader2 className="h-5 w-5 animate-spin text-violet-500" />
) : (
<>
<Upload className="h-5 w-5" />
<span className="text-[10px]">{slot.label}</span>
</>
)}
</div> </div>
<button )}
type="button" </motion.button>
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-white opacity-0 transition-opacity group-hover:opacity-100" ))}
onClick={(e) => { </div>
e.stopPropagation(); ) : (
updateAttachmentAtSlot(index, null); <div className="flex min-h-[132px] flex-col items-center justify-center rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/45">
}} <ImagePlus className="mb-2 h-5 w-5 opacity-60" />
> 先选择一个生图模式,系统会自动展示产品图1、产品图2和模特图槽位。
<X className="h-3 w-3" /> </div>
</button> )}
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 text-white/30">
{slotUploading === index ? (
<Loader2 className="h-5 w-5 animate-spin text-violet-500" />
) : (
<>
<Upload className="h-5 w-5" />
<span className="text-[10px]">{slot.label}</span>
</>
)}
</div>
)}
</motion.button>
))}
</div>
</motion.section> </motion.section>
{/* Prompt Input */} {/* Prompt Input */}
<motion.section <motion.section
className="rounded-xl border border-white/5 bg-white/[0.02] p-4" className="rounded-2xl border border-white/12 bg-white/[0.04] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
@ -541,7 +735,7 @@ export function AgentGenerationWorkspaceDarkV2({
{/* Settings */} {/* Settings */}
<motion.section <motion.section
className="rounded-xl border border-white/5 bg-white/[0.02] p-4" className="rounded-2xl border border-white/12 bg-white/[0.04] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }} transition={{ delay: 0.3 }}
@ -552,7 +746,7 @@ export function AgentGenerationWorkspaceDarkV2({
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<Select value={selectedModelId} onValueChange={setSelectedModelId}> <Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger className="h-10 rounded-lg border-white/5 bg-black text-white"> <SelectTrigger className="h-10 rounded-lg border-white/12 bg-black text-white">
<SelectValue placeholder="选择模型" /> <SelectValue placeholder="选择模型" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="border-white/10 bg-zinc-900"> <SelectContent className="border-white/10 bg-zinc-900">
@ -601,13 +795,27 @@ export function AgentGenerationWorkspaceDarkV2({
</div> </div>
{/* Right Panel - Results */} {/* Right Panel - Results */}
<div className="flex flex-col"> <div className="flex min-h-0 flex-col">
<motion.div <motion.div
className="relative flex-1 min-h-[500px] rounded-xl border border-white/5 bg-white/[0.02] overflow-hidden" className="relative flex min-h-[500px] flex-1 flex-col overflow-hidden rounded-[28px] border border-white/12 bg-white/[0.04] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, scale: 0.98 }} initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
> >
<div className="flex items-center justify-between border-b border-white/12 px-5 py-4">
<div>
<div className="text-sm font-semibold text-white">结果预览</div>
<div className="text-xs text-white/40">
生成完成后会同步回到画布,并在这里保留预览与切换。
</div>
</div>
{allGeneratedImages.length > 0 ? (
<div className="rounded-full bg-white/8 px-3 py-1 text-xs text-white/60">
共 {allGeneratedImages.length} 张
</div>
) : null}
</div>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{isGenerating ? ( {isGenerating ? (
<motion.div <motion.div
@ -615,7 +823,7 @@ export function AgentGenerationWorkspaceDarkV2({
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="flex h-full items-center justify-center" className="flex flex-1 items-center justify-center"
> >
<ImageGenerationLoading <ImageGenerationLoading
imageCount={imageParams.imageCount || 1} imageCount={imageParams.imageCount || 1}
@ -631,7 +839,7 @@ export function AgentGenerationWorkspaceDarkV2({
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
className="relative flex h-full items-center justify-center p-6" className="relative flex flex-1 items-center justify-center p-6"
> >
<img <img
src={buildPreviewImageUrl(displayedResultUrl, 1400)} src={buildPreviewImageUrl(displayedResultUrl, 1400)}
@ -640,7 +848,9 @@ export function AgentGenerationWorkspaceDarkV2({
onClick={() => setFullscreenPreview(displayedResultUrl)} onClick={() => setFullscreenPreview(displayedResultUrl)}
/> />
<button <button
onClick={() => setFullscreenPreview(displayedResultUrl)} onClick={() => {
void handleDownloadOriginalImage(displayedResultUrl);
}}
className="absolute bottom-6 right-6 flex h-10 items-center gap-2 rounded-full bg-white/10 px-4 text-sm text-white backdrop-blur-md transition-colors hover:bg-white/20" className="absolute bottom-6 right-6 flex h-10 items-center gap-2 rounded-full bg-white/10 px-4 text-sm text-white backdrop-blur-md transition-colors hover:bg-white/20"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@ -653,7 +863,7 @@ export function AgentGenerationWorkspaceDarkV2({
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="flex h-full flex-col items-center justify-center text-center" className="flex flex-1 flex-col items-center justify-center text-center"
> >
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5"> <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
<Wand2 className="h-8 w-8 text-white/20" /> <Wand2 className="h-8 w-8 text-white/20" />
@ -664,40 +874,41 @@ export function AgentGenerationWorkspaceDarkV2({
<p className="mt-2 text-sm text-white/30"> <p className="mt-2 text-sm text-white/30">
{selectedAgentPresetId {selectedAgentPresetId
? "配置图片槽位后点击开始生成" ? "配置图片槽位后点击开始生成"
: "从左侧选择或创建一个智能体"} : "从左侧选择一个生图模式后继续"}
</p> </p>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</motion.div>
{/* Thumbnails */} {allGeneratedImages.length > 0 ? (
{allGeneratedImages.length > 0 && ( <motion.div
<motion.div initial={{ opacity: 0, y: 12 }}
initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }} className="border-t border-white/12 bg-black/20 px-4 py-3"
className="mt-4 flex gap-2 overflow-x-auto pb-2" >
> <div className="flex gap-2 overflow-x-auto pb-1">
{allGeneratedImages.map((url) => ( {allGeneratedImages.map((url) => (
<button <button
key={url} key={url}
onClick={() => setSelectedResultUrl(url)} onClick={() => setSelectedResultUrl(url)}
className={cn( className={cn(
"relative flex-shrink-0 overflow-hidden rounded-lg transition-all", "relative flex-shrink-0 overflow-hidden rounded-xl transition-all",
selectedResultUrl === url displayedResultUrl === url
? "ring-2 ring-violet-500" ? "ring-2 ring-violet-500"
: "ring-1 ring-white/10 hover:ring-white/30" : "ring-1 ring-white/12 hover:ring-white/30"
)} )}
> >
<img <img
src={buildPreviewImageUrl(url, 200)} src={buildPreviewImageUrl(url, 240)}
alt="缩略图" alt="缩略图"
className="h-16 w-16 object-cover" className="h-20 w-20 object-cover"
/> />
</button> </button>
))} ))}
</motion.div> </div>
)} </motion.div>
) : null}
</motion.div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -10,11 +10,9 @@ import {
User, User,
Grid3X3, Grid3X3,
Wand2, Wand2,
ArrowRight,
LayoutGrid, LayoutGrid,
FolderOpen, FolderOpen,
Building2, Building2,
Image as ImageIcon,
Menu Menu
} from "lucide-react"; } from "lucide-react";
import useSWR from "swr"; import useSWR from "swr";
@ -25,6 +23,8 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { agentService } from "@/services/agents"; import { agentService } from "@/services/agents";
import type { AgentPreset } from "@/types/agent"; import type { AgentPreset } from "@/types/agent";
import { normalizeAgentWorkflowShowcaseConfig } from "@/lib/agents/workflow-showcase-config";
import { AgentWorkflowShowcaseBanner } from "./AgentWorkflowShowcaseBanner";
import { AgentPresetCardDark } from "./AgentPresetCardDark"; import { AgentPresetCardDark } from "./AgentPresetCardDark";
import { AgentPresetDialog } from "./AgentPresetDialog"; import { AgentPresetDialog } from "./AgentPresetDialog";
@ -36,6 +36,7 @@ interface AgentPresetPanelDarkV2Props {
onSelectPreset: (presetId: string | null) => void; onSelectPreset: (presetId: string | null) => void;
onCreateClick?: () => void; onCreateClick?: () => void;
embedded?: boolean; embedded?: boolean;
embeddedEntityLabel?: string;
} }
export function AgentPresetPanelDarkV2({ export function AgentPresetPanelDarkV2({
@ -43,11 +44,16 @@ export function AgentPresetPanelDarkV2({
onSelectPreset, onSelectPreset,
onCreateClick, onCreateClick,
embedded = false, embedded = false,
embeddedEntityLabel = "智能体",
}: AgentPresetPanelDarkV2Props) { }: AgentPresetPanelDarkV2Props) {
const [activeTab, setActiveTab] = useState<NavTab>("cases"); const [activeTab, setActiveTab] = useState<NavTab>("cases");
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [showCreateDialog, setShowCreateDialog] = useState(false); const [showCreateDialog, setShowCreateDialog] = useState(false);
const [editingPreset, setEditingPreset] = useState<AgentPreset | null>(null); const [editingPreset, setEditingPreset] = useState<AgentPreset | null>(null);
const { data: siteSettingsData } = useSWR<{ agentWorkflowShowcaseConfig?: unknown }>(
!embedded ? "/api/site-settings" : null,
(url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json())
);
const { data, isLoading, mutate } = useSWR( const { data, isLoading, mutate } = useSWR(
["agent-presets"], ["agent-presets"],
@ -151,7 +157,7 @@ export function AgentPresetPanelDarkV2({
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" />
<Input <Input
placeholder="搜索智能体..." placeholder={`搜索${embeddedEntityLabel}...`}
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
className={cn( className={cn(
@ -204,7 +210,7 @@ export function AgentPresetPanelDarkV2({
</AnimatePresence> </AnimatePresence>
) : ( ) : (
<div className="text-center py-8 text-white/40 text-sm"> <div className="text-center py-8 text-white/40 text-sm">
暂无智能体 暂无{embeddedEntityLabel}
</div> </div>
)} )}
</div> </div>
@ -235,7 +241,7 @@ export function AgentPresetPanelDarkV2({
<div className="min-h-screen bg-black"> <div className="min-h-screen bg-black">
{/* 顶部固定导航栏 */} {/* 顶部固定导航栏 */}
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl"> <header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1800px] px-6"> <div className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
<div className="flex h-16 items-center justify-between"> <div className="flex h-16 items-center justify-between">
{/* Logo & Site Name */} {/* Logo & Site Name */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -294,62 +300,21 @@ export function AgentPresetPanelDarkV2({
{/* Hero Section - 视频/功能展示区 */} {/* Hero Section - 视频/功能展示区 */}
<section className="relative pt-16"> <section className="relative pt-16">
<div className="mx-auto max-w-[1800px] px-6 py-8"> <div className="mx-auto max-w-[1480px] px-6 py-8 lg:px-8 xl:px-10">
{/* 标题区域 */} <AgentWorkflowShowcaseBanner
<motion.div config={normalizeAgentWorkflowShowcaseConfig(siteSettingsData?.agentWorkflowShowcaseConfig)}
className="mb-8 text-center" onActionClick={() => {
initial={{ opacity: 0, y: 20 }} const target = document.getElementById("agent-preset-grid");
animate={{ opacity: 1, y: 0 }} if (!target) return;
transition={{ duration: 0.6 }} target.scrollIntoView({ behavior: "smooth", block: "start" });
> }}
<h1 className="mb-3 text-3xl font-bold text-white md:text-4xl"> />
三倍速出片,电影级质感
</h1>
<p className="mb-6 text-base text-white/50">
第一步:风格选择 &nbsp;&nbsp; 第二步:智能体生图
</p>
<Button
className={cn(
"h-12 gap-2 rounded-full px-8",
"bg-gradient-to-r from-emerald-500 to-teal-500",
"text-white font-medium text-base",
"hover:from-emerald-400 hover:to-teal-400",
"shadow-[0_0_30px_rgba(16,185,129,0.3)]",
"transition-all duration-300"
)}
>
我要创作
<ArrowRight className="h-5 w-5" />
</Button>
</motion.div>
{/* 视频/功能卡片展示区域 - 预留空位 */}
<motion.div
className="relative mb-12 aspect-[21/9] w-full overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/50"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
{/* 这里将来放视频或其他功能卡片 */}
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-2xl bg-white/5 mx-auto">
<ImageIcon className="h-10 w-10 text-white/20" />
</div>
<p className="text-white/40">视频展示区(预留)</p>
<p className="mt-2 text-sm text-white/20">未来可放置宣传片或功能介绍</p>
</div>
</div>
{/* 装饰性渐变 */}
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent pointer-events-none" />
</motion.div>
</div> </div>
</section> </section>
{/* 智能体卡片网格区域 */} {/* 智能体卡片网格区域 */}
<section className="pb-20"> <section className="pb-20">
<div className="mx-auto max-w-[1800px] px-6"> <div id="agent-preset-grid" className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
{/* 搜索栏 */} {/* 搜索栏 */}
<motion.div <motion.div
className="mb-8 flex items-center gap-4" className="mb-8 flex items-center gap-4"
@ -420,7 +385,7 @@ export function AgentPresetPanelDarkV2({
{/* 页脚 */} {/* 页脚 */}
<footer className="border-t border-white/5 py-6"> <footer className="border-t border-white/5 py-6">
<div className="mx-auto max-w-[1800px] px-6"> <div className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
<div className="flex items-center justify-center gap-6 text-sm text-white/30"> <div className="flex items-center justify-center gap-6 text-sm text-white/30">
<a href="#" className="hover:text-white/60 transition-colors">使用协议</a> <a href="#" className="hover:text-white/60 transition-colors">使用协议</a>
<a href="#" className="hover:text-white/60 transition-colors">隐私条款</a> <a href="#" className="hover:text-white/60 transition-colors">隐私条款</a>

View File

@ -15,12 +15,14 @@ interface AgentWorkflowBrandModelPickerProps {
brand: string; brand: string;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null; selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
onSelectBrandModel: (value: AgentWorkflowSelectedBrandModel | null) => void; onSelectBrandModel: (value: AgentWorkflowSelectedBrandModel | null) => void;
theme?: "light" | "dark";
} }
export function AgentWorkflowBrandModelPicker({ export function AgentWorkflowBrandModelPicker({
brand, brand,
selectedBrandModel = null, selectedBrandModel = null,
onSelectBrandModel, onSelectBrandModel,
theme = "light",
}: AgentWorkflowBrandModelPickerProps) { }: AgentWorkflowBrandModelPickerProps) {
const { data } = useSWR<{ agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig }>( const { data } = useSWR<{ agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig }>(
"/api/site-settings", "/api/site-settings",
@ -40,34 +42,76 @@ export function AgentWorkflowBrandModelPicker({
[config.entries, normalizedBrand] [config.entries, normalizedBrand]
); );
const images = matchedEntry?.images || []; const images = matchedEntry?.images || [];
const isDark = theme === "dark";
return ( return (
<section className="space-y-3 rounded-2xl border border-[#dbe3ff] bg-[#f8faff] p-3"> <section
className={cn(
"space-y-3 rounded-2xl border p-4",
isDark ? "border-white/12 bg-white/[0.04]" : "border-[#dbe3ff] bg-[#f8faff]"
)}
>
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium text-[#334155]"> <div
<UserRound className="h-4 w-4 text-[#4f46e5]" /> className={cn(
"flex items-center gap-2 text-sm font-medium",
isDark ? "text-white/88" : "text-[#334155]"
)}
>
<UserRound className={cn("h-4 w-4", isDark ? "text-emerald-300" : "text-[#4f46e5]")} />
<span>品牌模特选择</span> <span>品牌模特选择</span>
</div> </div>
<div className="text-xs leading-5 text-[#5b6472]"> <div
className={cn(
"text-xs leading-5",
isDark ? "text-white/55" : "text-[#5b6472]"
)}
>
根据第一步选择的品牌,展示管理员预先上传的模特图。选中后会自动带入右侧“模特图”槽位。 根据第一步选择的品牌,展示管理员预先上传的模特图。选中后会自动带入右侧“模特图”槽位。
</div> </div>
</div> </div>
{!normalizedBrand ? ( {!normalizedBrand ? (
<div className="flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed bg-background/60 px-4 text-center text-sm text-muted-foreground"> <div
className={cn(
"flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed px-4 text-center text-sm",
isDark
? "border-white/12 bg-black/20 text-white/45"
: "bg-background/60 text-muted-foreground"
)}
>
<ImageIcon className="mb-2 h-5 w-5 opacity-60" /> <ImageIcon className="mb-2 h-5 w-5 opacity-60" />
先回到第一步选择品牌,第二步才会按品牌展示可选模特图。 先回到第一步选择品牌,第二步才会按品牌展示可选模特图。
</div> </div>
) : images.length > 0 ? ( ) : images.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground"> <div className={cn("flex flex-wrap gap-2 text-xs", isDark ? "text-white/55" : "text-muted-foreground")}>
<span className="rounded-full bg-background px-2 py-1">品牌:{normalizedBrand}</span> <span
<span className="rounded-full bg-background px-2 py-1"> className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
品牌:{normalizedBrand}
</span>
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
共 {images.length} 张模特图 共 {images.length} 张模特图
</span> </span>
{selectedBrandModel ? ( {selectedBrandModel ? (
<span className="rounded-full bg-background px-2 py-1">已选 1 张</span> <span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
已选 1 张
</span>
) : null} ) : null}
</div> </div>
<Button <Button
@ -104,11 +148,20 @@ export function AgentWorkflowBrandModelPicker({
className={cn( className={cn(
"group overflow-hidden rounded-2xl border bg-background text-left transition-all", "group overflow-hidden rounded-2xl border bg-background text-left transition-all",
selected selected
? "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]" ? isDark
: "border-border hover:border-primary/40 hover:shadow-sm" ? "border-emerald-400/70 bg-white/[0.03] shadow-[0_0_0_1px_rgba(52,211,153,0.2)]"
: "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
: isDark
? "border-white/12 bg-white/[0.02] hover:border-white/25 hover:shadow-sm"
: "border-border hover:border-primary/40 hover:shadow-sm"
)} )}
> >
<div className="relative aspect-square overflow-hidden bg-muted/40"> <div
className={cn(
"relative aspect-square overflow-hidden",
isDark ? "bg-white/[0.04]" : "bg-muted/40"
)}
>
<img <img
src={image.imageUrl} src={image.imageUrl}
alt={image.displayName || `${normalizedBrand} 模特图`} alt={image.displayName || `${normalizedBrand} 模特图`}
@ -124,7 +177,9 @@ export function AgentWorkflowBrandModelPicker({
className={cn( className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border text-white transition-colors", "flex h-6 w-6 items-center justify-center rounded-full border text-white transition-colors",
selected selected
? "border-primary bg-primary" ? isDark
? "border-emerald-400 bg-emerald-400 text-black"
: "border-primary bg-primary"
: "border-white/60 bg-black/35 opacity-0 group-hover:opacity-100" : "border-white/60 bg-black/35 opacity-0 group-hover:opacity-100"
)} )}
> >
@ -136,10 +191,10 @@ export function AgentWorkflowBrandModelPicker({
</div> </div>
</div> </div>
<div className="space-y-1 px-3 py-2"> <div className="space-y-1 px-3 py-2">
<div className="truncate text-xs font-medium"> <div className={cn("truncate text-xs font-medium", isDark ? "text-white/85" : "")}>
{image.displayName || `${normalizedBrand} 模特图`} {image.displayName || `${normalizedBrand} 模特图`}
</div> </div>
<div className="text-[11px] text-muted-foreground"> <div className={cn("text-[11px]", isDark ? "text-white/45" : "text-muted-foreground")}>
点击{selected ? "取消" : "选择"},自动进入模特图槽位 点击{selected ? "取消" : "选择"},自动进入模特图槽位
</div> </div>
</div> </div>
@ -149,7 +204,14 @@ export function AgentWorkflowBrandModelPicker({
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed bg-background/60 px-4 text-center text-sm text-muted-foreground"> <div
className={cn(
"flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed px-4 text-center text-sm",
isDark
? "border-white/12 bg-black/20 text-white/45"
: "bg-background/60 text-muted-foreground"
)}
>
<ImageIcon className="mb-2 h-5 w-5 opacity-60" /> <ImageIcon className="mb-2 h-5 w-5 opacity-60" />
品牌“{normalizedBrand}”暂未配置模特图库,请到系统设置上传对应模特图。 品牌“{normalizedBrand}”暂未配置模特图库,请到系统设置上传对应模特图。
</div> </div>

View File

@ -23,6 +23,7 @@ interface AgentWorkflowMaterialPickerProps {
onValueChange: (value: string) => void; onValueChange: (value: string) => void;
onSearch: () => void; onSearch: () => void;
onSelectedImageIdsChange: (next: string[]) => void; onSelectedImageIdsChange: (next: string[]) => void;
theme?: "light" | "dark";
} }
export function AgentWorkflowMaterialPicker({ export function AgentWorkflowMaterialPicker({
@ -41,7 +42,9 @@ export function AgentWorkflowMaterialPicker({
onValueChange, onValueChange,
onSearch, onSearch,
onSelectedImageIdsChange, onSelectedImageIdsChange,
theme = "light",
}: AgentWorkflowMaterialPickerProps) { }: AgentWorkflowMaterialPickerProps) {
const isDark = theme === "dark";
const selectedSet = useMemo( const selectedSet = useMemo(
() => new Set((selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean)), () => new Set((selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean)),
[selectedImageIds] [selectedImageIds]
@ -65,10 +68,17 @@ export function AgentWorkflowMaterialPicker({
}; };
return ( return (
<section className="space-y-3 rounded-2xl border bg-muted/20 p-4"> <section
className={cn(
"space-y-3 rounded-2xl border p-4",
isDark ? "border-white/12 bg-black/20" : "bg-muted/20"
)}
>
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-medium">{title}</div> <div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>{title}</div>
<div className="text-xs text-muted-foreground">{description}</div> <div className={cn("text-xs", isDark ? "text-white/50" : "text-muted-foreground")}>
{description}
</div>
</div> </div>
<div className="flex flex-col gap-2 md:flex-row"> <div className="flex flex-col gap-2 md:flex-row">
@ -77,6 +87,10 @@ export function AgentWorkflowMaterialPicker({
onChange={(event) => onValueChange(event.target.value)} onChange={(event) => onValueChange(event.target.value)}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
className={cn(
isDark &&
"border-white/12 bg-white/[0.04] text-white placeholder:text-white/25 focus-visible:ring-emerald-400/30"
)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
@ -89,6 +103,7 @@ export function AgentWorkflowMaterialPicker({
type="button" type="button"
onClick={onSearch} onClick={onSearch}
disabled={disabled || isLoading || !value.trim()} disabled={disabled || isLoading || !value.trim()}
className={cn(isDark && "border-white/12 bg-white/8 text-white hover:bg-white/12")}
> >
{isLoading ? ( {isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -100,10 +115,19 @@ export function AgentWorkflowMaterialPicker({
</div> </div>
{searchNote ? ( {searchNote ? (
<div className="text-xs text-muted-foreground">{searchNote}</div> <div className={cn("text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
{searchNote}
</div>
) : null} ) : null}
{disabled && disabledHint ? ( {disabled && disabledHint ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800"> <div
className={cn(
"rounded-xl border px-3 py-2 text-xs",
isDark
? "border-amber-500/20 bg-amber-500/10 text-amber-100"
: "border-amber-200 bg-amber-50 text-amber-800"
)}
>
{disabledHint} {disabledHint}
</div> </div>
) : null} ) : null}
@ -117,19 +141,39 @@ export function AgentWorkflowMaterialPicker({
{!disabled && result ? ( {!disabled && result ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground"> <div className={cn("flex flex-wrap gap-2 text-xs", isDark ? "text-white/52" : "text-muted-foreground")}>
<span className="rounded-full bg-background px-2 py-1"> <span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
命中:{result.matchedSku || result.query} 命中:{result.matchedSku || result.query}
</span> </span>
{result.productModel ? ( {result.productModel ? (
<span className="rounded-full bg-background px-2 py-1"> <span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
型号:{result.productModel} 型号:{result.productModel}
</span> </span>
) : null} ) : null}
<span className="rounded-full bg-background px-2 py-1"> <span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
共 {result.totalCount} 张 共 {result.totalCount} 张
</span> </span>
<span className="rounded-full bg-background px-2 py-1"> <span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
已选 {selectedCount} 张 已选 {selectedCount} 张
</span> </span>
</div> </div>
@ -166,11 +210,20 @@ export function AgentWorkflowMaterialPicker({
className={cn( className={cn(
"group overflow-hidden rounded-2xl border bg-background text-left transition-all", "group overflow-hidden rounded-2xl border bg-background text-left transition-all",
selected selected
? "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]" ? isDark
: "border-border hover:border-primary/40 hover:shadow-sm" ? "border-emerald-400/70 bg-white/[0.03] shadow-[0_0_0_1px_rgba(52,211,153,0.2)]"
: "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
: isDark
? "border-white/12 bg-white/[0.02] hover:border-white/25 hover:shadow-sm"
: "border-border hover:border-primary/40 hover:shadow-sm"
)} )}
> >
<div className="relative aspect-square overflow-hidden bg-muted/40"> <div
className={cn(
"relative aspect-square overflow-hidden",
isDark ? "bg-white/[0.04]" : "bg-muted/40"
)}
>
<img <img
src={image.imageUrl} src={image.imageUrl}
alt={image.label} alt={image.label}
@ -189,7 +242,9 @@ export function AgentWorkflowMaterialPicker({
className={cn( className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border text-white transition-colors", "flex h-6 w-6 items-center justify-center rounded-full border text-white transition-colors",
selected selected
? "border-primary bg-primary" ? isDark
? "border-emerald-400 bg-emerald-400 text-black"
: "border-primary bg-primary"
: "border-white/60 bg-black/35 opacity-0 group-hover:opacity-100" : "border-white/60 bg-black/35 opacity-0 group-hover:opacity-100"
)} )}
> >
@ -201,8 +256,10 @@ export function AgentWorkflowMaterialPicker({
</div> </div>
</div> </div>
<div className="space-y-1 px-3 py-2"> <div className="space-y-1 px-3 py-2">
<div className="truncate text-xs font-medium">{image.fileName}</div> <div className={cn("truncate text-xs font-medium", isDark ? "text-white/85" : "")}>
<div className="text-[11px] text-muted-foreground"> {image.fileName}
</div>
<div className={cn("text-[11px]", isDark ? "text-white/45" : "text-muted-foreground")}>
点击{selected ? "取消" : "选择"},可多选带入生图模式 点击{selected ? "取消" : "选择"},可多选带入生图模式
</div> </div>
</div> </div>
@ -212,7 +269,14 @@ export function AgentWorkflowMaterialPicker({
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed bg-background/60 px-4 text-center text-sm text-muted-foreground"> <div
className={cn(
"flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed px-4 text-center text-sm",
isDark
? "border-white/12 bg-white/[0.03] text-white/42"
: "bg-background/60 text-muted-foreground"
)}
>
{disabled ? ( {disabled ? (
<> <>
<ImageIcon className="mb-2 h-5 w-5 opacity-60" /> <ImageIcon className="mb-2 h-5 w-5 opacity-60" />
@ -233,7 +297,12 @@ export function AgentWorkflowMaterialPicker({
)} )}
{result?.images?.length ? ( {result?.images?.length ? (
<div className="flex items-start gap-2 rounded-xl bg-background px-3 py-2 text-xs text-muted-foreground"> <div
className={cn(
"flex items-start gap-2 rounded-xl px-3 py-2 text-xs",
isDark ? "bg-white/[0.04] text-white/45" : "bg-background text-muted-foreground"
)}
>
<RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" /> <RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" />
已选中的图片会在下一步优先带入右侧“生图模式”槽位,你也可以进入后继续替换。 已选中的图片会在下一步优先带入右侧“生图模式”槽位,你也可以进入后继续替换。
</div> </div>

View File

@ -4,6 +4,7 @@ import { useState } from "react";
import { Sparkles } from "lucide-react"; import { Sparkles } from "lucide-react";
import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker"; import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config"; import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import { cn } from "@/lib/utils";
import type { AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanel"; import type { AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanel";
import type { AgentWorkflowMaterialSelections } from "@/types/agent"; import type { AgentWorkflowMaterialSelections } from "@/types/agent";
@ -16,6 +17,7 @@ interface AgentWorkflowMaterialSelectionPanelProps {
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
disabledHint?: string; disabledHint?: string;
theme?: "light" | "dark";
} }
export function AgentWorkflowMaterialSelectionPanel({ export function AgentWorkflowMaterialSelectionPanel({
@ -27,6 +29,7 @@ export function AgentWorkflowMaterialSelectionPanel({
description = "先确定生图模式,再搜索主 SKU 或搭配 SKU 的白底图。选中的图片会自动进入对应槽位。", description = "先确定生图模式,再搜索主 SKU 或搭配 SKU 的白底图。选中的图片会自动进入对应槽位。",
disabled = false, disabled = false,
disabledHint = "请先在下方选择一个生图模式,再启用 SKU / SPU 搜图。", disabledHint = "请先在下方选择一个生图模式,再启用 SKU / SPU 搜图。",
theme = "light",
}: AgentWorkflowMaterialSelectionPanelProps) { }: AgentWorkflowMaterialSelectionPanelProps) {
const [materialSearchState, setMaterialSearchState] = useState<{ const [materialSearchState, setMaterialSearchState] = useState<{
primary: { isLoading: boolean; errorText: string | null }; primary: { isLoading: boolean; errorText: string | null };
@ -108,15 +111,33 @@ export function AgentWorkflowMaterialSelectionPanel({
updateMaterialSearchState(source, { isLoading: false }); updateMaterialSearchState(source, { isLoading: false });
} }
}; };
const isDark = theme === "dark";
return ( return (
<div className="space-y-3 rounded-2xl border border-[#dbe3ff] bg-[#f8faff] p-3"> <div
className={cn(
"space-y-3 rounded-2xl border p-4",
isDark ? "border-white/12 bg-white/[0.04]" : "border-[#dbe3ff] bg-[#f8faff]"
)}
>
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium text-[#334155]"> <div
<Sparkles className="h-4 w-4 text-[#4f46e5]" /> className={cn(
"flex items-center gap-2 text-sm font-medium",
isDark ? "text-white/88" : "text-[#334155]"
)}
>
<Sparkles className={cn("h-4 w-4", isDark ? "text-emerald-300" : "text-[#4f46e5]")} />
<span>{title}</span> <span>{title}</span>
</div> </div>
<div className="text-xs leading-5 text-[#5b6472]">{description}</div> <div
className={cn(
"text-xs leading-5",
isDark ? "text-white/55" : "text-[#5b6472]"
)}
>
{description}
</div>
</div> </div>
<AgentWorkflowMaterialPicker <AgentWorkflowMaterialPicker
@ -136,6 +157,7 @@ export function AgentWorkflowMaterialSelectionPanel({
onSearch={() => { onSearch={() => {
void handleSearchMaterials("primary"); void handleSearchMaterials("primary");
}} }}
theme={theme}
onSelectedImageIdsChange={(next) => onSelectedImageIdsChange={(next) =>
onMaterialsChange({ onMaterialsChange({
...materials, ...materials,
@ -164,6 +186,7 @@ export function AgentWorkflowMaterialSelectionPanel({
onSearch={() => { onSearch={() => {
void handleSearchMaterials("secondary"); void handleSearchMaterials("secondary");
}} }}
theme={theme}
onSelectedImageIdsChange={(next) => onSelectedImageIdsChange={(next) =>
onMaterialsChange({ onMaterialsChange({
...materials, ...materials,

View File

@ -2,15 +2,16 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { ArrowRight, Check, Copy, RotateCcw, Sparkles, ChevronRight, Wand2, LayoutGrid, FolderOpen, User, Building2, Menu } from "lucide-react"; import { ArrowRight, Check, Copy, RotateCcw, Sparkles, ChevronRight } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SearchableSelect } from "@/components/ui/searchable-select"; import { SearchableSelect } from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config"; import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config"; import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
import { import {
buildAgentWorkflowComposerContext, buildAgentWorkflowComposerContext,
getAgentWorkflowProgress, getAgentWorkflowProgress,
@ -18,6 +19,7 @@ import {
import type { import type {
AgentWorkflowComposerSelection, AgentWorkflowComposerSelection,
AgentWorkflowMaterialSelections, AgentWorkflowMaterialSelections,
AgentWorkflowShowcaseConfig,
AgentWorkflowStepKey, AgentWorkflowStepKey,
AgentWorkflowUiConfig, AgentWorkflowUiConfig,
} from "@/types/agent"; } from "@/types/agent";
@ -40,6 +42,10 @@ interface AgentWorkflowPanelDarkV2Props {
onReset?: () => void; onReset?: () => void;
onNextStep?: () => void; onNextStep?: () => void;
onSkipStep?: () => void; onSkipStep?: () => void;
siteName?: string | null;
siteLogo?: string | null;
siteLogoDark?: string | null;
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
} }
function buildSelectionSummary(params: { function buildSelectionSummary(params: {
@ -161,6 +167,10 @@ export function AgentWorkflowPanelDarkV2({
onReset, onReset,
onNextStep, onNextStep,
onSkipStep, onSkipStep,
siteName,
siteLogo,
siteLogoDark,
showcaseConfig,
}: AgentWorkflowPanelDarkV2Props) { }: AgentWorkflowPanelDarkV2Props) {
const brandOptions = useMemo( const brandOptions = useMemo(
() => config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [], () => config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
@ -222,126 +232,58 @@ export function AgentWorkflowPanelDarkV2({
} }
}; };
// 导航标签配置
const navTabs = [
{ id: "cases", label: "案例广场", icon: LayoutGrid },
{ id: "my-works", label: "我的作品", icon: FolderOpen },
{ id: "personal-assets", label: "个人资产库", icon: User },
{ id: "company-assets", label: "公司资产库", icon: Building2 },
];
return ( return (
<div className="min-h-screen bg-black"> <div className="min-h-screen bg-black">
{/* 顶部固定导航栏 */} <AgentCanvasTopNavDark
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl"> siteName={siteName}
<div className="mx-auto max-w-[1800px] px-6"> siteLogo={siteLogo}
<div className="flex h-16 items-center justify-between"> siteLogoDark={siteLogoDark}
{/* Logo & Site Name */} activeKey="agent"
<div className="flex items-center gap-3"> rightSlot={
<button className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/5 text-white/60 hover:bg-white/10 hover:text-white transition-colors"> <Button
<Menu className="h-5 w-5" /> variant="ghost"
</button> size="sm"
<div className="flex items-center gap-2.5"> onClick={() => {
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-purple-600"> if (onReset) {
<Wand2 className="h-4 w-4 text-white" /> onReset();
</div> return;
<span className="text-base font-semibold text-white">AI创作平台</span> }
</div> onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "" });
</div> onMaterialsChange({
primary: createEmptyAgentWorkflowMaterialSelection(),
{/* 导航标签 */} secondary: createEmptyAgentWorkflowMaterialSelection(),
<nav className="flex items-center gap-1 rounded-full bg-white/5 p-1"> });
{navTabs.map((tab) => { onSelectionsChange({});
const Icon = tab.icon; }}
return ( className="h-9 gap-1.5 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
<button >
key={tab.id} <RotateCcw className="h-3.5 w-3.5" />
className="flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium text-white/60 transition-all duration-300 hover:text-white hover:bg-white/5" 重置
> </Button>
<Icon className="h-4 w-4" /> }
{tab.label} />
</button>
);
})}
</nav>
{/* 右侧操作 */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => {
if (onReset) {
onReset();
return;
}
onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "" });
onMaterialsChange({
primary: createEmptyAgentWorkflowMaterialSelection(),
secondary: createEmptyAgentWorkflowMaterialSelection(),
});
onSelectionsChange({});
}}
className="h-9 gap-1.5 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
>
<RotateCcw className="h-3.5 w-3.5" />
重置
</Button>
</div>
</div>
</div>
</header>
{/* Hero Section */} {/* Hero Section */}
<section className="relative pt-16"> <section className="relative pt-16">
<div className="mx-auto max-w-[1800px] px-6 py-8"> <div className="mx-auto max-w-[1480px] px-6 py-8 lg:px-8 xl:px-10">
{/* 标题区域 */} <AgentWorkflowShowcaseBanner
<motion.div config={showcaseConfig}
className="mb-8 text-center" onActionClick={() => {
initial={{ opacity: 0, y: 20 }} const target = document.getElementById("agent-workflow-content");
animate={{ opacity: 1, y: 0 }} if (!target) return;
transition={{ duration: 0.6 }} target.scrollIntoView({ behavior: "smooth", block: "start" });
> }}
<h1 className="mb-3 text-3xl font-bold text-white md:text-4xl"> />
三倍速出片,电影级质感
</h1>
<p className="mb-6 text-base text-white/50">
<span className="text-emerald-400 font-medium">第一步:风格选择</span>
<span className="mx-2 text-white/20">→</span>
<span className="text-white/40">第二步:智能体生图</span>
</p>
</motion.div>
{/* 进度指示 */}
<motion.div
className="mx-auto mb-8 max-w-md"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<div className="flex items-center justify-between text-sm text-white/50 mb-2">
<span>完成进度</span>
<span className="font-medium text-white">{progress.completed}/{progress.total}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-teal-500"
initial={{ width: 0 }}
animate={{ width: `${progress.total > 0 ? (progress.completed / progress.total) * 100 : 0}%` }}
transition={{ duration: 0.5 }}
/>
</div>
</motion.div>
</div> </div>
</section> </section>
{/* Main Content */} {/* Main Content */}
<main className="pb-20"> <main className="pb-40">
<div className="mx-auto max-w-[1800px] px-6"> <div id="agent-workflow-content" className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
<ScrollArea className="h-[calc(100vh-300px)]"> <div className="space-y-8">
<div className="space-y-8 pr-4">
{/* 基础配置 */} {/* 基础配置 */}
<motion.section <motion.section
className="rounded-2xl border border-white/12 bg-white/[0.04] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
@ -359,6 +301,7 @@ export function AgentWorkflowPanelDarkV2({
placeholder="选择品牌" placeholder="选择品牌"
searchPlaceholder="输入品牌名筛选" searchPlaceholder="输入品牌名筛选"
emptyText="暂无品牌可选" emptyText="暂无品牌可选"
theme="dark"
onValueChange={(value) => onInputsChange({ ...inputs, brand: value })} onValueChange={(value) => onInputsChange({ ...inputs, brand: value })}
/> />
<SearchableSelect <SearchableSelect
@ -367,6 +310,7 @@ export function AgentWorkflowPanelDarkV2({
placeholder="选择店铺" placeholder="选择店铺"
searchPlaceholder="输入店铺名筛选" searchPlaceholder="输入店铺名筛选"
emptyText="暂无店铺可选" emptyText="暂无店铺可选"
theme="dark"
onValueChange={(value) => onInputsChange({ ...inputs, store: value })} onValueChange={(value) => onInputsChange({ ...inputs, store: value })}
/> />
</div> </div>
@ -433,6 +377,7 @@ export function AgentWorkflowPanelDarkV2({
{/* Prompt 预览 */} {/* Prompt 预览 */}
<motion.section <motion.section
className="rounded-2xl border border-white/10 bg-white/[0.035] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
> >
@ -456,49 +401,74 @@ export function AgentWorkflowPanelDarkV2({
className="resize-none rounded-xl border-white/5 bg-white/[0.03] text-sm text-white/80 placeholder:text-white/20 focus:border-white/10" className="resize-none rounded-xl border-white/5 bg-white/[0.03] text-sm text-white/80 placeholder:text-white/20 focus:border-white/10"
/> />
</motion.section> </motion.section>
{/* 底部操作 */}
{onNextStep && (
<motion.div
className="flex justify-end gap-3 pt-4 pb-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
{onSkipStep && (
<Button
variant="ghost"
onClick={onSkipStep}
className="h-11 gap-2 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
>
跳过
<ChevronRight className="h-4 w-4" />
</Button>
)}
<Button
onClick={onNextStep}
disabled={progress.total > 0 && !progress.isComplete}
className={cn(
"h-11 gap-2 rounded-full px-8",
"bg-gradient-to-r from-emerald-500 to-teal-500",
"text-white font-medium",
"hover:from-emerald-400 hover:to-teal-400",
"shadow-[0_0_30px_rgba(16,185,129,0.3)]",
"disabled:opacity-30"
)}
>
下一步:选择智能体
<ArrowRight className="h-4 w-4" />
</Button>
</motion.div>
)}
</div> </div>
</ScrollArea>
</div> </div>
</main> </main>
{onNextStep ? (
<motion.div
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4 sm:px-6"
>
<div className="pointer-events-auto mx-auto flex w-full max-w-5xl flex-col gap-4 rounded-[28px] border border-white/12 bg-black/85 px-4 py-4 shadow-[0_24px_80px_rgba(0,0,0,0.45)] backdrop-blur-2xl sm:px-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-sm font-medium text-white">工作流进度</div>
<div className="text-xs text-white/45">
已完成 {progress.completed} / {progress.total} 个步骤
{progress.isComplete ? ",可以直接进入下一步" : ",也可以选择跳过直接进入下一步"}
</div>
</div>
<div className="text-sm font-semibold text-white">
{progress.total > 0 ? Math.round((progress.completed / progress.total) * 100) : 0}%
</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-teal-400"
initial={{ width: 0 }}
animate={{ width: `${progress.total > 0 ? (progress.completed / progress.total) * 100 : 0}%` }}
transition={{ duration: 0.45 }}
/>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
{onSkipStep ? (
<Button
variant="ghost"
onClick={onSkipStep}
className="h-11 gap-2 rounded-full border border-white/10 bg-white/[0.03] px-6 text-white/75 hover:bg-white/10 hover:text-white"
>
跳过并进入下一步
<ChevronRight className="h-4 w-4" />
</Button>
) : null}
<Button
onClick={onNextStep}
disabled={progress.total > 0 && !progress.isComplete}
className={cn(
"h-11 gap-2 rounded-full px-8",
"bg-gradient-to-r from-emerald-500 to-teal-500",
"text-white font-medium",
"hover:from-emerald-400 hover:to-teal-400",
"shadow-[0_0_30px_rgba(16,185,129,0.3)]",
"disabled:opacity-30"
)}
>
下一步:选择生图模式
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</motion.div>
) : null}
{/* 页脚 */} {/* 页脚 */}
<footer className="border-t border-white/5 py-6"> <footer className="border-t border-white/5 pb-28 pt-6">
<div className="mx-auto max-w-[1800px] px-6"> <div className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
<div className="flex items-center justify-center gap-6 text-sm text-white/30"> <div className="flex items-center justify-center gap-6 text-sm text-white/30">
<a href="#" className="hover:text-white/60 transition-colors">使用协议</a> <a href="#" className="hover:text-white/60 transition-colors">使用协议</a>
<a href="#" className="hover:text-white/60 transition-colors">隐私条款</a> <a href="#" className="hover:text-white/60 transition-colors">隐私条款</a>

View File

@ -0,0 +1,126 @@
"use client";
import { motion } from "framer-motion";
import { ArrowRight, ImageIcon, PlayCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { defaultAgentWorkflowShowcaseConfig } from "@/lib/agents/workflow-showcase-config";
import type { AgentWorkflowShowcaseConfig } from "@/types/agent";
interface AgentWorkflowShowcaseBannerProps {
config?: AgentWorkflowShowcaseConfig | null;
onActionClick?: () => void;
className?: string;
}
export function AgentWorkflowShowcaseBanner({
config,
onActionClick,
className,
}: AgentWorkflowShowcaseBannerProps) {
const resolved = config || defaultAgentWorkflowShowcaseConfig;
const hasVideo = Boolean(resolved.videoUrl);
const hasPoster = Boolean(resolved.posterUrl);
const shouldRenderMedia = resolved.enabled !== false;
return (
<div className={cn("space-y-8", className)}>
<motion.div
className="text-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<h1 className="mb-3 text-3xl font-bold text-white md:text-4xl">{resolved.title}</h1>
<p className="mb-6 text-base text-white/50">{resolved.subtitle}</p>
{resolved.actionText ? (
<Button
type="button"
onClick={onActionClick}
className={cn(
"h-12 gap-2 rounded-full px-8",
"bg-gradient-to-r from-emerald-500 to-teal-500",
"text-base font-medium text-white",
"hover:from-emerald-400 hover:to-teal-400",
"shadow-[0_0_30px_rgba(16,185,129,0.3)]",
"transition-all duration-300"
)}
>
{resolved.actionText}
<ArrowRight className="h-5 w-5" />
</Button>
) : null}
</motion.div>
{shouldRenderMedia ? (
<motion.div
className="relative overflow-hidden rounded-[28px] border border-white/12 bg-[#101214] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.15 }}
>
<div className="aspect-[21/9] w-full">
{hasVideo ? (
<video
key={resolved.videoUrl}
className="h-full w-full object-cover"
src={resolved.videoUrl}
poster={resolved.posterUrl || undefined}
autoPlay
muted
loop
playsInline
preload="metadata"
/>
) : hasPoster ? (
<img
src={resolved.posterUrl || ""}
alt={resolved.overlayText || resolved.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center bg-[radial-gradient(circle_at_top,#1b2430_0%,#0e1013_55%,#070809_100%)] text-center">
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-3xl border border-white/10 bg-white/[0.04]">
<PlayCircle className="h-10 w-10 text-white/25" />
</div>
<div className="text-base font-medium text-white/70">顶部视频海报待配置</div>
<div className="mt-2 max-w-lg text-sm text-white/35">
在智能体系统设置中上传视频或封面图后,这里会自动展示循环播放海报。
</div>
</div>
)}
</div>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(16,185,129,0.12),transparent_42%)]" />
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-black/35" />
{resolved.overlayText ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-8 text-center">
<div className="max-w-[78%] text-2xl font-semibold tracking-[0.18em] text-white/22 sm:text-3xl lg:text-5xl">
{resolved.overlayText}
</div>
</div>
) : null}
<div className="pointer-events-none absolute inset-x-0 top-0 flex items-center justify-between px-5 py-5 sm:px-7">
<div className="rounded-full border border-white/14 bg-black/30 px-3 py-1 text-xs font-medium text-white/70 backdrop-blur-md">
视频海报
</div>
{hasVideo || hasPoster ? (
<div className="rounded-full border border-white/14 bg-black/30 px-3 py-1 text-xs text-white/55 backdrop-blur-md">
固定比例 21:9
</div>
) : null}
</div>
{resolved.footerText ? (
<div className="pointer-events-none absolute bottom-5 left-5 flex items-center gap-2 rounded-full border border-white/14 bg-black/35 px-3 py-1.5 text-sm text-white/75 backdrop-blur-md sm:left-7">
<ImageIcon className="h-4 w-4" />
<span>{resolved.footerText}</span>
</div>
) : null}
</motion.div>
) : null}
</div>
);
}

View File

@ -15,6 +15,7 @@ interface SearchableSelectProps {
searchPlaceholder?: string; searchPlaceholder?: string;
emptyText?: string; emptyText?: string;
onValueChange: (value: string) => void; onValueChange: (value: string) => void;
theme?: "light" | "dark";
} }
export function SearchableSelect({ export function SearchableSelect({
@ -24,9 +25,11 @@ export function SearchableSelect({
searchPlaceholder = "输入关键字搜索", searchPlaceholder = "输入关键字搜索",
emptyText = "暂无可选项", emptyText = "暂无可选项",
onValueChange, onValueChange,
theme = "light",
}: SearchableSelectProps) { }: SearchableSelectProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const isDark = theme === "dark";
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@ -46,21 +49,48 @@ export function SearchableSelect({
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="h-10 w-full justify-between px-3 text-left font-normal" className={cn(
"h-10 w-full justify-between px-3 text-left font-normal",
isDark &&
"h-12 rounded-2xl border-white/14 bg-white/[0.05] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] hover:bg-white/[0.08] hover:text-white"
)}
> >
<span className={cn("truncate", value ? "text-foreground" : "text-muted-foreground")}> <span
className={cn(
"truncate",
value
? isDark
? "text-white"
: "text-foreground"
: isDark
? "text-white/42"
: "text-muted-foreground"
)}
>
{value || placeholder} {value || placeholder}
</span> </span>
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" /> <ChevronsUpDown
className={cn("h-4 w-4", isDark ? "text-white/38" : "text-muted-foreground")}
/>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-3"> <PopoverContent
align="start"
className={cn(
"w-[var(--radix-popover-trigger-width)] p-3",
isDark && "border-white/12 bg-[#0c0f11]/98 text-white backdrop-blur-xl"
)}
>
<div className="space-y-3"> <div className="space-y-3">
<Input <Input
autoFocus autoFocus
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
className={cn(
isDark &&
"border-white/12 bg-white/[0.04] text-white placeholder:text-white/28 focus-visible:ring-emerald-400/30 focus-visible:ring-offset-0"
)}
/> />
<ScrollArea className="max-h-64"> <ScrollArea className="max-h-64">
<div className="space-y-1"> <div className="space-y-1">
@ -78,8 +108,12 @@ export function SearchableSelect({
className={cn( className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors", "flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
selected selected
? "bg-primary/10 text-primary" ? isDark
: "hover:bg-muted" ? "bg-emerald-500/15 text-emerald-300"
: "bg-primary/10 text-primary"
: isDark
? "text-white/72 hover:bg-white/8 hover:text-white"
: "hover:bg-muted"
)} )}
> >
<span className="truncate">{item}</span> <span className="truncate">{item}</span>
@ -88,7 +122,12 @@ export function SearchableSelect({
); );
}) })
) : ( ) : (
<div className="px-3 py-6 text-center text-sm text-muted-foreground"> <div
className={cn(
"px-3 py-6 text-center text-sm",
isDark ? "text-white/35" : "text-muted-foreground"
)}
>
{emptyText} {emptyText}
</div> </div>
)} )}

View File

@ -0,0 +1,47 @@
import type { AgentWorkflowShowcaseConfig } from "@/types/agent";
export const AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY = "agent_workflow_showcase_config";
export const defaultAgentWorkflowShowcaseConfig: AgentWorkflowShowcaseConfig = {
enabled: true,
title: "三倍速出片,电影级质感",
subtitle: "第一步:风格选择 第二步:生图模式",
actionText: "我要创作",
videoUrl: "",
posterUrl: "",
overlayText: "让每一步选择,都能直达高质感成片",
footerText: "用户作品",
};
function sanitizeText(value: unknown, maxLength: number) {
return String(value || "").trim().slice(0, maxLength);
}
export function normalizeAgentWorkflowShowcaseConfig(
value: unknown
): AgentWorkflowShowcaseConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return defaultAgentWorkflowShowcaseConfig;
}
const raw = value as Record<string, unknown>;
return {
enabled:
typeof raw.enabled === "boolean"
? raw.enabled
: defaultAgentWorkflowShowcaseConfig.enabled,
title:
sanitizeText(raw.title, 200) || defaultAgentWorkflowShowcaseConfig.title,
subtitle:
sanitizeText(raw.subtitle, 300) || defaultAgentWorkflowShowcaseConfig.subtitle,
actionText:
sanitizeText(raw.actionText, 48) || defaultAgentWorkflowShowcaseConfig.actionText,
videoUrl: sanitizeText(raw.videoUrl, 2000),
posterUrl: sanitizeText(raw.posterUrl, 2000) || null,
overlayText:
sanitizeText(raw.overlayText, 200) || defaultAgentWorkflowShowcaseConfig.overlayText,
footerText:
sanitizeText(raw.footerText, 64) || defaultAgentWorkflowShowcaseConfig.footerText,
};
}

View File

@ -63,6 +63,17 @@ export interface AgentWorkflowBrandModelConfig {
entries: AgentWorkflowBrandModelEntry[]; entries: AgentWorkflowBrandModelEntry[];
} }
export interface AgentWorkflowShowcaseConfig {
enabled: boolean;
title: string;
subtitle: string;
actionText: string;
videoUrl: string;
posterUrl?: string | null;
overlayText?: string | null;
footerText?: string | null;
}
export interface AgentWorkflowSelectedBrandModel extends AgentWorkflowBrandModelImage { export interface AgentWorkflowSelectedBrandModel extends AgentWorkflowBrandModelImage {
brandName: string; brandName: string;
} }