交互原型大版本

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

@@ -4,6 +4,47 @@
## 记录
### [CL-20260417-1510] 2026-04-17 15:10 — Cloudflare Quick Tunnel 脚本修复PS5.1 编码 + 本地 cloudflared
- **tags**: 内网穿透, cloudflared, PowerShell, start-tunnel, UTF-8, gitignore
- **affected_files**:
- art-agent/start-tunnel.ps1
- .gitignore
- **what**: 修复 `start-tunnel.ps1` 在 Windows PowerShell 5.1 下因脚本 UTF-8 无 BOM、含中文的 here-string`@"`)被误解析而导致的 `ParserError`。将写入 `frontend/.env.local` 的两处 here-string 改为 `[System.IO.File]::WriteAllLines` + UTF-8 无 BOM与 Next 读取 `.env` 习惯一致)。用 PowerShell 将脚本全文重写为 **UTF-8 BOM**,便于 PS5.1 正确分词。在脚本开头增加:若存在与脚本同目录的 `cloudflared.exe`,则把该目录插入 `PATH` 最前,便于未全局安装 cloudflared 时使用本地下载的可执行文件。根目录 `.gitignore` 增加 `art-agent/cloudflared.exe`,避免误提交约 65MB 二进制。
- **why**: 用户请求开启穿透时,脚本在 Cursor 集成终端中立即解析失败,隧道无法拉起;当前环境亦未必安装 winget/系统 PATH 中的 cloudflared需要可放置于项目目录的 fallback。
- **decisions**: 不改为仅英文输出(保留原有中文提示与框线字符);`.env.local` 注释改为英文短句以避免无 BOM 环境下再次出现字符串边界问题。
- **notes**: 会话中曾从 GitHub latest release 下载 `cloudflared-windows-amd64.exe``art-agent/cloudflared.exe` 并成功启动双隧道;该文件已被 gitignore。
### [CL-20260417-1500] 2026-04-17 15:00 — 按 INFOLAYER.md 实现完整前端信息架构Phase 1+ 占位骨架)
- **tags**: 前端, UI骨架, INFOLAYER, 多项目, 风格库, 角色库, 训练中心, 项目设置, TaskCard, 候选评估, 占位数据
- **affected_files**:
- art-agent/frontend/src/lib/types.ts
- art-agent/frontend/src/lib/placeholder-store.ts
- art-agent/frontend/src/lib/app-context.tsx
- art-agent/frontend/src/app/globals.css
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/app/gallery/page.tsx
- art-agent/frontend/src/app/styles/page.tsx
- art-agent/frontend/src/app/styles/[id]/page.tsx
- art-agent/frontend/src/app/characters/page.tsx
- art-agent/frontend/src/app/characters/[id]/page.tsx
- art-agent/frontend/src/app/training/page.tsx
- art-agent/frontend/src/app/training/[id]/page.tsx
- art-agent/frontend/src/app/training/new/page.tsx
- art-agent/frontend/src/app/settings/page.tsx
- art-agent/frontend/src/components/layout/top-nav.tsx
- art-agent/frontend/src/components/layout/project-switcher.tsx
- art-agent/frontend/src/components/sidebar/sidebar.tsx
- art-agent/frontend/src/components/sidebar/project-card.tsx
- art-agent/frontend/src/components/chat/session-quick-picks.tsx
- art-agent/frontend/src/components/chat/task-card.tsx
- art-agent/frontend/src/components/workbench/advanced-controls.tsx
- art-agent/frontend/src/components/workbench/candidate-panel.tsx
- **what**: 按 `docs/art-agent/INFOLAYER.md` 规范一次性铺开 Phase 13 产品形态的前端 UI 骨架:新增风格库 `/styles`、角色库 `/characters`、训练中心 `/training`、项目设置 `/settings` 四大页面模块(含列表 + 详情 + 创建流程);工作台输入区上方加入风格/角色/资产规格快选条、高级控制抽屉、候选评估侧面板、Task Card IR 占位组件;资源库新增资产类型/风格/角色多维过滤和语义搜索切换TopNav 扩充导航项与项目切换器Sidebar 加入项目信息卡;新增 `lib/placeholder-store.ts` 管理 Phase 1+ 占位数据(项目/风格集/角色卡/训练任务)并在 `app-context.tsx` 中统一暴露;`globals.css` 补充 `phase-chip` / `placeholder-card` / `hide-scrollbar` / `section-divider` 工具类。
- **why**: INFOLAYER.md 规划了 Phase 03 完整产品形态,其中 Phase 1+(多项目、风格库/角色库、训练中心、Task Card、候选评估等尚未具备后端能力。用户希望先把完整交互结构和界面落地"数据或展示上写占位"即可,方便内部演示、收集反馈以及后续按 Phase 增量接入真实后端。
- **decisions**: (1) 占位数据用独立的 `placeholder-store.ts` + `localStorage` 持久化,与现有 `store.ts` 解耦首次加载注入示例数据2 个项目 / 4 个风格集 / 2 个角色 / 4 个训练任务)。(2) 详情页直接用 `[id]/page.tsx` 同时处理 `new` 与编辑两种模式(判断 `params.id === 'new'`),不再新增 `/styles/new` 等专用子路由。(3) Task Card 作为独立可复用组件 (`components/chat/task-card.tsx`),不强制接入当前 chat loop等真实 IR 管线就绪时再 wire up。(4) 候选评估面板从"最后一条含 images 的 assistant 消息"取数据做 demo。(5) Gallery 风格/角色过滤用 prompt 关键词子串匹配做占位,未来等后端补 asset 归因字段后再切到真实字段。(6) 所有 Phase 1+ 功能按钮均挂 `phase-chip`P1/P2/P3明示阶段避免用户误以为已可用。
- **notes**: 所有"应用到会话""Fork""训练""批量下载"等交互都是 `alert("(占位)...")`,不产生真实后端调用;真实接入时搜索 `(占位)``phase-chip` 可快速定位需要替换的位置。`AppContext` 新增字段:`projects` / `activeProjectId` / `stylePacks` / `characters` / `trainingTasks` / `sessionStyleId` / `sessionCharacterId` / `sessionAssetSpec` 及对应 upsert/delete 方法;切换用户时会重新加载全部占位数据。`/styles/[id]``/characters/[id]` 的"生成图片"列表也是基于关键词从现有 assets 里筛的占位结果,不是真正的归因。`ChatMessage` 未扩展Task Card 目前仅以组件形式存在,实际渲染位置需要后续决定(工作台消息流或独立抽屉)。
- **source_chat**: [实现 INFOLAYER 完整 UI 骨架](607b2d99-ff5b-48a6-91b5-1ada004cd8d5)
### [CL-20260416-1600] 2026-04-16 16:00 — 视觉风格从暗绿底全面转为白底青绿(绢本山水)
- **tags**: 前端, 视觉风格, 色彩体系, CSS变量, 白底, 千里江山图
- **affected_files**:

View File

@@ -2,6 +2,8 @@
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
- [CL-20260417-1510] start-tunnel.ps1 修复 PS5.1 中文 here-string 解析失败WriteAllLines 写 .env、脚本 UTF-8 BOM、同目录 cloudflared.exe 优先;根 .gitignore 忽略下载的 cloudflared.exe
- [CL-20260417-1500] 按 INFOLAYER.md 一次性铺开 Phase 1+ 前端 UI 骨架:新增风格库/角色库/训练中心/项目设置四大页面模块+工作台快选条+高级控制+候选评估+Task Card+资源库多维过滤placeholder-store.ts 管理占位数据phase-chip 标注阶段
- [CL-20260416-1600] 视觉风格从暗绿底全面转为白底青绿(绢本山水):背景#FAFAF7+文字#1A1A1A+accent#2E8B7A13个文件颜色替换面板改白色毛玻璃云雾透明度降低VISUAL-STYLE-GUIDE.md重写
- [CL-20260416-1500] UX风格指南实现度补全涟漪/sendBounce/会话切换动画/Error飘散/云雾加载/流式opacity/排版类使用/左上切角/石青占位等10项全部落地文档每项标注✅状态+实现文件
- [CL-20260416-1330] 交互体验全面升级新增framer-motion动画引擎7级排版体系+4级Surface层级+网格底纹科技感,萤火粒子/数据流光/水墨晕染/投石入水/云雾凝聚/萤火点亮等微交互,用户/助手消息视觉拉开差距统一Loading/空态/Error状态全组件hover浮起+侧栏竖线+呼吸脉冲新增UX-STYLE-GUIDE.md文档
@@ -50,20 +52,3 @@
- [CL-20260413-0540] 非 vision LLM + 参考图时禁止猜测风格prompt 只描述内容不写画风,风格交由 IP-Adapter 处理
- [CL-20260413-0520] 多图生成改为单次 API 调用(利用模型原生批量参数),避免速率限制 + 修复空错误信息显示
- [CL-20260413-0500] 图片生成后在对话中显示实际使用的模型名称(如"由 Kolors IP-Adapter 生成"),便于模型溯源
- [CL-20260413-0440] 修复 Kolors IP-Adapter 404 错误:社区模型需 `owner/model:version_hash` 格式,补全版本 hash 后生图成功
- [CL-20260413-0430] 图片生成失败时将具体错误信息Replicate API 报错等)透传到前端展示,新增 tool_error SSE 事件
- [CL-20260413-0330] README 补充访问地址汇总(本机/局域网/外网)+ 穿透管理文档(安装/启停/排错)
- [CL-20260412-2345] 参考图上传独立化:选图即上传 + 环形进度条 + 成功/失败状态反馈 + 失败重试,后端新增独立上传端点
- [CL-20260412-2315] 修复 Cloudflare Tunnel 跨域开发警告next.config.ts 新增 allowedDevOrigins 通配符
- [CL-20260412-2300] 集成 Kolors IP-Adapter 模型:参考图可直接用于风格生成,模型注册表新增通用参考图支持机制
- [CL-20260412-2230] 用户消息气泡中显示参考图缩略图ChatMessage 新增 refImageUrl发送后在气泡中渲染参考图
- [CL-20260412-2200] 修复 DeepSeek 不支持 image_url 参考图上传报错:新增 vision 能力检测,非 vision 模型走文字提示
- [CL-20260412-2130] 修复图片生成失败时前端显示破碎图标:后端分离错误信息与有效 URL前端过滤无效 URL 不渲染空图片
- [CL-20260412-2100] 生图模型动态切换Provider 抽象架构 + 前端模型选择器 + Replicate 多模型flux-schnell/flux-dev/SDXL按消息粒度切换
- [CL-20260412-1800] 移动端适配 + Cloudflare Tunnel 内网穿透响应式布局改造9 个组件)+ 一键穿透脚本 + cloudflared 安装
- [CL-20260412-1530] 修复 .env 配置加载时序问题load_dotenv 前置 + override=True + config.py 改函数式懒读取
- [CL-20260412-1500] 品牌重命名 Art Agent → EPEEKit + 后端 API/模型配置集中化到 .env + config.py
- [CL-20260412-0300] 完成交互原型全面重构:三栏布局 + 多会话标签系统 + Canvas 标注 + 资源库页面
- [CL-20260411-1630] 安装 Node.js v24.14.1 + Python venv + 前后端依赖,后端启动验证通过
- [CL-20260411-1600] 从零创建美术 Agent MVP 全部前后端代码FastAPI + Next.js + Agent Loop + SSE
- [CL-20260411-1500] 完成美术 Agent 产品决策5 个核心问题和技术选型8 个维度)两份文档

View File

@@ -3,6 +3,16 @@
最近 ~10 次改动的摘要记录,按时间倒序排列。
当 Agent 检测到当前任务与近期改动相关时自动读取。
### [CL-20260417-1510] 2026-04-17 — Cloudflare 穿透脚本兼容 PS5.1 + 本地 cloudflared
- **tags**: 内网穿透, cloudflared, PowerShell, start-tunnel, env
- **affected_files**: art-agent/start-tunnel.ps1, .gitignore
- **summary**: Windows PowerShell 5.1 在 UTF-8 无 BOM 下解析含中文的 `@"` here-string 会报错,导致穿透脚本无法运行。改为用 `WriteAllLines` 写入 `frontend/.env.local`,并将脚本保存为 UTF-8 BOM。脚本启动时若存在 `art-agent/cloudflared.exe` 则自动加入 PATH 优先使用。根目录 `.gitignore` 忽略本地下载的 `cloudflared.exe`(体积大)。
### [CL-20260417-1500] 2026-04-17 — 按 INFOLAYER.md 实现完整前端信息架构Phase 1+ 占位骨架)
- **tags**: 前端, UI骨架, INFOLAYER, 多项目, 风格库, 角色库, 训练中心, 项目设置, TaskCard, 候选评估, 占位数据
- **affected_files**: types.ts, placeholder-store.ts, app-context.tsx, globals.css, app/page.tsx, app/gallery/page.tsx, app/styles/*, app/characters/*, app/training/*, app/settings/page.tsx, top-nav.tsx, project-switcher.tsx, project-card.tsx, session-quick-picks.tsx, task-card.tsx, advanced-controls.tsx, candidate-panel.tsx, sidebar.tsx
- **summary**: 一次性铺开 INFOLAYER.md Phase 13 完整 UI 骨架:新增 /styles /characters /training /settings 四大模块(列表+详情+创建流程),工作台加入风格/角色/规格快选条、高级控制抽屉、候选评估面板、Task Card 组件,资源库加二级过滤(资产类型/风格/角色)+ 语义搜索切换TopNav 加项目切换器和新导航项Sidebar 加项目信息卡。所有 Phase 1+ 数据通过新建的 placeholder-store.ts + localStorage 占位持久化首次注入示例AppContext 扩充相关字段。所有真实后端行为均为 `alert("(占位)...")`UI 上以 phase-chipP1/P2/P3明示阶段。搜索"(占位)"可快速定位待接入点。
### [CL-20260416-1600] 2026-04-16 — 视觉风格从暗绿底全面转为白底青绿(绢本山水)
- **tags**: 前端, 视觉风格, 色彩体系, CSS变量, 白底, 千里江山图
- **affected_files**: globals.css, page.tsx, login/page.tsx, gallery/page.tsx, chat-messages.tsx, chat-input.tsx, top-nav.tsx, sidebar.tsx, session-list.tsx, profile-modal.tsx, image-detail-panel.tsx, ambient-particles.tsx, VISUAL-STYLE-GUIDE.md

View File

@@ -83,7 +83,7 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后
4. 写入三个文件(按以下顺序):
a. 读取 changelog-full.md在 "## 记录" 下方追加 L1 条目
b. 读取 changelog-recent.md在顶部插入 L2 条目,如超过 10 条则移除最旧的
b. 读取 changelog-recent.md在顶部插入 L2 条目,如超过 20 条则移除最旧的与下方「L2 窗口」一致)
c. 读取 changelog-headlines.md在顶部插入 L3 条目,如超过 50 条则移除最旧的
5. 在回复末尾附一行提示:"[已记录到开发日志]"
@@ -247,3 +247,5 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后
### 已知必要检查
1. **大任务收尾遗漏风险** — 当单次任务涉及 5+ 个文件改动时Agent 容易在输出总结回复后遗漏被动写入流程。应在生成最终回复前,先执行 `changelog-recall.mdc` 中的"任务完成 Checklist",确认三层日志已写入后再输出回复。绝不能"先回复再补写"。
2. **L3/L2 滚动窗口必须落地** — 操作 A 第 4 步不是「插入即结束」写入后必须数清条目L3 为以 `- [` 开头的列表行L2 为 `### [CL-` 标题行。L3 超过 50 条、L2 超过 20 条时,必须从**文件底部(最旧)**整段删除条目直至恰好满足上限;禁止长期只追加不修剪。若发现 `changelog-headlines.md` 列表行多于 50说明上次写入未执行本项本次补修剪并自检。

View File

@@ -39,7 +39,7 @@
- **触发场景**: "开发日志"、"changelog"、"最近改了什么"、"回顾改动"、"查看开发记录"、代码改动后自动写入
- **输出**: .cursor/changelog/ 下的 changelog-full.md、changelog-recent.md、changelog-headlines.md
- **路径**: .cursor/skills/dev-changelog/SKILL.md
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklistL3 概要每次会话自动注入上下文stop Hook 双保险兜底
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklistL3 概要每次会话自动注入上下文stop Hook 双保险兜底;写入后须维护 L3≤50 / L2≤20 滚动窗口(见 Skill 自迭代检查项)
### pitfall-journal
- **类型**: 基础设施

2
.gitignore vendored
View File

@@ -34,6 +34,8 @@ out/
art-agent/backend/uploads/
art-agent/backend/generated/
art-agent/backend/data/
# 本地下载的 cloudflared CLI体积大见 README / start-tunnel.ps1
art-agent/cloudflared.exe
# ─── 工具二进制 ──────────────────────────────────────────
*.exe

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

View File

@@ -0,0 +1,257 @@
"use client";
import { useState, useRef, useEffect } from "react";
import Link from "next/link";
import { useApp } from "@/lib/app-context";
const ASSET_SPECS = [
"1024×1024 · 通用",
"768×1024 · 竖版卡牌",
"1536×1024 · 横版横幅",
"512×512 · Icon",
"透明背景 PNG",
];
interface PickerButtonProps {
label: string;
value?: string | null;
placeholder: string;
phaseBadge?: string;
onClear?: () => void;
onClick: () => void;
icon: React.ReactNode;
}
function PickerButton({ label, value, placeholder, phaseBadge, onClear, onClick, icon }: PickerButtonProps) {
const active = !!value;
return (
<div className="relative group">
<button
onClick={onClick}
className={`h-7 pl-2 pr-2.5 rounded-lg text-xs flex items-center gap-1.5 cursor-pointer transition-all whitespace-nowrap ${
active
? "bg-[var(--accent)]/10 border border-[var(--accent)]/30 text-[var(--accent)]"
: "border border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:border-[var(--accent)]/40 hover:text-[var(--text-primary)]"
}`}
title={`${label}${value || placeholder}`}
>
{icon}
<span className="typo-micro text-[10px]" style={{ textTransform: "none" }}>{label}</span>
<span className="text-[11px] font-medium max-w-[120px] truncate">
{value || placeholder}
</span>
{phaseBadge && (
<span className="phase-chip" style={{ marginLeft: 2 }}>{phaseBadge}</span>
)}
</button>
{active && onClear && (
<button
onClick={(e) => {
e.stopPropagation();
onClear();
}}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--bg-secondary)] border border-[var(--border)]
text-[var(--text-secondary)] text-[10px] flex items-center justify-center
opacity-0 group-hover:opacity-100 hover:text-[var(--hot)] hover:border-[var(--hot)]/40
transition-opacity cursor-pointer"
title="清除"
>
×
</button>
)}
</div>
);
}
export function SessionQuickPicks() {
const {
stylePacks, characters,
sessionStyleId, setSessionStyleId,
sessionCharacterId, setSessionCharacterId,
sessionAssetSpec, setSessionAssetSpec,
} = useApp();
const [openMenu, setOpenMenu] = useState<null | "style" | "character" | "spec">(null);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!openMenu) return;
const handler = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpenMenu(null);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [openMenu]);
const styleName = stylePacks.find((s) => s.id === sessionStyleId)?.name;
const charName = characters.find((c) => c.id === sessionCharacterId)?.name;
return (
<div ref={rootRef} className="relative px-3 md:px-5 pt-2 pb-1 flex items-center gap-1.5 flex-wrap">
<PickerButton
label="风格"
value={styleName}
placeholder="选择风格"
phaseBadge="P1"
icon={
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<circle cx="12" cy="12" r="9" strokeDasharray="2 3" />
</svg>
}
onClear={() => setSessionStyleId(null)}
onClick={() => setOpenMenu(openMenu === "style" ? null : "style")}
/>
<PickerButton
label="角色"
value={charName}
placeholder="选择角色"
phaseBadge="P1"
icon={
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</svg>
}
onClear={() => setSessionCharacterId(null)}
onClick={() => setOpenMenu(openMenu === "character" ? null : "character")}
/>
<PickerButton
label="规格"
value={sessionAssetSpec ?? undefined}
placeholder="输出规格"
icon={
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="M4 12h16M12 4v16" />
</svg>
}
onClear={() => setSessionAssetSpec(null)}
onClick={() => setOpenMenu(openMenu === "spec" ? null : "spec")}
/>
<span className="text-[10px] text-[var(--text-secondary)]/60 ml-auto hidden md:inline">
· Phase 1
</span>
{/* 下拉菜单(向上展开,避免被下方输入栏遮挡) */}
{openMenu && (
<div
className="absolute left-3 md:left-5 bottom-[calc(100%+4px)] z-50 w-80 max-w-[calc(100vw-24px)]
rounded-xl surface-2 border border-[var(--border)] shadow-xl
overflow-hidden backdrop-blur-xl"
>
{openMenu === "style" && (
<>
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<Link href="/styles" className="text-[10px] text-[var(--accent)] hover:underline">
</Link>
</div>
<div className="max-h-64 overflow-y-auto">
{stylePacks.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-[var(--text-secondary)]">
<Link href="/styles/new" className="text-[var(--accent)] hover:underline">
</Link>
</div>
) : (
stylePacks.map((s) => (
<button
key={s.id}
onClick={() => { setSessionStyleId(s.id); setOpenMenu(null); }}
className={`w-full px-3 py-2 text-left text-xs flex items-center gap-2 cursor-pointer transition-colors ${
sessionStyleId === s.id
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "hover:bg-[var(--accent)]/5 text-[var(--text-primary)]"
}`}
>
<div className="w-8 h-8 rounded-md bg-[var(--bg-tertiary)] flex-shrink-0 placeholder-card" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{s.name}</div>
<div className="truncate text-[10px] text-[var(--text-secondary)]">
{s.keywords.slice(0, 3).join(" · ") || "无关键词"}
</div>
</div>
</button>
))
)}
</div>
</>
)}
{openMenu === "character" && (
<>
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<Link href="/characters" className="text-[10px] text-[var(--accent)] hover:underline">
</Link>
</div>
<div className="max-h-64 overflow-y-auto">
{characters.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-[var(--text-secondary)]">
<Link href="/characters/new" className="text-[var(--accent)] hover:underline">
</Link>
</div>
) : (
characters.map((c) => (
<button
key={c.id}
onClick={() => { setSessionCharacterId(c.id); setOpenMenu(null); }}
className={`w-full px-3 py-2 text-left text-xs flex items-center gap-2 cursor-pointer transition-colors ${
sessionCharacterId === c.id
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "hover:bg-[var(--accent)]/5 text-[var(--text-primary)]"
}`}
>
<div className="w-8 h-8 rounded-full bg-[var(--bg-tertiary)] flex-shrink-0 placeholder-card" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{c.name}</div>
<div className="truncate text-[10px] text-[var(--text-secondary)]">
{c.visualHooks.slice(0, 3).join(" · ") || "无视觉锚点"}
</div>
</div>
</button>
))
)}
</div>
</>
)}
{openMenu === "spec" && (
<>
<div className="px-3 py-2 border-b border-[var(--border)]">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
</div>
<div className="max-h-64 overflow-y-auto">
{ASSET_SPECS.map((spec) => (
<button
key={spec}
onClick={() => { setSessionAssetSpec(spec); setOpenMenu(null); }}
className={`w-full px-3 py-2 text-left text-xs cursor-pointer transition-colors ${
sessionAssetSpec === spec
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "hover:bg-[var(--accent)]/5 text-[var(--text-primary)]"
}`}
>
{spec}
</button>
))}
</div>
<div className="px-3 py-2 border-t border-[var(--border)] text-[10px] text-[var(--text-secondary)]">
Phase 1
</div>
</>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { useApp } from "@/lib/app-context";
import type { TaskIR } from "@/lib/types";
interface TaskCardProps {
ir: TaskIR;
onConfirm?: (ir: TaskIR) => void;
onCancel?: () => void;
}
/**
* 任务说明卡Phase 1 占位组件)
*
* 对应 INFOLAYER.md 3.3.2 节:自然语言 → IR 的"任务说明卡"。
* 用于在对话中展示 Agent 解析出的任务意图,供用户确认、微调或取消。
* 数据结构见 types.ts 中的 TaskIR。
*/
export function TaskCard({ ir, onConfirm, onCancel }: TaskCardProps) {
const { stylePacks, characters } = useApp();
const [candidateCount, setCandidateCount] = useState(ir.candidateCount);
const styleName = stylePacks.find((s) => s.id === ir.styleId)?.name;
const charName = characters.find((c) => c.id === ir.characterId)?.name;
return (
<div className="rounded-xl border border-[var(--accent)]/20 bg-[var(--accent)]/5
px-4 py-3 max-w-xl backdrop-blur-sm">
<div className="flex items-center gap-2 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
<span className="typo-strong text-xs text-[var(--accent)]"></span>
<span className="phase-chip">Phase 1</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
<div>
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)]">{ir.taskType}</div>
</div>
<div>
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)]">{ir.template}</div>
</div>
<div>
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)] truncate">
{styleName ?? <span className="text-[var(--text-secondary)]"></span>}
</div>
</div>
<div>
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)] truncate">
{charName ?? <span className="text-[var(--text-secondary)]"></span>}
</div>
</div>
<div className="sm:col-span-2">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)]">{ir.outputSpec}</div>
</div>
</div>
<div className="mt-3 flex items-center gap-3 flex-wrap">
<label className="flex items-center gap-2 text-xs">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<input
type="number"
min={1}
max={8}
value={candidateCount}
onChange={(e) => setCandidateCount(parseInt(e.target.value) || 1)}
className="w-14 px-1.5 py-0.5 text-xs rounded-md bg-[var(--bg-primary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none text-center"
/>
</label>
<div className="ml-auto flex gap-1.5">
{onCancel && (
<button
onClick={onCancel}
className="px-2.5 py-1 text-xs rounded-md border border-[var(--border)]
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
cursor-pointer transition-all"
>
</button>
)}
<button
onClick={() => onConfirm?.({ ...ir, candidateCount })}
className="px-2.5 py-1 text-xs rounded-md font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
cursor-pointer transition-all"
>
</button>
</div>
</div>
<div className="text-[10px] text-[var(--text-secondary)] mt-2">
Agent Phase 1
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useApp } from "@/lib/app-context";
import { slideUp } from "@/components/ui/motion-presets";
export function ProjectSwitcher() {
const { projects, activeProject, activeProjectId, switchProject, upsertProject } = useApp();
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [newType, setNewType] = useState("2D RPG");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handler(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
setCreating(false);
}
}
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const handleCreate = () => {
if (!newName.trim()) return;
const now = Date.now();
upsertProject({
id: `proj-${now.toString(36)}`,
name: newName.trim(),
type: newType,
description: "",
createdAt: now,
updatedAt: now,
});
setNewName("");
setCreating(false);
setOpen(false);
};
if (!activeProject) return null;
return (
<div className="relative hidden sm:block" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 px-2.5 py-1.5 text-xs rounded-lg
border border-[var(--border)] bg-[var(--bg-tertiary)]/60
text-[var(--text-secondary)]
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
transition-all cursor-pointer"
title="切换项目Phase 1"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="max-w-[100px] md:max-w-[160px] truncate font-medium">
{activeProject.name}
</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<AnimatePresence>
{open && (
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
className="absolute left-0 top-full mt-1.5 w-72 rounded-xl surface-3 z-50 overflow-hidden"
>
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
<span className="typo-micro" style={{ textTransform: "none" }}>
Phase 1 ·
</span>
<button
onClick={() => setCreating(!creating)}
className="text-[10px] text-[var(--accent)] hover:underline cursor-pointer"
>
{creating ? "取消" : "+ 新建"}
</button>
</div>
{creating && (
<div className="p-2.5 border-b border-[var(--border)] space-y-1.5">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="项目名称"
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)]
rounded-md px-2 py-1.5 focus:outline-none focus:border-[var(--accent)]/50"
/>
<select
value={newType}
onChange={(e) => setNewType(e.target.value)}
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)]
rounded-md px-2 py-1.5 focus:outline-none cursor-pointer"
>
<option>2D RPG</option>
<option></option>
<option></option>
<option></option>
<option></option>
</select>
<button
onClick={handleCreate}
disabled={!newName.trim()}
className="w-full px-2 py-1.5 text-xs rounded-md bg-[var(--accent)]
text-[var(--bg-primary)] font-medium cursor-pointer
disabled:opacity-50"
>
</button>
</div>
)}
<div className="max-h-64 overflow-y-auto py-1">
{projects.map((p) => (
<button
key={p.id}
onClick={() => {
switchProject(p.id);
setOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm cursor-pointer
hover:bg-[var(--bg-tertiary)] transition-colors flex items-center gap-2 ${
p.id === activeProjectId ? "bg-[var(--accent)]/8" : ""
}`}
>
<div className={`w-6 h-6 rounded-md flex-shrink-0 flex items-center justify-center text-[10px] font-bold ${
p.id === activeProjectId
? "bg-[var(--accent)] text-[var(--bg-primary)]"
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]"
}`}>
{p.name.slice(0, 1).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className={`text-xs truncate ${
p.id === activeProjectId ? "text-[var(--accent)] font-medium" : "text-[var(--text-primary)]"
}`}>
{p.name}
</div>
<div className="text-[10px] text-[var(--text-secondary)] truncate">
{p.type}
</div>
</div>
{p.id === activeProjectId && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" className="text-[var(--accent)]">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -7,12 +7,14 @@ import { usePathname } from "next/navigation";
import { useApp } from "@/lib/app-context";
import { useAuth } from "@/lib/auth-context";
import { ProfileModal } from "@/components/profile-modal";
import { ProjectSwitcher } from "./project-switcher";
import { slideUp } from "@/components/ui/motion-presets";
const NAV_ITEMS = [
{
href: "/",
label: "对话",
label: "工作台",
phase: "Phase 0",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
@@ -22,6 +24,7 @@ const NAV_ITEMS = [
{
href: "/gallery",
label: "资源库",
phase: "Phase 0",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
@@ -30,6 +33,41 @@ const NAV_ITEMS = [
</svg>
),
},
{
href: "/styles",
label: "风格库",
phase: "Phase 1",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="13.5" cy="6.5" r=".5" />
<circle cx="17.5" cy="10.5" r=".5" />
<circle cx="8.5" cy="7.5" r=".5" />
<circle cx="6.5" cy="12.5" r=".5" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.55 0 1-.45 1-1v-3.5c0-.55.45-1 1-1h2c2.76 0 5-2.24 5-5 0-5.5-4.5-9.5-10-9.5z" />
</svg>
),
},
{
href: "/characters",
label: "角色库",
phase: "Phase 1",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
},
{
href: "/training",
label: "训练中心",
phase: "Phase 2",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L3 7v5c0 5 3.8 9.7 9 11 5.2-1.3 9-6 9-11V7l-9-5z" />
</svg>
),
},
] as const;
export function TopNav() {
@@ -53,7 +91,7 @@ export function TopNav() {
return (
<>
<header className="relative z-10 flex-shrink-0 h-14 border-b border-[var(--border)] surface-1 backdrop-blur-xl flex items-center px-4 md:px-5 gap-4 md:gap-6">
<header className="relative z-10 flex-shrink-0 h-14 border-b border-[var(--border)] surface-1 backdrop-blur-xl flex items-center px-4 md:px-5 gap-3 md:gap-5">
{/* 移动端汉堡菜单 */}
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
@@ -79,27 +117,42 @@ export function TopNav() {
<circle cx="23" cy="22.5" r="1" fill="white" />
</svg>
</div>
<span className="text-base font-bold tracking-tight text-[var(--text-primary)]">
<span className="text-base font-bold tracking-tight text-[var(--text-primary)] hidden sm:inline">
EPEEKit
</span>
</Link>
{/* 项目切换器 */}
<ProjectSwitcher />
{/* 导航 Tab */}
<nav className="flex items-center gap-1.5">
{NAV_ITEMS.map(({ href, label, icon }) => {
<nav className="flex items-center gap-1 overflow-x-auto hide-scrollbar">
{NAV_ITEMS.map(({ href, label, icon, phase }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={`relative flex items-center gap-1.5 px-3 md:px-4 py-2 text-sm rounded-lg transition-all ${
className={`relative flex items-center gap-1.5 px-2.5 md:px-3.5 py-2 text-sm rounded-lg transition-all whitespace-nowrap ${
active
? "glow-border text-[var(--accent)] font-medium !bg-[var(--bg-secondary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent hover:border-[var(--border-glow)]"
}`}
title={`${label}${phase}`}
>
{icon}
{label}
<span className="hidden md:inline">{label}</span>
{phase !== "Phase 0" && (
<span
className="hidden lg:inline text-[9px] px-1 py-px rounded font-semibold"
style={{
background: "rgba(184, 147, 90, 0.12)",
color: "var(--gold)",
}}
>
{phase.replace("Phase ", "P")}
</span>
)}
</Link>
);
})}
@@ -112,7 +165,7 @@ export function TopNav() {
<div className="relative" ref={menuRef}>
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)] border border-transparent
hover:border-[var(--border)] transition-all cursor-pointer"
@@ -121,7 +174,7 @@ export function TopNav() {
flex items-center justify-center text-[var(--accent)] text-xs font-semibold">
{(user.display_name || user.username).charAt(0).toUpperCase()}
</div>
<span className="hidden sm:inline max-w-[100px] truncate">
<span className="hidden sm:inline max-w-[80px] truncate">
{user.display_name || user.username}
</span>
</button>
@@ -132,29 +185,72 @@ export function TopNav() {
initial="hidden"
animate="visible"
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
className="absolute right-0 top-full mt-1.5 w-48 rounded-xl
className="absolute right-0 top-full mt-1.5 w-56 rounded-xl
surface-3 py-1.5 z-50"
>
<button
onClick={() => { setMenuOpen(false); setProfileOpen(true); }}
className="w-full text-left px-3.5 py-2 text-xs text-[var(--text-secondary)]
hover:text-[var(--accent)] hover:bg-[var(--bg-tertiary)]
border-b border-[var(--border)] transition-colors cursor-pointer
flex items-center gap-1.5"
>
<div className="px-3.5 py-2 text-xs text-[var(--text-secondary)]
border-b border-[var(--border)] flex items-center gap-1.5">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>{user.username}</span>
{user.is_admin && <span className="text-[var(--accent)]">()</span>}
<span className="truncate">{user.username}</span>
{user.is_admin && <span className="text-[var(--accent)] font-medium">()</span>}
</div>
<button
onClick={() => { setMenuOpen(false); setProfileOpen(true); }}
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors
flex items-center gap-2"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
/
</button>
<Link
href="/settings"
onClick={() => setMenuOpen(false)}
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors
flex items-center gap-2"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
<span className="ml-auto text-[9px] px-1 py-px rounded font-semibold"
style={{ background: "rgba(184, 147, 90, 0.12)", color: "var(--gold)" }}>
P1
</span>
</Link>
<button
onClick={() => { setMenuOpen(false); alert("全局设置Phase 2 规划中)\n- API Key 配置\n- 界面偏好\n- 通知设置"); }}
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-secondary)]
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
cursor-pointer transition-colors flex items-center gap-2"
title="Phase 2 占位"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20V10M6 20v-5M18 20V4" />
</svg>
<span className="ml-auto text-[9px] px-1 py-px rounded font-semibold"
style={{ background: "rgba(184, 147, 90, 0.12)", color: "var(--gold)" }}>
P2
</span>
</button>
<div className="border-t border-[var(--border)] my-1" />
<button
onClick={() => { setMenuOpen(false); logout(); }}
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-secondary)]
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
transition-colors cursor-pointer"
hover:text-[var(--hot)] hover:bg-[var(--bg-tertiary)]
transition-colors cursor-pointer flex items-center gap-2"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" />
</svg>
退
</button>
</motion.div>

View File

@@ -0,0 +1,104 @@
"use client";
import Link from "next/link";
import { useApp } from "@/lib/app-context";
/**
* 侧边栏项目信息卡Phase 1
*
* 展示当前项目名、绑定的默认风格缩略图(占位),以及快捷入口:
* 风格库 / 角色库 / 项目设置。数据来自 placeholder-store 的占位数据。
*/
export function ProjectCard() {
const { activeProject, stylePacks } = useApp();
if (!activeProject) return null;
const defaultStyle = activeProject.defaultStyleId
? stylePacks.find((s) => s.id === activeProject.defaultStyleId)
: stylePacks[0];
return (
<div className="mx-3 mb-2 rounded-xl placeholder-card p-3">
<div className="flex items-center justify-between mb-2">
<span className="typo-micro" style={{ textTransform: "none" }}>
</span>
<span className="phase-chip">P1</span>
</div>
<div className="flex items-center gap-2.5 mb-2.5">
<div className="w-10 h-10 rounded-lg flex-shrink-0 flex items-center justify-center
bg-gradient-to-br from-[var(--accent)]/15 to-[var(--accent-secondary)]/15
border border-[var(--border-glow)]">
{defaultStyle ? (
<span className="text-[10px] text-[var(--accent)] font-semibold tracking-tight px-1 text-center">
{defaultStyle.name.slice(0, 2)}
</span>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.8">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z" />
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-[var(--text-primary)] font-medium truncate">
{activeProject.name}
</div>
<div className="text-[10px] text-[var(--text-secondary)] truncate">
{activeProject.type}
{defaultStyle && <> · {defaultStyle.name}</>}
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-1.5">
<Link
href="/styles"
className="flex flex-col items-center justify-center gap-1 py-1.5 rounded-md
bg-[var(--bg-tertiary)]/60 text-[10px] text-[var(--text-secondary)]
hover:text-[var(--accent)] hover:bg-[var(--accent)]/8
cursor-pointer transition-colors"
title="风格库"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="13.5" cy="6.5" r=".5" />
<circle cx="17.5" cy="10.5" r=".5" />
<circle cx="8.5" cy="7.5" r=".5" />
<circle cx="6.5" cy="12.5" r=".5" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.55 0 1-.45 1-1v-3.5c0-.55.45-1 1-1h2c2.76 0 5-2.24 5-5 0-5.5-4.5-9.5-10-9.5z" />
</svg>
</Link>
<Link
href="/characters"
className="flex flex-col items-center justify-center gap-1 py-1.5 rounded-md
bg-[var(--bg-tertiary)]/60 text-[10px] text-[var(--text-secondary)]
hover:text-[var(--accent)] hover:bg-[var(--accent)]/8
cursor-pointer transition-colors"
title="角色库"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</Link>
<Link
href="/settings"
className="flex flex-col items-center justify-center gap-1 py-1.5 rounded-md
bg-[var(--bg-tertiary)]/60 text-[10px] text-[var(--text-secondary)]
hover:text-[var(--accent)] hover:bg-[var(--accent)]/8
cursor-pointer transition-colors"
title="项目设置"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
</Link>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { useApp } from "@/lib/app-context";
import { TagFilter } from "./tag-filter";
import { SessionList } from "./session-list";
import { ProjectCard } from "./project-card";
export function Sidebar() {
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
@@ -53,6 +54,7 @@ export function Sidebar() {
<TagFilter />
<SessionList />
<ProjectCard />
</aside>
</>
);

View File

@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
/**
* 高级控制面板Phase 2 占位)
*
* 对应 INFOLAYER.md 3.3 节「高级控制」:提供给高级用户的参数调节抽屉。
* 当前所有参数均为占位,不会真实下发到后端。
*/
export function AdvancedControls() {
const [open, setOpen] = useState(false);
const [cfg, setCfg] = useState(7.5);
const [steps, setSteps] = useState(28);
const [sampler, setSampler] = useState("DPM++ 2M Karras");
const [seed, setSeed] = useState("");
const [styleWeight, setStyleWeight] = useState(0.8);
const [charWeight, setCharWeight] = useState(0.85);
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
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">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span className="phase-chip">P2</span>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
className="absolute bottom-full mb-2 right-0 w-80 max-w-[calc(100vw-24px)]
rounded-xl surface-2 border border-[var(--border)] shadow-xl
backdrop-blur-xl z-40 p-3"
>
<div className="flex items-center justify-between mb-3">
<span className="typo-strong text-xs"></span>
<button
onClick={() => setOpen(false)}
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] cursor-pointer"
>
</button>
</div>
<div className="space-y-3">
<Slider label="CFG Scale" value={cfg} min={1} max={20} step={0.5} onChange={setCfg} />
<Slider label="Steps" value={steps} min={10} max={60} step={1} onChange={setSteps} />
<Slider label="风格强度" value={styleWeight} min={0} max={1.5} step={0.05} onChange={setStyleWeight} />
<Slider label="角色一致性" value={charWeight} min={0} max={1.5} step={0.05} onChange={setCharWeight} />
<div>
<label className="block typo-micro mb-1" style={{ textTransform: "none" }}></label>
<select
value={sampler}
onChange={(e) => setSampler(e.target.value)}
className="w-full px-2 py-1.5 text-xs rounded-md bg-[var(--bg-primary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
>
{["DPM++ 2M Karras", "Euler a", "DDIM", "UniPC"].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label className="block typo-micro mb-1" style={{ textTransform: "none" }}>Seed</label>
<div className="flex gap-1.5">
<input
value={seed}
onChange={(e) => setSeed(e.target.value)}
placeholder="留空为随机"
className="flex-1 px-2 py-1.5 text-xs rounded-md bg-[var(--bg-primary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none font-mono"
/>
<button
onClick={() => setSeed(Math.floor(Math.random() * 9_999_999).toString())}
className="px-2 py-1.5 text-xs rounded-md border border-[var(--border)]
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
cursor-pointer"
>
🎲
</button>
</div>
</div>
</div>
<div className="mt-3 pt-3 border-t border-[var(--border)] text-[10px] text-[var(--text-secondary)]">
Phase 2
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function Slider({
label, value, min, max, step, onChange,
}: { label: string; value: number; min: number; max: number; step: number; onChange: (v: number) => void }) {
return (
<div>
<div className="flex items-center justify-between mb-1">
<label className="typo-micro" style={{ textTransform: "none" }}>{label}</label>
<span className="text-[11px] text-[var(--accent)] font-mono">{value}</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-full accent-[var(--accent)]"
/>
</div>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import type { ImageAsset } from "@/lib/types";
import { getImageUrl } from "@/lib/api";
/**
* 候选评估面板Phase 1 占位)
*
* 对应 INFOLAYER.md 3.3.3 节:同轮生成的一组候选图的并排比较、
* 快速评分、标注差异、挑选定稿。
*/
interface CandidatePanelProps {
candidates: ImageAsset[];
onPick?: (id: string) => void;
onClose?: () => void;
}
export function CandidatePanel({ candidates, onPick, onClose }: CandidatePanelProps) {
const [ratings, setRatings] = useState<Record<string, number>>({});
const [notes, setNotes] = useState<Record<string, string>>({});
if (candidates.length === 0) return null;
return (
<div className="fixed inset-y-0 right-0 w-[min(680px,100vw)] z-40
bg-[var(--bg-secondary)]/95 backdrop-blur-xl
border-l border-[var(--border)] shadow-2xl
flex flex-col animate-in slide-in-from-right">
<div className="flex items-center justify-between px-5 py-3 border-b border-[var(--border)]">
<div className="flex items-center gap-2">
<h2 className="typo-h2"></h2>
<span className="phase-chip">Phase 1</span>
<span className="text-xs text-[var(--text-secondary)]">
{candidates.length}
</span>
</div>
<button
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-all"
>
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-4">
<div className="grid grid-cols-2 gap-4">
{candidates.map((c, i) => (
<div key={c.id} className="rounded-xl surface-2 overflow-hidden">
<div className="relative aspect-square bg-[var(--bg-tertiary)]">
{c.url ? (
<img src={getImageUrl(c.url)} alt={`候选 ${i + 1}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">
{i + 1}
</div>
)}
<div className="absolute top-2 left-2 px-2 py-0.5 rounded-md
bg-[var(--bg-primary)]/70 backdrop-blur-sm
text-[10px] font-mono text-[var(--text-primary)]">
#{i + 1}
</div>
</div>
<div className="p-2.5">
<div className="flex gap-0.5 mb-1.5">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() =>
setRatings((prev) => ({ ...prev, [c.id]: prev[c.id] === n ? 0 : n }))
}
className={`text-base leading-none cursor-pointer transition-colors ${
(ratings[c.id] ?? 0) >= n ? "text-[var(--accent)]" : "text-[var(--border)]"
}`}
>
</button>
))}
</div>
<input
value={notes[c.id] ?? ""}
onChange={(e) => setNotes((prev) => ({ ...prev, [c.id]: e.target.value }))}
placeholder="点评(占位)"
className="w-full px-2 py-1 text-[11px] rounded-md bg-[var(--bg-primary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
/>
<button
onClick={() => onPick?.(c.id)}
className="mt-1.5 w-full text-[11px] py-1 rounded-md
bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/30
hover:bg-[var(--accent)]/20 cursor-pointer transition-all"
>
稿
</button>
</div>
</div>
))}
</div>
<div className="placeholder-card rounded-lg p-3 text-xs text-[var(--text-secondary)]">
<div className="typo-strong text-xs mb-1 text-[var(--text-primary)]"></div>
/ / Phase 2
Agent
</div>
</div>
</div>
);
}

View File

@@ -9,7 +9,16 @@ import {
useState,
type ReactNode,
} from "react";
import type { Session, Tag, ImageAsset, ChatMessage } from "./types";
import type {
Session,
Tag,
ImageAsset,
ChatMessage,
Project,
StylePack,
CharacterCard,
TrainingTask,
} from "./types";
import {
loadSessions,
saveSessions,
@@ -28,6 +37,21 @@ import {
generateId,
setStoreUserId,
} from "./store";
import {
loadProjects,
saveProjects,
loadActiveProjectId,
saveActiveProjectId,
loadStylePacks,
upsertStylePack as storeUpsertStyle,
deleteStylePack as storeDeleteStyle,
loadCharacters,
upsertCharacter as storeUpsertChar,
deleteCharacter as storeDeleteChar,
loadTrainingTasks,
upsertTrainingTask as storeUpsertTraining,
deleteTrainingTask as storeDeleteTraining,
} from "./placeholder-store";
import { useAuth } from "./auth-context";
interface AppContextValue {
@@ -65,6 +89,34 @@ interface AppContextValue {
// 左栏折叠
sidebarCollapsed: boolean;
setSidebarCollapsed: (v: boolean) => void;
// ===== Phase 1+ 占位数据 =====
projects: Project[];
activeProjectId: string;
activeProject: Project | null;
switchProject: (id: string) => void;
upsertProject: (p: Project) => void;
stylePacks: StylePack[];
upsertStylePack: (sp: StylePack) => void;
deleteStylePack: (id: string) => void;
characters: CharacterCard[];
upsertCharacter: (c: CharacterCard) => void;
deleteCharacter: (id: string) => void;
trainingTasks: TrainingTask[];
upsertTrainingTask: (t: TrainingTask) => void;
deleteTrainingTask: (id: string) => void;
/** 当前会话绑定的风格/角色UI 层占位,不实际下发后端) */
sessionStyleId: string | null;
setSessionStyleId: (id: string | null) => void;
sessionCharacterId: string | null;
setSessionCharacterId: (id: string | null) => void;
/** 当前会话选择的资产规格(占位) */
sessionAssetSpec: string | null;
setSessionAssetSpec: (spec: string | null) => void;
}
const AppContext = createContext<AppContextValue | null>(null);
@@ -90,6 +142,16 @@ export function AppProvider({ children }: { children: ReactNode }) {
});
const [initialized, setInitialized] = useState(false);
// ===== Phase 1+ 占位数据 =====
const [projects, setProjects] = useState<Project[]>([]);
const [activeProjectId, setActiveProjectId] = useState<string>("");
const [stylePacks, setStylePacks] = useState<StylePack[]>([]);
const [characters, setCharacters] = useState<CharacterCard[]>([]);
const [trainingTasks, setTrainingTasks] = useState<TrainingTask[]>([]);
const [sessionStyleId, setSessionStyleId] = useState<string | null>(null);
const [sessionCharacterId, setSessionCharacterId] = useState<string | null>(null);
const [sessionAssetSpec, setSessionAssetSpec] = useState<string | null>(null);
// 当用户变化时,切换 store 的 userId 并重新加载数据
useEffect(() => {
if (!isAuthenticated || !user) {
@@ -100,8 +162,16 @@ export function AppProvider({ children }: { children: ReactNode }) {
setSessions(loadSessions());
setTags(loadTags());
setAssets(loadAssets());
setProjects(loadProjects());
setActiveProjectId(loadActiveProjectId());
setStylePacks(loadStylePacks());
setCharacters(loadCharacters());
setTrainingTasks(loadTrainingTasks());
setActiveSessionId(null);
setDetailImage(null);
setSessionStyleId(null);
setSessionCharacterId(null);
setSessionAssetSpec(null);
setInitialized(true);
}, [user?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -268,6 +338,61 @@ export function AppProvider({ children }: { children: ReactNode }) {
return result;
}, []);
// ===== Phase 1+ 占位操作 =====
const activeProject = useMemo(
() => projects.find((p) => p.id === activeProjectId) ?? null,
[projects, activeProjectId]
);
const switchProject = useCallback((id: string) => {
setActiveProjectId(id);
saveActiveProjectId(id);
setSessionStyleId(null);
setSessionCharacterId(null);
}, []);
const upsertProjectCb = useCallback((p: Project) => {
setProjects((prev) => {
const idx = prev.findIndex((x) => x.id === p.id);
const next = idx >= 0
? prev.map((x) => (x.id === p.id ? { ...p, updatedAt: Date.now() } : x))
: [{ ...p, updatedAt: Date.now() }, ...prev];
saveProjects(next);
return next;
});
}, []);
const upsertStylePackCb = useCallback((sp: StylePack) => {
storeUpsertStyle(sp);
setStylePacks(loadStylePacks());
}, []);
const deleteStylePackCb = useCallback((id: string) => {
storeDeleteStyle(id);
setStylePacks(loadStylePacks());
}, []);
const upsertCharacterCb = useCallback((c: CharacterCard) => {
storeUpsertChar(c);
setCharacters(loadCharacters());
}, []);
const deleteCharacterCb = useCallback((id: string) => {
storeDeleteChar(id);
setCharacters(loadCharacters());
}, []);
const upsertTrainingTaskCb = useCallback((t: TrainingTask) => {
storeUpsertTraining(t);
setTrainingTasks(loadTrainingTasks());
}, []);
const deleteTrainingTaskCb = useCallback((id: string) => {
storeDeleteTraining(id);
setTrainingTasks(loadTrainingTasks());
}, []);
const value = useMemo<AppContextValue>(
() => ({
sessions,
@@ -295,6 +420,26 @@ export function AppProvider({ children }: { children: ReactNode }) {
setDetailImage,
sidebarCollapsed,
setSidebarCollapsed,
projects,
activeProjectId,
activeProject,
switchProject,
upsertProject: upsertProjectCb,
stylePacks,
upsertStylePack: upsertStylePackCb,
deleteStylePack: deleteStylePackCb,
characters,
upsertCharacter: upsertCharacterCb,
deleteCharacter: deleteCharacterCb,
trainingTasks,
upsertTrainingTask: upsertTrainingTaskCb,
deleteTrainingTask: deleteTrainingTaskCb,
sessionStyleId,
setSessionStyleId,
sessionCharacterId,
setSessionCharacterId,
sessionAssetSpec,
setSessionAssetSpec,
}),
[
sessions, activeSessionId, activeSession,
@@ -303,6 +448,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
tags, addTag, deleteTagCb, selectedTagIds,
assets, addAssetCb, updateAssetCb, deleteAssetCb, toggleFavoriteCb,
detailImage, sidebarCollapsed,
projects, activeProjectId, activeProject, switchProject, upsertProjectCb,
stylePacks, upsertStylePackCb, deleteStylePackCb,
characters, upsertCharacterCb, deleteCharacterCb,
trainingTasks, upsertTrainingTaskCb, deleteTrainingTaskCb,
sessionStyleId, sessionCharacterId, sessionAssetSpec,
]
);

View File

@@ -0,0 +1,347 @@
/**
* Phase 1+ 占位数据 Store风格 / 角色 / 项目 / 训练任务)。
*
* 这些数据尚未接入后端,用 localStorage 持久化,首次载入时注入示例占位数据
* 供交互骨架使用。所有功能性操作应用到会话、训练、Fork 等)目前为占位动作,
* 在 UI 上给出反馈即可,不产生真实后端调用。
*/
import type {
Project,
StylePack,
CharacterCard,
TrainingTask,
} from "./types";
import { getStoreUserId, generateId } from "./store";
function storageKey(base: string): string {
const prefix = getStoreUserId() || "_anonymous";
return `epeekit-${prefix}-${base}`;
}
function readJSON<T>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
function writeJSON<T>(key: string, data: T) {
if (typeof window === "undefined") return;
localStorage.setItem(key, JSON.stringify(data));
}
// ==================== 项目 ====================
const DEFAULT_PROJECT_ID = "proj-default";
export function loadProjects(): Project[] {
const list = readJSON<Project[]>(storageKey("projects"), []);
if (list.length === 0) {
const now = Date.now();
const defaults: Project[] = [
{
id: DEFAULT_PROJECT_ID,
name: "默认项目(占位)",
description: "用于当前工作台的默认项目。真实的多项目能力为 Phase 1 规划。",
type: "2D RPG",
defaultSpec: "1024×1024 · 透明背景可选",
createdAt: now,
updatedAt: now,
},
{
id: "proj-sample-card",
name: "示例项目:卡牌游戏",
description: "示例占位数据。",
type: "卡牌",
defaultSpec: "768×1024 · PNG",
createdAt: now - 86400000,
updatedAt: now - 86400000,
},
];
writeJSON(storageKey("projects"), defaults);
writeJSON(storageKey("active-project"), DEFAULT_PROJECT_ID);
return defaults;
}
return list;
}
export function saveProjects(list: Project[]) {
writeJSON(storageKey("projects"), list);
}
export function loadActiveProjectId(): string {
const saved = readJSON<string>(storageKey("active-project"), "");
return saved || DEFAULT_PROJECT_ID;
}
export function saveActiveProjectId(id: string) {
writeJSON(storageKey("active-project"), id);
}
export function createProject(name: string, type: string): Project {
const now = Date.now();
const p: Project = {
id: generateId("proj-"),
name,
type,
description: "",
createdAt: now,
updatedAt: now,
};
const list = loadProjects();
list.unshift(p);
saveProjects(list);
return p;
}
// ==================== 风格集 ====================
export function loadStylePacks(): StylePack[] {
const list = readJSON<StylePack[]>(storageKey("style-packs"), []);
if (list.length === 0) {
const now = Date.now();
const defaults: StylePack[] = [
{
id: "style-anime-dark-fantasy-v1",
name: "暗黑幻想·二次元",
description: "偏暗色调的奇幻二次元风格,适合剑与魔法题材的立绘和场景。",
keywords: ["二次元", "暗黑幻想", "奇幻", "剑与魔法"],
source: "preset",
refImages: [
{ id: "ref-1", url: "", caption: "占位参考图 1" },
{ id: "ref-2", url: "", caption: "占位参考图 2" },
{ id: "ref-3", url: "", caption: "占位参考图 3" },
],
positivePrompt: "占位cinematic lighting, dark fantasy, anime style, high detail",
negativePrompt: "占位lowres, blurry, deformed",
usageCount: 17,
createdAt: now - 86400000 * 5,
updatedAt: now - 86400000 * 2,
},
{
id: "style-ink-wuxia-v1",
name: "水墨·武侠",
description: "水墨写意风格,留白与笔触表现,适合武侠场景和人物。",
keywords: ["水墨", "写意", "武侠", "国风"],
source: "preset",
refImages: [
{ id: "ref-1", url: "", caption: "占位参考图 1" },
{ id: "ref-2", url: "", caption: "占位参考图 2" },
],
positivePrompt: "占位chinese ink painting, wuxia style, flowing brush strokes",
negativePrompt: "占位photo, 3d, western style",
usageCount: 8,
createdAt: now - 86400000 * 10,
updatedAt: now - 86400000 * 7,
},
{
id: "style-pixel-retro-v1",
name: "像素·复古",
description: "16bit 像素风格,适合复古游戏的 icon、立绘和场景。",
keywords: ["像素", "复古", "16bit"],
source: "preset",
refImages: [
{ id: "ref-1", url: "", caption: "占位参考图" },
],
usageCount: 3,
createdAt: now - 86400000 * 20,
updatedAt: now - 86400000 * 18,
},
{
id: "style-cyber-neon-v1",
name: "赛博·霓虹",
description: "赛博朋克霓虹质感,青紫色调与雨夜街景的占位示例。",
keywords: ["赛博", "霓虹", "科幻"],
source: "community",
refImages: [
{ id: "ref-1", url: "", caption: "占位参考图" },
],
usageCount: 1,
forkFromId: "style-anime-dark-fantasy-v1",
createdAt: now - 86400000 * 3,
updatedAt: now - 86400000,
},
];
writeJSON(storageKey("style-packs"), defaults);
return defaults;
}
return list;
}
export function saveStylePacks(list: StylePack[]) {
writeJSON(storageKey("style-packs"), list);
}
export function upsertStylePack(sp: StylePack) {
const list = loadStylePacks();
const idx = list.findIndex((x) => x.id === sp.id);
if (idx >= 0) list[idx] = { ...sp, updatedAt: Date.now() };
else list.unshift(sp);
saveStylePacks(list);
}
export function deleteStylePack(id: string) {
saveStylePacks(loadStylePacks().filter((x) => x.id !== id));
}
// ==================== 角色卡 ====================
export function loadCharacters(): CharacterCard[] {
const list = readJSON<CharacterCard[]>(storageKey("characters"), []);
if (list.length === 0) {
const now = Date.now();
const defaults: CharacterCard[] = [
{
id: "char-elica-swordsman",
name: "剑士·艾丽卡",
description: "占位示例角色:银发剑士,性格冷静,擅长二刀流。",
gender: "女",
ageRange: "20 岁上下",
body: "修长",
personality: ["冷静", "果断", "寡言"],
story: "(占位)出身于北境贵族家庭,因家族变故而踏上旅途。",
visualHooks: ["银发", "紫瞳", "红披风", "双剑"],
consistency: "high",
refImages: [
{ id: "r1", url: "", angle: "正面", caption: "占位参考图" },
{ id: "r2", url: "", angle: "半侧面", caption: "占位参考图" },
{ id: "r3", url: "", angle: "表情·微笑", caption: "占位参考图" },
],
projectId: DEFAULT_PROJECT_ID,
createdAt: now - 86400000 * 3,
updatedAt: now - 86400000,
},
{
id: "char-mark-gunner",
name: "枪手·马克",
description: "占位示例角色:疤痕硬汉,惯用左轮,话少事多。",
gender: "男",
ageRange: "30 岁上下",
body: "结实",
personality: ["硬汉", "江湖气", "话少"],
story: "(占位)西部小镇通缉令上最贵的头颅。",
visualHooks: ["眉间疤", "棕色风衣", "左轮", "牛仔帽"],
consistency: "medium",
refImages: [
{ id: "r1", url: "", angle: "正面", caption: "占位参考图" },
],
projectId: "proj-sample-card",
createdAt: now - 86400000 * 8,
updatedAt: now - 86400000 * 2,
},
];
writeJSON(storageKey("characters"), defaults);
return defaults;
}
return list;
}
export function saveCharacters(list: CharacterCard[]) {
writeJSON(storageKey("characters"), list);
}
export function upsertCharacter(c: CharacterCard) {
const list = loadCharacters();
const idx = list.findIndex((x) => x.id === c.id);
if (idx >= 0) list[idx] = { ...c, updatedAt: Date.now() };
else list.unshift(c);
saveCharacters(list);
}
export function deleteCharacter(id: string) {
saveCharacters(loadCharacters().filter((x) => x.id !== id));
}
// ==================== 训练任务 ====================
export function loadTrainingTasks(): TrainingTask[] {
const list = readJSON<TrainingTask[]>(storageKey("training-tasks"), []);
if (list.length === 0) {
const now = Date.now();
const defaults: TrainingTask[] = [
{
id: "train-dark-fantasy-v2",
name: "暗黑幻想风格 v2",
type: "style_lora",
status: "running",
progress: 67,
sourceId: "style-anime-dark-fantasy-v1",
template: "sdxl_lora",
trainingSet: Array.from({ length: 24 }).map((_, i) => `asset-train-${i + 1}`),
logs: [
"[占位] epoch 4/6 loss=0.0321",
"[占位] epoch 5/6 loss=0.0278 ...",
],
currentStep: "Epoch 5 / 6",
etaSeconds: 480,
estCost: "≈ ¥18占位",
createdAt: now - 86400000,
updatedAt: now - 3600000,
},
{
id: "train-elica-v1",
name: "剑士·艾丽卡 v1",
type: "character_lora",
status: "completed",
progress: 100,
sourceId: "char-elica-swordsman",
template: "flux_lora",
trainingSet: Array.from({ length: 12 }).map((_, i) => `asset-train-${i + 1}`),
logs: ["[占位] 训练完成loss=0.0201"],
estCost: "≈ ¥12占位",
createdAt: now - 86400000 * 2,
updatedAt: now - 86400000 + 3 * 3600000,
},
{
id: "train-ink-wuxia-v1",
name: "水墨武侠 v1",
type: "style_lora",
status: "queued",
progress: 0,
sourceId: "style-ink-wuxia-v1",
template: "sdxl_lora",
trainingSet: [],
logs: [],
estCost: "≈ ¥20占位",
createdAt: now - 3600000,
updatedAt: now - 3600000,
},
{
id: "train-mark-v1-failed",
name: "枪手·马克 v1",
type: "character_lora",
status: "failed",
progress: 0,
sourceId: "char-mark-gunner",
template: "flux_lora",
trainingSet: [],
logs: ["[占位] 训练失败:训练集图片数量不足(示例)"],
createdAt: now - 86400000 * 3,
updatedAt: now - 86400000 * 3 + 1800000,
},
];
writeJSON(storageKey("training-tasks"), defaults);
return defaults;
}
return list;
}
export function saveTrainingTasks(list: TrainingTask[]) {
writeJSON(storageKey("training-tasks"), list);
}
export function upsertTrainingTask(t: TrainingTask) {
const list = loadTrainingTasks();
const idx = list.findIndex((x) => x.id === t.id);
if (idx >= 0) list[idx] = { ...t, updatedAt: Date.now() };
else list.unshift(t);
saveTrainingTasks(list);
}
export function deleteTrainingTask(id: string) {
saveTrainingTasks(loadTrainingTasks().filter((x) => x.id !== id));
}

View File

@@ -69,6 +69,142 @@ export interface LlmModelInfo {
vision?: boolean;
}
// ==================== Phase 1+ 占位类型 ====================
// 以下类型对应 INFOLAYER.md Phase 1/2 的产品形态,功能尚未完整接入后端,
// 目前以 localStorage + 预置占位数据的方式支撑前端交互骨架。
/** 项目Phase 1 */
export interface Project {
id: string;
name: string;
description: string;
/** 项目类型,如 2D RPG / 横版动作 / 卡牌 等 */
type: string;
/** 默认绑定的风格集 ID */
defaultStyleId?: string;
/** 默认输出规格备注(占位) */
defaultSpec?: string;
thumbnail?: string;
createdAt: number;
updatedAt: number;
}
/** 风格参考集中的一张参考图(占位) */
export interface StyleRefImage {
id: string;
url: string;
caption?: string;
}
/** 风格集Phase 1 */
export interface StylePack {
id: string;
name: string;
description: string;
/** 关键词标签,如「写实」「二次元」「像素」「水墨」「赛博」 */
keywords: string[];
/** 分类preset 平台预设 / custom 项目自建 / community 社区共享 */
source: "preset" | "custom" | "community";
refImages: StyleRefImage[];
/** 正向 Prompt 模板(占位) */
positivePrompt?: string;
/** 负向 Prompt 模板(占位) */
negativePrompt?: string;
/** 绑定的 LoRA 模型 IDPhase 2 占位) */
loraId?: string;
/** Fork 来源的风格 ID占位 */
forkFromId?: string;
/** 使用次数(占位) */
usageCount: number;
createdAt: number;
updatedAt: number;
}
/** 角色参考组中的一张参考图(占位) */
export interface CharacterRefImage {
id: string;
url: string;
/** 视角分类:正面 / 半侧面 / 半身 / 表情 / 服饰细节 等 */
angle: string;
caption?: string;
}
/** 角色卡Phase 1 */
export interface CharacterCard {
id: string;
name: string;
description: string;
/** 性别 */
gender?: string;
/** 年龄范围 */
ageRange?: string;
/** 体型 */
body?: string;
/** 性格关键词 */
personality: string[];
/** 背景故事摘要 */
story?: string;
/** 关键视觉要素(发色、瞳色、标志性饰品、武器等) */
visualHooks: string[];
/** 默认一致性级别 */
consistency: "low" | "medium" | "high";
/** 参考图组 */
refImages: CharacterRefImage[];
/** 绑定的 LoRA 模型 IDPhase 2 占位) */
loraId?: string;
/** 所属项目 */
projectId?: string;
createdAt: number;
updatedAt: number;
}
/** 训练任务类型 */
export type TrainingTaskType = "style_lora" | "character_lora";
/** 训练任务状态 */
export type TrainingTaskStatus = "queued" | "running" | "completed" | "failed" | "deprecated";
/** 训练任务Phase 2 */
export interface TrainingTask {
id: string;
name: string;
type: TrainingTaskType;
status: TrainingTaskStatus;
/** 0100 */
progress: number;
/** 来源风格或角色 ID */
sourceId?: string;
/** 训练模板(如 sdxl_lora / flux_lora */
template: string;
/** 训练集资产 ID 列表(占位) */
trainingSet: string[];
/** 训练日志片段(占位) */
logs: string[];
/** 当前 step 信息(占位) */
currentStep?: string;
/** 预估剩余时间(秒,占位) */
etaSeconds?: number;
/** 预估成本(占位) */
estCost?: string;
createdAt: number;
updatedAt: number;
}
/** 任务说明卡 IRPhase 1对应 3.3.2 节 Task Card */
export interface TaskIR {
/** 任务类型,如「角色立绘(全身)」 */
taskType: string;
styleId?: string;
characterId?: string;
/** 输出规格如「1024×1536 · 透明背景可选」 */
outputSpec: string;
/** 模板名,如 character_consistent_v1 */
template: string;
/** 候选数 */
candidateCount: number;
/** 用户原始指令,仅展示 */
userPrompt?: string;
}
/** 标注数据 */
export interface Annotation {
id: string;

View File

@@ -1,4 +1,4 @@
# EPEEKit 内网穿透启动脚本
# EPEEKit 内网穿透启动脚本
# 使用 Cloudflare Quick Tunnel无需账号免费
# 用法:在 PowerShell 中执行 .\start-tunnel.ps1
@@ -9,6 +9,12 @@ param(
$ErrorActionPreference = "Stop"
# 优先使用与本脚本同目录的 cloudflared.exe避免未安装 / 未加入 PATH
$LocalCf = Join-Path $PSScriptRoot "cloudflared.exe"
if (Test-Path -LiteralPath $LocalCf) {
$env:Path = "$PSScriptRoot;$env:Path"
}
# 检查 cloudflared 是否可用
try {
$null = Get-Command cloudflared -ErrorAction Stop
@@ -52,14 +58,15 @@ if (-not $backendUrl) {
Write-Host " 后端穿透地址: $backendUrl" -ForegroundColor Green
# 更新前端环境变量
# 更新前端环境变量(避免 PS5.1 对 UTF-8 here-string 解析问题,改用 WriteAllLines
$envFile = Join-Path $PSScriptRoot "frontend\.env.local"
@"
# API start-tunnel.ps1
NEXT_PUBLIC_API_URL=$backendUrl
"@ | Set-Content $envFile -Encoding UTF8
$envLines = @(
"# Backend API URL (updated by start-tunnel.ps1)"
"NEXT_PUBLIC_API_URL=$backendUrl"
)
[System.IO.File]::WriteAllLines($envFile, $envLines, [System.Text.UTF8Encoding]::new($false))
Write-Host " 已更新 frontend\.env.local" -ForegroundColor Green
Write-Host " Updated frontend\.env.local" -ForegroundColor Green
Write-Host ""
# 启动前端穿透
@@ -123,11 +130,12 @@ try {
Stop-Process -Id $frontendTunnel.Id -Force -ErrorAction SilentlyContinue
# 恢复 .env.local 为 localhost
@"
# API
NEXT_PUBLIC_API_URL=http://localhost:8000
"@ | Set-Content $envFile -Encoding UTF8
$restoreLines = @(
"# Backend API URL"
"NEXT_PUBLIC_API_URL=http://localhost:8000"
)
[System.IO.File]::WriteAllLines($envFile, $restoreLines, [System.Text.UTF8Encoding]::new($false))
Write-Host "已恢复 .env.local localhost" -ForegroundColor Green
Write-Host "Restored .env.local to localhost" -ForegroundColor Green
Write-Host "隧道已关闭" -ForegroundColor Green
}