加了一堆模型和一堆功能
This commit is contained in:
@@ -1,52 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback, type KeyboardEvent, type DragEvent } from "react";
|
||||
import { useRef, useState, useCallback, useImperativeHandle, forwardRef, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { uploadRefImage, type UploadProgress } from "@/lib/api";
|
||||
|
||||
type UploadStatus = "idle" | "uploading" | "done" | "error";
|
||||
|
||||
interface UploadState {
|
||||
interface UploadItem {
|
||||
id: string;
|
||||
status: UploadStatus;
|
||||
/** 本地预览 URL */
|
||||
previewUrl: string | null;
|
||||
/** 上传成功后的服务端路径 */
|
||||
serverUrl: string | null;
|
||||
/** 上传进度 0-100 */
|
||||
progress: number;
|
||||
/** 错误信息 */
|
||||
errorMsg: string | null;
|
||||
}
|
||||
|
||||
const INITIAL_UPLOAD: UploadState = {
|
||||
status: "idle",
|
||||
previewUrl: null,
|
||||
serverUrl: null,
|
||||
progress: 0,
|
||||
errorMsg: null,
|
||||
};
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (text: string, refImageServerUrl: string | null, imageModel: string | null) => void;
|
||||
onSend: (text: string, refImageServerUrls: string[], imageModel: string | null) => void;
|
||||
disabled: boolean;
|
||||
/** 上次使用的参考图服务端路径列表,用于自动沿用 */
|
||||
lastRefServerUrls?: string[];
|
||||
/** 上次参考图的完整可预览 URL 列表 */
|
||||
lastRefPreviewUrls?: string[];
|
||||
/** 用户主动清除沿用参考图时的回调 */
|
||||
onClearLastRefImage?: () => void;
|
||||
/** 文件在 ChatInput 区域内被 drop 时触发,通知父组件清除拖拽覆盖层 */
|
||||
onFileDrop?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
export interface ChatInputHandle {
|
||||
uploadFile: (file: File) => void;
|
||||
}
|
||||
|
||||
let _uploadCounter = 0;
|
||||
|
||||
/** 从剪贴板提取可上传的图片文件(Ctrl+V / 右键粘贴共用) */
|
||||
function imageFilesFromClipboard(data: DataTransfer | null): File[] {
|
||||
if (!data?.items?.length) return [];
|
||||
const out: File[] = [];
|
||||
for (let i = 0; i < data.items.length; i++) {
|
||||
const it = data.items[i];
|
||||
if (it.kind === "file" && it.type.startsWith("image/")) {
|
||||
const f = it.getAsFile();
|
||||
if (f) out.push(f);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
|
||||
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop },
|
||||
ref
|
||||
) {
|
||||
const [text, setText] = useState("");
|
||||
const [upload, setUpload] = useState<UploadState>(INITIAL_UPLOAD);
|
||||
const [uploads, setUploads] = useState<UploadItem[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const abortRefs = useRef<Map<string, AbortController>>(new Map());
|
||||
|
||||
const hasActiveUpload = uploads.some((u) => u.status === "uploading");
|
||||
const allDone = uploads.length > 0 && uploads.every((u) => u.status === "done" || u.status === "error");
|
||||
const doneUrls = uploads.filter((u) => u.status === "done" && u.serverUrl).map((u) => u.serverUrl!);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
if (upload.status === "uploading") return;
|
||||
if (hasActiveUpload) return;
|
||||
|
||||
onSend(trimmed, upload.serverUrl, selectedModel || null);
|
||||
const refUrls = doneUrls.length > 0 ? doneUrls : (lastRefServerUrls ?? []);
|
||||
onSend(trimmed, refUrls, selectedModel || null);
|
||||
setText("");
|
||||
clearUpload();
|
||||
clearAllUploads();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -56,247 +81,263 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const clearUpload = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setUpload((prev) => {
|
||||
if (prev.previewUrl) URL.revokeObjectURL(prev.previewUrl);
|
||||
return INITIAL_UPLOAD;
|
||||
const clearAllUploads = useCallback(() => {
|
||||
abortRefs.current.forEach((c) => c.abort());
|
||||
abortRefs.current.clear();
|
||||
setUploads((prev) => {
|
||||
prev.forEach((u) => { if (u.previewUrl) URL.revokeObjectURL(u.previewUrl); });
|
||||
return [];
|
||||
});
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
const removeUpload = useCallback((id: string) => {
|
||||
abortRefs.current.get(id)?.abort();
|
||||
abortRefs.current.delete(id);
|
||||
setUploads((prev) => {
|
||||
const item = prev.find((u) => u.id === id);
|
||||
if (item?.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
||||
return prev.filter((u) => u.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const startUpload = useCallback(async (file: File) => {
|
||||
abortRef.current?.abort();
|
||||
const id = `upload-${++_uploadCounter}`;
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
abortRefs.current.set(id, controller);
|
||||
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
setUpload({
|
||||
const newItem: UploadItem = {
|
||||
id,
|
||||
status: "uploading",
|
||||
previewUrl,
|
||||
serverUrl: null,
|
||||
progress: 0,
|
||||
errorMsg: null,
|
||||
});
|
||||
};
|
||||
setUploads((prev) => [...prev, newItem]);
|
||||
|
||||
try {
|
||||
const result = await uploadRefImage(
|
||||
file,
|
||||
(p: UploadProgress) => {
|
||||
setUpload((prev) => ({ ...prev, progress: p.percent }));
|
||||
setUploads((prev) =>
|
||||
prev.map((u) => (u.id === id ? { ...u, progress: p.percent } : u))
|
||||
);
|
||||
},
|
||||
controller.signal
|
||||
);
|
||||
setUpload((prev) => ({
|
||||
...prev,
|
||||
status: "done",
|
||||
serverUrl: result.url,
|
||||
progress: 100,
|
||||
}));
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === id ? { ...u, status: "done", serverUrl: result.url, progress: 100 } : u
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
const msg = err instanceof Error ? err.message : "上传失败";
|
||||
setUpload((prev) => ({
|
||||
...prev,
|
||||
status: "error",
|
||||
progress: 0,
|
||||
errorMsg: msg,
|
||||
}));
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === id ? { ...u, status: "error", progress: 0, errorMsg: msg } : u
|
||||
)
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({ uploadFile: startUpload }), [startUpload]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
startUpload(file);
|
||||
const files = e.target.files;
|
||||
if (files) {
|
||||
Array.from(files).forEach((f) => startUpload(f));
|
||||
}
|
||||
// 重置 input value,确保选同一文件也能触发
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const retryUpload = useCallback(() => {
|
||||
if (fileInputRef.current) fileInputRef.current.click();
|
||||
}, []);
|
||||
|
||||
// 拖拽
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
const handleDragLeave = () => setIsDragging(false);
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
startUpload(file);
|
||||
}
|
||||
onFileDrop?.();
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
files.forEach((f) => startUpload(f));
|
||||
};
|
||||
|
||||
const statusLabel = (() => {
|
||||
switch (upload.status) {
|
||||
case "uploading":
|
||||
return `上传中 ${upload.progress}%`;
|
||||
case "done":
|
||||
return "参考图已就绪";
|
||||
case "error":
|
||||
return upload.errorMsg || "上传失败";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const statusColor = (() => {
|
||||
switch (upload.status) {
|
||||
case "uploading":
|
||||
return "text-[var(--accent)]";
|
||||
case "done":
|
||||
return "text-green-400";
|
||||
case "error":
|
||||
return "text-red-400";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
const handlePasteCapture = useCallback(
|
||||
(e: ClipboardEvent) => {
|
||||
if (disabled) return;
|
||||
const files = imageFilesFromClipboard(e.clipboardData);
|
||||
if (files.length === 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
files.forEach((f) => startUpload(f));
|
||||
},
|
||||
[disabled, startUpload]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-t border-[var(--border)] bg-[var(--bg-secondary)] px-3 md:px-4 py-2 md:py-3 transition-colors ${
|
||||
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]" : ""
|
||||
className={`border-t border-[var(--border)] bg-[var(--bg-secondary)]/80 backdrop-blur-xl
|
||||
px-3 md:px-5 py-2.5 md:py-3 transition-colors ${
|
||||
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]/40" : ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onPasteCapture={handlePasteCapture}
|
||||
>
|
||||
{/* 拖拽提示 */}
|
||||
{isDragging && (
|
||||
<div className="mb-2 text-center text-xs text-[var(--accent)]">
|
||||
松开以添加参考图
|
||||
松开以添加参考图(可多张)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参考图上传状态区 */}
|
||||
{upload.status !== "idle" && (
|
||||
<div className="mb-3 flex items-start gap-2">
|
||||
<div className="relative flex-shrink-0">
|
||||
{upload.previewUrl && (
|
||||
{/* 沿用上次参考图提示 */}
|
||||
{uploads.length === 0 && lastRefPreviewUrls && lastRefPreviewUrls.length > 0 && (
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
{lastRefPreviewUrls.map((url, i) => (
|
||||
<img
|
||||
src={upload.previewUrl}
|
||||
alt="参考图"
|
||||
className={`w-16 h-16 rounded-lg object-cover border border-[var(--border)] transition-opacity ${
|
||||
upload.status === "uploading" ? "opacity-60" : ""
|
||||
}`}
|
||||
key={i}
|
||||
src={url}
|
||||
alt={`沿用参考图 ${i + 1}`}
|
||||
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/30
|
||||
shadow-[0_0_8px_rgba(77,184,164,0.1)]"
|
||||
/>
|
||||
)}
|
||||
{/* 上传中的遮罩进度环 */}
|
||||
{upload.status === "uploading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle
|
||||
cx="18" cy="18" r="14"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.2)"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<circle
|
||||
cx="18" cy="18" r="14"
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${upload.progress * 0.88} 88`}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* 成功 ✓ 角标 */}
|
||||
{upload.status === "done" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-green-500
|
||||
text-white text-[10px] flex items-center justify-center font-bold">
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
{/* 失败 ! 角标 */}
|
||||
{upload.status === "error" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-red-500
|
||||
text-white text-[10px] flex items-center justify-center font-bold">
|
||||
!
|
||||
</div>
|
||||
)}
|
||||
{/* 删除按钮(非上传中时显示) */}
|
||||
{upload.status !== "uploading" && (
|
||||
<button
|
||||
onClick={clearUpload}
|
||||
className="absolute -top-1.5 -left-1.5 w-5 h-5 rounded-full
|
||||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] text-xs flex items-center justify-center
|
||||
hover:bg-red-500 hover:text-white hover:border-red-500
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
沿用上次参考图({lastRefPreviewUrls.length} 张)
|
||||
</span>
|
||||
<button
|
||||
onClick={onClearLastRefImage}
|
||||
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--hot)]
|
||||
cursor-pointer transition-colors"
|
||||
title="清除参考图"
|
||||
>
|
||||
✕ 清除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1 mt-0.5 min-w-0">
|
||||
<span className={`text-xs ${statusColor} flex items-center gap-1.5`}>
|
||||
{upload.status === "uploading" && (
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse" />
|
||||
)}
|
||||
{statusLabel}
|
||||
</span>
|
||||
{/* 进度条 */}
|
||||
{upload.status === "uploading" && (
|
||||
<div className="w-32 h-1 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--accent)] rounded-full transition-all duration-200"
|
||||
style={{ width: `${upload.progress}%` }}
|
||||
{/* 参考图上传状态区(多图) */}
|
||||
{uploads.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{uploads.map((item) => (
|
||||
<div key={item.id} className="relative flex-shrink-0">
|
||||
{item.previewUrl && (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt="参考图"
|
||||
className={`w-16 h-16 rounded-xl object-cover border border-[var(--border)] transition-opacity ${
|
||||
item.status === "uploading" ? "opacity-60" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "uploading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(77,184,164,0.12)" strokeWidth="3" />
|
||||
<circle
|
||||
cx="18" cy="18" r="14" fill="none"
|
||||
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
|
||||
strokeDasharray={`${item.progress * 0.88} 88`}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "done" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--accent)]
|
||||
text-[var(--bg-primary)] text-[10px] flex items-center justify-center font-bold
|
||||
shadow-[0_0_8px_rgba(77,184,164,0.3)]">
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--hot)]
|
||||
text-white text-[10px] flex items-center justify-center font-bold">
|
||||
!
|
||||
</div>
|
||||
)}
|
||||
{item.status !== "uploading" && (
|
||||
<button
|
||||
onClick={() => removeUpload(item.id)}
|
||||
className="absolute -top-1.5 -left-1.5 w-5 h-5 rounded-full
|
||||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] text-xs flex items-center justify-center
|
||||
hover:bg-[var(--hot)] hover:text-white hover:border-[var(--hot)]
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* 汇总状态 */}
|
||||
<div className="flex flex-col justify-center gap-1 min-w-0">
|
||||
{hasActiveUpload && (
|
||||
<span className="text-xs text-[var(--accent)] flex items-center gap-1.5">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
|
||||
shadow-[0_0_6px_rgba(77,184,164,0.3)]" />
|
||||
上传中...
|
||||
</span>
|
||||
)}
|
||||
{/* 失败时显示重试 */}
|
||||
{upload.status === "error" && (
|
||||
<button
|
||||
onClick={retryUpload}
|
||||
className="text-xs text-[var(--accent)] hover:underline cursor-pointer w-fit"
|
||||
>
|
||||
重新选择
|
||||
</button>
|
||||
{allDone && (
|
||||
<span className="text-xs text-[var(--accent)]">
|
||||
{doneUrls.length} 张参考图已就绪
|
||||
</span>
|
||||
)}
|
||||
{uploads.some((u) => u.status === "error") && (
|
||||
<span className="text-xs text-[var(--hot)]">
|
||||
{uploads.filter((u) => u.status === "error").length} 张上传失败
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
{/* 模型选择器 */}
|
||||
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
|
||||
|
||||
{/* 上传参考图按钮 */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled || upload.status === "uploading"}
|
||||
className={`flex-shrink-0 w-10 h-10 rounded-lg border
|
||||
disabled={disabled || hasActiveUpload}
|
||||
className={`relative flex-shrink-0 w-10 h-10 rounded-xl border
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] hover:border-[var(--accent)]
|
||||
flex items-center justify-center transition-colors
|
||||
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||||
hover:bg-[var(--accent)]/5
|
||||
flex items-center justify-center transition-all
|
||||
disabled:opacity-50 cursor-pointer ${
|
||||
upload.status === "done"
|
||||
? "border-green-500/50 text-green-400"
|
||||
doneUrls.length > 0
|
||||
? "border-[var(--accent)]/40 text-[var(--accent)]"
|
||||
: "border-[var(--border)]"
|
||||
}`}
|
||||
title="上传参考图"
|
||||
title="上传参考图(可多选)"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
{doneUrls.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--accent)]
|
||||
text-[var(--bg-primary)] text-[9px] flex items-center justify-center font-bold">
|
||||
{doneUrls.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
@@ -306,15 +347,17 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="描述你想要的美术资源..."
|
||||
placeholder="描述你想要的美术资源...(可粘贴图片)"
|
||||
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-lg border border-[var(--border)]
|
||||
className="flex-1 resize-none rounded-xl border border-[var(--border)]
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-primary)]
|
||||
placeholder:text-[var(--text-secondary)]
|
||||
px-4 py-2.5 text-sm leading-relaxed
|
||||
focus:outline-none focus:border-[var(--accent)]
|
||||
disabled:opacity-50 transition-colors
|
||||
focus:outline-none focus:border-[var(--accent)]/50
|
||||
focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
|
||||
disabled:opacity-50 transition-all
|
||||
min-h-[42px] max-h-[120px]"
|
||||
style={{ fieldSizing: "content" } as React.CSSProperties}
|
||||
/>
|
||||
@@ -322,11 +365,12 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !text.trim() || upload.status === "uploading"}
|
||||
className="flex-shrink-0 w-10 h-10 rounded-lg
|
||||
bg-[var(--accent)] text-white
|
||||
disabled={disabled || !text.trim() || hasActiveUpload}
|
||||
className="flex-shrink-0 w-10 h-10 rounded-xl
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
flex items-center justify-center transition-colors
|
||||
hover:shadow-[0_0_15px_rgba(77,184,164,0.25)]
|
||||
flex items-center justify-center transition-all
|
||||
disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -336,4 +380,4 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,19 +13,19 @@ function CopyButton({ text }: { text: string }) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// fallback 不做处理
|
||||
// fallback
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-secondary)] transition-colors cursor-pointer"
|
||||
className="p-1 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:bg-[var(--accent)]/5 transition-colors cursor-pointer"
|
||||
title="复制文本"
|
||||
>
|
||||
{copied ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
@@ -54,25 +54,52 @@ export function ChatMessages({
|
||||
statusText,
|
||||
}: ChatMessagesProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-3 md:px-4 py-4 md:py-6 space-y-4 md:space-y-6">
|
||||
<div className="flex-1 overflow-y-auto px-3 md:px-5 py-4 md:py-6 space-y-4 md:space-y-5">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`group/msg flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`group/msg flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
style={{ animation: "slideUp 200ms ease-out" }}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<div
|
||||
className={`max-w-[90vw] md:max-w-[80vw] rounded-2xl px-3.5 md:px-4 py-2.5 md:py-3 ${
|
||||
className={`max-w-[90vw] md:max-w-[80vw] rounded-2xl px-4 md:px-5 py-3 ${
|
||||
msg.role === "user"
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
|
||||
? "bg-[var(--accent)]/15 text-[var(--text-primary)] border border-[var(--accent)]/25"
|
||||
: "glass-panel text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{msg.refImageUrl && (
|
||||
{/* 多图参考(新格式) */}
|
||||
{msg.refImageUrls && msg.refImageUrls.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{msg.refImageUrls.map((url, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={url}
|
||||
alt={`参考图 ${i + 1}`}
|
||||
className="max-w-[120px] max-h-[90px] rounded-lg object-cover
|
||||
border border-[var(--accent)]/20
|
||||
shadow-[0_0_10px_rgba(77,184,164,0.08)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block">
|
||||
参考图({msg.refImageUrls.length} 张)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 兼容旧数据:单图 */}
|
||||
{!msg.refImageUrls && msg.refImageUrl && (
|
||||
<div className="mb-2">
|
||||
<img
|
||||
src={msg.refImageUrl}
|
||||
alt="参考图"
|
||||
className="max-w-[160px] max-h-[120px] rounded-lg object-cover border border-white/20"
|
||||
className="max-w-[160px] max-h-[120px] rounded-lg object-cover
|
||||
border border-[var(--accent)]/20
|
||||
shadow-[0_0_10px_rgba(77,184,164,0.08)]"
|
||||
/>
|
||||
<span className="text-[10px] opacity-70 mt-1 block">参考图</span>
|
||||
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block">参考图</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed">{msg.content}</div>
|
||||
@@ -80,7 +107,7 @@ export function ChatMessages({
|
||||
<>
|
||||
<ImageGrid images={msg.images} />
|
||||
{msg.modelName && (
|
||||
<div className="mt-1.5 text-[10px] text-[var(--text-secondary)] opacity-70">
|
||||
<div className="mt-1.5 text-[10px] text-[var(--accent)]/50">
|
||||
由 {msg.modelName} 生成
|
||||
</div>
|
||||
)}
|
||||
@@ -98,11 +125,12 @@ export function ChatMessages({
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[90%] md:max-w-[80%] rounded-2xl px-3.5 md:px-4 py-2.5 md:py-3 bg-[var(--bg-tertiary)]">
|
||||
<div className="flex justify-start" style={{ animation: "slideUp 200ms ease-out" }}>
|
||||
<div className="max-w-[90%] md:max-w-[80%] rounded-2xl px-4 md:px-5 py-3 glass-panel">
|
||||
{statusText && (
|
||||
<div className="text-xs text-[var(--accent)] mb-2 flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse" />
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
|
||||
shadow-[0_0_8px_rgba(77,184,164,0.4)]" />
|
||||
{statusText}
|
||||
</div>
|
||||
)}
|
||||
@@ -111,10 +139,10 @@ export function ChatMessages({
|
||||
)}
|
||||
{streamingImages.length > 0 && <ImageGrid images={streamingImages} />}
|
||||
{!streamingText && !statusText && (
|
||||
<div className="flex gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--text-secondary)] animate-bounce" />
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--text-secondary)] animate-bounce [animation-delay:0.1s]" />
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--text-secondary)] animate-bounce [animation-delay:0.2s]" />
|
||||
<div className="flex gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce" />
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce [animation-delay:0.1s]" />
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce [animation-delay:0.2s]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -35,30 +35,34 @@ export function ImageGrid({ images }: ImageGridProps) {
|
||||
: "grid-cols-2 max-w-[320px] md:max-w-xl";
|
||||
|
||||
return (
|
||||
<div className={`grid ${gridCols} gap-2 my-3`}>
|
||||
<div className={`grid ${gridCols} gap-3 my-3`}>
|
||||
{images.map((asset, i) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="group relative rounded-lg overflow-hidden border border-[var(--border)]"
|
||||
className="group relative rounded-xl overflow-hidden neon-border
|
||||
bg-[var(--bg-card)] backdrop-blur-sm"
|
||||
>
|
||||
<img
|
||||
src={getImageUrl(asset.url)}
|
||||
alt={`生成图片 ${i + 1}`}
|
||||
className="w-full aspect-square object-cover cursor-pointer"
|
||||
className="w-full aspect-square object-cover cursor-pointer
|
||||
transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
onClick={() => setDetailImage(asset)}
|
||||
/>
|
||||
|
||||
{/* 操作栏:移动端始终可见,桌面端 hover 显示 */}
|
||||
{/* 底部渐变 + 操作栏 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 px-2 py-1.5
|
||||
bg-gradient-to-t from-black/80 to-transparent
|
||||
opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity
|
||||
flex items-center gap-1"
|
||||
className="absolute bottom-0 left-0 right-0 px-3 py-2
|
||||
bg-gradient-to-t from-black/80 via-black/40 to-transparent
|
||||
opacity-100 md:opacity-0 md:group-hover:opacity-100
|
||||
transition-opacity duration-200
|
||||
flex items-center gap-1.5"
|
||||
>
|
||||
<button
|
||||
onClick={() => setDetailImage(asset)}
|
||||
className="p-1 rounded text-white/80 hover:text-white cursor-pointer"
|
||||
className="p-1.5 rounded-lg text-white/70 hover:text-[var(--accent)]
|
||||
hover:bg-[var(--accent)]/10 cursor-pointer transition-colors"
|
||||
title="放大查看"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
@@ -67,7 +71,8 @@ export function ImageGrid({ images }: ImageGridProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(asset.url, i)}
|
||||
className="p-1 rounded text-white/80 hover:text-white cursor-pointer"
|
||||
className="p-1.5 rounded-lg text-white/70 hover:text-[var(--accent)]
|
||||
hover:bg-[var(--accent)]/10 cursor-pointer transition-colors"
|
||||
title="保存"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
@@ -79,8 +84,9 @@ export function ImageGrid({ images }: ImageGridProps) {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(asset.id);
|
||||
}}
|
||||
className="p-1 rounded cursor-pointer transition-colors"
|
||||
style={{ color: asset.favorited ? "#f59e0b" : "rgba(255,255,255,0.6)" }}
|
||||
className="p-1.5 rounded-lg cursor-pointer transition-colors
|
||||
hover:bg-[var(--accent)]/10"
|
||||
style={{ color: asset.favorited ? "var(--accent)" : "rgba(255,255,255,0.5)" }}
|
||||
title={asset.favorited ? "取消收藏" : "收藏"}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||||
@@ -88,6 +94,17 @@ export function ImageGrid({ images }: ImageGridProps) {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 最新生成标签 */}
|
||||
{i === 0 && images.length > 1 && (
|
||||
<div className="absolute top-2 left-2">
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-md
|
||||
bg-[var(--accent)]/20 text-[var(--accent)]
|
||||
border border-[var(--accent)]/30 backdrop-blur-sm">
|
||||
New
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -33,14 +33,10 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
||||
onChange(defaultId);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 后端不可用时静默降级
|
||||
});
|
||||
// 仅初始化时执行一次
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -61,10 +57,11 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
hover:border-[var(--accent)] transition-colors cursor-pointer"
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:border-[var(--accent)]/40 hover:bg-[var(--accent)]/5
|
||||
transition-all cursor-pointer"
|
||||
title="切换生图模型"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
@@ -84,9 +81,8 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-1.5 w-56 rounded-lg
|
||||
border border-[var(--border)] bg-[var(--bg-secondary)]
|
||||
shadow-lg overflow-hidden z-50"
|
||||
className="absolute bottom-full left-0 mb-1.5 w-56 rounded-xl
|
||||
glass-panel shadow-xl shadow-black/40 overflow-hidden z-50"
|
||||
>
|
||||
{models.map((m) => {
|
||||
const isActive = m.id === value;
|
||||
@@ -98,10 +94,10 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
||||
localStorage.setItem(getModelStorageKey(), m.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2.5 flex flex-col gap-0.5
|
||||
transition-colors cursor-pointer
|
||||
className={`w-full text-left px-3.5 py-2.5 flex flex-col gap-0.5
|
||||
transition-all cursor-pointer
|
||||
${isActive
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)]"
|
||||
? "bg-[var(--accent)]/8 text-[var(--accent)]"
|
||||
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
@@ -109,7 +105,8 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
||||
{m.name}
|
||||
{m.supports_ref_image && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full
|
||||
bg-emerald-500/15 text-emerald-400 font-normal leading-none">
|
||||
bg-[var(--accent)]/10 text-[var(--accent)] font-medium leading-none
|
||||
border border-[var(--accent)]/20">
|
||||
参考图
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -54,7 +54,8 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
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)]
|
||||
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)}
|
||||
@@ -67,15 +68,16 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
|
||||
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)]
|
||||
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-2.5 border-b border-[var(--border)]">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">图片详情</span>
|
||||
<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(--text-primary)]
|
||||
cursor-pointer transition-colors"
|
||||
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" />
|
||||
@@ -83,11 +85,10 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
</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)]">
|
||||
<div className="relative rounded-xl overflow-hidden neon-border bg-[var(--bg-primary)]">
|
||||
<img
|
||||
src={getImageUrl(detailImage.url)}
|
||||
alt="预览"
|
||||
@@ -97,28 +98,31 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
</div>
|
||||
|
||||
{/* 缩放控制 */}
|
||||
<div className="flex items-center justify-center gap-2 mt-2">
|
||||
<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 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
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">
|
||||
<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 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
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 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
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>
|
||||
@@ -129,10 +133,10 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
<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"
|
||||
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" />
|
||||
@@ -141,13 +145,12 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
</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)",
|
||||
}}
|
||||
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" />
|
||||
@@ -156,10 +159,10 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAnnotate(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md
|
||||
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)] transition-colors cursor-pointer"
|
||||
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" />
|
||||
@@ -168,10 +171,10 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
</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"
|
||||
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" />
|
||||
@@ -181,7 +184,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Prompt 信息 */}
|
||||
{/* Prompt */}
|
||||
{detailImage.prompt && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
@@ -194,7 +197,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-md bg-[var(--bg-primary)] border border-[var(--border)]
|
||||
<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>
|
||||
@@ -205,15 +208,15 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
{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">
|
||||
<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-full"
|
||||
style={{ backgroundColor: tag.color + "22", color: tag.color }}
|
||||
className="text-[10px] px-2 py-0.5 rounded-lg font-medium"
|
||||
style={{ backgroundColor: tag.color + "18", color: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
|
||||
@@ -5,10 +5,29 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { ProfileModal } from "@/components/profile-modal";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", label: "对话" },
|
||||
{ href: "/gallery", label: "资源库" },
|
||||
{
|
||||
href: "/",
|
||||
label: "对话",
|
||||
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" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/gallery",
|
||||
label: "资源库",
|
||||
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" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function TopNav() {
|
||||
@@ -16,6 +35,7 @@ export function TopNav() {
|
||||
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
|
||||
const { user, logout } = useAuth();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,12 +50,13 @@ export function TopNav() {
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<header className="flex-shrink-0 h-12 border-b border-[var(--border)] bg-[var(--bg-secondary)] flex items-center px-3 md:px-4 gap-3 md:gap-6">
|
||||
<>
|
||||
<header className="relative z-10 flex-shrink-0 h-14 border-b border-[var(--border)] bg-[var(--bg-secondary)]/80 backdrop-blur-xl flex items-center px-4 md:px-5 gap-4 md:gap-6">
|
||||
{/* 移动端汉堡菜单 */}
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="md:hidden flex-shrink-0 p-1.5 rounded-md
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
transition-colors cursor-pointer"
|
||||
title="菜单"
|
||||
>
|
||||
@@ -45,33 +66,37 @@ export function TopNav() {
|
||||
</button>
|
||||
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-2 flex-shrink-0">
|
||||
<svg width="22" height="22" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="7" fill="var(--accent)" />
|
||||
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="white" />
|
||||
<circle cx="23" cy="12" r="3.5" stroke="white" strokeWidth="1.8" fill="none" />
|
||||
<path d="M23 15.5v5" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<circle cx="23" cy="22.5" r="1" fill="white" />
|
||||
</svg>
|
||||
<Link href="/" className="flex items-center gap-2.5 flex-shrink-0 group">
|
||||
<div className="w-8 h-8 rounded-lg bg-[var(--accent)] flex items-center justify-center
|
||||
shadow-[0_0_12px_rgba(77,184,164,0.25)]
|
||||
group-hover:shadow-[0_0_20px_rgba(77,184,164,0.4)] transition-shadow">
|
||||
<svg width="18" height="18" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="#0C1210" />
|
||||
<circle cx="23" cy="12" r="3.5" stroke="#0C1210" strokeWidth="1.8" fill="none" />
|
||||
<path d="M23 15.5v5" stroke="#0C1210" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<circle cx="23" cy="22.5" r="1" fill="#0C1210" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-base font-bold tracking-tight text-[var(--text-primary)]">
|
||||
EPEEKit
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* 导航 Tab */}
|
||||
<nav className="flex items-center gap-1">
|
||||
{NAV_ITEMS.map(({ href, label }) => {
|
||||
<nav className="flex items-center gap-1.5">
|
||||
{NAV_ITEMS.map(({ href, label, icon }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`px-2.5 md:px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
className={`relative flex items-center gap-1.5 px-3 md:px-4 py-2 text-sm rounded-lg ${
|
||||
active
|
||||
? "bg-[var(--bg-tertiary)] text-[var(--text-primary)] font-medium"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
? "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)] transition-all"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
@@ -85,29 +110,39 @@ export function TopNav() {
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="flex items-center gap-2 px-2 md:px-3 py-1.5 rounded-md text-sm
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer"
|
||||
hover:bg-[var(--bg-tertiary)] border border-transparent
|
||||
hover:border-[var(--border)] transition-all cursor-pointer"
|
||||
>
|
||||
<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>
|
||||
<div className="w-7 h-7 rounded-full bg-[var(--accent)]/15 border border-[var(--accent)]/30
|
||||
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">
|
||||
{user.display_name || user.username}
|
||||
</span>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 w-44 rounded-lg
|
||||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
shadow-xl py-1 z-50">
|
||||
<div className="px-3 py-2 text-xs text-[var(--text-secondary)] border-b border-[var(--border)]">
|
||||
{user.username}
|
||||
{user.is_admin && <span className="ml-1 text-[var(--accent)]">(管理员)</span>}
|
||||
</div>
|
||||
<div className="absolute right-0 top-full mt-1.5 w-48 rounded-xl
|
||||
glass-panel shadow-xl shadow-black/30 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"
|
||||
>
|
||||
<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>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); logout(); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-[var(--text-secondary)]
|
||||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
|
||||
transition-colors cursor-pointer"
|
||||
>
|
||||
@@ -118,5 +153,8 @@ export function TopNav() {
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{profileOpen && <ProfileModal onClose={() => setProfileOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
233
art-agent/frontend/src/components/profile-modal.tsx
Normal file
233
art-agent/frontend/src/components/profile-modal.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useAuth, type AuthUser } from "@/lib/auth-context";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
interface MemoryItem {
|
||||
id: string;
|
||||
memory: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface GroupedMemories {
|
||||
label: string;
|
||||
items: MemoryItem[];
|
||||
}
|
||||
|
||||
function groupByTime(memories: MemoryItem[]): GroupedMemories[] {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const weekAgo = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const today: MemoryItem[] = [];
|
||||
const week: MemoryItem[] = [];
|
||||
const older: MemoryItem[] = [];
|
||||
|
||||
for (const m of memories) {
|
||||
const d = m.created_at ? new Date(m.created_at) : null;
|
||||
if (!d || isNaN(d.getTime())) {
|
||||
older.push(m);
|
||||
} else if (d >= todayStart) {
|
||||
today.push(m);
|
||||
} else if (d >= weekAgo) {
|
||||
week.push(m);
|
||||
} else {
|
||||
older.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
const groups: GroupedMemories[] = [];
|
||||
if (today.length > 0) groups.push({ label: "今天", items: today });
|
||||
if (week.length > 0) groups.push({ label: "最近 7 天", items: week });
|
||||
if (older.length > 0) groups.push({ label: "更早", items: older });
|
||||
return groups;
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const now = new Date();
|
||||
const isThisYear = d.getFullYear() === now.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const hour = String(d.getHours()).padStart(2, "0");
|
||||
const min = String(d.getMinutes()).padStart(2, "0");
|
||||
if (isThisYear) return `${month}-${day} ${hour}:${min}`;
|
||||
return `${d.getFullYear()}-${month}-${day} ${hour}:${min}`;
|
||||
}
|
||||
|
||||
export function ProfileModal({ onClose }: { onClose: () => void }) {
|
||||
const { user, token } = useAuth();
|
||||
const [memories, setMemories] = useState<MemoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchMemories = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetch(`${API_URL}/api/memory/list`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
setMemories(data.memories || []);
|
||||
if (data.error) setError(data.error);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "加载记忆失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMemories();
|
||||
}, [fetchMemories]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const groups = groupByTime(memories);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* 遮罩层 */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
{/* 弹窗主体 */}
|
||||
<div
|
||||
className="relative w-[90vw] max-w-[560px] max-h-[80vh] rounded-2xl glass-panel
|
||||
shadow-[0_0_40px_rgba(77,184,164,0.1)] border-[var(--border-glow)]
|
||||
flex flex-col overflow-hidden"
|
||||
style={{ animation: "slideUp 200ms ease-out" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 p-1.5 rounded-lg text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
|
||||
transition-colors cursor-pointer z-10"
|
||||
>
|
||||
<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 className="px-6 pt-6 pb-4 flex items-center gap-4 flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-full bg-[var(--accent)]/15 border-2 border-[var(--accent)]/40
|
||||
flex items-center justify-center text-[var(--accent)] text-xl font-bold
|
||||
shadow-[0_0_20px_rgba(77,184,164,0.12)]">
|
||||
{(user.display_name || user.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)] truncate">
|
||||
{user.display_name || user.username}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-sm text-[var(--text-secondary)]">@{user.username}</span>
|
||||
{user.is_admin && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-md bg-[var(--accent)]/10
|
||||
text-[var(--accent)] border border-[var(--accent)]/30">
|
||||
管理员
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className="mx-6 h-px bg-[var(--border)]" />
|
||||
|
||||
{/* 记忆区标题 */}
|
||||
<div className="px-6 pt-4 pb-2 flex items-center gap-2 flex-shrink-0">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
|
||||
<path d="M12 2a7 7 0 017 7c0 2.38-1.19 4.47-3 5.74V17a2 2 0 01-2 2h-4a2 2 0 01-2-2v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 017-7z" />
|
||||
<path d="M10 21h4" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">记忆系统</span>
|
||||
{!loading && (
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{memories.length} 条记录
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 记忆列表 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6 min-h-0">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-5 h-5 border-2 border-[var(--accent)]/30 border-t-[var(--accent)]
|
||||
rounded-full animate-spin" />
|
||||
<span className="ml-3 text-sm text-[var(--text-secondary)]">加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && memories.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-sm text-[var(--hot)]">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && memories.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)"
|
||||
strokeWidth="1.5" className="mx-auto mb-3 opacity-50">
|
||||
<path d="M12 2a7 7 0 017 7c0 2.38-1.19 4.47-3 5.74V17a2 2 0 01-2 2h-4a2 2 0 01-2-2v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 017-7z" />
|
||||
<path d="M10 21h4" />
|
||||
</svg>
|
||||
<p className="text-sm text-[var(--text-secondary)]">暂无记忆记录</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-60">
|
||||
与助手对话时,系统会自动记录关键信息
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && groups.map((group) => (
|
||||
<div key={group.label} className="mb-4 last:mb-0">
|
||||
<div className="text-xs font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-2">
|
||||
{group.label}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{group.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="px-3 py-2.5 rounded-lg bg-[var(--bg-tertiary)]/50
|
||||
border border-[var(--border)] hover:border-[var(--border-glow)]
|
||||
transition-colors group"
|
||||
>
|
||||
<p className="text-sm text-[var(--text-primary)] leading-relaxed">
|
||||
{item.memory}
|
||||
</p>
|
||||
{item.created_at && (
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-0
|
||||
group-hover:opacity-60 transition-opacity">
|
||||
{formatDate(item.created_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
import { getImageUrl, fetchLlmModels } from "@/lib/api";
|
||||
import type { LlmModelInfo } from "@/lib/types";
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
@@ -20,41 +21,68 @@ export function SessionList() {
|
||||
const {
|
||||
sessions, activeSessionId, tags, selectedTagIds,
|
||||
createSession, switchSession, deleteSession, renameSession,
|
||||
updateSessionLlmModel,
|
||||
} = useApp();
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; sessionId: string } | null>(null);
|
||||
const [menuSessionId, setMenuSessionId] = useState<string | null>(null);
|
||||
const [showModelSub, setShowModelSub] = useState(false);
|
||||
const [llmModels, setLlmModels] = useState<LlmModelInfo[]>([]);
|
||||
const [defaultLlmModel, setDefaultLlmModel] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const editRef = useRef<HTMLInputElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 标签过滤
|
||||
const filtered = selectedTagIds.length === 0
|
||||
? sessions
|
||||
: sessions.filter((s) => selectedTagIds.some((tid) => s.tags.includes(tid)));
|
||||
|
||||
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||
|
||||
// 关闭右键菜单
|
||||
useEffect(() => {
|
||||
const close = () => setContextMenu(null);
|
||||
window.addEventListener("click", close);
|
||||
return () => window.removeEventListener("click", close);
|
||||
fetchLlmModels()
|
||||
.then((data) => {
|
||||
setLlmModels(data.models);
|
||||
setDefaultLlmModel(data.default);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 编辑聚焦
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
if (menuSessionId) {
|
||||
window.addEventListener("mousedown", handler);
|
||||
return () => window.removeEventListener("mousedown", handler);
|
||||
}
|
||||
}, [menuSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingId) editRef.current?.focus();
|
||||
}, [editingId]);
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, sessionId: string) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, sessionId });
|
||||
const closeMenu = useCallback(() => {
|
||||
setMenuSessionId(null);
|
||||
setShowModelSub(false);
|
||||
}, []);
|
||||
|
||||
const handleDots = (e: React.MouseEvent, sessionId: string) => {
|
||||
e.stopPropagation();
|
||||
if (menuSessionId === sessionId) {
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
setMenuSessionId(sessionId);
|
||||
setShowModelSub(false);
|
||||
};
|
||||
|
||||
const startRename = (id: string, currentTitle: string) => {
|
||||
setEditingId(id);
|
||||
setEditTitle(currentTitle);
|
||||
setContextMenu(null);
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
const commitRename = () => {
|
||||
@@ -64,16 +92,26 @@ export function SessionList() {
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleSelectModel = (sessionId: string, modelId: string) => {
|
||||
updateSessionLlmModel(sessionId, modelId);
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
const menuSession = menuSessionId
|
||||
? sessions.find((s) => s.id === menuSessionId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto fog-scroll">
|
||||
{/* 新建按钮 */}
|
||||
<div className="px-3 py-2">
|
||||
<div className="px-3 py-3">
|
||||
<button
|
||||
onClick={() => createSession()}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-3 py-2
|
||||
text-sm rounded-lg border border-dashed border-[var(--border)]
|
||||
text-[var(--text-secondary)] hover:border-[var(--accent)]
|
||||
hover:text-[var(--accent)] transition-colors cursor-pointer"
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2.5
|
||||
text-sm rounded-xl border border-dashed border-[var(--accent)]/30
|
||||
text-[var(--accent)]/70 hover:border-[var(--accent)]
|
||||
hover:text-[var(--accent)] hover:bg-[var(--accent)]/5
|
||||
transition-all cursor-pointer"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
@@ -83,114 +121,199 @@ export function SessionList() {
|
||||
</div>
|
||||
|
||||
{/* 会话列表 */}
|
||||
<div className="px-2 pb-2 space-y-0.5">
|
||||
<div className="px-2 pb-2 space-y-1">
|
||||
{filtered.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => switchSession(session.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, session.id)}
|
||||
className={`group relative flex items-start gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer
|
||||
transition-colors ${
|
||||
session.id === activeSessionId
|
||||
? "bg-[var(--bg-tertiary)]"
|
||||
: "hover:bg-[var(--bg-tertiary)]/50"
|
||||
}`}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
{session.thumbnail ? (
|
||||
<img
|
||||
src={getImageUrl(session.thumbnail)}
|
||||
alt=""
|
||||
className="w-9 h-9 rounded object-cover flex-shrink-0 border border-[var(--border)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded bg-[var(--bg-primary)] border border-[var(--border)]
|
||||
flex items-center justify-center flex-shrink-0 text-[var(--text-secondary)] text-xs">
|
||||
💬
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 会话信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingId === session.id ? (
|
||||
<input
|
||||
ref={editRef}
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitRename();
|
||||
if (e.key === "Escape") setEditingId(null);
|
||||
}}
|
||||
className="w-full text-sm bg-transparent border-b border-[var(--accent)]
|
||||
text-[var(--text-primary)] focus:outline-none"
|
||||
<div key={session.id} className="relative">
|
||||
<div
|
||||
onClick={() => switchSession(session.id)}
|
||||
className={`group relative flex items-start gap-3 px-3 py-2.5 rounded-xl cursor-pointer
|
||||
transition-all ${
|
||||
session.id === activeSessionId
|
||||
? "glow-border !bg-[var(--bg-secondary)]"
|
||||
: "hover:bg-[var(--bg-tertiary)]/50 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
{session.thumbnail ? (
|
||||
<img
|
||||
src={getImageUrl(session.thumbnail)}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-lg object-cover flex-shrink-0 border border-[var(--border)]
|
||||
shadow-[0_0_8px_rgba(77,184,164,0.08)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-primary)] truncate">
|
||||
{session.title}
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--bg-primary)] border border-[var(--border)]
|
||||
flex items-center justify-center flex-shrink-0">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="1.5">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签 + 时间 */}
|
||||
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||
{session.tags.slice(0, 3).map((tid) => {
|
||||
const tag = tagMap.get(tid);
|
||||
if (!tag) return null;
|
||||
return (
|
||||
<span
|
||||
key={tid}
|
||||
className="text-[10px] px-1.5 py-px rounded-full"
|
||||
style={{ backgroundColor: tag.color + "22", color: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
{/* 会话信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingId === session.id ? (
|
||||
<input
|
||||
ref={editRef}
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitRename();
|
||||
if (e.key === "Escape") setEditingId(null);
|
||||
}}
|
||||
className="w-full text-sm bg-transparent border-b border-[var(--accent)]
|
||||
text-[var(--text-primary)] focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<div className={`text-sm truncate pr-5 ${
|
||||
session.id === activeSessionId ? "text-[var(--text-primary)] font-medium" : "text-[var(--text-primary)]"
|
||||
}`}>
|
||||
{session.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签 + 时间 */}
|
||||
<div className="flex items-center gap-1 mt-1 flex-wrap">
|
||||
{session.tags.slice(0, 3).map((tid) => {
|
||||
const tag = tagMap.get(tid);
|
||||
if (!tag) return null;
|
||||
return (
|
||||
<span
|
||||
key={tid}
|
||||
className="text-[10px] px-1.5 py-px rounded-full font-medium"
|
||||
style={{ backgroundColor: tag.color + "22", color: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{session.llmModel && (
|
||||
<span className="text-[10px] px-1.5 py-px rounded-full font-medium
|
||||
bg-[var(--accent)]/10 text-[var(--accent)]">
|
||||
{llmModels.find((m) => m.id === session.llmModel)?.name || session.llmModel}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="text-[10px] text-[var(--text-secondary)] ml-auto flex-shrink-0">
|
||||
{timeAgo(session.updatedAt)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-[var(--text-secondary)] ml-auto flex-shrink-0">
|
||||
{timeAgo(session.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 三点按钮 */}
|
||||
<button
|
||||
onClick={(e) => handleDots(e, session.id)}
|
||||
className="absolute right-2 top-2.5 p-1 rounded-md
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-tertiary)]
|
||||
opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<circle cx="12" cy="19" r="2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 下拉菜单:在会话项正下方展开 */}
|
||||
{menuSessionId === session.id && menuSession && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="mx-1 mt-1 glass-panel rounded-xl shadow-xl shadow-black/30 py-1.5
|
||||
overflow-hidden z-10 relative"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 模型选择(折叠/展开式) */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModelSub(!showModelSub);
|
||||
}}
|
||||
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 justify-between"
|
||||
>
|
||||
<span>对话模型</span>
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2"
|
||||
className={`transition-transform ${showModelSub ? "rotate-90" : ""}`}
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showModelSub && (
|
||||
<div className="border-t border-[var(--border)] mx-2 mt-1 pt-1 max-h-[240px] overflow-y-auto">
|
||||
{llmModels.map((model) => {
|
||||
const isActive = menuSession.llmModel
|
||||
? menuSession.llmModel === model.id
|
||||
: model.id === defaultLlmModel;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelectModel(menuSessionId!, model.id);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm cursor-pointer transition-colors
|
||||
rounded-lg flex items-center gap-2 ${
|
||||
isActive
|
||||
? "text-[var(--accent)] bg-[var(--accent)]/5"
|
||||
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 flex-shrink-0 text-center">
|
||||
{isActive && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{model.name}</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] truncate">{model.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-2 my-1 border-t border-[var(--border)]" />
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (menuSession) startRename(menuSession.id, menuSession.title);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
重命名
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteSession(menuSessionId!);
|
||||
closeMenu();
|
||||
}}
|
||||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--hot)]
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center text-xs text-[var(--text-secondary)] py-6">
|
||||
<div className="text-center text-xs text-[var(--text-secondary)] py-8">
|
||||
没有匹配的对话
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="fixed z-50 bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
rounded-lg shadow-xl py-1 min-w-[120px]"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
const s = sessions.find((s) => s.id === contextMenu.sessionId);
|
||||
if (s) startRename(s.id, s.title);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-sm text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer"
|
||||
>
|
||||
重命名
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteSession(contextMenu.sessionId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-sm text-red-400
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,30 +12,37 @@ export function Sidebar() {
|
||||
{/* 移动端遮罩 */}
|
||||
{!sidebarCollapsed && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-black/50 sidebar-overlay md:hidden"
|
||||
className="fixed inset-0 z-30 bg-black/60 sidebar-overlay md:hidden"
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`
|
||||
/* 移动端:固定定位抽屉 */
|
||||
fixed inset-y-0 left-0 z-40 w-[280px]
|
||||
md:relative md:inset-auto md:z-auto
|
||||
flex-shrink-0 border-r border-[var(--border)] bg-[var(--bg-secondary)]
|
||||
flex flex-col transition-transform duration-200
|
||||
flex-shrink-0 border-r border-[var(--border)]
|
||||
bg-[var(--bg-secondary)]/80 backdrop-blur-xl
|
||||
flex flex-col transition-transform duration-250 ease-in-out
|
||||
${sidebarCollapsed ? "-translate-x-full md:-translate-x-0 md:w-0 md:border-r-0" : "translate-x-0 md:w-[280px]"}
|
||||
${sidebarCollapsed ? "md:overflow-hidden" : ""}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--border)]">
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)] uppercase tracking-wider">
|
||||
对话
|
||||
</span>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2"
|
||||
style={{ filter: "drop-shadow(0 0 4px rgba(77, 184, 164, 0.35))" }}>
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
对话
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
transition-colors cursor-pointer p-0.5"
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
transition-colors cursor-pointer p-1 rounded-md
|
||||
hover:bg-[var(--accent)]/5"
|
||||
title="折叠侧边栏"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import type { Tag } from "@/lib/types";
|
||||
|
||||
const TAG_COLORS = [
|
||||
"#6366f1", "#f59e0b", "#10b981", "#ec4899",
|
||||
"#8b5cf6", "#06b6d4", "#f97316", "#84cc16",
|
||||
"#4DB8A4", "#3A8FB7", "#8b5cf6", "#B8935A",
|
||||
"#ec4899", "#C4654A", "#84cc16", "#6366f1",
|
||||
];
|
||||
|
||||
export function TagFilter() {
|
||||
@@ -34,15 +33,14 @@ export function TagFilter() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2 border-b border-[var(--border)]">
|
||||
{/* 标签 pills */}
|
||||
<div className="px-3 py-2.5 border-b border-[var(--border)]">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={clearFilter}
|
||||
className={`px-2 py-0.5 text-xs rounded-full transition-colors cursor-pointer ${
|
||||
className={`px-2.5 py-1 text-xs rounded-lg transition-all cursor-pointer ${
|
||||
selectedTagIds.length === 0
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
? "bg-[var(--accent)]/15 text-[var(--accent)] border border-[var(--accent)]/30"
|
||||
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
@@ -53,10 +51,10 @@ export function TagFilter() {
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggle(tag.id)}
|
||||
className="px-2 py-0.5 text-xs rounded-full transition-colors cursor-pointer border"
|
||||
className="px-2.5 py-1 text-xs rounded-lg transition-all cursor-pointer border"
|
||||
style={{
|
||||
backgroundColor: active ? tag.color + "22" : "transparent",
|
||||
borderColor: active ? tag.color : "var(--border)",
|
||||
backgroundColor: active ? tag.color + "18" : "transparent",
|
||||
borderColor: active ? tag.color + "60" : "var(--border)",
|
||||
color: active ? tag.color : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
@@ -66,74 +64,75 @@ export function TagFilter() {
|
||||
})}
|
||||
<button
|
||||
onClick={() => setShowManager(!showManager)}
|
||||
className="px-2 py-0.5 text-xs rounded-full
|
||||
className="px-2 py-1 text-xs rounded-lg
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
transition-colors cursor-pointer"
|
||||
hover:bg-[var(--accent)]/5
|
||||
transition-all cursor-pointer"
|
||||
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 1 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 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.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 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.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 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 标签管理面板 */}
|
||||
{showManager && (
|
||||
<div className="mt-2 p-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]">
|
||||
<div className="mt-2.5 p-3 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<input
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAddTag()}
|
||||
placeholder="新标签名称"
|
||||
className="flex-1 px-2 py-1 text-xs rounded bg-[var(--bg-primary)]
|
||||
className="flex-1 min-w-0 px-2.5 py-1.5 text-xs rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] text-[var(--text-primary)]
|
||||
placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]"
|
||||
focus:outline-none focus:border-[var(--accent)]/50"
|
||||
/>
|
||||
<div className="flex gap-0.5">
|
||||
{TAG_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setNewTagColor(c)}
|
||||
className="w-4 h-4 rounded-full cursor-pointer transition-transform"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
transform: newTagColor === c ? "scale(1.3)" : "scale(1)",
|
||||
boxShadow: newTagColor === c ? `0 0 0 2px var(--bg-tertiary), 0 0 0 3px ${c}` : "none",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddTag}
|
||||
disabled={!newTagName.trim()}
|
||||
className="px-2 py-1 text-xs rounded bg-[var(--accent)] text-white
|
||||
className="flex-shrink-0 px-2.5 py-1.5 text-xs rounded-lg bg-[var(--accent)] text-[var(--bg-primary)] font-medium
|
||||
disabled:opacity-40 cursor-pointer hover:bg-[var(--accent-hover)]
|
||||
transition-colors"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
{TAG_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setNewTagColor(c)}
|
||||
className="w-5 h-5 rounded-full cursor-pointer transition-transform"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
transform: newTagColor === c ? "scale(1.25)" : "scale(1)",
|
||||
boxShadow: newTagColor === c ? `0 0 0 2px var(--bg-tertiary), 0 0 0 3px ${c}` : "none",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 自定义标签列表(可删除) */}
|
||||
{tags.filter((t) => !t.builtin).length > 0 && (
|
||||
<div className="space-y-1 mt-1">
|
||||
<div className="space-y-1 mt-1.5">
|
||||
{tags
|
||||
.filter((t) => !t.builtin)
|
||||
.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between px-2 py-0.5 rounded text-xs"
|
||||
className="flex items-center justify-between px-2 py-1 rounded-lg text-xs
|
||||
hover:bg-[var(--bg-primary)]/50 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
{tag.name}
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: tag.color }} />
|
||||
<span className="text-[var(--text-primary)]">{tag.name}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => deleteTag(tag.id)}
|
||||
className="text-[var(--text-secondary)] hover:text-red-400 cursor-pointer"
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--hot)] cursor-pointer
|
||||
transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user