交互原型大版本
This commit is contained in:
@@ -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 1–3 产品形态的前端 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 0–3 完整产品形态,其中 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 — 视觉风格从暗绿底全面转为白底青绿(绢本山水)
|
### [CL-20260416-1600] 2026-04-16 16:00 — 视觉风格从暗绿底全面转为白底青绿(绢本山水)
|
||||||
- **tags**: 前端, 视觉风格, 色彩体系, CSS变量, 白底, 千里江山图
|
- **tags**: 前端, 视觉风格, 色彩体系, CSS变量, 白底, 千里江山图
|
||||||
- **affected_files**:
|
- **affected_files**:
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
|
最近 ~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#2E8B7A,13个文件颜色替换,面板改白色毛玻璃,云雾透明度降低,VISUAL-STYLE-GUIDE.md重写
|
- [CL-20260416-1600] 视觉风格从暗绿底全面转为白底青绿(绢本山水):背景#FAFAF7+文字#1A1A1A+accent#2E8B7A,13个文件颜色替换,面板改白色毛玻璃,云雾透明度降低,VISUAL-STYLE-GUIDE.md重写
|
||||||
- [CL-20260416-1500] UX风格指南实现度补全:涟漪/sendBounce/会话切换动画/Error飘散/云雾加载/流式opacity/排版类使用/左上切角/石青占位等10项全部落地,文档每项标注✅状态+实现文件
|
- [CL-20260416-1500] UX风格指南实现度补全:涟漪/sendBounce/会话切换动画/Error飘散/云雾加载/流式opacity/排版类使用/左上切角/石青占位等10项全部落地,文档每项标注✅状态+实现文件
|
||||||
- [CL-20260416-1330] 交互体验全面升级:新增framer-motion动画引擎,7级排版体系+4级Surface层级+网格底纹科技感,萤火粒子/数据流光/水墨晕染/投石入水/云雾凝聚/萤火点亮等微交互,用户/助手消息视觉拉开差距,统一Loading/空态/Error状态,全组件hover浮起+侧栏竖线+呼吸脉冲,新增UX-STYLE-GUIDE.md文档
|
- [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-0540] 非 vision LLM + 参考图时禁止猜测风格,prompt 只描述内容不写画风,风格交由 IP-Adapter 处理
|
||||||
- [CL-20260413-0520] 多图生成改为单次 API 调用(利用模型原生批量参数),避免速率限制 + 修复空错误信息显示
|
- [CL-20260413-0520] 多图生成改为单次 API 调用(利用模型原生批量参数),避免速率限制 + 修复空错误信息显示
|
||||||
- [CL-20260413-0500] 图片生成后在对话中显示实际使用的模型名称(如"由 Kolors IP-Adapter 生成"),便于模型溯源
|
- [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 个维度)两份文档
|
|
||||||
|
|||||||
@@ -3,6 +3,16 @@
|
|||||||
最近 ~10 次改动的摘要记录,按时间倒序排列。
|
最近 ~10 次改动的摘要记录,按时间倒序排列。
|
||||||
当 Agent 检测到当前任务与近期改动相关时自动读取。
|
当 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 1–3 完整 UI 骨架:新增 /styles /characters /training /settings 四大模块(列表+详情+创建流程),工作台加入风格/角色/规格快选条、高级控制抽屉、候选评估面板、Task Card 组件,资源库加二级过滤(资产类型/风格/角色)+ 语义搜索切换;TopNav 加项目切换器和新导航项;Sidebar 加项目信息卡。所有 Phase 1+ 数据通过新建的 placeholder-store.ts + localStorage 占位持久化(首次注入示例),AppContext 扩充相关字段。所有真实后端行为均为 `alert("(占位)...")`,UI 上以 phase-chip(P1/P2/P3)明示阶段。搜索"(占位)"可快速定位待接入点。
|
||||||
|
|
||||||
### [CL-20260416-1600] 2026-04-16 — 视觉风格从暗绿底全面转为白底青绿(绢本山水)
|
### [CL-20260416-1600] 2026-04-16 — 视觉风格从暗绿底全面转为白底青绿(绢本山水)
|
||||||
- **tags**: 前端, 视觉风格, 色彩体系, CSS变量, 白底, 千里江山图
|
- **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
|
- **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
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后
|
|||||||
|
|
||||||
4. 写入三个文件(按以下顺序):
|
4. 写入三个文件(按以下顺序):
|
||||||
a. 读取 changelog-full.md,在 "## 记录" 下方追加 L1 条目
|
a. 读取 changelog-full.md,在 "## 记录" 下方追加 L1 条目
|
||||||
b. 读取 changelog-recent.md,在顶部插入 L2 条目,如超过 10 条则移除最旧的
|
b. 读取 changelog-recent.md,在顶部插入 L2 条目,如超过 20 条则移除最旧的(与下方「L2 窗口」一致)
|
||||||
c. 读取 changelog-headlines.md,在顶部插入 L3 条目,如超过 50 条则移除最旧的
|
c. 读取 changelog-headlines.md,在顶部插入 L3 条目,如超过 50 条则移除最旧的
|
||||||
|
|
||||||
5. 在回复末尾附一行提示:"[已记录到开发日志]"
|
5. 在回复末尾附一行提示:"[已记录到开发日志]"
|
||||||
@@ -247,3 +247,5 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后
|
|||||||
### 已知必要检查
|
### 已知必要检查
|
||||||
|
|
||||||
1. **大任务收尾遗漏风险** — 当单次任务涉及 5+ 个文件改动时,Agent 容易在输出总结回复后遗漏被动写入流程。应在生成最终回复前,先执行 `changelog-recall.mdc` 中的"任务完成 Checklist",确认三层日志已写入后再输出回复。绝不能"先回复再补写"。
|
1. **大任务收尾遗漏风险** — 当单次任务涉及 5+ 个文件改动时,Agent 容易在输出总结回复后遗漏被动写入流程。应在生成最终回复前,先执行 `changelog-recall.mdc` 中的"任务完成 Checklist",确认三层日志已写入后再输出回复。绝不能"先回复再补写"。
|
||||||
|
|
||||||
|
2. **L3/L2 滚动窗口必须落地** — 操作 A 第 4 步不是「插入即结束」:写入后必须数清条目(L3 为以 `- [` 开头的列表行,L2 为 `### [CL-` 标题行)。L3 超过 50 条、L2 超过 20 条时,必须从**文件底部(最旧)**整段删除条目直至恰好满足上限;禁止长期只追加不修剪。若发现 `changelog-headlines.md` 列表行多于 50,说明上次写入未执行本项,本次补修剪并自检。
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
- **触发场景**: "开发日志"、"changelog"、"最近改了什么"、"回顾改动"、"查看开发记录"、代码改动后自动写入
|
- **触发场景**: "开发日志"、"changelog"、"最近改了什么"、"回顾改动"、"查看开发记录"、代码改动后自动写入
|
||||||
- **输出**: .cursor/changelog/ 下的 changelog-full.md、changelog-recent.md、changelog-headlines.md
|
- **输出**: .cursor/changelog/ 下的 changelog-full.md、changelog-recent.md、changelog-headlines.md
|
||||||
- **路径**: .cursor/skills/dev-changelog/SKILL.md
|
- **路径**: .cursor/skills/dev-changelog/SKILL.md
|
||||||
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklist);L3 概要每次会话自动注入上下文;stop Hook 双保险兜底
|
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklist);L3 概要每次会话自动注入上下文;stop Hook 双保险兜底;写入后须维护 L3≤50 / L2≤20 滚动窗口(见 Skill 自迭代检查项)
|
||||||
|
|
||||||
### pitfall-journal
|
### pitfall-journal
|
||||||
- **类型**: 基础设施
|
- **类型**: 基础设施
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -34,6 +34,8 @@ out/
|
|||||||
art-agent/backend/uploads/
|
art-agent/backend/uploads/
|
||||||
art-agent/backend/generated/
|
art-agent/backend/generated/
|
||||||
art-agent/backend/data/
|
art-agent/backend/data/
|
||||||
|
# 本地下载的 cloudflared CLI(体积大,见 README / start-tunnel.ps1)
|
||||||
|
art-agent/cloudflared.exe
|
||||||
|
|
||||||
# ─── 工具二进制 ──────────────────────────────────────────
|
# ─── 工具二进制 ──────────────────────────────────────────
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
511
art-agent/frontend/src/app/characters/[id]/page.tsx
Normal file
511
art-agent/frontend/src/app/characters/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
art-agent/frontend/src/app/characters/page.tsx
Normal file
217
art-agent/frontend/src/app/characters/page.tsx
Normal 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">试试调整筛选条件或点击“新建角色”</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,17 +12,30 @@ import type { ImageAsset } from "@/lib/types";
|
|||||||
|
|
||||||
type ViewMode = "grid" | "list";
|
type ViewMode = "grid" | "list";
|
||||||
type SortBy = "time" | "name";
|
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() {
|
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 [viewMode, setViewMode] = useState<ViewMode>("grid");
|
||||||
const [sortBy, setSortBy] = useState<SortBy>("time");
|
const [sortBy, setSortBy] = useState<SortBy>("time");
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchMode, setSearchMode] = useState<SearchMode>("keyword");
|
||||||
const [showFavOnly, setShowFavOnly] = useState(false);
|
const [showFavOnly, setShowFavOnly] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [detailAsset, setDetailAsset] = useState<ImageAsset | null>(null);
|
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]));
|
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||||
|
|
||||||
@@ -39,7 +52,37 @@ export default function GalleryPage() {
|
|||||||
|
|
||||||
if (searchQuery.trim()) {
|
if (searchQuery.trim()) {
|
||||||
const q = searchQuery.trim().toLowerCase();
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
if (searchMode === "keyword") {
|
||||||
result = result.filter((a) => a.prompt.toLowerCase().includes(q));
|
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) => {
|
result.sort((a, b) => {
|
||||||
@@ -48,7 +91,8 @@ export default function GalleryPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [assets, showFavOnly, selectedTagIds, searchQuery, sortBy]);
|
}, [assets, showFavOnly, selectedTagIds, searchQuery, searchMode, sortBy,
|
||||||
|
selectedAssetType, selectedStyleId, selectedCharacterId, stylePacks, characters]);
|
||||||
|
|
||||||
const toggleSelect = (id: string) => {
|
const toggleSelect = (id: string) => {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
@@ -144,11 +188,20 @@ export default function GalleryPage() {
|
|||||||
<input
|
<input
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="搜索 Prompt..."
|
placeholder={searchMode === "semantic" ? "语义搜索(占位)..." : "搜索 Prompt..."}
|
||||||
className="flex-1 bg-transparent border-none text-sm
|
className="flex-1 bg-transparent border-none text-sm
|
||||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||||
focus:outline-none"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 标签筛选 */}
|
{/* 标签筛选 */}
|
||||||
@@ -238,6 +291,84 @@ export default function GalleryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{selectedIds.size > 0 && (
|
||||||
<div className="flex-shrink-0 px-5 py-2.5 bg-[var(--accent)]/5 border-b border-[var(--accent)]/20
|
<div className="flex-shrink-0 px-5 py-2.5 bg-[var(--accent)]/5 border-b border-[var(--accent)]/20
|
||||||
|
|||||||
@@ -543,6 +543,52 @@ select option {
|
|||||||
animation: mistCondense 1.5s ease-out forwards, breathPulse 2s ease-in-out 1.5s infinite;
|
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 兼容 ===== */
|
/* ===== 100dvh 兼容 ===== */
|
||||||
@supports (height: 100dvh) {
|
@supports (height: 100dvh) {
|
||||||
.h-screen {
|
.h-screen {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { useCallback, useEffect, useRef, useState, type DragEvent } from "react"
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { ChatMessages } from "@/components/chat/chat-messages";
|
import { ChatMessages } from "@/components/chat/chat-messages";
|
||||||
import { ChatInput, type ChatInputHandle } from "@/components/chat/chat-input";
|
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 { Sidebar } from "@/components/sidebar/sidebar";
|
||||||
import { TopNav } from "@/components/layout/top-nav";
|
import { TopNav } from "@/components/layout/top-nav";
|
||||||
import { ImageDetailPanel } from "@/components/detail/image-detail-panel";
|
import { ImageDetailPanel } from "@/components/detail/image-detail-panel";
|
||||||
@@ -44,6 +47,7 @@ export default function Home() {
|
|||||||
const prevSessionId = useRef<string | null>(null);
|
const prevSessionId = useRef<string | null>(null);
|
||||||
const isSwitching = useRef(false);
|
const isSwitching = useRef(false);
|
||||||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||||
|
const [showCandidates, setShowCandidates] = useState(false);
|
||||||
|
|
||||||
const scrollToBottom = useCallback((instant?: boolean) => {
|
const scrollToBottom = useCallback((instant?: boolean) => {
|
||||||
if (isSwitching.current && !instant) return;
|
if (isSwitching.current && !instant) return;
|
||||||
@@ -460,6 +464,33 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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
|
<ChatInput
|
||||||
ref={chatInputRef}
|
ref={chatInputRef}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
@@ -482,6 +513,24 @@ export default function Home() {
|
|||||||
<ImageDetailPanel onAnnotationComplete={handleAnnotationComplete} />
|
<ImageDetailPanel onAnnotationComplete={handleAnnotationComplete} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
308
art-agent/frontend/src/app/settings/page.tsx
Normal file
308
art-agent/frontend/src/app/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
464
art-agent/frontend/src/app/styles/[id]/page.tsx
Normal file
464
art-agent/frontend/src/app/styles/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
art-agent/frontend/src/app/styles/page.tsx
Normal file
251
art-agent/frontend/src/app/styles/page.tsx
Normal 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">试试调整筛选条件或点击“新建风格”</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
347
art-agent/frontend/src/app/training/[id]/page.tsx
Normal file
347
art-agent/frontend/src/app/training/[id]/page.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { TopNav } from "@/components/layout/top-nav";
|
||||||
|
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||||
|
import { useApp } from "@/lib/app-context";
|
||||||
|
import type { TrainingTask, TrainingTaskStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<TrainingTaskStatus, string> = {
|
||||||
|
queued: "排队中",
|
||||||
|
running: "训练中",
|
||||||
|
completed: "已完成",
|
||||||
|
failed: "已失败",
|
||||||
|
deprecated: "已弃用",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<TrainingTaskStatus, string> = {
|
||||||
|
queued: "#6B7B8A",
|
||||||
|
running: "#2E8B7A",
|
||||||
|
completed: "#3A7FB8",
|
||||||
|
failed: "#C4654A",
|
||||||
|
deprecated: "#B8935A",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TrainingDetailPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const {
|
||||||
|
trainingTasks,
|
||||||
|
upsertTrainingTask,
|
||||||
|
deleteTrainingTask,
|
||||||
|
stylePacks,
|
||||||
|
characters,
|
||||||
|
} = useApp();
|
||||||
|
|
||||||
|
const [task, setTask] = useState<TrainingTask | null>(null);
|
||||||
|
const [logOpen, setLogOpen] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = trainingTasks.find((x) => x.id === params.id);
|
||||||
|
setTask(t ?? null);
|
||||||
|
}, [params.id, trainingTasks]);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col">
|
||||||
|
<TopNav />
|
||||||
|
<main className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="typo-caption mb-2">未找到该训练任务</p>
|
||||||
|
<Link href="/training" className="text-sm text-[var(--accent)] hover:underline">
|
||||||
|
返回训练中心
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source =
|
||||||
|
task.type === "style_lora"
|
||||||
|
? stylePacks.find((s) => s.id === task.sourceId)
|
||||||
|
: characters.find((c) => c.id === task.sourceId);
|
||||||
|
|
||||||
|
const handleUse = () => {
|
||||||
|
alert(`(占位)已绑定此 LoRA 到当前会话:${task.name}`);
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetrain = () => {
|
||||||
|
alert("(占位)将基于当前训练集重新启动训练任务。");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeprecate = () => {
|
||||||
|
if (!confirm("确认弃用该模型?")) return;
|
||||||
|
upsertTrainingTask({ ...task, status: "deprecated" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!confirm("确认删除该训练任务?")) return;
|
||||||
|
deleteTrainingTask(task.id);
|
||||||
|
router.push("/training");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col">
|
||||||
|
<AmbientParticles count={12} />
|
||||||
|
<TopNav />
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||||
|
{/* 面包屑 */}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] mb-4">
|
||||||
|
<Link href="/training" className="hover:text-[var(--accent)] cursor-pointer">
|
||||||
|
训练中心
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-[var(--text-primary)]">{task.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4 mb-5">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||||
|
<h1 className="typo-h1">{task.name}</h1>
|
||||||
|
<span
|
||||||
|
className="text-[10px] px-2 py-0.5 rounded-md font-medium"
|
||||||
|
style={{
|
||||||
|
background: STATUS_COLOR[task.status] + "22",
|
||||||
|
color: STATUS_COLOR[task.status],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[task.status]}
|
||||||
|
</span>
|
||||||
|
<span className="phase-chip">Phase 2</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{task.type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
|
||||||
|
{source && (
|
||||||
|
<>
|
||||||
|
{" · 来源:"}
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
task.type === "style_lora"
|
||||||
|
? `/styles/${task.sourceId}`
|
||||||
|
: `/characters/${task.sourceId}`
|
||||||
|
}
|
||||||
|
className="text-[var(--accent)] hover:underline"
|
||||||
|
>
|
||||||
|
{source.name}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{" · 模板:"}
|
||||||
|
{task.template}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{task.status === "completed" && (
|
||||||
|
<button
|
||||||
|
onClick={handleUse}
|
||||||
|
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||||
|
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||||
|
hover:bg-[var(--accent-hover)]
|
||||||
|
cursor-pointer btn-hover-lift transition-all"
|
||||||
|
>
|
||||||
|
使用该模型
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{task.status === "failed" && (
|
||||||
|
<button
|
||||||
|
onClick={handleRetrain}
|
||||||
|
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||||
|
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||||
|
text-[var(--text-primary)] hover:border-[var(--accent)]/40
|
||||||
|
cursor-pointer btn-hover-lift transition-all"
|
||||||
|
>
|
||||||
|
重新训练
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{task.status !== "deprecated" && task.status !== "queued" && (
|
||||||
|
<button
|
||||||
|
onClick={handleDeprecate}
|
||||||
|
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||||
|
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||||
|
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||||
|
cursor-pointer btn-hover-lift transition-all"
|
||||||
|
>
|
||||||
|
弃用
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||||
|
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||||
|
text-[var(--hot)]/60 hover:text-[var(--hot)]
|
||||||
|
hover:border-[var(--hot)]/40
|
||||||
|
cursor-pointer btn-hover-lift transition-all"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
{task.status === "running" && (
|
||||||
|
<div className="rounded-xl surface-2 p-4 mb-5">
|
||||||
|
<div className="flex items-center justify-between mb-2 text-xs">
|
||||||
|
<span className="typo-strong">{task.currentStep ?? "训练中..."}</span>
|
||||||
|
<span className="text-[var(--accent)] font-medium">{task.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-[var(--accent)] to-[var(--accent-secondary)] transition-all"
|
||||||
|
style={{ width: `${task.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{task.etaSeconds && (
|
||||||
|
<div className="text-[10px] text-[var(--text-secondary)] mt-1.5">
|
||||||
|
预计剩余 {Math.floor(task.etaSeconds / 60)} 分 {task.etaSeconds % 60} 秒(占位)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||||
|
{/* 左:日志 + 训练前后对比 + 训练集预览 */}
|
||||||
|
<div className="lg:col-span-2 space-y-5">
|
||||||
|
{/* 训练前后对比 */}
|
||||||
|
<section className="rounded-xl surface-2 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="typo-h2">训练前后效果对比</h2>
|
||||||
|
<span className="phase-chip">P2</span>
|
||||||
|
</div>
|
||||||
|
{task.status === "completed" ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>训练前</div>
|
||||||
|
<div className="aspect-square rounded-lg placeholder-card
|
||||||
|
flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
||||||
|
占位对比图
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>训练后</div>
|
||||||
|
<div className="aspect-square rounded-lg placeholder-card
|
||||||
|
flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
||||||
|
占位对比图
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] py-6 text-center">
|
||||||
|
训练完成后将显示同一 Prompt 下的前后对比
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 训练日志 */}
|
||||||
|
<section className="rounded-xl surface-2 overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setLogOpen(!logOpen)}
|
||||||
|
className="w-full px-4 py-2.5 flex items-center justify-between
|
||||||
|
hover:bg-[var(--bg-tertiary)]/50 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<span className="typo-strong text-xs">训练日志</span>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
className={`transition-transform ${logOpen ? "" : "rotate-180"}`}>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{logOpen && (
|
||||||
|
<div className="border-t border-[var(--border)] p-3 bg-[var(--bg-primary)]/40 font-mono text-[11px] max-h-60 overflow-y-auto">
|
||||||
|
{task.logs.length === 0 ? (
|
||||||
|
<p className="text-[var(--text-secondary)]">尚无日志输出</p>
|
||||||
|
) : (
|
||||||
|
task.logs.map((l, i) => (
|
||||||
|
<div key={i} className="text-[var(--text-secondary)] leading-relaxed">
|
||||||
|
{l}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 训练集预览 */}
|
||||||
|
<section>
|
||||||
|
<h2 className="typo-h2 mb-2">训练集预览</h2>
|
||||||
|
<p className="typo-caption mb-3">
|
||||||
|
训练集共 {task.trainingSet.length} 张图片(占位缩略)。
|
||||||
|
</p>
|
||||||
|
{task.trainingSet.length === 0 ? (
|
||||||
|
<div className="py-8 text-center placeholder-card rounded-xl text-xs text-[var(--text-secondary)]">
|
||||||
|
训练集未填充
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-4 md:grid-cols-6 gap-2">
|
||||||
|
{task.trainingSet.slice(0, 12).map((_, i) => (
|
||||||
|
<div key={i}
|
||||||
|
className="aspect-square rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||||
|
flex items-center justify-center text-[9px] text-[var(--text-secondary)]">
|
||||||
|
占位
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右:版本 + 成本 + 操作入口 */}
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<div className="placeholder-card rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="typo-strong text-xs">模型版本</span>
|
||||||
|
<span className="phase-chip">P2</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||||
|
<div>当前版本:v1.0(占位)</div>
|
||||||
|
<div>历史版本:v0.9、v0.8(占位)</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => alert("(占位)回滚到上一版本")}
|
||||||
|
className="mt-2 text-xs text-[var(--accent)] hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
回滚到上一版本 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl surface-2 p-4">
|
||||||
|
<span className="typo-strong text-xs block mb-2">训练信息</span>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||||
|
<div>模板:{task.template}</div>
|
||||||
|
<div>预估成本:{task.estCost ?? "—"}</div>
|
||||||
|
<div>创建时间:{new Date(task.createdAt).toLocaleString("zh-CN")}</div>
|
||||||
|
<div>更新时间:{new Date(task.updatedAt).toLocaleString("zh-CN")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{source && (
|
||||||
|
<div className="rounded-xl surface-2 p-4">
|
||||||
|
<span className="typo-strong text-xs block mb-2">来源</span>
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
task.type === "style_lora"
|
||||||
|
? `/styles/${task.sourceId}`
|
||||||
|
: `/characters/${task.sourceId}`
|
||||||
|
}
|
||||||
|
className="text-xs text-[var(--accent)] hover:underline block truncate"
|
||||||
|
>
|
||||||
|
{source.name}
|
||||||
|
</Link>
|
||||||
|
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
|
||||||
|
{task.type === "style_lora" ? "风格集" : "角色卡"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
353
art-agent/frontend/src/app/training/new/page.tsx
Normal file
353
art-agent/frontend/src/app/training/new/page.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo, Suspense } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { TopNav } from "@/components/layout/top-nav";
|
||||||
|
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||||
|
import { useApp } from "@/lib/app-context";
|
||||||
|
import { generateId } from "@/lib/store";
|
||||||
|
import type { TrainingTask, TrainingTaskType } from "@/lib/types";
|
||||||
|
|
||||||
|
type WizardStep = 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
|
const TEMPLATES = [
|
||||||
|
{ id: "sdxl_lora", name: "SDXL LoRA", desc: "基于 SDXL 底模,兼顾速度与效果(占位)" },
|
||||||
|
{ id: "flux_lora", name: "Flux LoRA", desc: "基于 Flux 底模,细节更精细(占位)" },
|
||||||
|
{ id: "sd15_lora", name: "SD 1.5 LoRA", desc: "轻量训练,适合像素/卡通(占位)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function TrainingWizardInner() {
|
||||||
|
const router = useRouter();
|
||||||
|
const search = useSearchParams();
|
||||||
|
const { stylePacks, characters, upsertTrainingTask } = useApp();
|
||||||
|
|
||||||
|
const initType = (search.get("type") as TrainingTaskType) || "style_lora";
|
||||||
|
const initSourceId = search.get("sourceId") ?? "";
|
||||||
|
|
||||||
|
const [step, setStep] = useState<WizardStep>(1);
|
||||||
|
const [type, setType] = useState<TrainingTaskType>(initType);
|
||||||
|
const [sourceId, setSourceId] = useState(initSourceId);
|
||||||
|
const [template, setTemplate] = useState("sdxl_lora");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [trainingSetSize, setTrainingSetSize] = useState(12);
|
||||||
|
|
||||||
|
const sources = type === "style_lora" ? stylePacks : characters;
|
||||||
|
|
||||||
|
const selectedSource = useMemo(
|
||||||
|
() => sources.find((s) => s.id === sourceId),
|
||||||
|
[sources, sourceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const canNext = useMemo(() => {
|
||||||
|
if (step === 1) return !!sourceId;
|
||||||
|
if (step === 2) return !!template && trainingSetSize > 0;
|
||||||
|
if (step === 3) return true;
|
||||||
|
return true;
|
||||||
|
}, [step, sourceId, template, trainingSetSize]);
|
||||||
|
|
||||||
|
const defaultName = useMemo(() => {
|
||||||
|
if (!selectedSource) return "";
|
||||||
|
return `${selectedSource.name} ${type === "style_lora" ? "风格" : "角色"} LoRA v1`;
|
||||||
|
}, [selectedSource, type]);
|
||||||
|
|
||||||
|
const finalName = name.trim() || defaultName;
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const t: TrainingTask = {
|
||||||
|
id: generateId("train-"),
|
||||||
|
name: finalName || "未命名训练任务",
|
||||||
|
type,
|
||||||
|
status: "queued",
|
||||||
|
progress: 0,
|
||||||
|
sourceId,
|
||||||
|
template,
|
||||||
|
trainingSet: Array.from({ length: trainingSetSize }).map((_, i) => `asset-pick-${i + 1}`),
|
||||||
|
logs: ["[占位] 训练任务已提交,排队中..."],
|
||||||
|
estCost: "≈ ¥18(占位)",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
upsertTrainingTask(t);
|
||||||
|
alert("(占位)训练任务已提交。真实训练能力尚未接入。");
|
||||||
|
router.push(`/training/${t.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepLabels = ["选择来源", "训练配置", "数据集校验", "提交确认"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col">
|
||||||
|
<AmbientParticles count={10} />
|
||||||
|
<TopNav />
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||||
|
{/* 面包屑 */}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] mb-4">
|
||||||
|
<Link href="/training" className="hover:text-[var(--accent)] cursor-pointer">
|
||||||
|
训练中心
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-[var(--text-primary)]">新建训练任务</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-5">
|
||||||
|
<h1 className="typo-h1">新建训练任务</h1>
|
||||||
|
<span className="phase-chip">Phase 2</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
{stepLabels.map((label, i) => {
|
||||||
|
const n = (i + 1) as WizardStep;
|
||||||
|
const active = step === n;
|
||||||
|
const done = step > n;
|
||||||
|
return (
|
||||||
|
<div key={label} className="flex-1 flex items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-medium
|
||||||
|
transition-all ${
|
||||||
|
active
|
||||||
|
? "bg-[var(--accent)] text-[var(--bg-primary)]"
|
||||||
|
: done
|
||||||
|
? "bg-[var(--accent)]/20 text-[var(--accent)]"
|
||||||
|
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{done ? "✓" : n}
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs hidden md:inline ${active ? "text-[var(--text-primary)] font-medium" : "text-[var(--text-secondary)]"}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{i < stepLabels.length - 1 && (
|
||||||
|
<div className={`flex-1 h-[1px] mx-2 ${done ? "bg-[var(--accent)]/40" : "bg-[var(--border)]"}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl surface-2 p-5 mb-5">
|
||||||
|
{/* Step 1: 选择来源 */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练类型</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(["style_lora", "character_lora"] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => {
|
||||||
|
setType(t);
|
||||||
|
setSourceId("");
|
||||||
|
}}
|
||||||
|
className={`flex-1 px-3 py-2.5 text-sm rounded-lg border cursor-pointer transition-all ${
|
||||||
|
type === t
|
||||||
|
? "border-[var(--accent)] bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
|
||||||
|
: "border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>
|
||||||
|
选择{type === "style_lora" ? "风格集" : "角色卡"}来源
|
||||||
|
</label>
|
||||||
|
{sources.length === 0 ? (
|
||||||
|
<div className="placeholder-card rounded-lg p-6 text-center text-xs text-[var(--text-secondary)]">
|
||||||
|
暂无可用{type === "style_lora" ? "风格集" : "角色卡"},请先创建
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-72 overflow-y-auto">
|
||||||
|
{sources.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setSourceId(s.id)}
|
||||||
|
className={`text-left px-3 py-2.5 rounded-lg border cursor-pointer transition-all ${
|
||||||
|
sourceId === s.id
|
||||||
|
? "border-[var(--accent)] bg-[var(--accent)]/8"
|
||||||
|
: "border-[var(--border)] hover:border-[var(--accent)]/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm text-[var(--text-primary)] font-medium truncate">{s.name}</div>
|
||||||
|
<div className="text-[10px] text-[var(--text-secondary)] truncate mt-0.5">
|
||||||
|
{s.description || "无描述"}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: 训练配置 */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练模板</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{TEMPLATES.map((tpl) => (
|
||||||
|
<button
|
||||||
|
key={tpl.id}
|
||||||
|
onClick={() => setTemplate(tpl.id)}
|
||||||
|
className={`w-full text-left px-3 py-2.5 rounded-lg border cursor-pointer transition-all ${
|
||||||
|
template === tpl.id
|
||||||
|
? "border-[var(--accent)] bg-[var(--accent)]/8"
|
||||||
|
: "border-[var(--border)] hover:border-[var(--accent)]/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-[var(--text-primary)] font-medium">{tpl.name}</span>
|
||||||
|
<span className="phase-chip">P2</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{tpl.desc}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>
|
||||||
|
训练轮次(占位) — 当前示例为模拟配置项
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{["快速 (3 epoch)", "标准 (6 epoch)", "深度 (10 epoch)"].map((opt, i) => (
|
||||||
|
<button
|
||||||
|
key={opt}
|
||||||
|
onClick={() => {}}
|
||||||
|
className={`px-2.5 py-2 text-xs rounded-lg border cursor-pointer ${
|
||||||
|
i === 1
|
||||||
|
? "border-[var(--accent)] bg-[var(--accent)]/8 text-[var(--accent)]"
|
||||||
|
: "border-[var(--border)] text-[var(--text-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: 数据集校验 */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练集规模(占位)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={200}
|
||||||
|
value={trainingSetSize}
|
||||||
|
onChange={(e) => setTrainingSetSize(parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||||
|
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-[var(--text-secondary)] mt-1.5">
|
||||||
|
建议 8–30 张(占位)。当前为模拟值,真实训练集选择待 Phase 2。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="placeholder-card rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="typo-strong text-xs">自动校验</span>
|
||||||
|
<span className="text-[10px] text-[var(--accent)]">✓ 通过(占位)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="text-[11px] text-[var(--text-secondary)] space-y-1">
|
||||||
|
<li>· 图片数量:{trainingSetSize} 张(符合建议范围)</li>
|
||||||
|
<li>· 分辨率:多数 ≥ 768px(占位)</li>
|
||||||
|
<li>· 重复检测:未发现重复图(占位)</li>
|
||||||
|
<li>· NSFW 扫描:未检出(占位)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: 提交确认 */}
|
||||||
|
{step === 4 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>任务名称</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={defaultName || "请先选择来源"}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||||
|
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg surface-2 p-3 text-xs text-[var(--text-secondary)] space-y-1">
|
||||||
|
<div>类型:<span className="text-[var(--text-primary)]">{type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}</span></div>
|
||||||
|
<div>来源:<span className="text-[var(--text-primary)]">{selectedSource?.name ?? "—"}</span></div>
|
||||||
|
<div>模板:<span className="text-[var(--text-primary)]">{template}</span></div>
|
||||||
|
<div>训练集:<span className="text-[var(--text-primary)]">{trainingSetSize} 张(占位)</span></div>
|
||||||
|
<div>预估成本:<span className="text-[var(--text-primary)]">≈ ¥18(占位)</span></div>
|
||||||
|
<div>预估耗时:<span className="text-[var(--text-primary)]">≈ 20 分钟(占位)</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="placeholder-card rounded-lg p-3 text-[11px] text-[var(--text-secondary)]">
|
||||||
|
提交后会进入排队队列。该页面所有交互为占位演示,提交不会产生真实训练调用。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav buttons */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (step === 1) router.push("/training");
|
||||||
|
else setStep((step - 1) as WizardStep);
|
||||||
|
}}
|
||||||
|
className="px-3.5 py-2 text-xs rounded-xl border border-[var(--border)]
|
||||||
|
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||||
|
hover:text-[var(--text-primary)] cursor-pointer btn-hover-lift transition-all"
|
||||||
|
>
|
||||||
|
{step === 1 ? "取消" : "上一步"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{step < 4 ? (
|
||||||
|
<button
|
||||||
|
onClick={() => canNext && setStep((step + 1) as WizardStep)}
|
||||||
|
disabled={!canNext}
|
||||||
|
className="px-4 py-2 text-xs rounded-xl font-medium
|
||||||
|
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||||
|
hover:bg-[var(--accent-hover)] disabled:opacity-40 disabled:cursor-not-allowed
|
||||||
|
cursor-pointer btn-hover-lift transition-all"
|
||||||
|
>
|
||||||
|
下一步
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="px-4 py-2 text-xs rounded-xl font-medium
|
||||||
|
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||||
|
hover:bg-[var(--accent-hover)]
|
||||||
|
cursor-pointer btn-hover-lift transition-all"
|
||||||
|
>
|
||||||
|
提交训练任务
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrainingWizardPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="h-screen" />}>
|
||||||
|
<TrainingWizardInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
art-agent/frontend/src/app/training/page.tsx
Normal file
186
art-agent/frontend/src/app/training/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { TopNav } from "@/components/layout/top-nav";
|
||||||
|
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||||
|
import { useApp } from "@/lib/app-context";
|
||||||
|
import { staggerContainer, staggerItem } from "@/components/ui/motion-presets";
|
||||||
|
import type { TrainingTask, TrainingTaskStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<TrainingTaskStatus, string> = {
|
||||||
|
queued: "排队中",
|
||||||
|
running: "训练中",
|
||||||
|
completed: "已完成",
|
||||||
|
failed: "已失败",
|
||||||
|
deprecated: "已弃用",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<TrainingTaskStatus, string> = {
|
||||||
|
queued: "#6B7B8A",
|
||||||
|
running: "#2E8B7A",
|
||||||
|
completed: "#3A7FB8",
|
||||||
|
failed: "#C4654A",
|
||||||
|
deprecated: "#B8935A",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TrainingPage() {
|
||||||
|
const { trainingTasks, stylePacks, characters } = useApp();
|
||||||
|
const [filter, setFilter] = useState<"all" | TrainingTaskStatus>("all");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (filter === "all") return trainingTasks;
|
||||||
|
return trainingTasks.filter((t) => t.status === filter);
|
||||||
|
}, [trainingTasks, filter]);
|
||||||
|
|
||||||
|
const sourceName = (t: TrainingTask) => {
|
||||||
|
if (!t.sourceId) return "—";
|
||||||
|
if (t.type === "style_lora") {
|
||||||
|
return stylePacks.find((s) => s.id === t.sourceId)?.name ?? t.sourceId;
|
||||||
|
}
|
||||||
|
return characters.find((c) => c.id === t.sourceId)?.name ?? t.sourceId;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col relative z-[1]">
|
||||||
|
<AmbientParticles count={14} />
|
||||||
|
<TopNav />
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<h1 className="typo-h1">训练中心</h1>
|
||||||
|
<span className="phase-chip">Phase 2</span>
|
||||||
|
</div>
|
||||||
|
<p className="typo-caption">
|
||||||
|
风格 / 角色 LoRA 训练任务管理。当前阶段均为占位数据,真实训练能力待 Phase 2 交付。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/training/new"
|
||||||
|
className="flex items-center gap-1.5 px-3.5 py-2 text-sm rounded-xl font-medium
|
||||||
|
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||||
|
hover:bg-[var(--accent-hover)]
|
||||||
|
hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
|
||||||
|
transition-all cursor-pointer btn-hover-lift flex-shrink-0"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
新建训练任务
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态筛选 */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-5 overflow-x-auto hide-scrollbar">
|
||||||
|
{(["all", "queued", "running", "completed", "failed", "deprecated"] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setFilter(s)}
|
||||||
|
className={`px-2.5 py-1 text-xs rounded-lg cursor-pointer transition-all whitespace-nowrap ${
|
||||||
|
filter === s
|
||||||
|
? "bg-[var(--accent)]/10 text-[var(--accent)] font-medium border border-[var(--accent)]/30"
|
||||||
|
: "border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s === "all" ? "全部" : STATUS_LABEL[s]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 任务表格 */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="text-center py-20 placeholder-card rounded-xl">
|
||||||
|
<p className="typo-display text-lg mb-2">暂无训练任务</p>
|
||||||
|
<p className="typo-caption">点击“新建训练任务”开始</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
variants={staggerContainer}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="rounded-xl surface-2 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* 表头(桌面端) */}
|
||||||
|
<div className="hidden md:grid grid-cols-[2fr_1fr_1fr_1.5fr_1fr_auto] gap-3 px-4 py-2.5
|
||||||
|
border-b border-[var(--border)] text-[10px] typo-micro"
|
||||||
|
style={{ textTransform: "none" }}>
|
||||||
|
<span>任务名称</span>
|
||||||
|
<span>类型</span>
|
||||||
|
<span>来源</span>
|
||||||
|
<span>状态 / 进度</span>
|
||||||
|
<span>创建时间</span>
|
||||||
|
<span className="w-10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.map((t) => (
|
||||||
|
<motion.div
|
||||||
|
key={t.id}
|
||||||
|
variants={staggerItem}
|
||||||
|
className="border-b border-[var(--border)] last:border-b-0
|
||||||
|
hover:bg-[var(--accent)]/5 transition-colors"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/training/${t.id}`}
|
||||||
|
className="block px-4 py-3 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="md:grid md:grid-cols-[2fr_1fr_1fr_1.5fr_1fr_auto] md:gap-3 md:items-center
|
||||||
|
flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||||
|
{t.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{t.type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] truncate">
|
||||||
|
{sourceName(t)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span
|
||||||
|
className="flex-shrink-0 text-[10px] px-2 py-0.5 rounded-md font-medium"
|
||||||
|
style={{
|
||||||
|
background: STATUS_COLOR[t.status] + "22",
|
||||||
|
color: STATUS_COLOR[t.status],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[t.status]}
|
||||||
|
</span>
|
||||||
|
{t.status === "running" && (
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-[var(--accent)] transition-all"
|
||||||
|
style={{ width: `${t.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[var(--accent)] flex-shrink-0">
|
||||||
|
{t.progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{new Date(t.createdAt).toLocaleDateString("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" strokeWidth="2"
|
||||||
|
className="text-[var(--text-secondary)] hidden md:block">
|
||||||
|
<path d="M9 18l6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
art-agent/frontend/src/components/chat/session-quick-picks.tsx
Normal file
257
art-agent/frontend/src/components/chat/session-quick-picks.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
art-agent/frontend/src/components/chat/task-card.tsx
Normal file
108
art-agent/frontend/src/components/chat/task-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
art-agent/frontend/src/components/layout/project-switcher.tsx
Normal file
165
art-agent/frontend/src/components/layout/project-switcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,12 +7,14 @@ import { usePathname } from "next/navigation";
|
|||||||
import { useApp } from "@/lib/app-context";
|
import { useApp } from "@/lib/app-context";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import { ProfileModal } from "@/components/profile-modal";
|
import { ProfileModal } from "@/components/profile-modal";
|
||||||
|
import { ProjectSwitcher } from "./project-switcher";
|
||||||
import { slideUp } from "@/components/ui/motion-presets";
|
import { slideUp } from "@/components/ui/motion-presets";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{
|
{
|
||||||
href: "/",
|
href: "/",
|
||||||
label: "对话",
|
label: "工作台",
|
||||||
|
phase: "Phase 0",
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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" />
|
<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",
|
href: "/gallery",
|
||||||
label: "资源库",
|
label: "资源库",
|
||||||
|
phase: "Phase 0",
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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" />
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
@@ -30,6 +33,41 @@ const NAV_ITEMS = [
|
|||||||
</svg>
|
</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;
|
] as const;
|
||||||
|
|
||||||
export function TopNav() {
|
export function TopNav() {
|
||||||
@@ -53,7 +91,7 @@ export function TopNav() {
|
|||||||
|
|
||||||
return (
|
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
|
<button
|
||||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
@@ -79,27 +117,42 @@ export function TopNav() {
|
|||||||
<circle cx="23" cy="22.5" r="1" fill="white" />
|
<circle cx="23" cy="22.5" r="1" fill="white" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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
|
EPEEKit
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* 项目切换器 */}
|
||||||
|
<ProjectSwitcher />
|
||||||
|
|
||||||
{/* 导航 Tab */}
|
{/* 导航 Tab */}
|
||||||
<nav className="flex items-center gap-1.5">
|
<nav className="flex items-center gap-1 overflow-x-auto hide-scrollbar">
|
||||||
{NAV_ITEMS.map(({ href, label, icon }) => {
|
{NAV_ITEMS.map(({ href, label, icon, phase }) => {
|
||||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
href={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
|
active
|
||||||
? "glow-border text-[var(--accent)] font-medium !bg-[var(--bg-secondary)]"
|
? "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)]"
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent hover:border-[var(--border-glow)]"
|
||||||
}`}
|
}`}
|
||||||
|
title={`${label}(${phase})`}
|
||||||
>
|
>
|
||||||
{icon}
|
{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>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -112,7 +165,7 @@ export function TopNav() {
|
|||||||
<div className="relative" ref={menuRef}>
|
<div className="relative" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
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)]
|
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||||
hover:bg-[var(--bg-tertiary)] border border-transparent
|
hover:bg-[var(--bg-tertiary)] border border-transparent
|
||||||
hover:border-[var(--border)] transition-all cursor-pointer"
|
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">
|
flex items-center justify-center text-[var(--accent)] text-xs font-semibold">
|
||||||
{(user.display_name || user.username).charAt(0).toUpperCase()}
|
{(user.display_name || user.username).charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden sm:inline max-w-[100px] truncate">
|
<span className="hidden sm:inline max-w-[80px] truncate">
|
||||||
{user.display_name || user.username}
|
{user.display_name || user.username}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -132,29 +185,72 @@ export function TopNav() {
|
|||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
|
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"
|
surface-3 py-1.5 z-50"
|
||||||
>
|
>
|
||||||
<button
|
<div className="px-3.5 py-2 text-xs text-[var(--text-secondary)]
|
||||||
onClick={() => { setMenuOpen(false); setProfileOpen(true); }}
|
border-b border-[var(--border)] flex items-center gap-1.5">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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" />
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||||
<circle cx="12" cy="7" r="4" />
|
<circle cx="12" cy="7" r="4" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{user.username}</span>
|
<span className="truncate">{user.username}</span>
|
||||||
{user.is_admin && <span className="text-[var(--accent)]">(管理员)</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>
|
</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
|
<button
|
||||||
onClick={() => { setMenuOpen(false); logout(); }}
|
onClick={() => { setMenuOpen(false); logout(); }}
|
||||||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-secondary)]
|
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)]
|
hover:text-[var(--hot)] hover:bg-[var(--bg-tertiary)]
|
||||||
transition-colors cursor-pointer"
|
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>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
104
art-agent/frontend/src/components/sidebar/project-card.tsx
Normal file
104
art-agent/frontend/src/components/sidebar/project-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useApp } from "@/lib/app-context";
|
import { useApp } from "@/lib/app-context";
|
||||||
import { TagFilter } from "./tag-filter";
|
import { TagFilter } from "./tag-filter";
|
||||||
import { SessionList } from "./session-list";
|
import { SessionList } from "./session-list";
|
||||||
|
import { ProjectCard } from "./project-card";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
|
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
|
||||||
@@ -53,6 +54,7 @@ export function Sidebar() {
|
|||||||
|
|
||||||
<TagFilter />
|
<TagFilter />
|
||||||
<SessionList />
|
<SessionList />
|
||||||
|
<ProjectCard />
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
art-agent/frontend/src/components/workbench/candidate-panel.tsx
Normal file
110
art-agent/frontend/src/components/workbench/candidate-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,16 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { Session, Tag, ImageAsset, ChatMessage } from "./types";
|
import type {
|
||||||
|
Session,
|
||||||
|
Tag,
|
||||||
|
ImageAsset,
|
||||||
|
ChatMessage,
|
||||||
|
Project,
|
||||||
|
StylePack,
|
||||||
|
CharacterCard,
|
||||||
|
TrainingTask,
|
||||||
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
loadSessions,
|
loadSessions,
|
||||||
saveSessions,
|
saveSessions,
|
||||||
@@ -28,6 +37,21 @@ import {
|
|||||||
generateId,
|
generateId,
|
||||||
setStoreUserId,
|
setStoreUserId,
|
||||||
} from "./store";
|
} 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";
|
import { useAuth } from "./auth-context";
|
||||||
|
|
||||||
interface AppContextValue {
|
interface AppContextValue {
|
||||||
@@ -65,6 +89,34 @@ interface AppContextValue {
|
|||||||
// 左栏折叠
|
// 左栏折叠
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
setSidebarCollapsed: (v: boolean) => void;
|
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);
|
const AppContext = createContext<AppContextValue | null>(null);
|
||||||
@@ -90,6 +142,16 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
const [initialized, setInitialized] = useState(false);
|
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 并重新加载数据
|
// 当用户变化时,切换 store 的 userId 并重新加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated || !user) {
|
if (!isAuthenticated || !user) {
|
||||||
@@ -100,8 +162,16 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
setSessions(loadSessions());
|
setSessions(loadSessions());
|
||||||
setTags(loadTags());
|
setTags(loadTags());
|
||||||
setAssets(loadAssets());
|
setAssets(loadAssets());
|
||||||
|
setProjects(loadProjects());
|
||||||
|
setActiveProjectId(loadActiveProjectId());
|
||||||
|
setStylePacks(loadStylePacks());
|
||||||
|
setCharacters(loadCharacters());
|
||||||
|
setTrainingTasks(loadTrainingTasks());
|
||||||
setActiveSessionId(null);
|
setActiveSessionId(null);
|
||||||
setDetailImage(null);
|
setDetailImage(null);
|
||||||
|
setSessionStyleId(null);
|
||||||
|
setSessionCharacterId(null);
|
||||||
|
setSessionAssetSpec(null);
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
}, [user?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [user?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -268,6 +338,61 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
return result;
|
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>(
|
const value = useMemo<AppContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
sessions,
|
sessions,
|
||||||
@@ -295,6 +420,26 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
setDetailImage,
|
setDetailImage,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
setSidebarCollapsed,
|
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,
|
sessions, activeSessionId, activeSession,
|
||||||
@@ -303,6 +448,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
tags, addTag, deleteTagCb, selectedTagIds,
|
tags, addTag, deleteTagCb, selectedTagIds,
|
||||||
assets, addAssetCb, updateAssetCb, deleteAssetCb, toggleFavoriteCb,
|
assets, addAssetCb, updateAssetCb, deleteAssetCb, toggleFavoriteCb,
|
||||||
detailImage, sidebarCollapsed,
|
detailImage, sidebarCollapsed,
|
||||||
|
projects, activeProjectId, activeProject, switchProject, upsertProjectCb,
|
||||||
|
stylePacks, upsertStylePackCb, deleteStylePackCb,
|
||||||
|
characters, upsertCharacterCb, deleteCharacterCb,
|
||||||
|
trainingTasks, upsertTrainingTaskCb, deleteTrainingTaskCb,
|
||||||
|
sessionStyleId, sessionCharacterId, sessionAssetSpec,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
347
art-agent/frontend/src/lib/placeholder-store.ts
Normal file
347
art-agent/frontend/src/lib/placeholder-store.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -69,6 +69,142 @@ export interface LlmModelInfo {
|
|||||||
vision?: boolean;
|
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 模型 ID(Phase 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 模型 ID(Phase 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;
|
||||||
|
/** 0–100 */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务说明卡 IR(Phase 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 {
|
export interface Annotation {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# EPEEKit 内网穿透启动脚本
|
# EPEEKit 内网穿透启动脚本
|
||||||
# 使用 Cloudflare Quick Tunnel(无需账号,免费)
|
# 使用 Cloudflare Quick Tunnel(无需账号,免费)
|
||||||
# 用法:在 PowerShell 中执行 .\start-tunnel.ps1
|
# 用法:在 PowerShell 中执行 .\start-tunnel.ps1
|
||||||
|
|
||||||
@@ -9,6 +9,12 @@ param(
|
|||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# 优先使用与本脚本同目录的 cloudflared.exe(避免未安装 / 未加入 PATH)
|
||||||
|
$LocalCf = Join-Path $PSScriptRoot "cloudflared.exe"
|
||||||
|
if (Test-Path -LiteralPath $LocalCf) {
|
||||||
|
$env:Path = "$PSScriptRoot;$env:Path"
|
||||||
|
}
|
||||||
|
|
||||||
# 检查 cloudflared 是否可用
|
# 检查 cloudflared 是否可用
|
||||||
try {
|
try {
|
||||||
$null = Get-Command cloudflared -ErrorAction Stop
|
$null = Get-Command cloudflared -ErrorAction Stop
|
||||||
@@ -52,14 +58,15 @@ if (-not $backendUrl) {
|
|||||||
|
|
||||||
Write-Host " 后端穿透地址: $backendUrl" -ForegroundColor Green
|
Write-Host " 后端穿透地址: $backendUrl" -ForegroundColor Green
|
||||||
|
|
||||||
# 更新前端环境变量
|
# 更新前端环境变量(避免 PS5.1 对 UTF-8 here-string 解析问题,改用 WriteAllLines)
|
||||||
$envFile = Join-Path $PSScriptRoot "frontend\.env.local"
|
$envFile = Join-Path $PSScriptRoot "frontend\.env.local"
|
||||||
@"
|
$envLines = @(
|
||||||
# 后端 API 地址(由 start-tunnel.ps1 自动更新)
|
"# Backend API URL (updated by start-tunnel.ps1)"
|
||||||
NEXT_PUBLIC_API_URL=$backendUrl
|
"NEXT_PUBLIC_API_URL=$backendUrl"
|
||||||
"@ | Set-Content $envFile -Encoding UTF8
|
)
|
||||||
|
[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 ""
|
Write-Host ""
|
||||||
|
|
||||||
# 启动前端穿透
|
# 启动前端穿透
|
||||||
@@ -123,11 +130,12 @@ try {
|
|||||||
Stop-Process -Id $frontendTunnel.Id -Force -ErrorAction SilentlyContinue
|
Stop-Process -Id $frontendTunnel.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
# 恢复 .env.local 为 localhost
|
# 恢复 .env.local 为 localhost
|
||||||
@"
|
$restoreLines = @(
|
||||||
# 后端 API 地址
|
"# Backend API URL"
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
"NEXT_PUBLIC_API_URL=http://localhost:8000"
|
||||||
"@ | Set-Content $envFile -Encoding UTF8
|
)
|
||||||
|
[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
|
Write-Host "隧道已关闭" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user