交互原型大版本

This commit is contained in:
Nostars Developer
2026-04-17 19:30:23 +08:00
parent 10f9c0061a
commit b12e55a776
29 changed files with 4574 additions and 57 deletions

View 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>
);
}

View 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">&ldquo;&rdquo;</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>
);
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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">&ldquo;&rdquo;</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>
);
}

View 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.9v0.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>
);
}

View 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">
830 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>
);
}

View 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">&ldquo;&rdquo;</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>
);
}