小优化,产品愿景,视觉规范,交互和导航大版本

This commit is contained in:
Nostars Developer
2026-04-16 18:11:35 +08:00
parent 5878f7d9f4
commit 10f9c0061a
51 changed files with 5010 additions and 847 deletions

View File

@@ -1,8 +1,12 @@
"use client";
import { useRef, useState, useCallback, useImperativeHandle, forwardRef, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
import { motion } from "framer-motion";
import { ModelSelector } from "./model-selector";
import { DataStream } from "@/components/ui/data-stream";
import { useRipple, RippleLayer } from "@/components/ui/ripple-effect";
import { uploadRefImage, type UploadProgress } from "@/lib/api";
import { sendBounce } from "@/components/ui/motion-presets";
type UploadStatus = "idle" | "uploading" | "done" | "error";
@@ -26,6 +30,8 @@ interface ChatInputProps {
onClearLastRefImage?: () => void;
/** 文件在 ChatInput 区域内被 drop 时触发,通知父组件清除拖拽覆盖层 */
onFileDrop?: () => void;
/** 是否正在生成图片(用于数据流光加速) */
isGeneratingImage?: boolean;
}
export interface ChatInputHandle {
@@ -34,7 +40,6 @@ export interface ChatInputHandle {
let _uploadCounter = 0;
/** 从剪贴板提取可上传的图片文件Ctrl+V / 右键粘贴共用) */
function imageFilesFromClipboard(data: DataTransfer | null): File[] {
if (!data?.items?.length) return [];
const out: File[] = [];
@@ -49,25 +54,38 @@ function imageFilesFromClipboard(data: DataTransfer | null): File[] {
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop },
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop, isGeneratingImage },
ref
) {
const [text, setText] = useState("");
const [uploads, setUploads] = useState<UploadItem[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [selectedModel, setSelectedModel] = useState<string>("");
const [isFocused, setIsFocused] = useState(false);
const { ripples, trigger: triggerRipple } = useRipple();
const fileInputRef = useRef<HTMLInputElement>(null);
const sendBtnRef = useRef<HTMLButtonElement>(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 canSend = text.trim() && !disabled && !hasActiveUpload;
const handleSubmit = () => {
const trimmed = text.trim();
if (!trimmed || disabled) return;
if (hasActiveUpload) return;
// 投石入水涟漪
if (sendBtnRef.current) {
const rect = sendBtnRef.current.getBoundingClientRect();
const parent = sendBtnRef.current.offsetParent as HTMLElement | null;
const parentRect = parent?.getBoundingClientRect() ?? rect;
triggerRipple(rect.left - parentRect.left + rect.width / 2, rect.top - parentRect.top + rect.height / 2);
}
const refUrls = doneUrls.length > 0 ? doneUrls : (lastRefServerUrls ?? []);
onSend(trimmed, refUrls, selectedModel || null);
setText("");
@@ -183,7 +201,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
return (
<div
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 ${
transition-colors ${
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]/40" : ""
}`}
onDragOver={handleDragOver}
@@ -191,192 +209,210 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
onDrop={handleDrop}
onPasteCapture={handlePasteCapture}
>
{isDragging && (
<div className="mb-2 text-center text-xs text-[var(--accent)]">
</div>
)}
{/* 数据流光 */}
<DataStream active={disabled} speed={isGeneratingImage ? "fast" : "slow"} />
{/* 沿用上次参考图提示 */}
{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
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)]"
/>
))}
<div className="relative px-3 md:px-5 py-2.5 md:py-3">
{isDragging && (
<div className="mb-2 text-center text-xs text-[var(--accent)]">
</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>
)}
)}
{/* 参考图上传状态区(多图) */}
{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 && (
{/* 沿用上次参考图提示 */}
{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={item.previewUrl}
alt="参考图"
className={`w-16 h-16 rounded-xl object-cover border border-[var(--border)] transition-opacity ${
item.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(46,139,122,0.1)]"
/>
))}
</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>
)}
{/* 参考图上传状态区(多图) */}
{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" : ""
}`}
/>
)}
{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(46,139,122,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(46,139,122,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(46,139,122,0.3)]" />
...
</span>
)}
{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>
{allDone && (
<span className="text-xs text-[var(--accent)]">
{doneUrls.length}
</span>
)}
{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>
{uploads.some((u) => u.status === "error") && (
<span className="text-xs text-[var(--hot)]">
{uploads.filter((u) => u.status === "error").length}
</span>
)}
</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>
)}
{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 className="flex items-end gap-2">
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
{/* 上传参考图按钮 */}
<button
onClick={() => fileInputRef.current?.click()}
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(--accent)] hover:border-[var(--accent)]/40
hover:bg-[var(--accent)]/5
flex items-center justify-center transition-all
disabled:opacity-50 cursor-pointer btn-hover-lift ${
doneUrls.length > 0
? "border-[var(--accent)]/40 text-[var(--accent)]"
: "border-[var(--border)]"
}`}
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"
/>
{/* 文字输入框 — 水面感应 */}
<div className={`relative flex-1 ${isFocused ? "water-focus-active" : ""}`}>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder="描述你想要的美术资源...(可粘贴图片)"
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
disabled={disabled}
rows={1}
className="w-full 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)]/50
disabled:opacity-50 transition-all
min-h-[42px] max-h-[120px]"
style={{
fieldSizing: "content",
boxShadow: isFocused
? `0 0 ${8 + Math.min(text.length, 50) * 0.3}px rgba(46,139,122,${0.04 + Math.min(text.length, 50) * 0.002})`
: "none",
} as React.CSSProperties}
/>
<div className="water-focus-line" />
</div>
{/* 发送按钮 — 投石入水 */}
<motion.button
ref={sendBtnRef}
onClick={handleSubmit}
disabled={!canSend}
whileTap={canSend ? sendBounce : undefined}
className={`flex-shrink-0 w-10 h-10 rounded-xl
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
hover:shadow-[0_0_15px_rgba(46,139,122,0.25)]
flex items-center justify-center transition-all
disabled:opacity-50 cursor-pointer
${canSend ? "breath-pulse" : ""}`}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
</motion.button>
</div>
)}
<div className="flex items-end gap-2">
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
{/* 上传参考图按钮 */}
<button
onClick={() => fileInputRef.current?.click()}
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(--accent)] hover:border-[var(--accent)]/40
hover:bg-[var(--accent)]/5
flex items-center justify-center transition-all
disabled:opacity-50 cursor-pointer ${
doneUrls.length > 0
? "border-[var(--accent)]/40 text-[var(--accent)]"
: "border-[var(--border)]"
}`}
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"
/>
{/* 文字输入框 */}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="描述你想要的美术资源...(可粘贴图片)"
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
disabled={disabled}
rows={1}
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)]/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}
/>
{/* 发送按钮 */}
<button
onClick={handleSubmit}
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)]
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">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
</button>
{/* 涟漪层 */}
<RippleLayer ripples={ripples} />
</div>
</div>
);

View File

@@ -1,8 +1,29 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import type { ChatMessage, ImageAsset } from "@/lib/types";
import { ImageGrid } from "./image-grid";
import { messageEnter, assistantEnter } from "@/components/ui/motion-presets";
function StreamingLines({ text }: { text: string }) {
const lines = useMemo(() => text.split("\n"), [text]);
const total = lines.length;
return (
<div className="whitespace-pre-wrap text-sm leading-relaxed">
{lines.map((line, i) => {
const fromEnd = total - 1 - i;
const opacity = fromEnd <= 0 ? 1 : fromEnd === 1 ? 0.85 : fromEnd === 2 ? 0.7 : 0.6;
return (
<span key={i} style={{ opacity, transition: "opacity 0.3s ease" }}>
{line}
{i < total - 1 && "\n"}
</span>
);
})}
</div>
);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
@@ -21,7 +42,7 @@ function CopyButton({ text }: { text: string }) {
<button
onClick={handleCopy}
className="p-1 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:bg-[var(--accent)]/5 transition-colors cursor-pointer"
hover:bg-[var(--accent)]/5 transition-colors cursor-pointer btn-hover-lift"
title="复制文本"
>
{copied ? (
@@ -56,17 +77,19 @@ export function ChatMessages({
return (
<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
<motion.div
key={msg.id}
variants={msg.role === "user" ? messageEnter : assistantEnter}
initial="hidden"
animate="visible"
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-4 md:px-5 py-3 ${
className={`max-w-[90vw] md:max-w-[80vw] px-4 md:px-5 py-3 ${
msg.role === "user"
? "bg-[var(--accent)]/15 text-[var(--text-primary)] border border-[var(--accent)]/25"
: "glass-panel text-[var(--text-primary)]"
? "user-bubble bg-gradient-to-r from-[var(--accent)]/8 to-[var(--accent)]/15 text-[var(--text-primary)]"
: "rounded-2xl surface-2 text-[var(--text-primary)] border-l-2 border-l-[var(--accent)]/40"
}`}
>
{/* 多图参考(新格式) */}
@@ -80,11 +103,11 @@ export function ChatMessages({
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)]"
shadow-[0_0_10px_rgba(46,139,122,0.08)]"
/>
))}
</div>
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block">
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block typo-micro" style={{ textTransform: "none" }}>
{msg.refImageUrls.length}
</span>
</div>
@@ -97,7 +120,7 @@ export function ChatMessages({
alt="参考图"
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)]"
shadow-[0_0_10px_rgba(46,139,122,0.08)]"
/>
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block"></span>
</div>
@@ -107,7 +130,7 @@ export function ChatMessages({
<>
<ImageGrid images={msg.images} />
{msg.modelName && (
<div className="mt-1.5 text-[10px] text-[var(--accent)]/50">
<div className="mt-1.5 text-[10px] text-[var(--accent)]/50 typo-caption">
{msg.modelName}
</div>
)}
@@ -121,33 +144,43 @@ export function ChatMessages({
</div>
)}
</div>
</div>
</motion.div>
))}
{isLoading && (
<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
shadow-[0_0_8px_rgba(77,184,164,0.4)]" />
{statusText}
</div>
)}
{streamingText && (
<div className="whitespace-pre-wrap text-sm leading-relaxed">{streamingText}</div>
)}
{streamingImages.length > 0 && <ImageGrid images={streamingImages} />}
{!streamingText && !statusText && (
<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>
</div>
)}
{/* AI 正在回复(流式) */}
<AnimatePresence>
{isLoading && (
<motion.div
key="streaming"
variants={assistantEnter}
initial="hidden"
animate="visible"
exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="flex justify-start"
>
<div className="max-w-[90%] md:max-w-[80%] rounded-2xl px-4 md:px-5 py-3 surface-2 border-l-2 border-l-[var(--accent)]/40">
{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
shadow-[0_0_8px_rgba(46,139,122,0.4)]" />
{statusText}
</div>
)}
{streamingText && (
<StreamingLines text={streamingText} />
)}
{streamingImages.length > 0 && <ImageGrid images={streamingImages} />}
{!streamingText && !statusText && (
<div className="flex items-end gap-1 h-5">
<div className="wave-dot" style={{ animationDelay: "0s" }} />
<div className="wave-dot" style={{ animationDelay: "0.15s" }} />
<div className="wave-dot" style={{ animationDelay: "0.3s" }} />
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,15 +1,43 @@
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
import { getImageUrl } from "@/lib/api";
import { useApp } from "@/lib/app-context";
import { useFireflyBurst, FireflyBurst } from "@/components/ui/firefly-burst";
import { inkReveal, staggerContainer, staggerItem } from "@/components/ui/motion-presets";
import type { ImageAsset } from "@/lib/types";
interface ImageGridProps {
images: ImageAsset[];
}
function FavoriteButton({ asset }: { asset: ImageAsset }) {
const { toggleFavorite } = useApp();
const { sparks, burst } = useFireflyBurst();
return (
<button
onClick={(e) => {
e.stopPropagation();
if (!asset.favorited) burst();
toggleFavorite(asset.id);
}}
className="relative p-1.5 rounded-lg cursor-pointer transition-colors
hover:bg-[var(--accent)]/10"
style={{ color: asset.favorited ? "var(--gold)" : "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">
<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>
<FireflyBurst sparks={sparks} />
</button>
);
}
export function ImageGrid({ images }: ImageGridProps) {
const { setDetailImage, toggleFavorite } = useApp();
const { setDetailImage } = useApp();
const handleDownload = async (imageUrl: string, index: number) => {
try {
@@ -35,21 +63,29 @@ export function ImageGrid({ images }: ImageGridProps) {
: "grid-cols-2 max-w-[320px] md:max-w-xl";
return (
<div className={`grid ${gridCols} gap-3 my-3`}>
<motion.div
className={`grid ${gridCols} gap-3 my-3`}
variants={staggerContainer}
initial="hidden"
animate="visible"
>
{images.map((asset, i) => (
<div
<motion.div
key={asset.id}
variants={staggerItem}
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
transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
onClick={() => setDetailImage(asset)}
/>
<ImageReveal src={getImageUrl(asset.url)} alt={`生成图片 ${i + 1}`}>
<img
src={getImageUrl(asset.url)}
alt={`生成图片 ${i + 1}`}
className="w-full aspect-square object-cover cursor-pointer
transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
onClick={() => setDetailImage(asset)}
/>
</ImageReveal>
{/* 底部渐变 + 操作栏 */}
<div
@@ -79,34 +115,47 @@ export function ImageGrid({ images }: ImageGridProps) {
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
toggleFavorite(asset.id);
}}
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">
<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>
</button>
<FavoriteButton asset={asset} />
</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
<span className="typo-micro px-2 py-0.5 rounded-md
bg-[var(--accent)]/20 text-[var(--accent)]
border border-[var(--accent)]/30 backdrop-blur-sm">
border border-[var(--accent)]/30 backdrop-blur-sm"
style={{ textTransform: "uppercase" }}>
New
</span>
</div>
)}
</div>
</motion.div>
))}
</motion.div>
);
}
function ImageReveal({ src, alt, children }: { src: string; alt: string; children: React.ReactNode }) {
const [loaded, setLoaded] = useState(false);
return (
<div className="relative">
{!loaded && (
<div className="w-full aspect-square stone-placeholder" />
)}
<motion.div
variants={inkReveal}
initial="hidden"
animate={loaded ? "visible" : "hidden"}
>
<img
src={src}
alt={alt}
className="hidden"
onLoad={() => setLoaded(true)}
/>
{children}
</motion.div>
</div>
);
}

View File

@@ -1,8 +1,10 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import type { ImageModelInfo } from "@/lib/types";
import { fetchModels } from "@/lib/api";
import { slideUp } from "@/components/ui/motion-presets";
import { getStoreUserId } from "@/lib/store";
@@ -61,7 +63,7 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:border-[var(--accent)]/40 hover:bg-[var(--accent)]/5
transition-all cursor-pointer"
transition-all cursor-pointer btn-hover-lift"
title="切换生图模型"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -79,49 +81,56 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
</svg>
</button>
{open && (
<div
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;
return (
<button
key={m.id}
onClick={() => {
onChange(m.id);
localStorage.setItem(getModelStorageKey(), m.id);
setOpen(false);
}}
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)]/8 text-[var(--accent)]"
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
}`}
>
<span className="text-sm font-medium flex items-center gap-1.5">
{m.name}
{m.supports_ref_image && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full
bg-[var(--accent)]/10 text-[var(--accent)] font-medium leading-none
border border-[var(--accent)]/20">
</span>
)}
{isActive && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
)}
</span>
<span className="text-xs text-[var(--text-secondary)]">{m.description}</span>
</button>
);
})}
</div>
)}
<AnimatePresence>
{open && (
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
className="absolute bottom-full left-0 mb-1.5 w-56 rounded-xl
surface-3 overflow-hidden z-50"
>
{models.map((m) => {
const isActive = m.id === value;
return (
<button
key={m.id}
onClick={() => {
onChange(m.id);
localStorage.setItem(getModelStorageKey(), m.id);
setOpen(false);
}}
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)]/8 text-[var(--accent)]"
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
}`}
>
<span className="text-sm font-medium flex items-center gap-1.5">
{m.name}
{m.supports_ref_image && (
<span className="typo-micro px-1.5 py-0.5 rounded-full
bg-[var(--accent)]/10 text-[var(--accent)] leading-none
border border-[var(--accent)]/20"
style={{ textTransform: "none", fontSize: "10px" }}>
</span>
)}
{isActive && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
)}
</span>
<span className="typo-caption">{m.description}</span>
</button>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,9 +1,12 @@
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
import { getImageUrl } from "@/lib/api";
import { useApp } from "@/lib/app-context";
import { AnnotationCanvas } from "./annotation-canvas";
import { useFireflyBurst, FireflyBurst } from "@/components/ui/firefly-burst";
import { slideUp } from "@/components/ui/motion-presets";
import type { Annotation, AnnotationData } from "@/lib/types";
interface ImageDetailPanelProps {
@@ -14,6 +17,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
const { detailImage, setDetailImage, toggleFavorite, tags, deleteAssetById } = useApp();
const [scale, setScale] = useState(1);
const [showAnnotate, setShowAnnotate] = useState(false);
const { sparks, burst } = useFireflyBurst();
if (!detailImage) return null;
@@ -67,10 +71,15 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
}
return (
<aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
<motion.aside
variants={slideUp}
initial="hidden"
animate="visible"
className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
md:w-[360px] flex-shrink-0 border-l border-[var(--border)]
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
flex flex-col overflow-hidden">
flex flex-col overflow-hidden"
>
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
@@ -103,7 +112,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
hover:border-[var(--accent)]/40 cursor-pointer transition-all btn-hover-lift"
>
</button>
@@ -114,7 +123,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
hover:border-[var(--accent)]/40 cursor-pointer transition-all btn-hover-lift"
>
+
</button>
@@ -122,7 +131,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
onClick={() => setScale(1)}
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
hover:border-[var(--accent)]/40 cursor-pointer transition-all btn-hover-lift"
>
</button>
@@ -135,8 +144,8 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
onClick={handleDownload}
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(77,184,164,0.25)]
transition-all cursor-pointer"
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
transition-all cursor-pointer btn-hover-lift"
>
<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" />
@@ -144,11 +153,14 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
</button>
<button
onClick={() => toggleFavorite(detailImage.id)}
className={`flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
border transition-all cursor-pointer ${
onClick={() => {
if (!detailImage.favorited) burst();
toggleFavorite(detailImage.id);
}}
className={`relative flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
border transition-all cursor-pointer btn-hover-lift ${
detailImage.favorited
? "border-[var(--accent)]/40 bg-[var(--accent)]/8 text-[var(--accent)]"
? "border-[var(--gold)]/40 bg-[var(--gold)]/8 text-[var(--gold)]"
: "border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
@@ -156,13 +168,14 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
<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 ? "已收藏" : "收藏"}
<FireflyBurst sparks={sparks} />
</button>
<button
onClick={() => setShowAnnotate(true)}
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 transition-all cursor-pointer"
hover:border-[var(--accent)]/40 transition-all cursor-pointer btn-hover-lift"
>
<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" />
@@ -174,7 +187,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
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"
hover:border-[var(--hot)]/40 transition-all cursor-pointer btn-hover-lift"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
@@ -188,7 +201,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
{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>
<span className="typo-caption font-medium">Prompt</span>
<button
onClick={handleCopyPrompt}
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
@@ -207,7 +220,7 @@ 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>
<span className="typo-caption font-medium block mb-1.5"></span>
<div className="flex flex-wrap gap-1.5">
{detailImage.tags.map((tid) => {
const tag = tagMap.get(tid);
@@ -228,12 +241,12 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
{/* 元信息 */}
<div className="px-4 pb-4">
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5"></span>
<span className="typo-caption font-medium 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>
</motion.aside>
);
}

View File

@@ -1,11 +1,13 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
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";
import { slideUp } from "@/components/ui/motion-presets";
const NAV_ITEMS = [
{
@@ -51,7 +53,7 @@ export function TopNav() {
return (
<>
<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">
<header className="relative z-10 flex-shrink-0 h-14 border-b border-[var(--border)] surface-1 backdrop-blur-xl flex items-center px-4 md:px-5 gap-4 md:gap-6">
{/* 移动端汉堡菜单 */}
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
@@ -68,13 +70,13 @@ export function TopNav() {
{/* Logo */}
<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">
shadow-[0_0_12px_rgba(46,139,122,0.25)]
group-hover:shadow-[0_0_20px_rgba(46,139,122,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" />
<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>
</div>
<span className="text-base font-bold tracking-tight text-[var(--text-primary)]">
@@ -90,10 +92,10 @@ export function TopNav() {
<Link
key={href}
href={href}
className={`relative flex items-center gap-1.5 px-3 md:px-4 py-2 text-sm rounded-lg ${
className={`relative flex items-center gap-1.5 px-3 md:px-4 py-2 text-sm rounded-lg transition-all ${
active
? "glow-border text-[var(--accent)] font-medium !bg-[var(--bg-secondary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent hover:border-[var(--border-glow)] transition-all"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent hover:border-[var(--border-glow)]"
}`}
>
{icon}
@@ -123,33 +125,41 @@ export function TopNav() {
{user.display_name || user.username}
</span>
</button>
{menuOpen && (
<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"
<AnimatePresence>
{menuOpen && (
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
className="absolute right-0 top-full mt-1.5 w-48 rounded-xl
surface-3 py-1.5 z-50"
>
<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.5 py-2 text-sm text-[var(--text-secondary)]
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
transition-colors cursor-pointer"
>
退
</button>
</div>
)}
<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.5 py-2 text-sm text-[var(--text-secondary)]
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
transition-colors cursor-pointer"
>
退
</button>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</header>

View File

@@ -1,7 +1,9 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useAuth, type AuthUser } from "@/lib/auth-context";
import { motion } from "framer-motion";
import { useAuth } from "@/lib/auth-context";
import { slideUp } from "@/components/ui/motion-presets";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
@@ -107,14 +109,20 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
onClick={onClose}
>
{/* 遮罩层 */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
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)]
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
className="relative w-[90vw] max-w-[560px] max-h-[80vh] rounded-2xl surface-3
flex flex-col overflow-hidden"
style={{ animation: "slideUp 200ms ease-out" }}
onClick={(e) => e.stopPropagation()}
>
{/* 关闭按钮 */}
@@ -133,18 +141,19 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
<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)]">
shadow-[0_0_20px_rgba(46,139,122,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">
<h2 className="typo-h1 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>
<span className="typo-caption">@{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 className="typo-micro px-1.5 py-0.5 rounded-md bg-[var(--accent)]/10
text-[var(--accent)] border border-[var(--accent)]/30"
style={{ textTransform: "none" }}>
</span>
)}
@@ -152,7 +161,6 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
</div>
</div>
{/* 分割线 */}
<div className="mx-6 h-px bg-[var(--border)]" />
{/* 记忆区标题 */}
@@ -161,9 +169,9 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
<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>
<span className="typo-strong text-sm"></span>
{!loading && (
<span className="text-xs text-[var(--text-secondary)]">
<span className="typo-caption">
{memories.length}
</span>
)}
@@ -173,14 +181,17 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
<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" />
<div className="flex items-end gap-1 h-5">
<div className="wave-dot" style={{ animationDelay: "0s" }} />
<div className="wave-dot" style={{ animationDelay: "0.15s" }} />
<div className="wave-dot" style={{ animationDelay: "0.3s" }} />
</div>
<span className="ml-3 text-sm text-[var(--text-secondary)]">...</span>
</div>
)}
{!loading && error && memories.length === 0 && (
<div className="text-center py-12">
<div className="text-center py-12 error-shake">
<p className="text-sm text-[var(--hot)]">{error}</p>
</div>
)}
@@ -193,7 +204,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
<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 className="typo-caption mt-1 opacity-60">
</p>
</div>
@@ -201,7 +212,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
{!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">
<div className="typo-micro mb-2">
{group.label}
</div>
<div className="space-y-1.5">
@@ -216,7 +227,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
{item.memory}
</p>
{item.created_at && (
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-0
<p className="typo-caption mt-1 opacity-0
group-hover:opacity-60 transition-opacity">
{formatDate(item.created_at)}
</p>
@@ -227,7 +238,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
</div>
))}
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -1,8 +1,10 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useApp } from "@/lib/app-context";
import { getImageUrl, fetchLlmModels } from "@/lib/api";
import { slideUp } from "@/components/ui/motion-presets";
import type { LlmModelInfo } from "@/lib/types";
function timeAgo(ts: number): string {
@@ -111,7 +113,7 @@ export function SessionList() {
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"
transition-all cursor-pointer btn-hover-lift"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M5 12h14" />
@@ -127,7 +129,7 @@ export function SessionList() {
<div
onClick={() => switchSession(session.id)}
className={`group relative flex items-start gap-3 px-3 py-2.5 rounded-xl cursor-pointer
transition-all ${
transition-all session-hover-line ${
session.id === activeSessionId
? "glow-border !bg-[var(--bg-secondary)]"
: "hover:bg-[var(--bg-tertiary)]/50 border border-transparent"
@@ -139,7 +141,7 @@ export function SessionList() {
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)]"
shadow-[0_0_8px_rgba(46,139,122,0.08)]"
/>
) : (
<div className="w-10 h-10 rounded-lg bg-[var(--bg-primary)] border border-[var(--border)]
@@ -181,8 +183,8 @@ export function SessionList() {
return (
<span
key={tid}
className="text-[10px] px-1.5 py-px rounded-full font-medium"
style={{ backgroundColor: tag.color + "22", color: tag.color }}
className="typo-micro px-1.5 py-px rounded-full"
style={{ backgroundColor: tag.color + "22", color: tag.color, textTransform: "none", fontSize: "10px" }}
>
{tag.name}
</span>
@@ -194,7 +196,7 @@ export function SessionList() {
{llmModels.find((m) => m.id === session.llmModel)?.name || session.llmModel}
</span>
)}
<span className="text-[10px] text-[var(--text-secondary)] ml-auto flex-shrink-0">
<span className="typo-caption ml-auto flex-shrink-0" style={{ fontSize: "10px" }}>
{timeAgo(session.updatedAt)}
</span>
</div>
@@ -216,95 +218,100 @@ export function SessionList() {
</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"
{/* 下拉菜单 */}
<AnimatePresence>
{menuSessionId === session.id && menuSession && (
<motion.div
ref={menuRef}
variants={slideUp}
initial="hidden"
animate="visible"
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
className="mx-1 mt-1 surface-3 rounded-xl py-1.5
overflow-hidden z-10 relative"
onClick={(e) => e.stopPropagation()}
>
<span></span>
<svg
width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2"
className={`transition-transform ${showModelSub ? "rotate-90" : ""}`}
<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"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
<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>
)}
{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)]" />
<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={() => {
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>
)}
<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>
</motion.div>
)}
</AnimatePresence>
</div>
))}

View File

@@ -22,7 +22,7 @@ export function Sidebar() {
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)]/80 backdrop-blur-xl
surface-1 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" : ""}
@@ -31,10 +31,10 @@ export function Sidebar() {
<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))" }}>
style={{ filter: "drop-shadow(0 0 4px rgba(46, 139, 122, 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 className="typo-strong text-sm">
</span>
</div>

View File

@@ -0,0 +1,78 @@
"use client";
import { useMemo } from "react";
interface Particle {
id: number;
left: string;
top: string;
size: number;
delay: string;
duration: string;
opacity: number;
}
function generateParticles(count: number): Particle[] {
const particles: Particle[] = [];
for (let i = 0; i < count; i++) {
particles.push({
id: i,
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
size: 2 + Math.random() * 3,
delay: `${Math.random() * 20}s`,
duration: `${15 + Math.random() * 25}s`,
opacity: 0.12 + Math.random() * 0.18,
});
}
return particles;
}
interface AmbientParticlesProps {
count?: number;
}
export function AmbientParticles({ count = 18 }: AmbientParticlesProps) {
const particles = useMemo(() => generateParticles(count), [count]);
return (
<div className="fixed inset-0 pointer-events-none z-0 overflow-hidden" aria-hidden="true">
{/* 远景雾 */}
<div className="fog-layer-far" />
{/* 近景雾 */}
<div className="fog-layer-near" />
{/* 萤火粒子 */}
{particles.map((p) => (
<div
key={p.id}
className="absolute rounded-full"
style={{
left: p.left,
top: p.top,
width: p.size,
height: p.size,
backgroundColor: "var(--accent)",
opacity: p.opacity,
animation: `particleDrift ${p.duration} ease-in-out infinite, particleFlicker 4s ease-in-out infinite`,
animationDelay: `${p.delay}, ${parseFloat(p.delay) + 1}s`,
boxShadow: `0 0 ${p.size * 3}px rgba(46, 139, 122, ${p.opacity * 0.6})`,
willChange: "transform, opacity",
}}
/>
))}
<style>{`
@keyframes particleDrift {
0% { transform: translate(0, 0); }
25% { transform: translate(${20 + Math.random() * 30}px, -${15 + Math.random() * 20}px); }
50% { transform: translate(-${10 + Math.random() * 20}px, ${10 + Math.random() * 25}px); }
75% { transform: translate(${15 + Math.random() * 20}px, ${5 + Math.random() * 15}px); }
100% { transform: translate(0, 0); }
}
@keyframes particleFlicker {
0%, 100% { opacity: var(--particle-base-opacity, 0.15); }
50% { opacity: calc(var(--particle-base-opacity, 0.15) * 2.2); }
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
interface DataStreamProps {
active: boolean;
speed?: "slow" | "normal" | "fast";
}
export function DataStream({ active, speed = "normal" }: DataStreamProps) {
if (!active) return null;
const speedClass =
speed === "slow" ? "data-stream-slow" :
speed === "fast" ? "data-stream-fast" : "";
return (
<div className="w-full overflow-hidden" aria-hidden="true">
<div className={`data-stream ${speedClass}`} />
</div>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { sparkleAnim } from "./motion-presets";
interface Spark {
id: number;
angle: number;
distance: number;
}
let _sparkId = 0;
export function useFireflyBurst() {
const [sparks, setSparks] = useState<Spark[]>([]);
const burst = useCallback(() => {
const count = 5 + Math.floor(Math.random() * 3);
const newSparks: Spark[] = [];
for (let i = 0; i < count; i++) {
newSparks.push({
id: ++_sparkId,
angle: (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.5,
distance: 12 + Math.random() * 18,
});
}
setSparks(newSparks);
setTimeout(() => setSparks([]), 1000);
}, []);
return { sparks, burst };
}
interface FireflyBurstProps {
sparks: Spark[];
}
export function FireflyBurst({ sparks }: FireflyBurstProps) {
return (
<AnimatePresence>
{sparks.map((s) => (
<motion.div
key={s.id}
className="absolute pointer-events-none"
style={{
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: "var(--gold)",
boxShadow: "0 0 6px var(--gold)",
left: "50%",
top: "50%",
marginLeft: -2,
marginTop: -2,
}}
animate={sparkleAnim(s.angle, s.distance)}
/>
))}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,113 @@
import type { Transition, Variants } from "framer-motion";
/* ===== Spring 物理参数 ===== */
export const spring: Transition = { type: "spring", stiffness: 400, damping: 25 };
export const springGentle: Transition = { type: "spring", stiffness: 200, damping: 20 };
/* ===== 通用入场变体 ===== */
export const fadeIn: Variants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.2 } },
};
export const slideUp: Variants = {
hidden: { opacity: 0, y: 8 },
visible: { opacity: 1, y: 0, transition: { duration: 0.2, ease: "easeOut" } },
};
export const mistReveal: Variants = {
hidden: { opacity: 0, filter: "blur(8px)" },
visible: { opacity: 1, filter: "blur(0px)", transition: { duration: 0.4, ease: "easeOut" } },
};
/* ===== 消息入场 ===== */
export const messageEnter: Variants = {
hidden: { opacity: 0, y: 12, scale: 0.98 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.25, ease: "easeOut" },
},
};
export const assistantEnter: Variants = {
hidden: { opacity: 0, filter: "blur(8px)", y: 6 },
visible: {
opacity: 1,
filter: "blur(0px)",
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
/* ===== 按钮反馈 ===== */
export const buttonTap = { scale: 0.85 };
export const sendBounce = {
scale: [1, 0.85, 1.08, 1],
transition: { duration: 0.35, ease: "easeInOut" },
};
/* ===== 涟漪 ===== */
export const rippleAnim = {
scale: [0, 2.5],
opacity: [0.4, 0],
transition: { duration: 0.6, ease: "easeOut" as const },
};
/* ===== 萤火粒子 ===== */
export const sparkleAnim = (angle: number, distance: number) => ({
x: [0, Math.cos(angle) * distance],
y: [0, Math.sin(angle) * distance],
scale: [0, 1.2, 0],
opacity: [0, 1, 0],
transition: { duration: 0.7 + Math.random() * 0.3, ease: "easeOut" as const },
});
/* ===== 滚动到底部按钮 — 落叶归根 ===== */
export const scrollBtnEnter: Variants = {
hidden: { opacity: 0, y: 20, scale: 0.8 },
visible: { opacity: 1, y: 0, scale: 1, transition: { ...springGentle } },
exit: { opacity: 0, y: 10, scale: 0.8, transition: { duration: 0.15 } },
};
export const scrollBtnClick = {
y: [-0, -6, 20],
opacity: [1, 1, 0],
scale: [1, 1.1, 0.8],
transition: { duration: 0.35, ease: "easeIn" as const },
};
/* ===== 会话切换 — 翻山越岭 ===== */
export const sessionSlide: Variants = {
enter: (dir: number) => ({ opacity: 0, y: dir > 0 ? 30 : -30 }),
center: { opacity: 1, y: 0, transition: { duration: 0.25, ease: "easeOut" } },
exit: (dir: number) => ({ opacity: 0, y: dir > 0 ? -30 : 30, transition: { duration: 0.2, ease: "easeIn" } }),
};
/* ===== Error 飘散消失 ===== */
export const errorFloat: Variants = {
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -12, transition: { duration: 0.5, ease: "easeOut" } },
};
/* ===== stagger 容器 ===== */
export const staggerContainer: Variants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.06 },
},
};
export const staggerItem: Variants = {
hidden: { opacity: 0, y: 8 },
visible: { opacity: 1, y: 0, transition: { duration: 0.2 } },
};
/* ===== 水墨揭示 ===== */
export const inkReveal: Variants = {
hidden: { clipPath: "circle(0% at 50% 50%)" },
visible: {
clipPath: "circle(75% at 50% 50%)",
transition: { duration: 0.6, ease: "easeOut" },
},
};

View File

@@ -0,0 +1,53 @@
"use client";
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { rippleAnim } from "./motion-presets";
interface Ripple {
id: number;
x: number;
y: number;
}
let _rippleId = 0;
export function useRipple() {
const [ripples, setRipples] = useState<Ripple[]>([]);
const trigger = useCallback((x: number, y: number) => {
const id = ++_rippleId;
setRipples((prev) => [...prev, { id, x, y }]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== id));
}, 700);
}, []);
return { ripples, trigger };
}
interface RippleLayerProps {
ripples: Ripple[];
}
export function RippleLayer({ ripples }: RippleLayerProps) {
return (
<AnimatePresence>
{ripples.map((r) => (
<motion.div
key={r.id}
className="absolute pointer-events-none"
style={{
left: r.x - 10,
top: r.y - 10,
width: 20,
height: 20,
borderRadius: "50%",
border: "2px solid var(--accent)",
}}
animate={rippleAnim}
/>
))}
</AnimatePresence>
);
}