Files
EPEEAIKit/art-agent/frontend/src/components/detail/image-detail-panel.tsx

240 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { getImageUrl } from "@/lib/api";
import { useApp } from "@/lib/app-context";
import { AnnotationCanvas } from "./annotation-canvas";
import type { Annotation, AnnotationData } from "@/lib/types";
interface ImageDetailPanelProps {
onAnnotationComplete?: (data: AnnotationData) => void;
}
export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps) {
const { detailImage, setDetailImage, toggleFavorite, tags, deleteAssetById } = useApp();
const [scale, setScale] = useState(1);
const [showAnnotate, setShowAnnotate] = useState(false);
if (!detailImage) return null;
const tagMap = new Map(tags.map((t) => [t.id, t]));
const handleDownload = async () => {
try {
const fullUrl = getImageUrl(detailImage.url);
const response = await fetch(fullUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `epeekit-${Date.now()}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch {
alert("下载失败");
}
};
const handleCopyPrompt = () => {
navigator.clipboard.writeText(detailImage.prompt);
};
const handleAnnotationComplete = (annotations: Annotation[], snapshot: string) => {
setShowAnnotate(false);
const data: AnnotationData = {
imageUrl: detailImage.url,
annotations,
snapshot,
};
onAnnotationComplete?.(data);
};
if (showAnnotate) {
return (
<aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
md:w-[480px] flex-shrink-0 border-l border-[var(--border)]
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
flex flex-col overflow-hidden">
<AnnotationCanvas
imageUrl={getImageUrl(detailImage.url)}
onComplete={handleAnnotationComplete}
onCancel={() => setShowAnnotate(false)}
/>
</aside>
);
}
return (
<aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
md:w-[360px] flex-shrink-0 border-l border-[var(--border)]
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
flex flex-col overflow-hidden">
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
<button
onClick={() => setDetailImage(null)}
className="text-[var(--text-secondary)] hover:text-[var(--accent)]
cursor-pointer transition-colors p-1 rounded-lg hover:bg-[var(--accent)]/5"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto">
{/* 图片预览 */}
<div className="p-4">
<div className="relative rounded-xl overflow-hidden neon-border bg-[var(--bg-primary)]">
<img
src={getImageUrl(detailImage.url)}
alt="预览"
className="w-full transition-transform"
style={{ transform: `scale(${scale})`, transformOrigin: "center" }}
/>
</div>
{/* 缩放控制 */}
<div className="flex items-center justify-center gap-2 mt-3">
<button
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
>
</button>
<span className="text-xs text-[var(--text-secondary)] min-w-[40px] text-center font-medium">
{Math.round(scale * 100)}%
</span>
<button
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
>
+
</button>
<button
onClick={() => setScale(1)}
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
>
</button>
</div>
</div>
{/* 操作按钮 */}
<div className="px-4 pb-3 flex flex-wrap gap-2">
<button
onClick={handleDownload}
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(77,184,164,0.25)]
transition-all cursor-pointer"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
<button
onClick={() => toggleFavorite(detailImage.id)}
className={`flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
border transition-all cursor-pointer ${
detailImage.favorited
? "border-[var(--accent)]/40 bg-[var(--accent)]/8 text-[var(--accent)]"
: "border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill={detailImage.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
{detailImage.favorited ? "已收藏" : "收藏"}
</button>
<button
onClick={() => setShowAnnotate(true)}
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 transition-all cursor-pointer"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
</button>
<button
onClick={() => deleteAssetById(detailImage.id)}
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--bg-tertiary)] text-[var(--hot)]/60
hover:text-[var(--hot)] border border-[var(--border)]
hover:border-[var(--hot)]/40 transition-all cursor-pointer"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
</svg>
</button>
</div>
{/* Prompt */}
{detailImage.prompt && (
<div className="px-4 pb-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-[var(--text-secondary)]">Prompt</span>
<button
onClick={handleCopyPrompt}
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
cursor-pointer transition-colors"
>
</button>
</div>
<div className="p-3 rounded-xl bg-[var(--bg-primary)] border border-[var(--border)]
text-xs text-[var(--text-secondary)] leading-relaxed break-all">
{detailImage.prompt}
</div>
</div>
)}
{/* 标签 */}
{detailImage.tags.length > 0 && (
<div className="px-4 pb-3">
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5"></span>
<div className="flex flex-wrap gap-1.5">
{detailImage.tags.map((tid) => {
const tag = tagMap.get(tid);
if (!tag) return null;
return (
<span
key={tid}
className="text-[10px] px-2 py-0.5 rounded-lg font-medium"
style={{ backgroundColor: tag.color + "18", color: tag.color }}
>
{tag.name}
</span>
);
})}
</div>
</div>
)}
{/* 元信息 */}
<div className="px-4 pb-4">
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5"></span>
<div className="text-xs text-[var(--text-secondary)] space-y-1">
<div>{new Date(detailImage.createdAt).toLocaleString("zh-CN")}</div>
</div>
</div>
</div>
</aside>
);
}