交互原型大版本
This commit is contained in:
347
art-agent/frontend/src/app/training/[id]/page.tsx
Normal file
347
art-agent/frontend/src/app/training/[id]/page.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import type { TrainingTask, TrainingTaskStatus } from "@/lib/types";
|
||||
|
||||
const STATUS_LABEL: Record<TrainingTaskStatus, string> = {
|
||||
queued: "排队中",
|
||||
running: "训练中",
|
||||
completed: "已完成",
|
||||
failed: "已失败",
|
||||
deprecated: "已弃用",
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<TrainingTaskStatus, string> = {
|
||||
queued: "#6B7B8A",
|
||||
running: "#2E8B7A",
|
||||
completed: "#3A7FB8",
|
||||
failed: "#C4654A",
|
||||
deprecated: "#B8935A",
|
||||
};
|
||||
|
||||
export default function TrainingDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const {
|
||||
trainingTasks,
|
||||
upsertTrainingTask,
|
||||
deleteTrainingTask,
|
||||
stylePacks,
|
||||
characters,
|
||||
} = useApp();
|
||||
|
||||
const [task, setTask] = useState<TrainingTask | null>(null);
|
||||
const [logOpen, setLogOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const t = trainingTasks.find((x) => x.id === params.id);
|
||||
setTask(t ?? null);
|
||||
}, [params.id, trainingTasks]);
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<TopNav />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="typo-caption mb-2">未找到该训练任务</p>
|
||||
<Link href="/training" className="text-sm text-[var(--accent)] hover:underline">
|
||||
返回训练中心
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const source =
|
||||
task.type === "style_lora"
|
||||
? stylePacks.find((s) => s.id === task.sourceId)
|
||||
: characters.find((c) => c.id === task.sourceId);
|
||||
|
||||
const handleUse = () => {
|
||||
alert(`(占位)已绑定此 LoRA 到当前会话:${task.name}`);
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleRetrain = () => {
|
||||
alert("(占位)将基于当前训练集重新启动训练任务。");
|
||||
};
|
||||
|
||||
const handleDeprecate = () => {
|
||||
if (!confirm("确认弃用该模型?")) return;
|
||||
upsertTrainingTask({ ...task, status: "deprecated" });
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm("确认删除该训练任务?")) return;
|
||||
deleteTrainingTask(task.id);
|
||||
router.push("/training");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<AmbientParticles count={12} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
{/* 面包屑 */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] mb-4">
|
||||
<Link href="/training" className="hover:text-[var(--accent)] cursor-pointer">
|
||||
训练中心
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--text-primary)]">{task.name}</span>
|
||||
</div>
|
||||
|
||||
{/* 头部 */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<h1 className="typo-h1">{task.name}</h1>
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-md font-medium"
|
||||
style={{
|
||||
background: STATUS_COLOR[task.status] + "22",
|
||||
color: STATUS_COLOR[task.status],
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[task.status]}
|
||||
</span>
|
||||
<span className="phase-chip">Phase 2</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{task.type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
|
||||
{source && (
|
||||
<>
|
||||
{" · 来源:"}
|
||||
<Link
|
||||
href={
|
||||
task.type === "style_lora"
|
||||
? `/styles/${task.sourceId}`
|
||||
: `/characters/${task.sourceId}`
|
||||
}
|
||||
className="text-[var(--accent)] hover:underline"
|
||||
>
|
||||
{source.name}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{" · 模板:"}
|
||||
{task.template}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{task.status === "completed" && (
|
||||
<button
|
||||
onClick={handleUse}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
使用该模型
|
||||
</button>
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
<button
|
||||
onClick={handleRetrain}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-primary)] hover:border-[var(--accent)]/40
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
重新训练
|
||||
</button>
|
||||
)}
|
||||
{task.status !== "deprecated" && task.status !== "queued" && (
|
||||
<button
|
||||
onClick={handleDeprecate}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
弃用
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--hot)]/60 hover:text-[var(--hot)]
|
||||
hover:border-[var(--hot)]/40
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{task.status === "running" && (
|
||||
<div className="rounded-xl surface-2 p-4 mb-5">
|
||||
<div className="flex items-center justify-between mb-2 text-xs">
|
||||
<span className="typo-strong">{task.currentStep ?? "训练中..."}</span>
|
||||
<span className="text-[var(--accent)] font-medium">{task.progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[var(--accent)] to-[var(--accent-secondary)] transition-all"
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{task.etaSeconds && (
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-1.5">
|
||||
预计剩余 {Math.floor(task.etaSeconds / 60)} 分 {task.etaSeconds % 60} 秒(占位)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
{/* 左:日志 + 训练前后对比 + 训练集预览 */}
|
||||
<div className="lg:col-span-2 space-y-5">
|
||||
{/* 训练前后对比 */}
|
||||
<section className="rounded-xl surface-2 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="typo-h2">训练前后效果对比</h2>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
{task.status === "completed" ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>训练前</div>
|
||||
<div className="aspect-square rounded-lg placeholder-card
|
||||
flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
||||
占位对比图
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>训练后</div>
|
||||
<div className="aspect-square rounded-lg placeholder-card
|
||||
flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
||||
占位对比图
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-secondary)] py-6 text-center">
|
||||
训练完成后将显示同一 Prompt 下的前后对比
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 训练日志 */}
|
||||
<section className="rounded-xl surface-2 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setLogOpen(!logOpen)}
|
||||
className="w-full px-4 py-2.5 flex items-center justify-between
|
||||
hover:bg-[var(--bg-tertiary)]/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<span className="typo-strong text-xs">训练日志</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
className={`transition-transform ${logOpen ? "" : "rotate-180"}`}>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
{logOpen && (
|
||||
<div className="border-t border-[var(--border)] p-3 bg-[var(--bg-primary)]/40 font-mono text-[11px] max-h-60 overflow-y-auto">
|
||||
{task.logs.length === 0 ? (
|
||||
<p className="text-[var(--text-secondary)]">尚无日志输出</p>
|
||||
) : (
|
||||
task.logs.map((l, i) => (
|
||||
<div key={i} className="text-[var(--text-secondary)] leading-relaxed">
|
||||
{l}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 训练集预览 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">训练集预览</h2>
|
||||
<p className="typo-caption mb-3">
|
||||
训练集共 {task.trainingSet.length} 张图片(占位缩略)。
|
||||
</p>
|
||||
{task.trainingSet.length === 0 ? (
|
||||
<div className="py-8 text-center placeholder-card rounded-xl text-xs text-[var(--text-secondary)]">
|
||||
训练集未填充
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 md:grid-cols-6 gap-2">
|
||||
{task.trainingSet.slice(0, 12).map((_, i) => (
|
||||
<div key={i}
|
||||
className="aspect-square rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
flex items-center justify-center text-[9px] text-[var(--text-secondary)]">
|
||||
占位
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 右:版本 + 成本 + 操作入口 */}
|
||||
<aside className="space-y-4">
|
||||
<div className="placeholder-card rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="typo-strong text-xs">模型版本</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>当前版本:v1.0(占位)</div>
|
||||
<div>历史版本:v0.9、v0.8(占位)</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => alert("(占位)回滚到上一版本")}
|
||||
className="mt-2 text-xs text-[var(--accent)] hover:underline cursor-pointer"
|
||||
>
|
||||
回滚到上一版本 →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl surface-2 p-4">
|
||||
<span className="typo-strong text-xs block mb-2">训练信息</span>
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>模板:{task.template}</div>
|
||||
<div>预估成本:{task.estCost ?? "—"}</div>
|
||||
<div>创建时间:{new Date(task.createdAt).toLocaleString("zh-CN")}</div>
|
||||
<div>更新时间:{new Date(task.updatedAt).toLocaleString("zh-CN")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{source && (
|
||||
<div className="rounded-xl surface-2 p-4">
|
||||
<span className="typo-strong text-xs block mb-2">来源</span>
|
||||
<Link
|
||||
href={
|
||||
task.type === "style_lora"
|
||||
? `/styles/${task.sourceId}`
|
||||
: `/characters/${task.sourceId}`
|
||||
}
|
||||
className="text-xs text-[var(--accent)] hover:underline block truncate"
|
||||
>
|
||||
{source.name}
|
||||
</Link>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
|
||||
{task.type === "style_lora" ? "风格集" : "角色卡"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
353
art-agent/frontend/src/app/training/new/page.tsx
Normal file
353
art-agent/frontend/src/app/training/new/page.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { generateId } from "@/lib/store";
|
||||
import type { TrainingTask, TrainingTaskType } from "@/lib/types";
|
||||
|
||||
type WizardStep = 1 | 2 | 3 | 4;
|
||||
|
||||
const TEMPLATES = [
|
||||
{ id: "sdxl_lora", name: "SDXL LoRA", desc: "基于 SDXL 底模,兼顾速度与效果(占位)" },
|
||||
{ id: "flux_lora", name: "Flux LoRA", desc: "基于 Flux 底模,细节更精细(占位)" },
|
||||
{ id: "sd15_lora", name: "SD 1.5 LoRA", desc: "轻量训练,适合像素/卡通(占位)" },
|
||||
];
|
||||
|
||||
function TrainingWizardInner() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const { stylePacks, characters, upsertTrainingTask } = useApp();
|
||||
|
||||
const initType = (search.get("type") as TrainingTaskType) || "style_lora";
|
||||
const initSourceId = search.get("sourceId") ?? "";
|
||||
|
||||
const [step, setStep] = useState<WizardStep>(1);
|
||||
const [type, setType] = useState<TrainingTaskType>(initType);
|
||||
const [sourceId, setSourceId] = useState(initSourceId);
|
||||
const [template, setTemplate] = useState("sdxl_lora");
|
||||
const [name, setName] = useState("");
|
||||
const [trainingSetSize, setTrainingSetSize] = useState(12);
|
||||
|
||||
const sources = type === "style_lora" ? stylePacks : characters;
|
||||
|
||||
const selectedSource = useMemo(
|
||||
() => sources.find((s) => s.id === sourceId),
|
||||
[sources, sourceId]
|
||||
);
|
||||
|
||||
const canNext = useMemo(() => {
|
||||
if (step === 1) return !!sourceId;
|
||||
if (step === 2) return !!template && trainingSetSize > 0;
|
||||
if (step === 3) return true;
|
||||
return true;
|
||||
}, [step, sourceId, template, trainingSetSize]);
|
||||
|
||||
const defaultName = useMemo(() => {
|
||||
if (!selectedSource) return "";
|
||||
return `${selectedSource.name} ${type === "style_lora" ? "风格" : "角色"} LoRA v1`;
|
||||
}, [selectedSource, type]);
|
||||
|
||||
const finalName = name.trim() || defaultName;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const now = Date.now();
|
||||
const t: TrainingTask = {
|
||||
id: generateId("train-"),
|
||||
name: finalName || "未命名训练任务",
|
||||
type,
|
||||
status: "queued",
|
||||
progress: 0,
|
||||
sourceId,
|
||||
template,
|
||||
trainingSet: Array.from({ length: trainingSetSize }).map((_, i) => `asset-pick-${i + 1}`),
|
||||
logs: ["[占位] 训练任务已提交,排队中..."],
|
||||
estCost: "≈ ¥18(占位)",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
upsertTrainingTask(t);
|
||||
alert("(占位)训练任务已提交。真实训练能力尚未接入。");
|
||||
router.push(`/training/${t.id}`);
|
||||
};
|
||||
|
||||
const stepLabels = ["选择来源", "训练配置", "数据集校验", "提交确认"];
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<AmbientParticles count={10} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
{/* 面包屑 */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] mb-4">
|
||||
<Link href="/training" className="hover:text-[var(--accent)] cursor-pointer">
|
||||
训练中心
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--text-primary)]">新建训练任务</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<h1 className="typo-h1">新建训练任务</h1>
|
||||
<span className="phase-chip">Phase 2</span>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{stepLabels.map((label, i) => {
|
||||
const n = (i + 1) as WizardStep;
|
||||
const active = step === n;
|
||||
const done = step > n;
|
||||
return (
|
||||
<div key={label} className="flex-1 flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-medium
|
||||
transition-all ${
|
||||
active
|
||||
? "bg-[var(--accent)] text-[var(--bg-primary)]"
|
||||
: done
|
||||
? "bg-[var(--accent)]/20 text-[var(--accent)]"
|
||||
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
{done ? "✓" : n}
|
||||
</div>
|
||||
<span className={`text-xs hidden md:inline ${active ? "text-[var(--text-primary)] font-medium" : "text-[var(--text-secondary)]"}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{i < stepLabels.length - 1 && (
|
||||
<div className={`flex-1 h-[1px] mx-2 ${done ? "bg-[var(--accent)]/40" : "bg-[var(--border)]"}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl surface-2 p-5 mb-5">
|
||||
{/* Step 1: 选择来源 */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练类型</label>
|
||||
<div className="flex gap-2">
|
||||
{(["style_lora", "character_lora"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => {
|
||||
setType(t);
|
||||
setSourceId("");
|
||||
}}
|
||||
className={`flex-1 px-3 py-2.5 text-sm rounded-lg border cursor-pointer transition-all ${
|
||||
type === t
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
|
||||
: "border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{t === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>
|
||||
选择{type === "style_lora" ? "风格集" : "角色卡"}来源
|
||||
</label>
|
||||
{sources.length === 0 ? (
|
||||
<div className="placeholder-card rounded-lg p-6 text-center text-xs text-[var(--text-secondary)]">
|
||||
暂无可用{type === "style_lora" ? "风格集" : "角色卡"},请先创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-72 overflow-y-auto">
|
||||
{sources.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setSourceId(s.id)}
|
||||
className={`text-left px-3 py-2.5 rounded-lg border cursor-pointer transition-all ${
|
||||
sourceId === s.id
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/8"
|
||||
: "border-[var(--border)] hover:border-[var(--accent)]/40"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm text-[var(--text-primary)] font-medium truncate">{s.name}</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] truncate mt-0.5">
|
||||
{s.description || "无描述"}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: 训练配置 */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练模板</label>
|
||||
<div className="space-y-2">
|
||||
{TEMPLATES.map((tpl) => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
onClick={() => setTemplate(tpl.id)}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg border cursor-pointer transition-all ${
|
||||
template === tpl.id
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/8"
|
||||
: "border-[var(--border)] hover:border-[var(--accent)]/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-primary)] font-medium">{tpl.name}</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{tpl.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>
|
||||
训练轮次(占位) — 当前示例为模拟配置项
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{["快速 (3 epoch)", "标准 (6 epoch)", "深度 (10 epoch)"].map((opt, i) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => {}}
|
||||
className={`px-2.5 py-2 text-xs rounded-lg border cursor-pointer ${
|
||||
i === 1
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/8 text-[var(--accent)]"
|
||||
: "border-[var(--border)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: 数据集校验 */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练集规模(占位)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={trainingSetSize}
|
||||
onChange={(e) => setTrainingSetSize(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--text-secondary)] mt-1.5">
|
||||
建议 8–30 张(占位)。当前为模拟值,真实训练集选择待 Phase 2。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="placeholder-card rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="typo-strong text-xs">自动校验</span>
|
||||
<span className="text-[10px] text-[var(--accent)]">✓ 通过(占位)</span>
|
||||
</div>
|
||||
<ul className="text-[11px] text-[var(--text-secondary)] space-y-1">
|
||||
<li>· 图片数量:{trainingSetSize} 张(符合建议范围)</li>
|
||||
<li>· 分辨率:多数 ≥ 768px(占位)</li>
|
||||
<li>· 重复检测:未发现重复图(占位)</li>
|
||||
<li>· NSFW 扫描:未检出(占位)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: 提交确认 */}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>任务名称</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={defaultName || "请先选择来源"}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg surface-2 p-3 text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>类型:<span className="text-[var(--text-primary)]">{type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}</span></div>
|
||||
<div>来源:<span className="text-[var(--text-primary)]">{selectedSource?.name ?? "—"}</span></div>
|
||||
<div>模板:<span className="text-[var(--text-primary)]">{template}</span></div>
|
||||
<div>训练集:<span className="text-[var(--text-primary)]">{trainingSetSize} 张(占位)</span></div>
|
||||
<div>预估成本:<span className="text-[var(--text-primary)]">≈ ¥18(占位)</span></div>
|
||||
<div>预估耗时:<span className="text-[var(--text-primary)]">≈ 20 分钟(占位)</span></div>
|
||||
</div>
|
||||
|
||||
<div className="placeholder-card rounded-lg p-3 text-[11px] text-[var(--text-secondary)]">
|
||||
提交后会进入排队队列。该页面所有交互为占位演示,提交不会产生真实训练调用。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (step === 1) router.push("/training");
|
||||
else setStep((step - 1) as WizardStep);
|
||||
}}
|
||||
className="px-3.5 py-2 text-xs rounded-xl border border-[var(--border)]
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
{step === 1 ? "取消" : "上一步"}
|
||||
</button>
|
||||
|
||||
{step < 4 ? (
|
||||
<button
|
||||
onClick={() => canNext && setStep((step + 1) as WizardStep)}
|
||||
disabled={!canNext}
|
||||
className="px-4 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)] disabled:opacity-40 disabled:cursor-not-allowed
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
提交训练任务
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrainingWizardPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="h-screen" />}>
|
||||
<TrainingWizardInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
186
art-agent/frontend/src/app/training/page.tsx
Normal file
186
art-agent/frontend/src/app/training/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { staggerContainer, staggerItem } from "@/components/ui/motion-presets";
|
||||
import type { TrainingTask, TrainingTaskStatus } from "@/lib/types";
|
||||
|
||||
const STATUS_LABEL: Record<TrainingTaskStatus, string> = {
|
||||
queued: "排队中",
|
||||
running: "训练中",
|
||||
completed: "已完成",
|
||||
failed: "已失败",
|
||||
deprecated: "已弃用",
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<TrainingTaskStatus, string> = {
|
||||
queued: "#6B7B8A",
|
||||
running: "#2E8B7A",
|
||||
completed: "#3A7FB8",
|
||||
failed: "#C4654A",
|
||||
deprecated: "#B8935A",
|
||||
};
|
||||
|
||||
export default function TrainingPage() {
|
||||
const { trainingTasks, stylePacks, characters } = useApp();
|
||||
const [filter, setFilter] = useState<"all" | TrainingTaskStatus>("all");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === "all") return trainingTasks;
|
||||
return trainingTasks.filter((t) => t.status === filter);
|
||||
}, [trainingTasks, filter]);
|
||||
|
||||
const sourceName = (t: TrainingTask) => {
|
||||
if (!t.sourceId) return "—";
|
||||
if (t.type === "style_lora") {
|
||||
return stylePacks.find((s) => s.id === t.sourceId)?.name ?? t.sourceId;
|
||||
}
|
||||
return characters.find((c) => c.id === t.sourceId)?.name ?? t.sourceId;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col relative z-[1]">
|
||||
<AmbientParticles count={14} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-6xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<h1 className="typo-h1">训练中心</h1>
|
||||
<span className="phase-chip">Phase 2</span>
|
||||
</div>
|
||||
<p className="typo-caption">
|
||||
风格 / 角色 LoRA 训练任务管理。当前阶段均为占位数据,真实训练能力待 Phase 2 交付。
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/training/new"
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-sm rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
|
||||
transition-all cursor-pointer btn-hover-lift flex-shrink-0"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
新建训练任务
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<div className="flex items-center gap-1.5 mb-5 overflow-x-auto hide-scrollbar">
|
||||
{(["all", "queued", "running", "completed", "failed", "deprecated"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilter(s)}
|
||||
className={`px-2.5 py-1 text-xs rounded-lg cursor-pointer transition-all whitespace-nowrap ${
|
||||
filter === s
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)] font-medium border border-[var(--accent)]/30"
|
||||
: "border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{s === "all" ? "全部" : STATUS_LABEL[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 任务表格 */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-20 placeholder-card rounded-xl">
|
||||
<p className="typo-display text-lg mb-2">暂无训练任务</p>
|
||||
<p className="typo-caption">点击“新建训练任务”开始</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="rounded-xl surface-2 overflow-hidden"
|
||||
>
|
||||
{/* 表头(桌面端) */}
|
||||
<div className="hidden md:grid grid-cols-[2fr_1fr_1fr_1.5fr_1fr_auto] gap-3 px-4 py-2.5
|
||||
border-b border-[var(--border)] text-[10px] typo-micro"
|
||||
style={{ textTransform: "none" }}>
|
||||
<span>任务名称</span>
|
||||
<span>类型</span>
|
||||
<span>来源</span>
|
||||
<span>状态 / 进度</span>
|
||||
<span>创建时间</span>
|
||||
<span className="w-10" />
|
||||
</div>
|
||||
|
||||
{filtered.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
variants={staggerItem}
|
||||
className="border-b border-[var(--border)] last:border-b-0
|
||||
hover:bg-[var(--accent)]/5 transition-colors"
|
||||
>
|
||||
<Link
|
||||
href={`/training/${t.id}`}
|
||||
className="block px-4 py-3 cursor-pointer"
|
||||
>
|
||||
<div className="md:grid md:grid-cols-[2fr_1fr_1fr_1.5fr_1fr_auto] md:gap-3 md:items-center
|
||||
flex flex-col gap-1.5">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{t.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{t.type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] truncate">
|
||||
{sourceName(t)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="flex-shrink-0 text-[10px] px-2 py-0.5 rounded-md font-medium"
|
||||
style={{
|
||||
background: STATUS_COLOR[t.status] + "22",
|
||||
color: STATUS_COLOR[t.status],
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[t.status]}
|
||||
</span>
|
||||
{t.status === "running" && (
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--accent)] transition-all"
|
||||
style={{ width: `${t.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--accent)] flex-shrink-0">
|
||||
{t.progress}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{new Date(t.createdAt).toLocaleDateString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2"
|
||||
className="text-[var(--text-secondary)] hidden md:block">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user