交互原型大版本

This commit is contained in:
Nostars Developer
2026-04-17 19:30:23 +08:00
parent 10f9c0061a
commit b12e55a776
29 changed files with 4574 additions and 57 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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