交互原型大版本
This commit is contained in:
353
art-agent/frontend/src/app/training/new/page.tsx
Normal file
353
art-agent/frontend/src/app/training/new/page.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { generateId } from "@/lib/store";
|
||||
import type { TrainingTask, TrainingTaskType } from "@/lib/types";
|
||||
|
||||
type WizardStep = 1 | 2 | 3 | 4;
|
||||
|
||||
const TEMPLATES = [
|
||||
{ id: "sdxl_lora", name: "SDXL LoRA", desc: "基于 SDXL 底模,兼顾速度与效果(占位)" },
|
||||
{ id: "flux_lora", name: "Flux LoRA", desc: "基于 Flux 底模,细节更精细(占位)" },
|
||||
{ id: "sd15_lora", name: "SD 1.5 LoRA", desc: "轻量训练,适合像素/卡通(占位)" },
|
||||
];
|
||||
|
||||
function TrainingWizardInner() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const { stylePacks, characters, upsertTrainingTask } = useApp();
|
||||
|
||||
const initType = (search.get("type") as TrainingTaskType) || "style_lora";
|
||||
const initSourceId = search.get("sourceId") ?? "";
|
||||
|
||||
const [step, setStep] = useState<WizardStep>(1);
|
||||
const [type, setType] = useState<TrainingTaskType>(initType);
|
||||
const [sourceId, setSourceId] = useState(initSourceId);
|
||||
const [template, setTemplate] = useState("sdxl_lora");
|
||||
const [name, setName] = useState("");
|
||||
const [trainingSetSize, setTrainingSetSize] = useState(12);
|
||||
|
||||
const sources = type === "style_lora" ? stylePacks : characters;
|
||||
|
||||
const selectedSource = useMemo(
|
||||
() => sources.find((s) => s.id === sourceId),
|
||||
[sources, sourceId]
|
||||
);
|
||||
|
||||
const canNext = useMemo(() => {
|
||||
if (step === 1) return !!sourceId;
|
||||
if (step === 2) return !!template && trainingSetSize > 0;
|
||||
if (step === 3) return true;
|
||||
return true;
|
||||
}, [step, sourceId, template, trainingSetSize]);
|
||||
|
||||
const defaultName = useMemo(() => {
|
||||
if (!selectedSource) return "";
|
||||
return `${selectedSource.name} ${type === "style_lora" ? "风格" : "角色"} LoRA v1`;
|
||||
}, [selectedSource, type]);
|
||||
|
||||
const finalName = name.trim() || defaultName;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const now = Date.now();
|
||||
const t: TrainingTask = {
|
||||
id: generateId("train-"),
|
||||
name: finalName || "未命名训练任务",
|
||||
type,
|
||||
status: "queued",
|
||||
progress: 0,
|
||||
sourceId,
|
||||
template,
|
||||
trainingSet: Array.from({ length: trainingSetSize }).map((_, i) => `asset-pick-${i + 1}`),
|
||||
logs: ["[占位] 训练任务已提交,排队中..."],
|
||||
estCost: "≈ ¥18(占位)",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
upsertTrainingTask(t);
|
||||
alert("(占位)训练任务已提交。真实训练能力尚未接入。");
|
||||
router.push(`/training/${t.id}`);
|
||||
};
|
||||
|
||||
const stepLabels = ["选择来源", "训练配置", "数据集校验", "提交确认"];
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<AmbientParticles count={10} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
{/* 面包屑 */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] mb-4">
|
||||
<Link href="/training" className="hover:text-[var(--accent)] cursor-pointer">
|
||||
训练中心
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--text-primary)]">新建训练任务</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<h1 className="typo-h1">新建训练任务</h1>
|
||||
<span className="phase-chip">Phase 2</span>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{stepLabels.map((label, i) => {
|
||||
const n = (i + 1) as WizardStep;
|
||||
const active = step === n;
|
||||
const done = step > n;
|
||||
return (
|
||||
<div key={label} className="flex-1 flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-medium
|
||||
transition-all ${
|
||||
active
|
||||
? "bg-[var(--accent)] text-[var(--bg-primary)]"
|
||||
: done
|
||||
? "bg-[var(--accent)]/20 text-[var(--accent)]"
|
||||
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
{done ? "✓" : n}
|
||||
</div>
|
||||
<span className={`text-xs hidden md:inline ${active ? "text-[var(--text-primary)] font-medium" : "text-[var(--text-secondary)]"}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{i < stepLabels.length - 1 && (
|
||||
<div className={`flex-1 h-[1px] mx-2 ${done ? "bg-[var(--accent)]/40" : "bg-[var(--border)]"}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl surface-2 p-5 mb-5">
|
||||
{/* Step 1: 选择来源 */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练类型</label>
|
||||
<div className="flex gap-2">
|
||||
{(["style_lora", "character_lora"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => {
|
||||
setType(t);
|
||||
setSourceId("");
|
||||
}}
|
||||
className={`flex-1 px-3 py-2.5 text-sm rounded-lg border cursor-pointer transition-all ${
|
||||
type === t
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
|
||||
: "border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{t === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>
|
||||
选择{type === "style_lora" ? "风格集" : "角色卡"}来源
|
||||
</label>
|
||||
{sources.length === 0 ? (
|
||||
<div className="placeholder-card rounded-lg p-6 text-center text-xs text-[var(--text-secondary)]">
|
||||
暂无可用{type === "style_lora" ? "风格集" : "角色卡"},请先创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-72 overflow-y-auto">
|
||||
{sources.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setSourceId(s.id)}
|
||||
className={`text-left px-3 py-2.5 rounded-lg border cursor-pointer transition-all ${
|
||||
sourceId === s.id
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/8"
|
||||
: "border-[var(--border)] hover:border-[var(--accent)]/40"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm text-[var(--text-primary)] font-medium truncate">{s.name}</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] truncate mt-0.5">
|
||||
{s.description || "无描述"}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: 训练配置 */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练模板</label>
|
||||
<div className="space-y-2">
|
||||
{TEMPLATES.map((tpl) => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
onClick={() => setTemplate(tpl.id)}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg border cursor-pointer transition-all ${
|
||||
template === tpl.id
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/8"
|
||||
: "border-[var(--border)] hover:border-[var(--accent)]/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-primary)] font-medium">{tpl.name}</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{tpl.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>
|
||||
训练轮次(占位) — 当前示例为模拟配置项
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{["快速 (3 epoch)", "标准 (6 epoch)", "深度 (10 epoch)"].map((opt, i) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => {}}
|
||||
className={`px-2.5 py-2 text-xs rounded-lg border cursor-pointer ${
|
||||
i === 1
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/8 text-[var(--accent)]"
|
||||
: "border-[var(--border)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: 数据集校验 */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练集规模(占位)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={trainingSetSize}
|
||||
onChange={(e) => setTrainingSetSize(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--text-secondary)] mt-1.5">
|
||||
建议 8–30 张(占位)。当前为模拟值,真实训练集选择待 Phase 2。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="placeholder-card rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="typo-strong text-xs">自动校验</span>
|
||||
<span className="text-[10px] text-[var(--accent)]">✓ 通过(占位)</span>
|
||||
</div>
|
||||
<ul className="text-[11px] text-[var(--text-secondary)] space-y-1">
|
||||
<li>· 图片数量:{trainingSetSize} 张(符合建议范围)</li>
|
||||
<li>· 分辨率:多数 ≥ 768px(占位)</li>
|
||||
<li>· 重复检测:未发现重复图(占位)</li>
|
||||
<li>· NSFW 扫描:未检出(占位)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: 提交确认 */}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>任务名称</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={defaultName || "请先选择来源"}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg surface-2 p-3 text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>类型:<span className="text-[var(--text-primary)]">{type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}</span></div>
|
||||
<div>来源:<span className="text-[var(--text-primary)]">{selectedSource?.name ?? "—"}</span></div>
|
||||
<div>模板:<span className="text-[var(--text-primary)]">{template}</span></div>
|
||||
<div>训练集:<span className="text-[var(--text-primary)]">{trainingSetSize} 张(占位)</span></div>
|
||||
<div>预估成本:<span className="text-[var(--text-primary)]">≈ ¥18(占位)</span></div>
|
||||
<div>预估耗时:<span className="text-[var(--text-primary)]">≈ 20 分钟(占位)</span></div>
|
||||
</div>
|
||||
|
||||
<div className="placeholder-card rounded-lg p-3 text-[11px] text-[var(--text-secondary)]">
|
||||
提交后会进入排队队列。该页面所有交互为占位演示,提交不会产生真实训练调用。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (step === 1) router.push("/training");
|
||||
else setStep((step - 1) as WizardStep);
|
||||
}}
|
||||
className="px-3.5 py-2 text-xs rounded-xl border border-[var(--border)]
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
{step === 1 ? "取消" : "上一步"}
|
||||
</button>
|
||||
|
||||
{step < 4 ? (
|
||||
<button
|
||||
onClick={() => canNext && setStep((step + 1) as WizardStep)}
|
||||
disabled={!canNext}
|
||||
className="px-4 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)] disabled:opacity-40 disabled:cursor-not-allowed
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
提交训练任务
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrainingWizardPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="h-screen" />}>
|
||||
<TrainingWizardInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user