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