加了一堆模型和一堆功能

This commit is contained in:
2026-04-16 00:36:21 +08:00
parent 47c0863bab
commit 40de992516
34 changed files with 3111 additions and 718 deletions

View File

@@ -85,7 +85,7 @@ export default function GalleryPage() {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch {
// 忽略单张下载失败
// ignore
}
}
};
@@ -109,17 +109,29 @@ export default function GalleryPage() {
};
return (
<div className="h-screen flex flex-col">
<div className="h-screen flex flex-col relative z-[1]">
<TopNav />
<div className="flex-1 flex overflow-hidden">
{/* 主内容 */}
<main className="flex-1 flex flex-col min-w-0">
{/* 工具栏 */}
<div className="flex-shrink-0 border-b border-[var(--border)] bg-[var(--bg-secondary)] px-3 md:px-4 py-2
<div className="flex-shrink-0 border-b border-[var(--border)] bg-[var(--bg-secondary)]/80
backdrop-blur-xl px-4 md:px-5 py-3
flex items-center gap-2 md:gap-3 flex-wrap">
{/* 区块标题 */}
<div className="flex items-center gap-2 mr-3">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" 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>
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
</div>
{/* 搜索 */}
<div className="flex items-center gap-1.5 flex-1 min-w-[140px] md:min-w-[200px] max-w-[360px]">
<div className="flex items-center gap-1.5 flex-1 min-w-[140px] md:min-w-[200px] max-w-[360px]
px-3 py-1.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
focus-within:border-[var(--accent)]/40 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
@@ -128,14 +140,14 @@ export default function GalleryPage() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索 Prompt..."
className="flex-1 px-2 py-1 text-sm bg-transparent border-none
className="flex-1 bg-transparent border-none text-sm
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none"
/>
</div>
{/* 标签筛选 */}
<div className="flex items-center gap-1 flex-wrap">
<div className="flex items-center gap-1.5 flex-wrap">
{tags.map((tag) => {
const active = selectedTagIds.includes(tag.id);
return (
@@ -146,10 +158,10 @@ export default function GalleryPage() {
active ? prev.filter((id) => id !== tag.id) : [...prev, tag.id]
)
}
className="px-2 py-0.5 text-[10px] rounded-full cursor-pointer border transition-colors"
className="px-2.5 py-1 text-[11px] rounded-lg cursor-pointer border transition-all font-medium"
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)",
}}
>
@@ -164,11 +176,12 @@ export default function GalleryPage() {
{/* 收藏筛选 */}
<button
onClick={() => setShowFavOnly(!showFavOnly)}
className="flex items-center gap-1 px-2 py-1 text-xs rounded cursor-pointer transition-colors"
style={{
color: showFavOnly ? "#f59e0b" : "var(--text-secondary)",
backgroundColor: showFavOnly ? "#f59e0b15" : "transparent",
}}
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-lg cursor-pointer
transition-all border ${
showFavOnly
? "border-[var(--accent)]/30 bg-[var(--accent)]/8 text-[var(--accent)]"
: "border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill={showFavOnly ? "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" />
@@ -180,19 +193,22 @@ export default function GalleryPage() {
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortBy)}
className="text-xs px-2 py-1 rounded bg-[var(--bg-tertiary)] border border-[var(--border)]
text-[var(--text-secondary)] cursor-pointer focus:outline-none"
className="text-xs px-2.5 py-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
text-[var(--text-secondary)] cursor-pointer focus:outline-none
focus:border-[var(--accent)]/40"
>
<option value="time"></option>
<option value="name"></option>
</select>
{/* 视图切换 */}
<div className="flex items-center gap-0.5 bg-[var(--bg-tertiary)] rounded p-0.5">
<div className="flex items-center gap-0.5 bg-[var(--bg-tertiary)] rounded-lg p-0.5 border border-[var(--border)]">
<button
onClick={() => setViewMode("grid")}
className={`p-1 rounded cursor-pointer transition-colors ${
viewMode === "grid" ? "bg-[var(--bg-primary)] text-[var(--text-primary)]" : "text-[var(--text-secondary)]"
className={`p-1.5 rounded-md cursor-pointer transition-all ${
viewMode === "grid"
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
title="网格视图"
>
@@ -203,8 +219,10 @@ export default function GalleryPage() {
</button>
<button
onClick={() => setViewMode("list")}
className={`p-1 rounded cursor-pointer transition-colors ${
viewMode === "list" ? "bg-[var(--bg-primary)] text-[var(--text-primary)]" : "text-[var(--text-secondary)]"
className={`p-1.5 rounded-md cursor-pointer transition-all ${
viewMode === "list"
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
title="列表视图"
>
@@ -217,11 +235,11 @@ export default function GalleryPage() {
{/* 批量操作栏 */}
{selectedIds.size > 0 && (
<div className="flex-shrink-0 px-4 py-2 bg-[var(--accent)]/10 border-b border-[var(--accent)]/30
flex items-center gap-3">
<div className="flex-shrink-0 px-5 py-2.5 bg-[var(--accent)]/5 border-b border-[var(--accent)]/20
flex items-center gap-3 backdrop-blur-sm">
<button
onClick={selectAll}
className="text-xs text-[var(--accent)] cursor-pointer"
className="text-xs text-[var(--accent)] cursor-pointer hover:underline"
>
{selectedIds.size === filtered.length ? "取消全选" : "全选"}
</button>
@@ -231,15 +249,19 @@ export default function GalleryPage() {
<div className="flex-1" />
<button
onClick={handleBatchDownload}
className="px-2.5 py-1 text-xs rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] border border-[var(--border)] cursor-pointer"
className="px-3 py-1.5 text-xs 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={handleBatchDelete}
className="px-2.5 py-1 text-xs rounded bg-[var(--bg-tertiary)] text-red-400
hover:text-red-300 border border-[var(--border)] cursor-pointer"
className="px-3 py-1.5 text-xs rounded-lg bg-[var(--bg-tertiary)]
text-[var(--hot)]/70 hover:text-[var(--hot)]
border border-[var(--border)] hover:border-[var(--hot)]/40
cursor-pointer transition-all"
>
</button>
@@ -247,45 +269,53 @@ export default function GalleryPage() {
)}
{/* 图片内容区 */}
<div className="flex-1 overflow-y-auto p-4">
<div className="flex-1 overflow-y-auto p-4 md:p-5">
{filtered.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center space-y-2">
<div className="text-3xl">📁</div>
<div className="text-center space-y-3">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
bg-[var(--accent)]/10 border border-[var(--accent)]/20">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" opacity="0.6">
<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>
</div>
<p className="text-sm text-[var(--text-secondary)]">
{assets.length === 0 ? "还没有生成过图片" : "没有匹配的资源"}
</p>
</div>
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 md:gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 md:gap-4">
{filtered.map((asset) => (
<div
key={asset.id}
className={`group relative rounded-lg overflow-hidden border transition-colors cursor-pointer ${
className={`group relative rounded-xl overflow-hidden neon-border cursor-pointer
bg-[var(--bg-card)] backdrop-blur-sm transition-all ${
selectedIds.has(asset.id)
? "border-[var(--accent)] ring-1 ring-[var(--accent)]"
: "border-[var(--border)] hover:border-[var(--border)]/80"
? "!border-[var(--accent)] ring-1 ring-[var(--accent)]/30 shadow-[0_0_15px_rgba(77,184,164,0.08)]"
: ""
}`}
onClick={() => setDetailAsset(asset)}
>
{/* 选择框 */}
<div
className="absolute top-2 left-2 z-10"
className="absolute top-2.5 left-2.5 z-10"
onClick={(e) => {
e.stopPropagation();
toggleSelect(asset.id);
}}
>
<div
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
className={`w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all ${
selectedIds.has(asset.id)
? "bg-[var(--accent)] border-[var(--accent)]"
: "border-white/50 bg-black/30 opacity-100 md:opacity-0 md:group-hover:opacity-100"
? "bg-[var(--accent)] border-[var(--accent)] shadow-[0_0_8px_rgba(77,184,164,0.25)]"
: "border-white/40 bg-black/30 backdrop-blur-sm opacity-100 md:opacity-0 md:group-hover:opacity-100"
}`}
>
{selectedIds.has(asset.id) && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--bg-primary)" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
@@ -295,24 +325,25 @@ export default function GalleryPage() {
<img
src={getImageUrl(asset.url)}
alt={asset.prompt.slice(0, 50)}
className="w-full aspect-square object-cover"
className="w-full aspect-square object-cover
transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
/>
{/* 底部信息 */}
<div className="p-2 bg-[var(--bg-secondary)]">
<div className="text-[10px] text-[var(--text-secondary)] truncate">
<div className="p-2.5 bg-[var(--bg-secondary)]/80 backdrop-blur-sm">
<div className="text-[11px] text-[var(--text-secondary)] truncate">
{asset.prompt || "无 prompt"}
</div>
<div className="flex items-center gap-1 mt-1 flex-wrap">
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
{asset.tags.slice(0, 2).map((tid) => {
const tag = tagMap.get(tid);
if (!tag) return null;
return (
<span
key={tid}
className="text-[9px] px-1 py-px rounded-full"
style={{ backgroundColor: tag.color + "22", color: tag.color }}
className="text-[9px] px-1.5 py-0.5 rounded-md font-medium"
style={{ backgroundColor: tag.color + "18", color: tag.color }}
>
{tag.name}
</span>
@@ -324,16 +355,17 @@ export default function GalleryPage() {
</div>
</div>
{/* 操作按钮:移动端始终可见 */}
<div className="absolute top-2 right-2 flex gap-1
{/* 操作按钮 */}
<div className="absolute top-2.5 right-2.5 flex gap-1.5
opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
handleDownload(asset);
}}
className="w-7 h-7 rounded bg-black/60 text-white/80 hover:text-white
flex items-center justify-center cursor-pointer"
className="w-7 h-7 rounded-lg bg-black/50 backdrop-blur-sm text-white/70
hover:text-[var(--accent)] hover:bg-black/70
flex items-center justify-center cursor-pointer transition-colors"
title="下载"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -345,8 +377,9 @@ export default function GalleryPage() {
e.stopPropagation();
toggleFavorite(asset.id);
}}
className="w-7 h-7 rounded bg-black/60 flex items-center justify-center cursor-pointer"
style={{ color: asset.favorited ? "#f59e0b" : "rgba(255,255,255,0.6)" }}
className="w-7 h-7 rounded-lg bg-black/50 backdrop-blur-sm
flex items-center justify-center cursor-pointer transition-colors"
style={{ color: asset.favorited ? "var(--accent)" : "rgba(255,255,255,0.5)" }}
title={asset.favorited ? "取消收藏" : "收藏"}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
@@ -358,15 +391,14 @@ export default function GalleryPage() {
))}
</div>
) : (
/* 列表视图 */
<div className="space-y-1">
<div className="space-y-1.5">
{filtered.map((asset) => (
<div
key={asset.id}
className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl cursor-pointer transition-all ${
selectedIds.has(asset.id)
? "bg-[var(--accent)]/10"
: "hover:bg-[var(--bg-tertiary)]"
? "bg-[var(--accent)]/8 border border-[var(--accent)]/25"
: "hover:bg-[var(--bg-tertiary)] border border-transparent"
}`}
onClick={() => setDetailAsset(asset)}
>
@@ -377,14 +409,14 @@ export default function GalleryPage() {
}}
>
<div
className={`w-4 h-4 rounded border flex items-center justify-center ${
className={`w-4 h-4 rounded-md border flex items-center justify-center transition-all ${
selectedIds.has(asset.id)
? "bg-[var(--accent)] border-[var(--accent)]"
: "border-[var(--border)]"
}`}
>
{selectedIds.has(asset.id) && (
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="var(--bg-primary)" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
@@ -394,7 +426,7 @@ export default function GalleryPage() {
<img
src={getImageUrl(asset.url)}
alt=""
className="w-10 h-10 rounded object-cover border border-[var(--border)]"
className="w-10 h-10 rounded-lg object-cover border border-[var(--border)]"
loading="lazy"
/>
@@ -409,8 +441,8 @@ export default function GalleryPage() {
return (
<span
key={tid}
className="text-[9px] px-1 py-px rounded-full"
style={{ backgroundColor: tag.color + "22", color: tag.color }}
className="text-[9px] px-1.5 py-0.5 rounded-md font-medium"
style={{ backgroundColor: tag.color + "18", color: tag.color }}
>
{tag.name}
</span>
@@ -428,8 +460,8 @@ export default function GalleryPage() {
e.stopPropagation();
toggleFavorite(asset.id);
}}
className="cursor-pointer flex-shrink-0"
style={{ color: asset.favorited ? "#f59e0b" : "var(--text-secondary)" }}
className="cursor-pointer flex-shrink-0 transition-colors"
style={{ color: asset.favorited ? "var(--accent)" : "var(--text-secondary)" }}
>
<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" />
@@ -441,7 +473,7 @@ export default function GalleryPage() {
e.stopPropagation();
handleDownload(asset);
}}
className="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--text-primary)] flex-shrink-0"
className="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--accent)] flex-shrink-0 transition-colors"
>
<svg width="14" height="14" 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" />
@@ -473,7 +505,6 @@ export default function GalleryPage() {
);
}
// 资源详情 Modal
function GalleryDetailModal({
asset,
tags,
@@ -493,20 +524,22 @@ function GalleryDetailModal({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={onClose}
>
<div
className="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border)]
max-w-3xl w-full mx-2 md:mx-4 max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col"
className="glass-panel rounded-2xl max-w-3xl w-full mx-2 md:mx-4
max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col
shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-[var(--border)]">
<span className="text-sm font-medium text-[var(--text-primary)]"></span>
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--border)]">
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
<button
onClick={onClose}
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] cursor-pointer"
className="text-[var(--text-secondary)] hover:text-[var(--accent)] cursor-pointer
transition-colors p-1 rounded-lg hover:bg-[var(--accent)]/5"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
@@ -517,24 +550,23 @@ function GalleryDetailModal({
{/* 内容 */}
<div className="flex-1 overflow-y-auto p-5">
<div className="flex gap-5 flex-col md:flex-row">
{/* 图片 */}
<div className="flex-1 min-w-0">
<img
src={getImageUrl(asset.url)}
alt={asset.prompt}
className="w-full rounded-lg border border-[var(--border)]"
className="w-full rounded-xl border border-[var(--border)]"
/>
</div>
{/* 信息 */}
<div className="w-full md:w-[260px] flex-shrink-0 space-y-4">
{/* 操作按钮 */}
<div className="flex flex-wrap gap-2">
<button
onClick={onDownload}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md
bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]
cursor-pointer transition-colors"
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)]
cursor-pointer transition-all"
>
<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" />
@@ -543,13 +575,12 @@ function GalleryDetailModal({
</button>
<button
onClick={onToggleFavorite}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md
border cursor-pointer transition-colors"
style={{
backgroundColor: asset.favorited ? "#f59e0b22" : "var(--bg-tertiary)",
borderColor: asset.favorited ? "#f59e0b" : "var(--border)",
color: asset.favorited ? "#f59e0b" : "var(--text-secondary)",
}}
className={`flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
border cursor-pointer transition-all ${
asset.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={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" />
@@ -558,10 +589,10 @@ function GalleryDetailModal({
</button>
<button
onClick={onDelete}
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
cursor-pointer transition-colors"
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
cursor-pointer transition-all"
>
</button>
@@ -570,17 +601,17 @@ function GalleryDetailModal({
{/* Prompt */}
{asset.prompt && (
<div>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-[var(--text-secondary)]">Prompt</span>
<button
onClick={() => navigator.clipboard.writeText(asset.prompt)}
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
cursor-pointer"
cursor-pointer transition-colors"
>
</button>
</div>
<div className="p-2 rounded 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">
{asset.prompt}
</div>
@@ -590,16 +621,16 @@ function GalleryDetailModal({
{/* 标签 */}
{asset.tags.length > 0 && (
<div>
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1"></span>
<div className="flex flex-wrap gap-1">
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5"></span>
<div className="flex flex-wrap gap-1.5">
{asset.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>
@@ -610,9 +641,9 @@ function GalleryDetailModal({
)}
{/* 元信息 */}
<div className="text-xs text-[var(--text-secondary)] space-y-1">
<div className="text-xs text-[var(--text-secondary)] space-y-1.5">
<div>{new Date(asset.createdAt).toLocaleString("zh-CN")}</div>
<div> ID{asset.sessionId}</div>
<div className="truncate"> ID{asset.sessionId}</div>
</div>
</div>
</div>

View File

@@ -1,25 +1,200 @@
@import "tailwindcss";
/* ===== 青绿山水色彩体系 ===== */
:root {
--bg-primary: #0a0a0a;
--bg-secondary: #141414;
--bg-tertiary: #1e1e1e;
--text-primary: #e5e5e5;
--text-secondary: #a3a3a3;
--accent: #6366f1;
--accent-hover: #818cf8;
--border: #2e2e2e;
--bg-primary: #0C1210;
--bg-secondary: #131E1A;
--bg-tertiary: #1C2A24;
--bg-card: rgba(16, 30, 24, 0.65);
--text-primary: #E0E8E2;
--text-secondary: #7A9485;
--accent: #4DB8A4;
--accent-hover: #6CD4BE;
--accent-secondary: #3A8FB7;
--hot: #C4654A;
--border: rgba(77, 184, 164, 0.12);
--border-glow: rgba(77, 184, 164, 0.25);
--mist: rgba(77, 184, 164, 0.06);
--gold: #B8935A;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: system-ui, -apple-system, sans-serif;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
overscroll-behavior: none;
-webkit-tap-highlight-color: transparent;
}
/* 自定义滚动条 */
/* ===== 山雾背景 — 远景层 ===== */
@keyframes fogDriftFar {
0% { transform: translate(0, 0) scale(1); }
33% { transform: translate(2%, -1.5%) scale(1.02); }
66% { transform: translate(-1.5%, 1%) scale(0.98); }
100% { transform: translate(0, 0) scale(1); }
}
@keyframes fogDriftNear {
0% { transform: translate(0, 0) scale(1); }
25% { transform: translate(-3%, 2%) scale(1.03); }
50% { transform: translate(1%, -1%) scale(0.97); }
75% { transform: translate(2.5%, 1.5%) scale(1.01); }
100% { transform: translate(0, 0) scale(1); }
}
body::before {
content: '';
position: fixed;
inset: -10%;
background:
radial-gradient(ellipse 80% 50% at 15% 75%, rgba(77, 184, 164, 0.07) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 75% 25%, rgba(58, 143, 183, 0.05) 0%, transparent 55%),
radial-gradient(ellipse 90% 60% at 50% 50%, rgba(77, 184, 164, 0.03) 0%, transparent 70%);
animation: fogDriftFar 60s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
body::after {
content: '';
position: fixed;
inset: -15%;
background:
radial-gradient(ellipse 70% 45% at 80% 70%, rgba(77, 184, 164, 0.06) 0%, transparent 55%),
radial-gradient(ellipse 50% 60% at 25% 30%, rgba(58, 143, 183, 0.04) 0%, transparent 50%);
animation: fogDriftNear 45s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
/* ===== 山雾玻璃面板 ===== */
.glass-panel {
background:
linear-gradient(165deg, rgba(16, 30, 24, 0.15) 0%, transparent 50%),
rgba(16, 30, 24, 0.55);
backdrop-filter: blur(24px) saturate(1.1);
-webkit-backdrop-filter: blur(24px) saturate(1.1);
border: 1px solid var(--border);
}
/* ===== 静态霓虹边框(卡片等普通元素) ===== */
.neon-border {
border: 1px solid var(--border);
transition: border-color 200ms ease, box-shadow 200ms ease;
}
.neon-border:hover {
border-color: var(--border-glow);
box-shadow: 0 0 18px rgba(77, 184, 164, 0.08),
inset 0 0 12px rgba(77, 184, 164, 0.03);
}
/* ===== 云烟缭绕边框 ===== */
/*
* .glow-border — 选中态的云烟边框
* 常态:石青色微弱均匀发光
* hover云烟缓慢缭绕opacity 平滑过渡
* 移开:云烟淡散回静态
*/
@property --glow-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
@keyframes mistSpin {
to { --glow-angle: 360deg; }
}
.glow-border {
position: relative;
background: var(--bg-secondary);
border: 1px solid rgba(77, 184, 164, 0.18) !important;
box-shadow: 0 0 10px rgba(77, 184, 164, 0.05),
inset 0 0 8px rgba(77, 184, 164, 0.02);
transition: border-color 400ms ease, box-shadow 400ms ease;
z-index: 0;
}
.glow-border:hover {
border-color: rgba(77, 184, 164, 0.28) !important;
box-shadow: 0 0 16px rgba(77, 184, 164, 0.08),
inset 0 0 12px rgba(77, 184, 164, 0.03);
}
/* 云烟边框层 — 宽弧段柔化渐变6s 缓慢旋转 */
.glow-border::before {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1.5px;
background: conic-gradient(
from var(--glow-angle),
transparent 0%,
transparent 15%,
rgba(77, 184, 164, 0.2) 25%,
rgba(58, 143, 183, 0.35) 38%,
rgba(77, 184, 164, 0.4) 50%,
rgba(58, 143, 183, 0.35) 62%,
rgba(77, 184, 164, 0.2) 75%,
transparent 85%,
transparent 100%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
animation: mistSpin 6s linear infinite;
opacity: 0;
transition: opacity 600ms ease;
pointer-events: none;
z-index: -1;
}
/* 雾气扩散层 — 更大范围、更模糊 */
.glow-border::after {
content: '';
position: absolute;
inset: -6px;
border-radius: inherit;
background: conic-gradient(
from var(--glow-angle),
transparent 0%,
transparent 20%,
rgba(77, 184, 164, 0.06) 35%,
rgba(58, 143, 183, 0.08) 50%,
rgba(77, 184, 164, 0.06) 65%,
transparent 80%,
transparent 100%
);
filter: blur(12px);
animation: mistSpin 6s linear infinite;
opacity: 0;
transition: opacity 600ms ease;
pointer-events: none;
z-index: -2;
}
.glow-border:hover::before { opacity: 1; }
.glow-border:hover::after { opacity: 1; }
/* ===== 雾气滚动边缘 ===== */
.fog-scroll {
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0px,
black 36px,
black calc(100% - 36px),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0px,
black 36px,
black calc(100% - 36px),
transparent 100%
);
}
/* ===== 自定义滚动条 ===== */
::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -28,17 +203,18 @@ body {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
background: rgba(77, 184, 164, 0.15);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(77, 184, 164, 0.28);
}
/* 原生 select 下拉框暗色 */
select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
/* 移动端侧边栏遮罩动画 */
.sidebar-overlay {
animation: fadeIn 200ms ease-out;
}
@@ -46,8 +222,11 @@ select option {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* 移动端防止 iOS 地址栏 bounce */
@supports (height: 100dvh) {
.h-screen {
height: 100dvh;

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, type FormEvent } from "react";
import { useState, useEffect, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context";
@@ -12,8 +12,13 @@ export default function LoginPage() {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isAuthenticated) {
router.replace("/");
}
}, [isAuthenticated, router]);
if (isAuthenticated) {
router.replace("/");
return null;
}
@@ -33,15 +38,38 @@ export default function LoginPage() {
}
return (
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)]">
<div className="w-full max-w-sm mx-4">
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)] relative">
{/* 背景环境光 */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute inset-0"
style={{
background: `
radial-gradient(ellipse at 30% 70%, rgba(77, 184, 164, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 70% 30%, rgba(58, 143, 183, 0.06) 0%, transparent 50%)
`,
}}
/>
</div>
<div className="w-full max-w-sm mx-4 relative z-10">
<div className="text-center mb-8">
<div className="text-4xl mb-3">🎨</div>
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">EPEEKit</h1>
<p className="text-sm text-[var(--text-secondary)] mt-1">AI </p>
{/* Logo 发光 */}
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-4
bg-[var(--accent)]/10 border border-[var(--accent)]/20
shadow-[0_0_40px_rgba(77,184,164,0.15)]">
<svg width="32" height="32" 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="var(--accent)" />
<circle cx="23" cy="12" r="3.5" stroke="var(--accent)" strokeWidth="1.8" fill="none" />
<path d="M23 15.5v5" stroke="var(--accent)" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
</svg>
</div>
<h1 className="text-2xl font-bold text-[var(--text-primary)]">EPEEKit</h1>
<p className="text-sm text-[var(--text-secondary)] mt-1.5">AI </p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit}
className="glass-panel rounded-2xl p-6 space-y-4 shadow-xl shadow-black/20">
<div>
<input
type="text"
@@ -50,11 +78,12 @@ export default function LoginPage() {
onChange={(e) => setUsername(e.target.value)}
autoFocus
autoComplete="username"
className="w-full px-4 py-3 rounded-lg
bg-[var(--bg-secondary)] border border-[var(--border)]
className="w-full px-4 py-3 rounded-xl
bg-[var(--bg-primary)] border border-[var(--border)]
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none focus:border-[var(--accent)]
transition-colors"
focus:outline-none focus:border-[var(--accent)]/50
focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
transition-all"
/>
</div>
<div>
@@ -64,16 +93,18 @@ export default function LoginPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="w-full px-4 py-3 rounded-lg
bg-[var(--bg-secondary)] border border-[var(--border)]
className="w-full px-4 py-3 rounded-xl
bg-[var(--bg-primary)] border border-[var(--border)]
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none focus:border-[var(--accent)]
transition-colors"
focus:outline-none focus:border-[var(--accent)]/50
focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
transition-all"
/>
</div>
{error && (
<div className="text-sm text-red-400 bg-red-400/10 rounded-lg px-4 py-2.5">
<div className="text-sm text-[var(--hot)] bg-[var(--hot)]/10 rounded-xl px-4 py-2.5
border border-[var(--hot)]/20">
{error}
</div>
)}
@@ -81,11 +112,12 @@ export default function LoginPage() {
<button
type="submit"
disabled={loading || !username.trim() || !password}
className="w-full py-3 rounded-lg font-medium
bg-[var(--accent)] text-white
className="w-full py-3 rounded-xl font-semibold
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
hover:shadow-[0_0_20px_rgba(77,184,164,0.25)]
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors cursor-pointer"
transition-all cursor-pointer"
>
{loading ? "登录中..." : "登录"}
</button>

View File

@@ -1,8 +1,8 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import { ChatMessages } from "@/components/chat/chat-messages";
import { ChatInput } from "@/components/chat/chat-input";
import { ChatInput, type ChatInputHandle } from "@/components/chat/chat-input";
import { Sidebar } from "@/components/sidebar/sidebar";
import { TopNav } from "@/components/layout/top-nav";
import { ImageDetailPanel } from "@/components/detail/image-detail-panel";
@@ -30,6 +30,11 @@ export default function Home() {
const [streamingImages, setStreamingImages] = useState<ImageAsset[]>([]);
const [statusText, setStatusText] = useState("");
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
const [lastRefImageUrls, setLastRefImageUrls] = useState<string[]>([]);
const lastRefPerSession = useRef<Map<string, string[]>>(new Map());
const [mainDragging, setMainDragging] = useState(false);
const dragCounter = useRef(0);
const chatInputRef = useRef<ChatInputHandle>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollPositions = useRef<Map<string, number>>(new Map());
const prevSessionId = useRef<string | null>(null);
@@ -67,13 +72,14 @@ export default function Home() {
return () => el.removeEventListener("scroll", handler);
}, [activeSessionId]);
// 会话切换:标记 switching等 DOM 更新后恢复位置
// 会话切换:标记 switching等 DOM 更新后恢复位置 + 恢复参考图状态
useEffect(() => {
if (!activeSessionId) return;
if (prevSessionId.current && prevSessionId.current !== activeSessionId) {
isSwitching.current = true;
}
prevSessionId.current = activeSessionId;
setLastRefImageUrls(lastRefPerSession.current.get(activeSessionId) ?? []);
}, [activeSessionId]);
useEffect(() => {
@@ -95,11 +101,11 @@ export default function Home() {
}, [activeSessionId, messages.length]);
const handleSend = useCallback(
async (text: string, refImageServerUrl: string | null, imageModel: string | null = null) => {
async (text: string, refImageServerUrls: string[], imageModel: string | null = null) => {
if (!activeSessionId || !activeSession) return;
let finalText = text;
let finalRefServerUrl = refImageServerUrl;
let finalRefServerUrls = [...refImageServerUrls];
if (pendingAnnotation) {
const annotationDescs = pendingAnnotation.annotations
@@ -116,14 +122,13 @@ export default function Home() {
finalText = `${text}\n\n--- 图片标注 ---\n${annotationDescs}`;
}
// 标注截图作为参考图:需要先上传再获取 URL
if (pendingAnnotation.snapshot && !refImageServerUrl) {
if (pendingAnnotation.snapshot && finalRefServerUrls.length === 0) {
try {
const res = await fetch(pendingAnnotation.snapshot);
const blob = await res.blob();
const file = new File([blob], "annotation.png", { type: "image/png" });
const uploadResult = await uploadRefImage(file);
finalRefServerUrl = uploadResult.url;
finalRefServerUrls = [uploadResult.url];
} catch {
// 忽略转换/上传失败
}
@@ -132,17 +137,18 @@ export default function Home() {
setPendingAnnotation(null);
}
// 用户消息中的参考图预览:优先使用服务端路径(通过 getImageUrl 转为完整 URL
let previewUrl: string | undefined;
if (finalRefServerUrl) {
previewUrl = getImageUrl(finalRefServerUrl);
if (finalRefServerUrls.length > 0) {
lastRefPerSession.current.set(activeSessionId, finalRefServerUrls);
setLastRefImageUrls(finalRefServerUrls);
}
const previewUrls = finalRefServerUrls.map((u) => getImageUrl(u));
const userMessage: ChatMessage = {
id: generateId("msg-"),
role: "user",
content: finalText,
refImageUrl: previewUrl,
refImageUrls: previewUrls.length > 0 ? previewUrls : undefined,
};
appendMessage(activeSessionId, userMessage);
setIsLoading(true);
@@ -160,8 +166,10 @@ export default function Home() {
let collectedImages: ImageAsset[] = [];
let usedModelName = "";
const llmModel = activeSession?.llmModel ?? null;
try {
for await (const event of sendChat(apiMessages, finalRefServerUrl, imageModel, activeSessionId)) {
for await (const event of sendChat(apiMessages, finalRefServerUrls.length > 0 ? finalRefServerUrls : null, imageModel, activeSessionId, llmModel)) {
switch (event.type) {
case "text_delta":
assistantText += event.data.text as string;
@@ -261,21 +269,61 @@ export default function Home() {
}, []);
return (
<div className="h-screen flex flex-col">
<div className="h-screen flex flex-col relative z-[1]">
<TopNav />
<div className="flex-1 flex overflow-hidden">
<Sidebar />
<main className="flex-1 flex flex-col min-w-0 relative">
{/* 桌面端侧边栏展开按钮(移动端用顶栏汉堡菜单替代) */}
<main
className="flex-1 flex flex-col min-w-0 relative"
onDragEnter={(e: DragEvent) => {
e.preventDefault();
dragCounter.current++;
if (e.dataTransfer.types.includes("Files")) setMainDragging(true);
}}
onDragOver={(e: DragEvent) => e.preventDefault()}
onDragLeave={(e: DragEvent) => {
e.preventDefault();
dragCounter.current--;
if (dragCounter.current <= 0) { dragCounter.current = 0; setMainDragging(false); }
}}
onDrop={(e: DragEvent) => {
e.preventDefault();
dragCounter.current = 0;
setMainDragging(false);
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
files.forEach((f) => chatInputRef.current?.uploadFile(f));
}}
>
{/* 拖拽覆盖层 */}
{mainDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center
bg-[var(--bg-primary)]/80 backdrop-blur-sm
border-2 border-dashed border-[var(--accent)]/50 rounded-xl m-2
pointer-events-none">
<div className="text-center space-y-2">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none"
stroke="var(--accent)" strokeWidth="1.5" className="mx-auto opacity-70">
<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>
<p className="text-sm text-[var(--accent)] font-medium"></p>
</div>
</div>
)}
{/* 桌面端侧边栏展开按钮 */}
{sidebarCollapsed && (
<button
onClick={() => setSidebarCollapsed(false)}
className="absolute left-1 top-2 z-10 p-1.5 rounded-md
bg-[var(--bg-secondary)] border border-[var(--border)]
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
transition-colors cursor-pointer
className="absolute left-2 top-2 z-10 p-1.5 rounded-lg
bg-[var(--bg-secondary)]/80 backdrop-blur-sm
border border-[var(--border)]
text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:border-[var(--accent)]/40
transition-all cursor-pointer
hidden md:flex"
title="展开侧边栏"
>
@@ -287,12 +335,13 @@ export default function Home() {
{/* 标注提示条 */}
{pendingAnnotation && (
<div className="flex-shrink-0 px-4 py-2 bg-[var(--accent)]/10 border-b border-[var(--accent)]/30
flex items-center gap-3">
<div className="flex-shrink-0 px-5 py-2.5 bg-[var(--accent)]/5 border-b border-[var(--accent)]/20
flex items-center gap-3 backdrop-blur-sm">
<img
src={pendingAnnotation.snapshot}
alt="标注预览"
className="w-10 h-10 rounded object-cover border border-[var(--accent)]"
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/40
shadow-[0_0_8px_rgba(77,184,164,0.12)]"
/>
<span className="text-xs text-[var(--accent)]">
{pendingAnnotation.annotations.length}
@@ -300,27 +349,37 @@ export default function Home() {
<button
onClick={() => setPendingAnnotation(null)}
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]
cursor-pointer"
cursor-pointer transition-colors"
>
</button>
</div>
)}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={scrollRef} className="flex-1 overflow-y-auto fog-scroll">
{messages.length === 0 && !isLoading ? (
<div className="h-full flex items-center justify-center">
<div className="text-center space-y-3">
<div className="text-4xl">🎨</div>
<h2 className="text-xl font-medium text-[var(--text-primary)]">
<div className="text-center space-y-4">
{/* Logo 发光效果 */}
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl
bg-[var(--accent)]/10 border border-[var(--accent)]/20
shadow-[0_0_30px_rgba(77,184,164,0.12)]">
<svg width="32" height="32" 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="var(--accent)" />
<circle cx="23" cy="12" r="3.5" stroke="var(--accent)" strokeWidth="1.8" fill="none" />
<path d="M23 15.5v5" stroke="var(--accent)" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
</svg>
</div>
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
使 EPEEKit
</h2>
<p className="text-sm text-[var(--text-secondary)] max-w-md">
<p className="text-sm text-[var(--text-secondary)] max-w-md leading-relaxed">
<br />
</p>
<div className="flex flex-wrap justify-center gap-2 mt-4">
<div className="flex flex-wrap justify-center gap-2 mt-5">
{[
"画一个赛博朋克风格的退出按钮",
"设计一个卡通风格的金币图标",
@@ -328,11 +387,12 @@ export default function Home() {
].map((hint) => (
<button
key={hint}
onClick={() => handleSend(hint, null, null)}
className="text-xs px-3 py-1.5 rounded-full
onClick={() => handleSend(hint, [], null)}
className="text-xs px-4 py-2 rounded-xl
border border-[var(--border)] text-[var(--text-secondary)]
hover:border-[var(--accent)] hover:text-[var(--accent)]
transition-colors cursor-pointer"
hover:border-[var(--accent)]/50 hover:text-[var(--accent)]
hover:bg-[var(--accent)]/5
transition-all cursor-pointer"
>
{hint}
</button>
@@ -355,10 +415,10 @@ export default function Home() {
<button
onClick={() => scrollToBottom()}
className="absolute bottom-16 right-4 z-10
w-8 h-8 rounded-full flex items-center justify-center
bg-[var(--bg-tertiary)] border border-[var(--border)]
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
hover:border-[var(--accent)] shadow-lg
w-9 h-9 rounded-xl flex items-center justify-center
glass-panel text-[var(--text-secondary)]
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
shadow-lg shadow-black/30
transition-all duration-200 cursor-pointer
animate-[fadeIn_150ms_ease-out]"
title="回到底部"
@@ -369,7 +429,21 @@ export default function Home() {
</button>
)}
<ChatInput onSend={handleSend} disabled={isLoading} />
<ChatInput
ref={chatInputRef}
onSend={handleSend}
disabled={isLoading}
lastRefServerUrls={lastRefImageUrls}
lastRefPreviewUrls={lastRefImageUrls.map((u) => getImageUrl(u))}
onClearLastRefImage={() => {
if (activeSessionId) lastRefPerSession.current.delete(activeSessionId);
setLastRefImageUrls([]);
}}
onFileDrop={() => {
dragCounter.current = 0;
setMainDragging(false);
}}
/>
</main>
{detailImage && (

View File

@@ -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>
);
}
});

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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)} />}
</>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -2,7 +2,7 @@
* 后端 API 调用封装 + SSE 流式读取。
*/
import type { ApiMessage, ImageModelInfo } from "./types";
import type { ApiMessage, ImageModelInfo, LlmModelInfo } from "./types";
import { getStoredToken } from "./auth-context";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
@@ -108,15 +108,34 @@ export async function fetchModels(): Promise<{
return resp.json();
}
/**
* 获取可用的 LLM 对话模型列表。
*/
export async function fetchLlmModels(): Promise<{
models: LlmModelInfo[];
default: string;
}> {
const resp = await fetch(`${API_URL}/api/llm-models`, {
headers: authHeaders(),
});
if (resp.status === 401) {
window.location.href = "/login";
throw new Error("登录已过期");
}
if (!resp.ok) throw new Error(`获取 LLM 模型列表失败: ${resp.status}`);
return resp.json();
}
/**
* 发送对话消息到后端,返回 SSE 事件的异步迭代器。
* refImageUrl: 已通过 uploadRefImage 上传后的服务端路径(如 /uploads/xxx.png
* refImageUrls: 已通过 uploadRefImage 上传后的服务端路径列表
*/
export async function* sendChat(
messages: ApiMessage[],
refImageUrl?: string | null,
refImageUrls?: string[] | null,
imageModel?: string | null,
sessionId?: string | null
sessionId?: string | null,
llmModel?: string | null,
): AsyncGenerator<SSEEvent> {
const formData = new FormData();
@@ -126,8 +145,8 @@ export async function* sendChat(
}));
formData.append("messages", JSON.stringify(apiMessages));
if (refImageUrl) {
formData.append("ref_image_url", refImageUrl);
if (refImageUrls && refImageUrls.length > 0) {
formData.append("ref_image_urls", JSON.stringify(refImageUrls));
}
if (imageModel) {
@@ -138,6 +157,10 @@ export async function* sendChat(
formData.append("session_id", sessionId);
}
if (llmModel) {
formData.append("llm_model", llmModel);
}
const response = await fetch(`${API_URL}/api/chat`, {
method: "POST",
body: formData,

View File

@@ -40,6 +40,7 @@ interface AppContextValue {
deleteSession: (id: string) => void;
renameSession: (id: string, title: string) => void;
updateSessionTags: (id: string, tags: string[]) => void;
updateSessionLlmModel: (id: string, llmModel: string) => void;
appendMessage: (sessionId: string, message: ChatMessage) => void;
updateSessionThumbnail: (sessionId: string, url: string) => void;
@@ -183,6 +184,17 @@ export function AppProvider({ children }: { children: ReactNode }) {
);
}, []);
const updateSessionLlmModel = useCallback((id: string, llmModel: string) => {
setSessions((prev) =>
prev.map((s) => {
if (s.id !== id) return s;
const updated = { ...s, llmModel, updatedAt: Date.now() };
storeUpdateSession(updated);
return updated;
})
);
}, []);
const appendMessage = useCallback((sessionId: string, message: ChatMessage) => {
setSessions((prev) =>
prev.map((s) => {
@@ -266,6 +278,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
deleteSession: deleteSessionCb,
renameSession,
updateSessionTags,
updateSessionLlmModel,
appendMessage,
updateSessionThumbnail,
tags,
@@ -286,7 +299,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
[
sessions, activeSessionId, activeSession,
createSession, switchSession, deleteSessionCb,
renameSession, updateSessionTags, appendMessage, updateSessionThumbnail,
renameSession, updateSessionTags, updateSessionLlmModel, appendMessage, updateSessionThumbnail,
tags, addTag, deleteTagCb, selectedTagIds,
assets, addAssetCb, updateAssetCb, deleteAssetCb, toggleFavoriteCb,
detailImage, sidebarCollapsed,
@@ -294,7 +307,17 @@ export function AppProvider({ children }: { children: ReactNode }) {
);
// 未登录时(如 /login 页面)直接渲染 children不注入 AppContext
if (!initialized) return <>{children}</>;
// 已登录但尚未初始化完成时显示加载状态,避免子组件调用 useApp() 拿到 null
if (!initialized) {
if (isAuthenticated) {
return (
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)]">
<div className="text-[var(--text-secondary)] text-sm">...</div>
</div>
);
}
return <>{children}</>;
}
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

View File

@@ -25,7 +25,9 @@ export interface ChatMessage {
role: "user" | "assistant";
content: string;
images?: ImageAsset[];
/** 用户消息附带的参考图(本地预览 URL 或上传后的服务端路径) */
/** 用户消息附带的参考图列表(本地预览 URL 或上传后的服务端路径) */
refImageUrls?: string[];
/** @deprecated 兼容旧数据,单张参考图 */
refImageUrl?: string;
/** 生成图片时使用的模型名称 */
modelName?: string;
@@ -38,6 +40,8 @@ export interface Session {
messages: ChatMessage[];
/** 最后生成的图片 URL用于缩略图 */
thumbnail?: string;
/** 该对话使用的 LLM 模型短 IDundefined 时使用后端默认 */
llmModel?: string;
createdAt: number;
updatedAt: number;
}
@@ -57,6 +61,14 @@ export interface ImageModelInfo {
supports_ref_image?: boolean;
}
/** LLM 对话模型信息(从后端 GET /api/llm-models 返回) */
export interface LlmModelInfo {
id: string;
name: string;
description: string;
vision?: boolean;
}
/** 标注数据 */
export interface Annotation {
id: string;