Files
EPEEAIKit/art-agent/frontend/src/app/styles/[id]/page.tsx
Nostars Developer b12e55a776 交互原型大版本
2026-04-17 19:30:23 +08:00

465 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}