354 lines
16 KiB
TypeScript
354 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|