"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(1); const [type, setType] = useState(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 (
{/* 面包屑 */}
训练中心 / 新建训练任务

新建训练任务

Phase 2
{/* Step indicator */}
{stepLabels.map((label, i) => { const n = (i + 1) as WizardStep; const active = step === n; const done = step > n; return (
{done ? "✓" : n}
{i < stepLabels.length - 1 && (
)}
); })}
{/* Step 1: 选择来源 */} {step === 1 && (
{(["style_lora", "character_lora"] as const).map((t) => ( ))}
{sources.length === 0 ? (
暂无可用{type === "style_lora" ? "风格集" : "角色卡"},请先创建
) : (
{sources.map((s) => ( ))}
)}
)} {/* Step 2: 训练配置 */} {step === 2 && (
{TEMPLATES.map((tpl) => ( ))}
{["快速 (3 epoch)", "标准 (6 epoch)", "深度 (10 epoch)"].map((opt, i) => ( ))}
)} {/* Step 3: 数据集校验 */} {step === 3 && (
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" />

建议 8–30 张(占位)。当前为模拟值,真实训练集选择待 Phase 2。

自动校验 ✓ 通过(占位)
  • · 图片数量:{trainingSetSize} 张(符合建议范围)
  • · 分辨率:多数 ≥ 768px(占位)
  • · 重复检测:未发现重复图(占位)
  • · NSFW 扫描:未检出(占位)
)} {/* Step 4: 提交确认 */} {step === 4 && (
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" />
类型:{type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
来源:{selectedSource?.name ?? "—"}
模板:{template}
训练集:{trainingSetSize} 张(占位)
预估成本:≈ ¥18(占位)
预估耗时:≈ 20 分钟(占位)
提交后会进入排队队列。该页面所有交互为占位演示,提交不会产生真实训练调用。
)}
{/* Nav buttons */}
{step < 4 ? ( ) : ( )}
); } export default function TrainingWizardPage() { return ( }> ); }