交互原型大版本
This commit is contained in:
464
art-agent/frontend/src/app/styles/[id]/page.tsx
Normal file
464
art-agent/frontend/src/app/styles/[id]/page.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import type { StylePack, StyleRefImage } from "@/lib/types";
|
||||
|
||||
export default function StyleDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const isNew = params.id === "new";
|
||||
const {
|
||||
stylePacks,
|
||||
upsertStylePack,
|
||||
deleteStylePack,
|
||||
setSessionStyleId,
|
||||
assets,
|
||||
} = useApp();
|
||||
|
||||
const [pack, setPack] = useState<StylePack | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
const now = Date.now();
|
||||
setPack({
|
||||
id: `style-${now.toString(36)}`,
|
||||
name: "",
|
||||
description: "",
|
||||
keywords: [],
|
||||
source: "custom",
|
||||
refImages: [],
|
||||
positivePrompt: "",
|
||||
negativePrompt: "",
|
||||
usageCount: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
const found = stylePacks.find((s) => s.id === params.id);
|
||||
if (found) setPack(found);
|
||||
}
|
||||
}, [params.id, stylePacks, isNew]);
|
||||
|
||||
if (!pack) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<TopNav />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<p className="typo-caption">加载中...</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!pack.name.trim()) {
|
||||
alert("请填写风格名称");
|
||||
return;
|
||||
}
|
||||
upsertStylePack(pack);
|
||||
alert("已保存(占位)");
|
||||
if (isNew) router.push(`/styles/${pack.id}`);
|
||||
};
|
||||
|
||||
const handleApplyToSession = () => {
|
||||
setSessionStyleId(pack.id);
|
||||
alert(`已将风格 "${pack.name}" 应用到当前会话(占位)`);
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleFork = () => {
|
||||
const now = Date.now();
|
||||
const forked: StylePack = {
|
||||
...pack,
|
||||
id: `style-${now.toString(36)}`,
|
||||
name: `${pack.name} (副本)`,
|
||||
source: "custom",
|
||||
forkFromId: pack.id,
|
||||
usageCount: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
upsertStylePack(forked);
|
||||
alert("已创建副本(占位)");
|
||||
router.push(`/styles/${forked.id}`);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm(`确认删除风格 "${pack.name}"?(占位操作)`)) return;
|
||||
deleteStylePack(pack.id);
|
||||
router.push("/styles");
|
||||
};
|
||||
|
||||
const addRef = () => {
|
||||
const id = `ref-${Date.now()}`;
|
||||
setPack({
|
||||
...pack,
|
||||
refImages: [...pack.refImages, { id, url: "", caption: "占位参考图" }],
|
||||
});
|
||||
};
|
||||
|
||||
const removeRef = (id: string) => {
|
||||
setPack({ ...pack, refImages: pack.refImages.filter((r) => r.id !== id) });
|
||||
};
|
||||
|
||||
const addKeyword = (kw: string) => {
|
||||
if (!kw || pack.keywords.includes(kw)) return;
|
||||
setPack({ ...pack, keywords: [...pack.keywords, kw] });
|
||||
};
|
||||
|
||||
const removeKeyword = (kw: string) => {
|
||||
setPack({ ...pack, keywords: pack.keywords.filter((k) => k !== kw) });
|
||||
};
|
||||
|
||||
// 历史预览(按 prompt 关键词简单匹配)
|
||||
const relatedAssets = assets
|
||||
.filter((a) =>
|
||||
pack.keywords.some((k) => a.prompt.toLowerCase().includes(k.toLowerCase()))
|
||||
)
|
||||
.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<AmbientParticles count={12} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
{/* 面包屑 */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] mb-4">
|
||||
<Link href="/styles" className="hover:text-[var(--accent)] cursor-pointer">
|
||||
风格库
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--text-primary)]">
|
||||
{isNew ? "新建风格" : pack.name || "未命名"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 标题 + 操作 */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<input
|
||||
value={pack.name}
|
||||
onChange={(e) => setPack({ ...pack, name: e.target.value })}
|
||||
placeholder="风格名称"
|
||||
className="text-2xl font-bold bg-transparent border-none
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/60
|
||||
focus:outline-none min-w-0 flex-1"
|
||||
/>
|
||||
<span className="phase-chip flex-shrink-0">Phase 1</span>
|
||||
</div>
|
||||
{pack.forkFromId && (
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
Fork 自:
|
||||
<Link
|
||||
href={`/styles/${pack.forkFromId}`}
|
||||
className="text-[var(--accent)] hover:underline"
|
||||
>
|
||||
{stylePacks.find((s) => s.id === pack.forkFromId)?.name || pack.forkFromId}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleApplyToSession}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
应用到当前会话
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-primary)] hover:border-[var(--accent)]/40
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
{!isNew && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleFork}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
复制 / Fork
|
||||
</button>
|
||||
<Link
|
||||
href={`/training/new?type=style&source=${pack.id}`}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--gold)]/30 bg-[var(--gold)]/8
|
||||
text-[var(--gold)] hover:bg-[var(--gold)]/15
|
||||
cursor-pointer btn-hover-lift transition-all
|
||||
flex items-center gap-1"
|
||||
>
|
||||
训练风格 LoRA
|
||||
<span className="text-[9px]">P2</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--hot)]/60 hover:text-[var(--hot)]
|
||||
hover:border-[var(--hot)]/40
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 左侧:基本信息 + Prompt */}
|
||||
<div className="lg:col-span-2 space-y-5">
|
||||
{/* 描述 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">风格描述</h2>
|
||||
<textarea
|
||||
value={pack.description}
|
||||
onChange={(e) => setPack({ ...pack, description: e.target.value })}
|
||||
placeholder="用一句话描述这个风格的核心特征..."
|
||||
rows={3}
|
||||
className="w-full p-3 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-sm text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]/50 resize-none"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 关键词 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">关键词标签</h2>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{pack.keywords.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg
|
||||
bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
|
||||
>
|
||||
{k}
|
||||
<button
|
||||
onClick={() => removeKeyword(k)}
|
||||
className="hover:text-[var(--hot)] cursor-pointer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<KeywordInput onAdd={addKeyword} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 风格参考集 */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="typo-h2">风格参考集</h2>
|
||||
<button
|
||||
onClick={addRef}
|
||||
className="text-xs px-2.5 py-1 rounded-lg border border-[var(--border)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||||
>
|
||||
+ 添加参考图(占位)
|
||||
</button>
|
||||
</div>
|
||||
<p className="typo-caption mb-3">
|
||||
3-10 张代表该风格核心特征的参考图,可增删替换。
|
||||
</p>
|
||||
<div className="grid grid-cols-3 md:grid-cols-4 gap-2.5">
|
||||
{pack.refImages.map((ref) => (
|
||||
<RefImageCard
|
||||
key={ref.id}
|
||||
ref_={ref}
|
||||
onRemove={() => removeRef(ref.id)}
|
||||
/>
|
||||
))}
|
||||
{pack.refImages.length === 0 && (
|
||||
<div className="col-span-3 md:col-span-4 py-8 text-center text-xs text-[var(--text-secondary)]
|
||||
placeholder-card rounded-xl">
|
||||
尚未添加参考图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Prompt 模板 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">Prompt 模板</h2>
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>
|
||||
正向 Prompt
|
||||
</div>
|
||||
<textarea
|
||||
value={pack.positivePrompt ?? ""}
|
||||
onChange={(e) => setPack({ ...pack, positivePrompt: e.target.value })}
|
||||
placeholder="描述风格特征的 Prompt 片段,会在生成时前置注入..."
|
||||
rows={2}
|
||||
className="w-full p-2.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-xs text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]/50 resize-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>
|
||||
负向 Prompt
|
||||
</div>
|
||||
<textarea
|
||||
value={pack.negativePrompt ?? ""}
|
||||
onChange={(e) => setPack({ ...pack, negativePrompt: e.target.value })}
|
||||
placeholder="要避免的风格特征..."
|
||||
rows={2}
|
||||
className="w-full p-2.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-xs text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]/50 resize-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 历史预览 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">使用该风格生成的图片</h2>
|
||||
<p className="typo-caption mb-3">
|
||||
自动从资源库聚合(占位:基于关键词匹配的示意)。
|
||||
</p>
|
||||
{relatedAssets.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-[var(--text-secondary)]
|
||||
placeholder-card rounded-xl">
|
||||
尚无历史生成
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
|
||||
{relatedAssets.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="aspect-square rounded-lg overflow-hidden border border-[var(--border)]"
|
||||
>
|
||||
<div className="w-full h-full bg-[var(--bg-tertiary)] flex items-center justify-center text-[9px] text-[var(--text-secondary)]">
|
||||
占位缩略
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 右侧:Phase 2 占位(LoRA / Embedding) + 元信息 */}
|
||||
<aside className="space-y-4">
|
||||
<div className="placeholder-card rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="typo-strong text-xs">风格 LoRA</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed mb-2">
|
||||
{pack.loraId
|
||||
? `已绑定:${pack.loraId}(占位)`
|
||||
: "尚未绑定。可通过训练中心生成风格 LoRA 并绑定到此风格。"}
|
||||
</p>
|
||||
<Link
|
||||
href={`/training/new?type=style&source=${pack.id}`}
|
||||
className="text-xs text-[var(--gold)] hover:underline"
|
||||
>
|
||||
前往训练 →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="placeholder-card rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="typo-strong text-xs">风格 Embedding</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||
自动计算的风格向量表示,用于语义检索和相似风格推荐。(占位)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl surface-2 p-4">
|
||||
<span className="typo-strong text-xs block mb-2">使用统计</span>
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>使用次数:{pack.usageCount}</div>
|
||||
<div>
|
||||
来源:
|
||||
{pack.source === "preset" ? "平台预设" : pack.source === "custom" ? "项目自建" : "社区共享"}
|
||||
</div>
|
||||
<div>创建时间:{new Date(pack.createdAt).toLocaleDateString("zh-CN")}</div>
|
||||
<div>更新时间:{new Date(pack.updatedAt).toLocaleDateString("zh-CN")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeywordInput({ onAdd }: { onAdd: (kw: string) => void }) {
|
||||
const [val, setVal] = useState("");
|
||||
return (
|
||||
<input
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && val.trim()) {
|
||||
onAdd(val.trim());
|
||||
setVal("");
|
||||
}
|
||||
}}
|
||||
placeholder="+ 添加关键词(回车)"
|
||||
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]/50 min-w-[120px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RefImageCard({
|
||||
ref_,
|
||||
onRemove,
|
||||
}: {
|
||||
ref_: StyleRefImage;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative aspect-square rounded-lg overflow-hidden
|
||||
border border-[var(--border)] group">
|
||||
{ref_.url ? (
|
||||
<img src={ref_.url} alt={ref_.caption} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-[var(--accent)]/10 to-[var(--accent-secondary)]/5
|
||||
flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] text-[var(--accent)]/60 mb-0.5">占位</div>
|
||||
<div className="text-[9px] text-[var(--text-secondary)]">
|
||||
{ref_.caption}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="absolute top-1 right-1 w-5 h-5 rounded-full
|
||||
bg-black/60 text-white text-xs opacity-0 group-hover:opacity-100
|
||||
cursor-pointer transition-opacity flex items-center justify-center"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user