kit初版,模型引入,agent优化
This commit is contained in:
487
art-agent/frontend/src/components/detail/annotation-canvas.tsx
Normal file
487
art-agent/frontend/src/components/detail/annotation-canvas.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useCallback, type MouseEvent } from "react";
|
||||
import type { Annotation } from "@/lib/types";
|
||||
import { generateId } from "@/lib/store";
|
||||
|
||||
type Tool = "rect" | "arrow" | "freehand" | "text";
|
||||
|
||||
interface AnnotationCanvasProps {
|
||||
imageUrl: string;
|
||||
onComplete: (annotations: Annotation[], snapshot: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function AnnotationCanvas({ imageUrl, onComplete, onCancel }: AnnotationCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const [tool, setTool] = useState<Tool>("rect");
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [currentPos, setCurrentPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [freehandPoints, setFreehandPoints] = useState<{ x: number; y: number }[]>([]);
|
||||
const [editingAnnotation, setEditingAnnotation] = useState<string | null>(null);
|
||||
const [editText, setEditText] = useState("");
|
||||
const [undoStack, setUndoStack] = useState<Annotation[][]>([]);
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
|
||||
// 获取鼠标相对于 canvas 的坐标(归一化到图片尺寸)
|
||||
const getRelPos = useCallback((e: MouseEvent): { x: number; y: number } | null => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.clientX - rect.left) / rect.width,
|
||||
y: (e.clientY - rect.top) / rect.height,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 加载图片
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
setImgLoaded(true);
|
||||
};
|
||||
img.src = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
// 渲染 canvas
|
||||
const render = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const img = imageRef.current;
|
||||
if (!canvas || !img) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
// 绘制已有标注
|
||||
for (const ann of annotations) {
|
||||
drawAnnotation(ctx, ann, w, h, false);
|
||||
}
|
||||
|
||||
// 绘制当前正在创建的标注
|
||||
if (drawing && startPos && currentPos) {
|
||||
ctx.strokeStyle = "#f43f5e";
|
||||
ctx.lineWidth = Math.max(2, w * 0.003);
|
||||
ctx.setLineDash([w * 0.005, w * 0.003]);
|
||||
|
||||
if (tool === "rect") {
|
||||
const rx = startPos.x * w, ry = startPos.y * h;
|
||||
const rw = (currentPos.x - startPos.x) * w;
|
||||
const rh = (currentPos.y - startPos.y) * h;
|
||||
ctx.strokeRect(rx, ry, rw, rh);
|
||||
} else if (tool === "arrow") {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startPos.x * w, startPos.y * h);
|
||||
ctx.lineTo(currentPos.x * w, currentPos.y * h);
|
||||
ctx.stroke();
|
||||
drawArrowHead(ctx, startPos.x * w, startPos.y * h, currentPos.x * w, currentPos.y * h, w * 0.015);
|
||||
} else if (tool === "freehand" && freehandPoints.length > 1) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(freehandPoints[0].x * w, freehandPoints[0].y * h);
|
||||
for (let i = 1; i < freehandPoints.length; i++) {
|
||||
ctx.lineTo(freehandPoints[i].x * w, freehandPoints[i].y * h);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}, [annotations, drawing, startPos, currentPos, freehandPoints, tool]);
|
||||
|
||||
useEffect(() => {
|
||||
if (imgLoaded) render();
|
||||
}, [imgLoaded, render]);
|
||||
|
||||
// 鼠标事件
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (tool === "text") {
|
||||
const pos = getRelPos(e);
|
||||
if (!pos) return;
|
||||
const ann: Annotation = {
|
||||
id: generateId("ann-"),
|
||||
type: "text",
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
text: "",
|
||||
};
|
||||
pushUndo();
|
||||
setAnnotations((prev) => [...prev, ann]);
|
||||
setEditingAnnotation(ann.id);
|
||||
setEditText("");
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = getRelPos(e);
|
||||
if (!pos) return;
|
||||
setDrawing(true);
|
||||
setStartPos(pos);
|
||||
setCurrentPos(pos);
|
||||
if (tool === "freehand") {
|
||||
setFreehandPoints([pos]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!drawing) return;
|
||||
const pos = getRelPos(e);
|
||||
if (!pos) return;
|
||||
setCurrentPos(pos);
|
||||
if (tool === "freehand") {
|
||||
setFreehandPoints((prev) => [...prev, pos]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!drawing || !startPos || !currentPos) {
|
||||
setDrawing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
pushUndo();
|
||||
|
||||
if (tool === "rect") {
|
||||
const ann: Annotation = {
|
||||
id: generateId("ann-"),
|
||||
type: "rect",
|
||||
x: Math.min(startPos.x, currentPos.x),
|
||||
y: Math.min(startPos.y, currentPos.y),
|
||||
w: Math.abs(currentPos.x - startPos.x),
|
||||
h: Math.abs(currentPos.y - startPos.y),
|
||||
text: "",
|
||||
};
|
||||
setAnnotations((prev) => [...prev, ann]);
|
||||
setEditingAnnotation(ann.id);
|
||||
setEditText("");
|
||||
} else if (tool === "arrow") {
|
||||
const ann: Annotation = {
|
||||
id: generateId("ann-"),
|
||||
type: "arrow",
|
||||
x: startPos.x,
|
||||
y: startPos.y,
|
||||
w: currentPos.x - startPos.x,
|
||||
h: currentPos.y - startPos.y,
|
||||
text: "",
|
||||
};
|
||||
setAnnotations((prev) => [...prev, ann]);
|
||||
setEditingAnnotation(ann.id);
|
||||
setEditText("");
|
||||
} else if (tool === "freehand") {
|
||||
const ann: Annotation = {
|
||||
id: generateId("ann-"),
|
||||
type: "freehand",
|
||||
x: freehandPoints[0]?.x ?? 0,
|
||||
y: freehandPoints[0]?.y ?? 0,
|
||||
points: [...freehandPoints],
|
||||
text: "",
|
||||
};
|
||||
setAnnotations((prev) => [...prev, ann]);
|
||||
setFreehandPoints([]);
|
||||
}
|
||||
|
||||
setDrawing(false);
|
||||
setStartPos(null);
|
||||
setCurrentPos(null);
|
||||
};
|
||||
|
||||
const pushUndo = () => {
|
||||
setUndoStack((prev) => [...prev, [...annotations]]);
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (undoStack.length === 0) return;
|
||||
const prev = undoStack[undoStack.length - 1];
|
||||
setUndoStack((s) => s.slice(0, -1));
|
||||
setAnnotations(prev);
|
||||
setEditingAnnotation(null);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
pushUndo();
|
||||
setAnnotations([]);
|
||||
setEditingAnnotation(null);
|
||||
};
|
||||
|
||||
const commitText = () => {
|
||||
if (!editingAnnotation) return;
|
||||
setAnnotations((prev) =>
|
||||
prev.map((a) => (a.id === editingAnnotation ? { ...a, text: editText } : a))
|
||||
);
|
||||
setEditingAnnotation(null);
|
||||
setEditText("");
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
// 先提交正在编辑的文本
|
||||
let finalAnnotations = annotations;
|
||||
if (editingAnnotation) {
|
||||
finalAnnotations = annotations.map((a) =>
|
||||
a.id === editingAnnotation ? { ...a, text: editText } : a
|
||||
);
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// 最终渲染一次(含所有文本)
|
||||
const img = imageRef.current;
|
||||
if (img) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
for (const ann of finalAnnotations) {
|
||||
drawAnnotation(ctx, ann, canvas.width, canvas.height, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = canvas.toDataURL("image/png");
|
||||
onComplete(finalAnnotations, snapshot);
|
||||
};
|
||||
|
||||
const TOOLS: { key: Tool; label: string; icon: string }[] = [
|
||||
{ key: "rect", label: "矩形", icon: "□" },
|
||||
{ key: "arrow", label: "箭头", icon: "→" },
|
||||
{ key: "freehand", label: "画笔", icon: "✎" },
|
||||
{ key: "text", label: "文字", icon: "T" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-1 px-3 py-2 border-b border-[var(--border)] bg-[var(--bg-tertiary)] flex-wrap">
|
||||
{TOOLS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTool(t.key)}
|
||||
className={`px-2.5 py-1 text-xs rounded cursor-pointer transition-colors ${
|
||||
tool === t.key
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-primary)]"
|
||||
}`}
|
||||
title={t.label}
|
||||
>
|
||||
{t.icon} {t.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="w-px h-4 bg-[var(--border)] mx-1" />
|
||||
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
disabled={undoStack.length === 0}
|
||||
className="px-2 py-1 text-xs rounded text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] bg-[var(--bg-primary)]
|
||||
disabled:opacity-30 cursor-pointer"
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={annotations.length === 0}
|
||||
className="px-2 py-1 text-xs rounded text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] bg-[var(--bg-primary)]
|
||||
disabled:opacity-30 cursor-pointer"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-2.5 py-1 text-xs rounded text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
className="px-3 py-1 text-xs rounded bg-[var(--accent)] text-white
|
||||
hover:bg-[var(--accent-hover)] cursor-pointer"
|
||||
>
|
||||
完成标注
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Canvas 区域 */}
|
||||
<div ref={containerRef} className="flex-1 overflow-auto p-3 relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="max-w-full rounded-lg cursor-crosshair"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
/>
|
||||
|
||||
{/* 文字输入弹出框 */}
|
||||
{editingAnnotation && (() => {
|
||||
const ann = annotations.find((a) => a.id === editingAnnotation);
|
||||
if (!ann) return null;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const container = containerRef.current;
|
||||
if (!container) return null;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
let px: number, py: number;
|
||||
if (ann.type === "rect") {
|
||||
px = (ann.x + (ann.w ?? 0)) * rect.width + (rect.left - containerRect.left);
|
||||
py = ann.y * rect.height + (rect.top - containerRect.top);
|
||||
} else {
|
||||
px = ann.x * rect.width + (rect.left - containerRect.left) + 10;
|
||||
py = ann.y * rect.height + (rect.top - containerRect.top);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-10 bg-[var(--bg-secondary)] border border-[var(--accent)]
|
||||
rounded-lg shadow-lg p-2 min-w-[180px]"
|
||||
style={{ left: px, top: py }}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitText();
|
||||
if (e.key === "Escape") {
|
||||
setAnnotations((prev) => prev.filter((a) => a.id !== editingAnnotation));
|
||||
setEditingAnnotation(null);
|
||||
}
|
||||
}}
|
||||
placeholder="输入批注..."
|
||||
className="w-full px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border)]
|
||||
rounded text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<div className="flex justify-end gap-1 mt-1.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
setAnnotations((prev) => prev.filter((a) => a.id !== editingAnnotation));
|
||||
setEditingAnnotation(null);
|
||||
}}
|
||||
className="px-2 py-0.5 text-[10px] text-[var(--text-secondary)] cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={commitText}
|
||||
className="px-2 py-0.5 text-[10px] bg-[var(--accent)] text-white
|
||||
rounded cursor-pointer"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 绘制辅助函数 ---
|
||||
|
||||
function drawAnnotation(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
ann: Annotation,
|
||||
w: number,
|
||||
h: number,
|
||||
isFinal: boolean,
|
||||
) {
|
||||
const lineWidth = Math.max(2, w * 0.003);
|
||||
ctx.strokeStyle = "#f43f5e";
|
||||
ctx.fillStyle = "#f43f5e";
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.setLineDash([]);
|
||||
|
||||
switch (ann.type) {
|
||||
case "rect":
|
||||
ctx.strokeRect(ann.x * w, ann.y * h, (ann.w ?? 0) * w, (ann.h ?? 0) * h);
|
||||
break;
|
||||
case "arrow": {
|
||||
const x1 = ann.x * w, y1 = ann.y * h;
|
||||
const x2 = (ann.x + (ann.w ?? 0)) * w, y2 = (ann.y + (ann.h ?? 0)) * h;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
drawArrowHead(ctx, x1, y1, x2, y2, w * 0.015);
|
||||
break;
|
||||
}
|
||||
case "freehand":
|
||||
if (ann.points && ann.points.length > 1) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ann.points[0].x * w, ann.points[0].y * h);
|
||||
for (let i = 1; i < ann.points.length; i++) {
|
||||
ctx.lineTo(ann.points[i].x * w, ann.points[i].y * h);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
break;
|
||||
case "text":
|
||||
break;
|
||||
}
|
||||
|
||||
// 绘制文字标签
|
||||
if (ann.text) {
|
||||
const fontSize = Math.max(12, w * 0.018);
|
||||
ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
|
||||
const metrics = ctx.measureText(ann.text);
|
||||
const padding = fontSize * 0.4;
|
||||
|
||||
let tx: number, ty: number;
|
||||
if (ann.type === "rect") {
|
||||
tx = ann.x * w;
|
||||
ty = ann.y * h - padding;
|
||||
} else {
|
||||
tx = ann.x * w;
|
||||
ty = ann.y * h - padding;
|
||||
}
|
||||
|
||||
// 文字背景
|
||||
ctx.fillStyle = "rgba(244, 63, 94, 0.85)";
|
||||
ctx.fillRect(
|
||||
tx - padding * 0.5,
|
||||
ty - fontSize,
|
||||
metrics.width + padding,
|
||||
fontSize + padding
|
||||
);
|
||||
|
||||
// 文字
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillText(ann.text, tx, ty);
|
||||
}
|
||||
}
|
||||
|
||||
function drawArrowHead(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
fromX: number, fromY: number,
|
||||
toX: number, toY: number,
|
||||
size: number,
|
||||
) {
|
||||
const angle = Math.atan2(toY - fromY, toX - fromX);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toX, toY);
|
||||
ctx.lineTo(toX - size * Math.cos(angle - Math.PI / 6), toY - size * Math.sin(angle - Math.PI / 6));
|
||||
ctx.lineTo(toX - size * Math.cos(angle + Math.PI / 6), toY - size * Math.sin(angle + Math.PI / 6));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
236
art-agent/frontend/src/components/detail/image-detail-panel.tsx
Normal file
236
art-agent/frontend/src/components/detail/image-detail-panel.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"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)]
|
||||
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)]
|
||||
flex flex-col overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--border)]">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">图片详情</span>
|
||||
<button
|
||||
onClick={() => setDetailImage(null)}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
<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-lg overflow-hidden border border-[var(--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-2">
|
||||
<button
|
||||
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
|
||||
className="text-xs px-2 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="text-xs text-[var(--text-secondary)] min-w-[40px] text-center">
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
|
||||
className="text-xs px-2 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setScale(1)}
|
||||
className="text-xs px-2 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
>
|
||||
重置
|
||||
</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 py-1.5 text-xs rounded-md
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] border border-[var(--border)]
|
||||
hover:border-[var(--accent)] transition-colors 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 py-1.5 text-xs rounded-md
|
||||
border transition-colors cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: detailImage.favorited ? "#f59e0b22" : "var(--bg-tertiary)",
|
||||
borderColor: detailImage.favorited ? "#f59e0b" : "var(--border)",
|
||||
color: detailImage.favorited ? "#f59e0b" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<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 py-1.5 text-xs rounded-md
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] border border-[var(--border)]
|
||||
hover:border-[var(--accent)] transition-colors 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 py-1.5 text-xs rounded-md
|
||||
bg-[var(--bg-tertiary)] text-red-400/70
|
||||
hover:text-red-400 border border-[var(--border)]
|
||||
hover:border-red-400/50 transition-colors 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-2.5 rounded-md 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">
|
||||
{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-full"
|
||||
style={{ backgroundColor: tag.color + "22", 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user