465 lines
18 KiB
TypeScript
465 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|