交互原型大版本
This commit is contained in:
511
art-agent/frontend/src/app/characters/[id]/page.tsx
Normal file
511
art-agent/frontend/src/app/characters/[id]/page.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
"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 { CharacterCard, CharacterRefImage } from "@/lib/types";
|
||||
|
||||
const ANGLES = [
|
||||
"正面",
|
||||
"半侧面",
|
||||
"侧面",
|
||||
"背面",
|
||||
"半身",
|
||||
"全身",
|
||||
"表情·微笑",
|
||||
"表情·愤怒",
|
||||
"表情·惊讶",
|
||||
"服饰细节",
|
||||
];
|
||||
|
||||
export default function CharacterDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const isNew = params.id === "new";
|
||||
const {
|
||||
characters,
|
||||
upsertCharacter,
|
||||
deleteCharacter,
|
||||
setSessionCharacterId,
|
||||
projects,
|
||||
activeProjectId,
|
||||
assets,
|
||||
} = useApp();
|
||||
|
||||
const [c, setC] = useState<CharacterCard | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
const now = Date.now();
|
||||
setC({
|
||||
id: `char-${now.toString(36)}`,
|
||||
name: "",
|
||||
description: "",
|
||||
personality: [],
|
||||
visualHooks: [],
|
||||
consistency: "medium",
|
||||
refImages: [],
|
||||
projectId: activeProjectId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
const found = characters.find((x) => x.id === params.id);
|
||||
if (found) setC(found);
|
||||
}
|
||||
}, [params.id, characters, isNew, activeProjectId]);
|
||||
|
||||
if (!c) {
|
||||
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 (!c.name.trim()) {
|
||||
alert("请填写角色名称");
|
||||
return;
|
||||
}
|
||||
upsertCharacter(c);
|
||||
alert("已保存(占位)");
|
||||
if (isNew) router.push(`/characters/${c.id}`);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
setSessionCharacterId(c.id);
|
||||
alert(`已将角色 "${c.name}" 应用到当前会话(占位)`);
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm(`确认删除角色 "${c.name}"?`)) return;
|
||||
deleteCharacter(c.id);
|
||||
router.push("/characters");
|
||||
};
|
||||
|
||||
const addRef = (angle: string) => {
|
||||
setC({
|
||||
...c,
|
||||
refImages: [
|
||||
...c.refImages,
|
||||
{ id: `r-${Date.now()}`, url: "", angle, caption: "占位参考图" },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const removeRef = (id: string) => {
|
||||
setC({ ...c, refImages: c.refImages.filter((r) => r.id !== id) });
|
||||
};
|
||||
|
||||
const addPersonality = (kw: string) => {
|
||||
if (!kw || c.personality.includes(kw)) return;
|
||||
setC({ ...c, personality: [...c.personality, kw] });
|
||||
};
|
||||
|
||||
const addHook = (kw: string) => {
|
||||
if (!kw || c.visualHooks.includes(kw)) return;
|
||||
setC({ ...c, visualHooks: [...c.visualHooks, kw] });
|
||||
};
|
||||
|
||||
const relatedAssets = assets
|
||||
.filter((a) =>
|
||||
c.visualHooks.some((h) => a.prompt.toLowerCase().includes(h.toLowerCase())) ||
|
||||
a.prompt.toLowerCase().includes(c.name.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="/characters" className="hover:text-[var(--accent)] cursor-pointer">
|
||||
角色库
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--text-primary)]">
|
||||
{isNew ? "新建角色" : c.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={c.name}
|
||||
onChange={(e) => setC({ ...c, 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 flex-1"
|
||||
/>
|
||||
<span className="phase-chip flex-shrink-0">Phase 1</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
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 && (
|
||||
<>
|
||||
<Link
|
||||
href={`/training/new?type=character&source=${c.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">
|
||||
<div className="lg:col-span-2 space-y-5">
|
||||
{/* 基本信息 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">基本信息</h2>
|
||||
<textarea
|
||||
value={c.description}
|
||||
onChange={(e) => setC({ ...c, description: e.target.value })}
|
||||
placeholder="一句话简要描述这个角色..."
|
||||
rows={2}
|
||||
className="w-full p-3 mb-3 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-sm focus:outline-none focus:border-[var(--accent)]/50 resize-none"
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
<LabeledInput label="性别" value={c.gender ?? ""}
|
||||
onChange={(v) => setC({ ...c, gender: v })} />
|
||||
<LabeledInput label="年龄段" value={c.ageRange ?? ""}
|
||||
onChange={(v) => setC({ ...c, ageRange: v })} />
|
||||
<LabeledInput label="体型" value={c.body ?? ""}
|
||||
onChange={(v) => setC({ ...c, body: v })} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 性格关键词 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">性格关键词</h2>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{c.personality.map((k) => (
|
||||
<TagChip
|
||||
key={k}
|
||||
label={k}
|
||||
onRemove={() => setC({ ...c, personality: c.personality.filter((x) => x !== k) })}
|
||||
/>
|
||||
))}
|
||||
<KeywordInput onAdd={addPersonality} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 背景故事 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">背景故事摘要</h2>
|
||||
<textarea
|
||||
value={c.story ?? ""}
|
||||
onChange={(e) => setC({ ...c, story: e.target.value })}
|
||||
placeholder="角色的背景设定、重要经历..."
|
||||
rows={3}
|
||||
className="w-full p-3 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-sm focus:outline-none focus:border-[var(--accent)]/50 resize-none"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 关键视觉要素 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">关键视觉要素</h2>
|
||||
<p className="typo-caption mb-2">
|
||||
角色的识别性特征(发色、瞳色、标志性饰品、武器等)。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{c.visualHooks.map((h) => (
|
||||
<TagChip
|
||||
key={h}
|
||||
label={h}
|
||||
onRemove={() => setC({ ...c, visualHooks: c.visualHooks.filter((x) => x !== h) })}
|
||||
/>
|
||||
))}
|
||||
<KeywordInput onAdd={addHook} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 角色参考组 */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="typo-h2">角色参考组</h2>
|
||||
<AngleDropdown onPick={addRef} />
|
||||
</div>
|
||||
<p className="typo-caption mb-3">
|
||||
多角度参考图集合:正面 / 半侧面 / 半身 / 表情变化 / 关键服饰细节。
|
||||
</p>
|
||||
<div className="grid grid-cols-3 md:grid-cols-4 gap-2.5">
|
||||
{c.refImages.map((ref) => (
|
||||
<RefImageCard
|
||||
key={ref.id}
|
||||
ref_={ref}
|
||||
onRemove={() => removeRef(ref.id)}
|
||||
/>
|
||||
))}
|
||||
{c.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>
|
||||
|
||||
{/* 生成历史 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">使用该角色生成的图片</h2>
|
||||
{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)]
|
||||
bg-[var(--bg-tertiary)] flex items-center justify-center text-[9px] text-[var(--text-secondary)]">
|
||||
占位缩略
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
{/* 一致性级别 */}
|
||||
<div className="rounded-xl surface-2 p-4">
|
||||
<span className="typo-strong text-xs block mb-2">默认一致性级别</span>
|
||||
<div className="flex gap-1.5">
|
||||
{(["low", "medium", "high"] as const).map((lv) => (
|
||||
<button
|
||||
key={lv}
|
||||
onClick={() => setC({ ...c, consistency: lv })}
|
||||
className={`flex-1 px-2 py-1.5 text-xs rounded-lg cursor-pointer transition-all ${
|
||||
c.consistency === lv
|
||||
? "bg-[var(--accent)]/15 text-[var(--accent)] font-medium border border-[var(--accent)]/40"
|
||||
: "border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{lv === "low" ? "低" : lv === "medium" ? "中" : "高"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--text-secondary)] mt-2 leading-relaxed">
|
||||
高一致性会强制使用角色参考图引导生成。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 所属项目 */}
|
||||
<div className="rounded-xl surface-2 p-4">
|
||||
<span className="typo-strong text-xs block mb-2">所属项目</span>
|
||||
<select
|
||||
value={c.projectId ?? ""}
|
||||
onChange={(e) => setC({ ...c, projectId: e.target.value })}
|
||||
className="w-full text-xs px-2 py-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="">未归属</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* LoRA */}
|
||||
<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">
|
||||
{c.loraId
|
||||
? `已绑定:${c.loraId}(占位)`
|
||||
: "尚未绑定。可通过训练中心生成角色 LoRA。"}
|
||||
</p>
|
||||
<Link
|
||||
href={`/training/new?type=character&source=${c.id}`}
|
||||
className="text-xs text-[var(--gold)] hover:underline"
|
||||
>
|
||||
前往训练 →
|
||||
</Link>
|
||||
</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>创建时间:{new Date(c.createdAt).toLocaleDateString("zh-CN")}</div>
|
||||
<div>更新时间:{new Date(c.updatedAt).toLocaleDateString("zh-CN")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LabeledInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>{label}</div>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-xs focus:outline-none focus:border-[var(--accent)]/50"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg
|
||||
bg-[var(--accent)]/10 text-[var(--accent)] font-medium">
|
||||
{label}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="hover:text-[var(--hot)] cursor-pointer"
|
||||
>×</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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)]
|
||||
focus:outline-none focus:border-[var(--accent)]/50 min-w-[120px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AngleDropdown({ onPick }: { onPick: (angle: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
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>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-44 surface-3 rounded-xl py-1 z-10">
|
||||
{ANGLES.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => { onPick(a); setOpen(false); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors"
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefImageCard({
|
||||
ref_,
|
||||
onRemove,
|
||||
}: {
|
||||
ref_: CharacterRefImage;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative aspect-[3/4] rounded-lg overflow-hidden
|
||||
border border-[var(--border)] group">
|
||||
{ref_.url ? (
|
||||
<img src={ref_.url} alt={ref_.angle} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-[var(--accent-secondary)]/10 to-[var(--accent)]/5
|
||||
flex items-center justify-center p-2">
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] text-[var(--accent)]/70 font-medium mb-0.5">
|
||||
{ref_.angle}
|
||||
</div>
|
||||
<div className="text-[9px] text-[var(--text-secondary)]">占位</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>
|
||||
);
|
||||
}
|
||||
217
art-agent/frontend/src/app/characters/page.tsx
Normal file
217
art-agent/frontend/src/app/characters/page.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { staggerContainer, staggerItem } from "@/components/ui/motion-presets";
|
||||
import type { CharacterCard } from "@/lib/types";
|
||||
|
||||
export default function CharactersPage() {
|
||||
const { characters, projects, activeProjectId } = useApp();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [projectFilter, setProjectFilter] = useState<string>("all");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = [...characters];
|
||||
if (projectFilter !== "all") {
|
||||
list = list.filter((c) => c.projectId === projectFilter);
|
||||
}
|
||||
if (query.trim()) {
|
||||
const q = query.trim().toLowerCase();
|
||||
list = list.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.description.toLowerCase().includes(q) ||
|
||||
c.visualHooks.some((h) => h.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [characters, projectFilter, query]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col relative z-[1]">
|
||||
<AmbientParticles count={16} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<h1 className="typo-h1">角色库</h1>
|
||||
<span className="phase-chip">Phase 1</span>
|
||||
</div>
|
||||
<p className="typo-caption">
|
||||
项目角色卡。包含多角度参考图组、关键视觉要素、一致性级别设置。
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/characters/new"
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-sm rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
|
||||
transition-all cursor-pointer btn-hover-lift flex-shrink-0"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
新建角色
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mb-6">
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[200px] max-w-[360px]
|
||||
px-3 py-1.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
focus-within:border-[var(--accent)]/40 transition-colors">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索角色名 / 描述 / 视觉要素..."
|
||||
className="flex-1 bg-transparent border-none text-sm
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 项目筛选 */}
|
||||
<select
|
||||
value={projectFilter}
|
||||
onChange={(e) => setProjectFilter(e.target.value)}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] cursor-pointer focus:outline-none
|
||||
focus:border-[var(--accent)]/40"
|
||||
>
|
||||
<option value="all">全部项目</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name === projects.find((x) => x.id === activeProjectId)?.name
|
||||
? `${p.name}(当前)`
|
||||
: p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="typo-display text-lg mb-2">没有匹配的角色</p>
|
||||
<p className="typo-caption">试试调整筛选条件或点击“新建角色”</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
{filtered.map((c) => (
|
||||
<CharCard key={c.id} c={c} projects={projects} />
|
||||
))}
|
||||
<motion.div variants={staggerItem}>
|
||||
<Link
|
||||
href="/characters/new"
|
||||
className="aspect-[3/4] rounded-xl placeholder-card
|
||||
flex flex-col items-center justify-center gap-2
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:border-[var(--accent)]/50 cursor-pointer
|
||||
transition-all btn-hover-lift"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
<span className="text-xs">新建角色</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CharCard({
|
||||
c,
|
||||
projects,
|
||||
}: {
|
||||
c: CharacterCard;
|
||||
projects: { id: string; name: string }[];
|
||||
}) {
|
||||
const projectName = c.projectId
|
||||
? projects.find((p) => p.id === c.projectId)?.name
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<motion.div variants={staggerItem}>
|
||||
<Link
|
||||
href={`/characters/${c.id}`}
|
||||
className="block rounded-xl overflow-hidden neon-border
|
||||
bg-[var(--bg-card)] backdrop-blur-sm cursor-pointer"
|
||||
>
|
||||
<div className="aspect-[3/4] relative overflow-hidden
|
||||
bg-gradient-to-br from-[var(--accent-secondary)]/15 via-[var(--accent)]/10 to-transparent">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-[var(--bg-card)] border border-[var(--accent)]/30
|
||||
flex items-center justify-center mx-auto mb-2">
|
||||
<span className="text-2xl font-bold text-[var(--accent)]">
|
||||
{c.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-[var(--text-secondary)]">
|
||||
头像占位 · {c.refImages.length} 张参考
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c.loraId && (
|
||||
<span className="absolute top-2 right-2 phase-chip-accent phase-chip">
|
||||
LoRA
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)] truncate mb-1">
|
||||
{c.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] line-clamp-2 mb-2 min-h-[24px]">
|
||||
{c.description}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-[var(--text-secondary)]">
|
||||
<span className="truncate">{projectName || "未归属项目"}</span>
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded-md font-medium"
|
||||
style={{
|
||||
background:
|
||||
c.consistency === "high"
|
||||
? "rgba(46, 139, 122, 0.15)"
|
||||
: c.consistency === "medium"
|
||||
? "rgba(58, 127, 184, 0.12)"
|
||||
: "rgba(107, 123, 138, 0.12)",
|
||||
color:
|
||||
c.consistency === "high"
|
||||
? "var(--accent)"
|
||||
: c.consistency === "medium"
|
||||
? "var(--accent-secondary)"
|
||||
: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
一致性:
|
||||
{c.consistency === "high" ? "高" : c.consistency === "medium" ? "中" : "低"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -12,17 +12,30 @@ import type { ImageAsset } from "@/lib/types";
|
||||
|
||||
type ViewMode = "grid" | "list";
|
||||
type SortBy = "time" | "name";
|
||||
type SearchMode = "keyword" | "semantic";
|
||||
|
||||
const ASSET_TYPES = [
|
||||
{ id: "icon", label: "Icon", keywords: ["icon", "图标", "徽标"] },
|
||||
{ id: "character", label: "角色立绘", keywords: ["立绘", "角色", "character", "半身", "全身"] },
|
||||
{ id: "scene", label: "场景", keywords: ["场景", "背景", "scene", "landscape"] },
|
||||
{ id: "item", label: "道具", keywords: ["道具", "武器", "装备", "item"] },
|
||||
{ id: "ui", label: "UI 元素", keywords: ["ui", "按钮", "卡片", "边框"] },
|
||||
];
|
||||
|
||||
export default function GalleryPage() {
|
||||
const { assets, tags, toggleFavorite, deleteAssetById, updateAsset } = useApp();
|
||||
const { assets, tags, toggleFavorite, deleteAssetById, stylePacks, characters } = useApp();
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid");
|
||||
const [sortBy, setSortBy] = useState<SortBy>("time");
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchMode, setSearchMode] = useState<SearchMode>("keyword");
|
||||
const [showFavOnly, setShowFavOnly] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [detailAsset, setDetailAsset] = useState<ImageAsset | null>(null);
|
||||
const [selectedAssetType, setSelectedAssetType] = useState<string | null>(null);
|
||||
const [selectedStyleId, setSelectedStyleId] = useState<string | null>(null);
|
||||
const [selectedCharacterId, setSelectedCharacterId] = useState<string | null>(null);
|
||||
|
||||
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||
|
||||
@@ -39,7 +52,37 @@ export default function GalleryPage() {
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
result = result.filter((a) => a.prompt.toLowerCase().includes(q));
|
||||
if (searchMode === "keyword") {
|
||||
result = result.filter((a) => a.prompt.toLowerCase().includes(q));
|
||||
} else {
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
result = result.filter((a) => {
|
||||
const text = a.prompt.toLowerCase();
|
||||
return tokens.some((t) => text.includes(t));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedAssetType) {
|
||||
const kws = ASSET_TYPES.find((t) => t.id === selectedAssetType)?.keywords ?? [];
|
||||
result = result.filter((a) => kws.some((k) => a.prompt.toLowerCase().includes(k.toLowerCase())));
|
||||
}
|
||||
|
||||
if (selectedStyleId) {
|
||||
const sp = stylePacks.find((s) => s.id === selectedStyleId);
|
||||
const kws = sp?.keywords ?? [];
|
||||
result = result.filter((a) =>
|
||||
kws.some((k) => a.prompt.toLowerCase().includes(k.toLowerCase())) ||
|
||||
a.prompt.toLowerCase().includes((sp?.name ?? "").toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedCharacterId) {
|
||||
const ch = characters.find((c) => c.id === selectedCharacterId);
|
||||
const kws = [...(ch?.visualHooks ?? []), ch?.name].filter(Boolean) as string[];
|
||||
result = result.filter((a) =>
|
||||
kws.some((k) => a.prompt.toLowerCase().includes(k.toLowerCase()))
|
||||
);
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
@@ -48,7 +91,8 @@ export default function GalleryPage() {
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [assets, showFavOnly, selectedTagIds, searchQuery, sortBy]);
|
||||
}, [assets, showFavOnly, selectedTagIds, searchQuery, searchMode, sortBy,
|
||||
selectedAssetType, selectedStyleId, selectedCharacterId, stylePacks, characters]);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
@@ -144,11 +188,20 @@ export default function GalleryPage() {
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索 Prompt..."
|
||||
placeholder={searchMode === "semantic" ? "语义搜索(占位)..." : "搜索 Prompt..."}
|
||||
className="flex-1 bg-transparent border-none text-sm
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setSearchMode(searchMode === "keyword" ? "semantic" : "keyword")}
|
||||
className="flex items-center gap-1 text-[10px] text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] cursor-pointer transition-colors"
|
||||
title="切换搜索模式"
|
||||
>
|
||||
{searchMode === "semantic" ? "语义" : "关键词"}
|
||||
{searchMode === "semantic" && <span className="phase-chip">P2</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 标签筛选 */}
|
||||
@@ -238,6 +291,84 @@ export default function GalleryPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 二级过滤器:资产类型 / 风格 / 角色 */}
|
||||
<div className="flex-shrink-0 border-b border-[var(--border)] surface-1
|
||||
px-4 md:px-5 py-2 flex items-center gap-3 flex-wrap text-xs">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>类型</span>
|
||||
<button
|
||||
onClick={() => setSelectedAssetType(null)}
|
||||
className={`px-2 py-0.5 rounded-md cursor-pointer transition-all text-[11px] ${
|
||||
selectedAssetType === null
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/30"
|
||||
: "border border-[var(--border)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{ASSET_TYPES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setSelectedAssetType(t.id === selectedAssetType ? null : t.id)}
|
||||
className={`px-2 py-0.5 rounded-md cursor-pointer transition-all text-[11px] ${
|
||||
selectedAssetType === t.id
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/30"
|
||||
: "border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{stylePacks.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>风格</span>
|
||||
<select
|
||||
value={selectedStyleId ?? ""}
|
||||
onChange={(e) => setSelectedStyleId(e.target.value || null)}
|
||||
className="px-2 py-0.5 text-[11px] rounded-md bg-[var(--bg-tertiary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none cursor-pointer"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{stylePacks.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{characters.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>角色</span>
|
||||
<select
|
||||
value={selectedCharacterId ?? ""}
|
||||
onChange={(e) => setSelectedCharacterId(e.target.value || null)}
|
||||
className="px-2 py-0.5 text-[11px] rounded-md bg-[var(--bg-tertiary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none cursor-pointer"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedAssetType || selectedStyleId || selectedCharacterId) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedAssetType(null);
|
||||
setSelectedStyleId(null);
|
||||
setSelectedCharacterId(null);
|
||||
}}
|
||||
className="ml-auto text-[10px] text-[var(--text-secondary)] hover:text-[var(--hot)] cursor-pointer"
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex-shrink-0 px-5 py-2.5 bg-[var(--accent)]/5 border-b border-[var(--accent)]/20
|
||||
|
||||
@@ -543,6 +543,52 @@ select option {
|
||||
animation: mistCondense 1.5s ease-out forwards, breathPulse 2s ease-in-out 1.5s infinite;
|
||||
}
|
||||
|
||||
/* ===== 隐藏滚动条(保留滚动能力) ===== */
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* ===== Phase 徽章 ===== */
|
||||
.phase-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
background: rgba(184, 147, 90, 0.12);
|
||||
color: var(--gold);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.phase-chip-accent {
|
||||
background: rgba(46, 139, 122, 0.12);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== 占位卡片(透明虚线边框) ===== */
|
||||
.placeholder-card {
|
||||
border: 1px dashed rgba(46, 139, 122, 0.25);
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
135deg,
|
||||
rgba(46, 139, 122, 0.02) 0px,
|
||||
rgba(46, 139, 122, 0.02) 12px,
|
||||
transparent 12px,
|
||||
transparent 24px
|
||||
);
|
||||
}
|
||||
|
||||
/* ===== 段落分隔(用于 Phase 说明) ===== */
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent 0%, var(--border-glow) 50%, transparent 100%);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* ===== 100dvh 兼容 ===== */
|
||||
@supports (height: 100dvh) {
|
||||
.h-screen {
|
||||
|
||||
@@ -4,6 +4,9 @@ import { useCallback, useEffect, useRef, useState, type DragEvent } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChatMessages } from "@/components/chat/chat-messages";
|
||||
import { ChatInput, type ChatInputHandle } from "@/components/chat/chat-input";
|
||||
import { SessionQuickPicks } from "@/components/chat/session-quick-picks";
|
||||
import { AdvancedControls } from "@/components/workbench/advanced-controls";
|
||||
import { CandidatePanel } from "@/components/workbench/candidate-panel";
|
||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { ImageDetailPanel } from "@/components/detail/image-detail-panel";
|
||||
@@ -44,6 +47,7 @@ export default function Home() {
|
||||
const prevSessionId = useRef<string | null>(null);
|
||||
const isSwitching = useRef(false);
|
||||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||
const [showCandidates, setShowCandidates] = useState(false);
|
||||
|
||||
const scrollToBottom = useCallback((instant?: boolean) => {
|
||||
if (isSwitching.current && !instant) return;
|
||||
@@ -460,6 +464,33 @@ export default function Home() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex-shrink-0 border-t border-[var(--border)] bg-[var(--bg-secondary)]/60 backdrop-blur-md
|
||||
flex items-center">
|
||||
<div className="flex-1 min-w-0">
|
||||
<SessionQuickPicks />
|
||||
</div>
|
||||
<div className="px-3 md:px-5 py-2 flex items-center gap-1.5">
|
||||
<AdvancedControls />
|
||||
<button
|
||||
onClick={() => setShowCandidates(true)}
|
||||
className="h-7 px-2 rounded-lg text-xs flex items-center gap-1.5
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||||
title="候选评估(占位)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
候选
|
||||
<span className="phase-chip">P1</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSend={handleSend}
|
||||
@@ -482,6 +513,24 @@ export default function Home() {
|
||||
<ImageDetailPanel onAnnotationComplete={handleAnnotationComplete} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCandidates && (
|
||||
<CandidatePanel
|
||||
candidates={
|
||||
messages
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant" && m.images && m.images.length > 0)?.images ??
|
||||
streamingImages ??
|
||||
[]
|
||||
}
|
||||
onPick={(id) => {
|
||||
alert(`(占位)已选 ${id} 作为定稿`);
|
||||
setShowCandidates(false);
|
||||
}}
|
||||
onClose={() => setShowCandidates(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
308
art-agent/frontend/src/app/settings/page.tsx
Normal file
308
art-agent/frontend/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
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";
|
||||
|
||||
const PROJECT_TYPES = [
|
||||
"2D RPG",
|
||||
"横版动作",
|
||||
"卡牌",
|
||||
"模拟经营",
|
||||
"策略",
|
||||
"美术包",
|
||||
"其他",
|
||||
];
|
||||
|
||||
export default function ProjectSettingsPage() {
|
||||
const { activeProject, upsertProject, stylePacks, projects, switchProject } = useApp();
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [desc, setDesc] = useState("");
|
||||
const [defaultStyleId, setDefaultStyleId] = useState<string>("");
|
||||
const [defaultSpec, setDefaultSpec] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProject) return;
|
||||
setName(activeProject.name);
|
||||
setType(activeProject.type);
|
||||
setDesc(activeProject.description);
|
||||
setDefaultStyleId(activeProject.defaultStyleId ?? "");
|
||||
setDefaultSpec(activeProject.defaultSpec ?? "");
|
||||
}, [activeProject]);
|
||||
|
||||
if (!activeProject) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<TopNav />
|
||||
<main className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
||||
尚无激活项目
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
upsertProject({
|
||||
...activeProject,
|
||||
name: name.trim() || activeProject.name,
|
||||
type,
|
||||
description: desc,
|
||||
defaultStyleId: defaultStyleId || undefined,
|
||||
defaultSpec: defaultSpec || undefined,
|
||||
});
|
||||
alert("已保存项目设置(占位:未同步后端)");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<AmbientParticles count={10} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<h1 className="typo-h1">项目设置</h1>
|
||||
<span className="phase-chip">Phase 1</span>
|
||||
</div>
|
||||
<p className="typo-caption mb-6">
|
||||
管理当前项目的基本信息、默认风格与输出规格,以及成员协作(占位)。
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-5">
|
||||
{/* 侧栏:锚点 */}
|
||||
<aside className="lg:col-span-1">
|
||||
<nav className="sticky top-4 space-y-1 text-xs">
|
||||
{[
|
||||
{ id: "basic", label: "基本信息" },
|
||||
{ id: "defaults", label: "默认设置" },
|
||||
{ id: "members", label: "成员与权限" },
|
||||
{ id: "billing", label: "计费与额度" },
|
||||
{ id: "danger", label: "危险操作" },
|
||||
].map((n) => (
|
||||
<a
|
||||
key={n.id}
|
||||
href={`#${n.id}`}
|
||||
className="block px-3 py-1.5 rounded-lg text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] hover:bg-[var(--accent)]/6
|
||||
transition-all"
|
||||
>
|
||||
{n.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="lg:col-span-3 space-y-5">
|
||||
{/* 基本信息 */}
|
||||
<section id="basic" className="rounded-xl surface-2 p-5">
|
||||
<h2 className="typo-h2 mb-4">基本信息</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}>项目名称</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}>项目类型</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PROJECT_TYPES.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setType(t)}
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border cursor-pointer transition-all ${
|
||||
type === t
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
|
||||
: "border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}>项目描述</label>
|
||||
<textarea
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="简单描述项目定位、目标用户等(占位)"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 默认设置 */}
|
||||
<section id="defaults" className="rounded-xl surface-2 p-5">
|
||||
<h2 className="typo-h2 mb-4">默认设置</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}>默认风格集</label>
|
||||
<select
|
||||
value={defaultStyleId}
|
||||
onChange={(e) => setDefaultStyleId(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
>
|
||||
<option value="">未设置</option>
|
||||
{stylePacks.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-[var(--text-secondary)] mt-1">
|
||||
新建会话时会自动套用此风格(占位,尚未真实联动)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}>默认输出规格</label>
|
||||
<input
|
||||
value={defaultSpec}
|
||||
onChange={(e) => setDefaultSpec(e.target.value)}
|
||||
placeholder="例如:1024×1024 · 透明背景可选"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="placeholder-card rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="typo-strong text-xs">命名规范与目录结构</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--text-secondary)]">
|
||||
定义导出到项目工程目录时的命名约定和资源归档位置。待 Phase 2 交付。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 成员与权限 */}
|
||||
<section id="members" className="rounded-xl surface-2 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="typo-h2">成员与权限</h2>
|
||||
<span className="phase-chip">Phase 3</span>
|
||||
</div>
|
||||
<div className="placeholder-card rounded-lg p-6 text-center text-xs text-[var(--text-secondary)]">
|
||||
多人协作、角色权限、审核流程占位。Phase 3 交付。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 计费与额度 */}
|
||||
<section id="billing" className="rounded-xl surface-2 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="typo-h2">计费与额度</h2>
|
||||
<span className="phase-chip">Phase 3</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="placeholder-card rounded-lg p-3">
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mb-0.5">本月已用</div>
|
||||
<div className="text-xl font-semibold text-[var(--text-primary)]">¥ 128</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">占位数值</div>
|
||||
</div>
|
||||
<div className="placeholder-card rounded-lg p-3">
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mb-0.5">剩余额度</div>
|
||||
<div className="text-xl font-semibold text-[var(--text-primary)]">¥ 872</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">占位数值</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 危险操作 */}
|
||||
<section id="danger" className="rounded-xl surface-2 p-5 border border-[var(--hot)]/20">
|
||||
<h2 className="typo-h2 mb-4 text-[var(--hot)]/80">危险操作</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-[var(--text-primary)]">归档项目</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)]">归档后不再出现在切换器中,可从"全局设置"恢复(占位)</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => alert("(占位)归档项目")}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-[var(--border)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
>
|
||||
归档
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-[var(--hot)]/80">删除项目</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)]">不可恢复。所有关联会话、风格集、角色卡将被清除(占位)</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!confirm("确认删除该项目?此操作不可撤销(占位)")) return;
|
||||
alert("(占位)已请求删除项目。真实删除逻辑待接入。");
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs rounded-lg text-[var(--hot)]/80
|
||||
hover:text-[var(--hot)] hover:bg-[var(--hot)]/10 border border-[var(--hot)]/30
|
||||
cursor-pointer"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 保存栏 */}
|
||||
<div className="flex items-center justify-between sticky bottom-0 py-3 bg-[var(--bg-primary)]/80 backdrop-blur-md -mx-5 px-5">
|
||||
<div className="text-[10px] text-[var(--text-secondary)]">
|
||||
项目 ID: {activeProject.id} · 创建于 {new Date(activeProject.createdAt).toLocaleDateString("zh-CN")}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="px-3.5 py-2 text-xs rounded-xl border border-[var(--border)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
>
|
||||
返回工作台
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 其他项目(快速切换) */}
|
||||
{projects.length > 1 && (
|
||||
<section className="rounded-xl surface-2 p-5">
|
||||
<h2 className="typo-h2 mb-3">其他项目</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{projects.filter((p) => p.id !== activeProject.id).map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => switchProject(p.id)}
|
||||
className="text-left p-3 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/40
|
||||
cursor-pointer transition-all"
|
||||
>
|
||||
<div className="text-sm text-[var(--text-primary)] truncate">{p.name}</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{p.type}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
251
art-agent/frontend/src/app/styles/page.tsx
Normal file
251
art-agent/frontend/src/app/styles/page.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { staggerContainer, staggerItem } from "@/components/ui/motion-presets";
|
||||
import type { StylePack } from "@/lib/types";
|
||||
|
||||
type SourceFilter = "all" | "preset" | "custom" | "community";
|
||||
|
||||
const ALL_KEYWORDS = ["写实", "二次元", "像素", "水墨", "赛博", "奇幻", "写意", "科幻", "复古"];
|
||||
|
||||
export default function StylesPage() {
|
||||
const { stylePacks } = useApp();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [source, setSource] = useState<SourceFilter>("all");
|
||||
const [keyword, setKeyword] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = [...stylePacks];
|
||||
if (source !== "all") list = list.filter((s) => s.source === source);
|
||||
if (keyword) list = list.filter((s) => s.keywords.includes(keyword));
|
||||
if (query.trim()) {
|
||||
const q = query.trim().toLowerCase();
|
||||
list = list.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.description.toLowerCase().includes(q) ||
|
||||
s.keywords.some((k) => k.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [stylePacks, source, keyword, query]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col relative z-[1]">
|
||||
<AmbientParticles count={16} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<h1 className="typo-h1">风格库</h1>
|
||||
<span className="phase-chip">Phase 1</span>
|
||||
</div>
|
||||
<p className="typo-caption">
|
||||
项目级风格集。每个风格包含关键词、参考集、Prompt 模板和(可选的)LoRA 绑定。
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/styles/new"
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-sm rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
|
||||
transition-all cursor-pointer btn-hover-lift flex-shrink-0"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
新建风格
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-5">
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[200px] max-w-[360px]
|
||||
px-3 py-1.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
focus-within:border-[var(--accent)]/40 transition-colors">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索风格名 / 描述 / 关键词..."
|
||||
className="flex-1 bg-transparent border-none text-sm
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 来源筛选 */}
|
||||
<div className="flex items-center gap-0.5 bg-[var(--bg-tertiary)] rounded-lg p-0.5 border border-[var(--border)]">
|
||||
{(["all", "preset", "custom", "community"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSource(s)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md cursor-pointer transition-all ${
|
||||
source === s
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{s === "all" ? "全部" : s === "preset" ? "预设" : s === "custom" ? "自建" : "社区"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类筛选(关键词) */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-6">
|
||||
<span className="typo-micro mr-1" style={{ textTransform: "none" }}>分类:</span>
|
||||
<button
|
||||
onClick={() => setKeyword(null)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded-lg cursor-pointer transition-all ${
|
||||
!keyword
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/30"
|
||||
: "text-[var(--text-secondary)] border border-transparent hover:border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{ALL_KEYWORDS.map((k) => {
|
||||
const active = keyword === k;
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setKeyword(active ? null : k)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded-lg cursor-pointer transition-all ${
|
||||
active
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/30"
|
||||
: "text-[var(--text-secondary)] border border-transparent hover:border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
{k}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 卡片网格 */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="typo-display text-lg mb-2">没有匹配的风格</p>
|
||||
<p className="typo-caption">试试调整筛选条件或点击“新建风格”</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
{filtered.map((pack) => (
|
||||
<StyleCard key={pack.id} pack={pack} />
|
||||
))}
|
||||
{/* 新建卡片 */}
|
||||
<motion.div variants={staggerItem}>
|
||||
<Link
|
||||
href="/styles/new"
|
||||
className="aspect-[3/4] rounded-xl placeholder-card
|
||||
flex flex-col items-center justify-center gap-2
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:border-[var(--accent)]/50 cursor-pointer
|
||||
transition-all btn-hover-lift"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
<span className="text-xs">新建风格</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StyleCard({ pack }: { pack: StylePack }) {
|
||||
const sourceLabel =
|
||||
pack.source === "preset" ? "预设" : pack.source === "custom" ? "自建" : "社区";
|
||||
|
||||
return (
|
||||
<motion.div variants={staggerItem}>
|
||||
<Link
|
||||
href={`/styles/${pack.id}`}
|
||||
className="block rounded-xl overflow-hidden neon-border
|
||||
bg-[var(--bg-card)] backdrop-blur-sm cursor-pointer
|
||||
transition-all"
|
||||
>
|
||||
{/* 代表图(占位) */}
|
||||
<div className="aspect-[4/3] relative overflow-hidden
|
||||
bg-gradient-to-br from-[var(--accent)]/15 via-[var(--accent-secondary)]/10 to-transparent">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-[var(--accent)] tracking-tight opacity-60">
|
||||
{pack.name.slice(0, 2)}
|
||||
</div>
|
||||
<div className="text-[9px] text-[var(--text-secondary)] mt-1">
|
||||
代表图占位({pack.refImages.length} 张参考)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 来源标签 */}
|
||||
<span
|
||||
className="absolute top-2 right-2 text-[9px] px-1.5 py-0.5 rounded-md font-medium"
|
||||
style={{
|
||||
background:
|
||||
pack.source === "preset"
|
||||
? "rgba(46, 139, 122, 0.15)"
|
||||
: pack.source === "custom"
|
||||
? "rgba(58, 127, 184, 0.15)"
|
||||
: "rgba(184, 147, 90, 0.15)",
|
||||
color:
|
||||
pack.source === "preset"
|
||||
? "var(--accent)"
|
||||
: pack.source === "custom"
|
||||
? "var(--accent-secondary)"
|
||||
: "var(--gold)",
|
||||
}}
|
||||
>
|
||||
{sourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)] truncate mb-1">
|
||||
{pack.name}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{pack.keywords.slice(0, 3).map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="text-[9px] px-1.5 py-0.5 rounded-md
|
||||
bg-[var(--accent)]/8 text-[var(--accent)] font-medium"
|
||||
>
|
||||
{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-[var(--text-secondary)]">
|
||||
<span>使用 {pack.usageCount} 次</span>
|
||||
{pack.loraId && <span className="phase-chip-accent phase-chip">LoRA</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
347
art-agent/frontend/src/app/training/[id]/page.tsx
Normal file
347
art-agent/frontend/src/app/training/[id]/page.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
"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 { TrainingTask, TrainingTaskStatus } from "@/lib/types";
|
||||
|
||||
const STATUS_LABEL: Record<TrainingTaskStatus, string> = {
|
||||
queued: "排队中",
|
||||
running: "训练中",
|
||||
completed: "已完成",
|
||||
failed: "已失败",
|
||||
deprecated: "已弃用",
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<TrainingTaskStatus, string> = {
|
||||
queued: "#6B7B8A",
|
||||
running: "#2E8B7A",
|
||||
completed: "#3A7FB8",
|
||||
failed: "#C4654A",
|
||||
deprecated: "#B8935A",
|
||||
};
|
||||
|
||||
export default function TrainingDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const {
|
||||
trainingTasks,
|
||||
upsertTrainingTask,
|
||||
deleteTrainingTask,
|
||||
stylePacks,
|
||||
characters,
|
||||
} = useApp();
|
||||
|
||||
const [task, setTask] = useState<TrainingTask | null>(null);
|
||||
const [logOpen, setLogOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const t = trainingTasks.find((x) => x.id === params.id);
|
||||
setTask(t ?? null);
|
||||
}, [params.id, trainingTasks]);
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<TopNav />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="typo-caption mb-2">未找到该训练任务</p>
|
||||
<Link href="/training" className="text-sm text-[var(--accent)] hover:underline">
|
||||
返回训练中心
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const source =
|
||||
task.type === "style_lora"
|
||||
? stylePacks.find((s) => s.id === task.sourceId)
|
||||
: characters.find((c) => c.id === task.sourceId);
|
||||
|
||||
const handleUse = () => {
|
||||
alert(`(占位)已绑定此 LoRA 到当前会话:${task.name}`);
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleRetrain = () => {
|
||||
alert("(占位)将基于当前训练集重新启动训练任务。");
|
||||
};
|
||||
|
||||
const handleDeprecate = () => {
|
||||
if (!confirm("确认弃用该模型?")) return;
|
||||
upsertTrainingTask({ ...task, status: "deprecated" });
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm("确认删除该训练任务?")) return;
|
||||
deleteTrainingTask(task.id);
|
||||
router.push("/training");
|
||||
};
|
||||
|
||||
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="/training" className="hover:text-[var(--accent)] cursor-pointer">
|
||||
训练中心
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--text-primary)]">{task.name}</span>
|
||||
</div>
|
||||
|
||||
{/* 头部 */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<h1 className="typo-h1">{task.name}</h1>
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-md font-medium"
|
||||
style={{
|
||||
background: STATUS_COLOR[task.status] + "22",
|
||||
color: STATUS_COLOR[task.status],
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[task.status]}
|
||||
</span>
|
||||
<span className="phase-chip">Phase 2</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{task.type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
|
||||
{source && (
|
||||
<>
|
||||
{" · 来源:"}
|
||||
<Link
|
||||
href={
|
||||
task.type === "style_lora"
|
||||
? `/styles/${task.sourceId}`
|
||||
: `/characters/${task.sourceId}`
|
||||
}
|
||||
className="text-[var(--accent)] hover:underline"
|
||||
>
|
||||
{source.name}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{" · 模板:"}
|
||||
{task.template}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{task.status === "completed" && (
|
||||
<button
|
||||
onClick={handleUse}
|
||||
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>
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
<button
|
||||
onClick={handleRetrain}
|
||||
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>
|
||||
)}
|
||||
{task.status !== "deprecated" && task.status !== "queued" && (
|
||||
<button
|
||||
onClick={handleDeprecate}
|
||||
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"
|
||||
>
|
||||
弃用
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* 进度条 */}
|
||||
{task.status === "running" && (
|
||||
<div className="rounded-xl surface-2 p-4 mb-5">
|
||||
<div className="flex items-center justify-between mb-2 text-xs">
|
||||
<span className="typo-strong">{task.currentStep ?? "训练中..."}</span>
|
||||
<span className="text-[var(--accent)] font-medium">{task.progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[var(--accent)] to-[var(--accent-secondary)] transition-all"
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{task.etaSeconds && (
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-1.5">
|
||||
预计剩余 {Math.floor(task.etaSeconds / 60)} 分 {task.etaSeconds % 60} 秒(占位)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
{/* 左:日志 + 训练前后对比 + 训练集预览 */}
|
||||
<div className="lg:col-span-2 space-y-5">
|
||||
{/* 训练前后对比 */}
|
||||
<section className="rounded-xl surface-2 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="typo-h2">训练前后效果对比</h2>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
{task.status === "completed" ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>训练前</div>
|
||||
<div className="aspect-square rounded-lg placeholder-card
|
||||
flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
||||
占位对比图
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>训练后</div>
|
||||
<div className="aspect-square rounded-lg placeholder-card
|
||||
flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
||||
占位对比图
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-secondary)] py-6 text-center">
|
||||
训练完成后将显示同一 Prompt 下的前后对比
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 训练日志 */}
|
||||
<section className="rounded-xl surface-2 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setLogOpen(!logOpen)}
|
||||
className="w-full px-4 py-2.5 flex items-center justify-between
|
||||
hover:bg-[var(--bg-tertiary)]/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<span className="typo-strong text-xs">训练日志</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
className={`transition-transform ${logOpen ? "" : "rotate-180"}`}>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
{logOpen && (
|
||||
<div className="border-t border-[var(--border)] p-3 bg-[var(--bg-primary)]/40 font-mono text-[11px] max-h-60 overflow-y-auto">
|
||||
{task.logs.length === 0 ? (
|
||||
<p className="text-[var(--text-secondary)]">尚无日志输出</p>
|
||||
) : (
|
||||
task.logs.map((l, i) => (
|
||||
<div key={i} className="text-[var(--text-secondary)] leading-relaxed">
|
||||
{l}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 训练集预览 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">训练集预览</h2>
|
||||
<p className="typo-caption mb-3">
|
||||
训练集共 {task.trainingSet.length} 张图片(占位缩略)。
|
||||
</p>
|
||||
{task.trainingSet.length === 0 ? (
|
||||
<div className="py-8 text-center placeholder-card rounded-xl text-xs text-[var(--text-secondary)]">
|
||||
训练集未填充
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 md:grid-cols-6 gap-2">
|
||||
{task.trainingSet.slice(0, 12).map((_, i) => (
|
||||
<div key={i}
|
||||
className="aspect-square rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
flex items-center justify-center text-[9px] text-[var(--text-secondary)]">
|
||||
占位
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 右:版本 + 成本 + 操作入口 */}
|
||||
<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">模型版本</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>当前版本:v1.0(占位)</div>
|
||||
<div>历史版本:v0.9、v0.8(占位)</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => alert("(占位)回滚到上一版本")}
|
||||
className="mt-2 text-xs text-[var(--accent)] hover:underline cursor-pointer"
|
||||
>
|
||||
回滚到上一版本 →
|
||||
</button>
|
||||
</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>模板:{task.template}</div>
|
||||
<div>预估成本:{task.estCost ?? "—"}</div>
|
||||
<div>创建时间:{new Date(task.createdAt).toLocaleString("zh-CN")}</div>
|
||||
<div>更新时间:{new Date(task.updatedAt).toLocaleString("zh-CN")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{source && (
|
||||
<div className="rounded-xl surface-2 p-4">
|
||||
<span className="typo-strong text-xs block mb-2">来源</span>
|
||||
<Link
|
||||
href={
|
||||
task.type === "style_lora"
|
||||
? `/styles/${task.sourceId}`
|
||||
: `/characters/${task.sourceId}`
|
||||
}
|
||||
className="text-xs text-[var(--accent)] hover:underline block truncate"
|
||||
>
|
||||
{source.name}
|
||||
</Link>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
|
||||
{task.type === "style_lora" ? "风格集" : "角色卡"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
353
art-agent/frontend/src/app/training/new/page.tsx
Normal file
353
art-agent/frontend/src/app/training/new/page.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } 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 { generateId } from "@/lib/store";
|
||||
import type { TrainingTask, TrainingTaskType } from "@/lib/types";
|
||||
|
||||
type WizardStep = 1 | 2 | 3 | 4;
|
||||
|
||||
const TEMPLATES = [
|
||||
{ id: "sdxl_lora", name: "SDXL LoRA", desc: "基于 SDXL 底模,兼顾速度与效果(占位)" },
|
||||
{ id: "flux_lora", name: "Flux LoRA", desc: "基于 Flux 底模,细节更精细(占位)" },
|
||||
{ id: "sd15_lora", name: "SD 1.5 LoRA", desc: "轻量训练,适合像素/卡通(占位)" },
|
||||
];
|
||||
|
||||
function TrainingWizardInner() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const { stylePacks, characters, upsertTrainingTask } = useApp();
|
||||
|
||||
const initType = (search.get("type") as TrainingTaskType) || "style_lora";
|
||||
const initSourceId = search.get("sourceId") ?? "";
|
||||
|
||||
const [step, setStep] = useState<WizardStep>(1);
|
||||
const [type, setType] = useState<TrainingTaskType>(initType);
|
||||
const [sourceId, setSourceId] = useState(initSourceId);
|
||||
const [template, setTemplate] = useState("sdxl_lora");
|
||||
const [name, setName] = useState("");
|
||||
const [trainingSetSize, setTrainingSetSize] = useState(12);
|
||||
|
||||
const sources = type === "style_lora" ? stylePacks : characters;
|
||||
|
||||
const selectedSource = useMemo(
|
||||
() => sources.find((s) => s.id === sourceId),
|
||||
[sources, sourceId]
|
||||
);
|
||||
|
||||
const canNext = useMemo(() => {
|
||||
if (step === 1) return !!sourceId;
|
||||
if (step === 2) return !!template && trainingSetSize > 0;
|
||||
if (step === 3) return true;
|
||||
return true;
|
||||
}, [step, sourceId, template, trainingSetSize]);
|
||||
|
||||
const defaultName = useMemo(() => {
|
||||
if (!selectedSource) return "";
|
||||
return `${selectedSource.name} ${type === "style_lora" ? "风格" : "角色"} LoRA v1`;
|
||||
}, [selectedSource, type]);
|
||||
|
||||
const finalName = name.trim() || defaultName;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const now = Date.now();
|
||||
const t: TrainingTask = {
|
||||
id: generateId("train-"),
|
||||
name: finalName || "未命名训练任务",
|
||||
type,
|
||||
status: "queued",
|
||||
progress: 0,
|
||||
sourceId,
|
||||
template,
|
||||
trainingSet: Array.from({ length: trainingSetSize }).map((_, i) => `asset-pick-${i + 1}`),
|
||||
logs: ["[占位] 训练任务已提交,排队中..."],
|
||||
estCost: "≈ ¥18(占位)",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
upsertTrainingTask(t);
|
||||
alert("(占位)训练任务已提交。真实训练能力尚未接入。");
|
||||
router.push(`/training/${t.id}`);
|
||||
};
|
||||
|
||||
const stepLabels = ["选择来源", "训练配置", "数据集校验", "提交确认"];
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<AmbientParticles count={10} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl 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="/training" className="hover:text-[var(--accent)] cursor-pointer">
|
||||
训练中心
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--text-primary)]">新建训练任务</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<h1 className="typo-h1">新建训练任务</h1>
|
||||
<span className="phase-chip">Phase 2</span>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{stepLabels.map((label, i) => {
|
||||
const n = (i + 1) as WizardStep;
|
||||
const active = step === n;
|
||||
const done = step > n;
|
||||
return (
|
||||
<div key={label} className="flex-1 flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-medium
|
||||
transition-all ${
|
||||
active
|
||||
? "bg-[var(--accent)] text-[var(--bg-primary)]"
|
||||
: done
|
||||
? "bg-[var(--accent)]/20 text-[var(--accent)]"
|
||||
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
{done ? "✓" : n}
|
||||
</div>
|
||||
<span className={`text-xs hidden md:inline ${active ? "text-[var(--text-primary)] font-medium" : "text-[var(--text-secondary)]"}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{i < stepLabels.length - 1 && (
|
||||
<div className={`flex-1 h-[1px] mx-2 ${done ? "bg-[var(--accent)]/40" : "bg-[var(--border)]"}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl surface-2 p-5 mb-5">
|
||||
{/* Step 1: 选择来源 */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练类型</label>
|
||||
<div className="flex gap-2">
|
||||
{(["style_lora", "character_lora"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => {
|
||||
setType(t);
|
||||
setSourceId("");
|
||||
}}
|
||||
className={`flex-1 px-3 py-2.5 text-sm rounded-lg border cursor-pointer transition-all ${
|
||||
type === t
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
|
||||
: "border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{t === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>
|
||||
选择{type === "style_lora" ? "风格集" : "角色卡"}来源
|
||||
</label>
|
||||
{sources.length === 0 ? (
|
||||
<div className="placeholder-card rounded-lg p-6 text-center text-xs text-[var(--text-secondary)]">
|
||||
暂无可用{type === "style_lora" ? "风格集" : "角色卡"},请先创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-72 overflow-y-auto">
|
||||
{sources.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setSourceId(s.id)}
|
||||
className={`text-left px-3 py-2.5 rounded-lg border cursor-pointer transition-all ${
|
||||
sourceId === s.id
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/8"
|
||||
: "border-[var(--border)] hover:border-[var(--accent)]/40"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm text-[var(--text-primary)] font-medium truncate">{s.name}</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] truncate mt-0.5">
|
||||
{s.description || "无描述"}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: 训练配置 */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练模板</label>
|
||||
<div className="space-y-2">
|
||||
{TEMPLATES.map((tpl) => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
onClick={() => setTemplate(tpl.id)}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg border cursor-pointer transition-all ${
|
||||
template === tpl.id
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/8"
|
||||
: "border-[var(--border)] hover:border-[var(--accent)]/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-primary)] font-medium">{tpl.name}</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{tpl.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>
|
||||
训练轮次(占位) — 当前示例为模拟配置项
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{["快速 (3 epoch)", "标准 (6 epoch)", "深度 (10 epoch)"].map((opt, i) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => {}}
|
||||
className={`px-2.5 py-2 text-xs rounded-lg border cursor-pointer ${
|
||||
i === 1
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/8 text-[var(--accent)]"
|
||||
: "border-[var(--border)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: 数据集校验 */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练集规模(占位)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={trainingSetSize}
|
||||
onChange={(e) => setTrainingSetSize(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--text-secondary)] mt-1.5">
|
||||
建议 8–30 张(占位)。当前为模拟值,真实训练集选择待 Phase 2。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="placeholder-card rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="typo-strong text-xs">自动校验</span>
|
||||
<span className="text-[10px] text-[var(--accent)]">✓ 通过(占位)</span>
|
||||
</div>
|
||||
<ul className="text-[11px] text-[var(--text-secondary)] space-y-1">
|
||||
<li>· 图片数量:{trainingSetSize} 张(符合建议范围)</li>
|
||||
<li>· 分辨率:多数 ≥ 768px(占位)</li>
|
||||
<li>· 重复检测:未发现重复图(占位)</li>
|
||||
<li>· NSFW 扫描:未检出(占位)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: 提交确认 */}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>任务名称</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={defaultName || "请先选择来源"}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg surface-2 p-3 text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>类型:<span className="text-[var(--text-primary)]">{type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}</span></div>
|
||||
<div>来源:<span className="text-[var(--text-primary)]">{selectedSource?.name ?? "—"}</span></div>
|
||||
<div>模板:<span className="text-[var(--text-primary)]">{template}</span></div>
|
||||
<div>训练集:<span className="text-[var(--text-primary)]">{trainingSetSize} 张(占位)</span></div>
|
||||
<div>预估成本:<span className="text-[var(--text-primary)]">≈ ¥18(占位)</span></div>
|
||||
<div>预估耗时:<span className="text-[var(--text-primary)]">≈ 20 分钟(占位)</span></div>
|
||||
</div>
|
||||
|
||||
<div className="placeholder-card rounded-lg p-3 text-[11px] text-[var(--text-secondary)]">
|
||||
提交后会进入排队队列。该页面所有交互为占位演示,提交不会产生真实训练调用。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (step === 1) router.push("/training");
|
||||
else setStep((step - 1) as WizardStep);
|
||||
}}
|
||||
className="px-3.5 py-2 text-xs rounded-xl border border-[var(--border)]
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
{step === 1 ? "取消" : "上一步"}
|
||||
</button>
|
||||
|
||||
{step < 4 ? (
|
||||
<button
|
||||
onClick={() => canNext && setStep((step + 1) as WizardStep)}
|
||||
disabled={!canNext}
|
||||
className="px-4 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)] disabled:opacity-40 disabled:cursor-not-allowed
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-4 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrainingWizardPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="h-screen" />}>
|
||||
<TrainingWizardInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
186
art-agent/frontend/src/app/training/page.tsx
Normal file
186
art-agent/frontend/src/app/training/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { staggerContainer, staggerItem } from "@/components/ui/motion-presets";
|
||||
import type { TrainingTask, TrainingTaskStatus } from "@/lib/types";
|
||||
|
||||
const STATUS_LABEL: Record<TrainingTaskStatus, string> = {
|
||||
queued: "排队中",
|
||||
running: "训练中",
|
||||
completed: "已完成",
|
||||
failed: "已失败",
|
||||
deprecated: "已弃用",
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<TrainingTaskStatus, string> = {
|
||||
queued: "#6B7B8A",
|
||||
running: "#2E8B7A",
|
||||
completed: "#3A7FB8",
|
||||
failed: "#C4654A",
|
||||
deprecated: "#B8935A",
|
||||
};
|
||||
|
||||
export default function TrainingPage() {
|
||||
const { trainingTasks, stylePacks, characters } = useApp();
|
||||
const [filter, setFilter] = useState<"all" | TrainingTaskStatus>("all");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === "all") return trainingTasks;
|
||||
return trainingTasks.filter((t) => t.status === filter);
|
||||
}, [trainingTasks, filter]);
|
||||
|
||||
const sourceName = (t: TrainingTask) => {
|
||||
if (!t.sourceId) return "—";
|
||||
if (t.type === "style_lora") {
|
||||
return stylePacks.find((s) => s.id === t.sourceId)?.name ?? t.sourceId;
|
||||
}
|
||||
return characters.find((c) => c.id === t.sourceId)?.name ?? t.sourceId;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col relative z-[1]">
|
||||
<AmbientParticles count={14} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-6xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<h1 className="typo-h1">训练中心</h1>
|
||||
<span className="phase-chip">Phase 2</span>
|
||||
</div>
|
||||
<p className="typo-caption">
|
||||
风格 / 角色 LoRA 训练任务管理。当前阶段均为占位数据,真实训练能力待 Phase 2 交付。
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/training/new"
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-sm rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
|
||||
transition-all cursor-pointer btn-hover-lift flex-shrink-0"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
新建训练任务
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<div className="flex items-center gap-1.5 mb-5 overflow-x-auto hide-scrollbar">
|
||||
{(["all", "queued", "running", "completed", "failed", "deprecated"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilter(s)}
|
||||
className={`px-2.5 py-1 text-xs rounded-lg cursor-pointer transition-all whitespace-nowrap ${
|
||||
filter === s
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)] font-medium border border-[var(--accent)]/30"
|
||||
: "border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{s === "all" ? "全部" : STATUS_LABEL[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 任务表格 */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-20 placeholder-card rounded-xl">
|
||||
<p className="typo-display text-lg mb-2">暂无训练任务</p>
|
||||
<p className="typo-caption">点击“新建训练任务”开始</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="rounded-xl surface-2 overflow-hidden"
|
||||
>
|
||||
{/* 表头(桌面端) */}
|
||||
<div className="hidden md:grid grid-cols-[2fr_1fr_1fr_1.5fr_1fr_auto] gap-3 px-4 py-2.5
|
||||
border-b border-[var(--border)] text-[10px] typo-micro"
|
||||
style={{ textTransform: "none" }}>
|
||||
<span>任务名称</span>
|
||||
<span>类型</span>
|
||||
<span>来源</span>
|
||||
<span>状态 / 进度</span>
|
||||
<span>创建时间</span>
|
||||
<span className="w-10" />
|
||||
</div>
|
||||
|
||||
{filtered.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
variants={staggerItem}
|
||||
className="border-b border-[var(--border)] last:border-b-0
|
||||
hover:bg-[var(--accent)]/5 transition-colors"
|
||||
>
|
||||
<Link
|
||||
href={`/training/${t.id}`}
|
||||
className="block px-4 py-3 cursor-pointer"
|
||||
>
|
||||
<div className="md:grid md:grid-cols-[2fr_1fr_1fr_1.5fr_1fr_auto] md:gap-3 md:items-center
|
||||
flex flex-col gap-1.5">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{t.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{t.type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] truncate">
|
||||
{sourceName(t)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="flex-shrink-0 text-[10px] px-2 py-0.5 rounded-md font-medium"
|
||||
style={{
|
||||
background: STATUS_COLOR[t.status] + "22",
|
||||
color: STATUS_COLOR[t.status],
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[t.status]}
|
||||
</span>
|
||||
{t.status === "running" && (
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--accent)] transition-all"
|
||||
style={{ width: `${t.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--accent)] flex-shrink-0">
|
||||
{t.progress}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{new Date(t.createdAt).toLocaleDateString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2"
|
||||
className="text-[var(--text-secondary)] hidden md:block">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user