feat: 我的笔记列表与详情页 UI 重构(Gemini 风格)
- StudioNotebooksPage: 卡片改为灰底无边框圆角网格,hover 显示三点菜单,支持重命名(PATCH)和删除(DELETE) - StudioNotebookDetailPage: 详情页背景改为主题色渐变,内容垂直居中,输入框内联布局,模型选择器移至右侧,历史对话限显 5 条可展开/滚动 - api.ts: 新增 deleteSearchSpace、renameSearchSpace、uploadSourceText 接口 - 来源 Dialog:新增「复制的文字」面板,支持粘贴文本上传 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
1a1d22d6ee
commit
c714351819
3 files changed
+456
-152
No files matched your search
@@ -49,6 +49,43 @@ export async function createSearchSpace(
|
|||||||
if (!res.ok) throw new Error(`创建失败:${res.status}`);
|
if (!res.ok) throw new Error(`创建失败:${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadSourceText(
|
||||||
|
notebookId: number,
|
||||||
|
content: string,
|
||||||
|
title: string,
|
||||||
|
token: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const apiBase = base();
|
||||||
|
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||||
|
const res = await fetch(`${apiBase}/api/v1/documents/textupload`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ search_space_id: notebookId, content, title: title || "粘贴的文字" }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`上传失败:${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSearchSpace(id: number, token: string): Promise<void> {
|
||||||
|
const apiBase = base();
|
||||||
|
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||||
|
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`删除失败:${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameSearchSpace(id: number, name: string, token: string): Promise<void> {
|
||||||
|
const apiBase = base();
|
||||||
|
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||||
|
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`重命名失败:${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChunkRead {
|
export interface ChunkRead {
|
||||||
id: number;
|
id: number;
|
||||||
content: string;
|
content: string;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
Send,
|
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -23,7 +22,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
|
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
|
||||||
import { fetchDocumentByChunk, uploadSourceFiles } from "@/core/studio/api";
|
import { fetchDocumentByChunk, uploadSourceFiles, uploadSourceText } from "@/core/studio/api";
|
||||||
import type { DocumentWithChunks } from "@/core/studio/api";
|
import type { DocumentWithChunks } from "@/core/studio/api";
|
||||||
import type { SourceItem } from "@/core/studio/types";
|
import type { SourceItem } from "@/core/studio/types";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
@@ -132,6 +131,11 @@ function SourcesDialog({
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadError, setUploadError] = useState("");
|
const [uploadError, setUploadError] = useState("");
|
||||||
|
const [panel, setPanel] = useState<"sources" | "text">("sources");
|
||||||
|
const [textContent, setTextContent] = useState("");
|
||||||
|
const [textTitle, setTextTitle] = useState("");
|
||||||
|
const [submittingText, setSubmittingText] = useState(false);
|
||||||
|
const [textError, setTextError] = useState("");
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files ?? []);
|
const files = Array.from(e.target.files ?? []);
|
||||||
@@ -149,6 +153,23 @@ function SourcesDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTextSubmit = async () => {
|
||||||
|
if (!textContent.trim()) return;
|
||||||
|
setSubmittingText(true);
|
||||||
|
setTextError("");
|
||||||
|
try {
|
||||||
|
await uploadSourceText(notebookId, textContent.trim(), textTitle.trim(), token);
|
||||||
|
setTextContent("");
|
||||||
|
setTextTitle("");
|
||||||
|
setPanel("sources");
|
||||||
|
onUploadSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setTextError(err instanceof Error ? err.message : "上传失败");
|
||||||
|
} finally {
|
||||||
|
setSubmittingText(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
@@ -171,6 +192,7 @@ function SourcesDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
{/* 左侧导航 */}
|
||||||
<div className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-3">
|
<div className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-3">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -182,14 +204,15 @@ function SourcesDialog({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => { setPanel("sources"); fileInputRef.current?.click(); }}
|
||||||
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{uploading ? <Loader2 className="size-4 shrink-0 animate-spin" /> : <Plus className="size-4 shrink-0" />}
|
{uploading ? <Loader2 className="size-4 shrink-0 animate-spin" /> : <Plus className="size-4 shrink-0" />}
|
||||||
<span className="text-left leading-tight">上传文件</span>
|
<span className="text-left leading-tight">上传文件</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
onClick={() => { setPanel("text"); setTextError(""); }}
|
||||||
|
className={`flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm transition-colors hover:bg-muted hover:text-foreground ${panel === "text" ? "bg-muted text-foreground" : "text-muted-foreground"}`}
|
||||||
>
|
>
|
||||||
<AlignLeft className="size-4 shrink-0" />
|
<AlignLeft className="size-4 shrink-0" />
|
||||||
<span className="text-left leading-tight">复制的文字</span>
|
<span className="text-left leading-tight">复制的文字</span>
|
||||||
@@ -199,23 +222,59 @@ function SourcesDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
{/* 右侧内容 */}
|
||||||
<div className="grid grid-cols-4 gap-3">
|
{panel === "sources" ? (
|
||||||
{isLoadingSources
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
|
<div className="grid grid-cols-4 gap-3">
|
||||||
: sources.map((src) => (
|
{isLoadingSources
|
||||||
<SourceCard
|
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
|
||||||
key={src.id}
|
: sources.map((src) => (
|
||||||
item={src}
|
<SourceCard
|
||||||
selected={selectedSourceIds.includes(src.id)}
|
key={src.id}
|
||||||
onToggle={onToggle}
|
item={src}
|
||||||
/>
|
selected={selectedSourceIds.includes(src.id)}
|
||||||
))}
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!isLoadingSources && sources.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isLoadingSources && sources.length === 0 && (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
|
<div className="flex flex-1 flex-col gap-3 p-5">
|
||||||
)}
|
<input
|
||||||
</div>
|
autoFocus
|
||||||
|
value={textTitle}
|
||||||
|
onChange={(e) => setTextTitle(e.target.value)}
|
||||||
|
placeholder="标题(可选)"
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={textContent}
|
||||||
|
onChange={(e) => setTextContent(e.target.value)}
|
||||||
|
placeholder="在此粘贴或输入文字内容…"
|
||||||
|
className="flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
{textError && <p className="text-xs text-destructive">{textError}</p>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPanel("sources")}
|
||||||
|
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={!textContent.trim() || submittingText}
|
||||||
|
onClick={() => void handleTextSubmit()}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submittingText && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
添加来源
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -236,6 +295,8 @@ export default function StudioNotebookDetailPage() {
|
|||||||
// null = 显示历史线程列表;string = 显示该线程的聊天
|
// null = 显示历史线程列表;string = 显示该线程的聊天
|
||||||
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
||||||
const [citationChunkId, setCitationChunkId] = useState<number | null>(null);
|
const [citationChunkId, setCitationChunkId] = useState<number | null>(null);
|
||||||
|
const [threadsExpanded, setThreadsExpanded] = useState(false);
|
||||||
|
const THREADS_LIMIT = 5;
|
||||||
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
|
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
|
||||||
const [citationLoading, setCitationLoading] = useState(false);
|
const [citationLoading, setCitationLoading] = useState(false);
|
||||||
|
|
||||||
@@ -326,7 +387,7 @@ export default function StudioNotebookDetailPage() {
|
|||||||
className="pointer-events-none absolute inset-0"
|
className="pointer-events-none absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"radial-gradient(ellipse 60% 50% at 50% 60%, hsl(var(--primary) / 0.12) 0%, transparent 70%)",
|
"linear-gradient(145deg, hsl(var(--primary) / 0.22) 0%, hsl(var(--primary) / 0.10) 45%, transparent 80%)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -373,73 +434,133 @@ export default function StudioNotebookDetailPage() {
|
|||||||
|
|
||||||
{/* 列表视图:笔记本标题 + 历史线程列表 */}
|
{/* 列表视图:笔记本标题 + 历史线程列表 */}
|
||||||
{!isChatView && (
|
{!isChatView && (
|
||||||
<div className="relative z-10 flex flex-1 flex-col items-center px-8 py-10 overflow-y-auto">
|
<div className="relative z-10 flex flex-1 flex-col overflow-hidden">
|
||||||
{/* 右上角跳转按钮 */}
|
{/* 右上角跳转按钮 */}
|
||||||
<a
|
<a
|
||||||
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
|
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="absolute right-6 top-4 flex items-center gap-1.5 rounded-full bg-muted px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
|
className="absolute right-6 top-4 z-10 flex items-center gap-1.5 rounded-full bg-muted/70 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
<BookOpen className="size-3.5" />
|
<BookOpen className="size-3.5" />
|
||||||
<span>我的笔记</span>
|
<span>我的笔记</span>
|
||||||
</a>
|
</a>
|
||||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted text-4xl">
|
|
||||||
{notebook.avatar_url ? (
|
{/* 垂直居中主区域:图标 → 标题 → 来源 → 输入框 → 空状态/历史 */}
|
||||||
<img src={notebook.avatar_url} alt="" className="size-10 rounded-xl object-contain" />
|
<div className="flex flex-1 flex-col items-center justify-center overflow-y-auto px-8 pb-12 pt-16">
|
||||||
) : (
|
<div className="flex size-14 items-center justify-center leading-none select-none">
|
||||||
<Files className="size-8 text-muted-foreground" />
|
{notebook.avatar_url ? (
|
||||||
|
<img src={notebook.avatar_url} alt="" className="size-12 rounded-xl object-contain" />
|
||||||
|
) : (
|
||||||
|
<Files className="size-10 text-foreground/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-5 max-w-xl text-center text-3xl font-normal leading-snug text-foreground">
|
||||||
|
{notebook.name}
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setSourcesOpen(true)}
|
||||||
|
className="mt-4 flex cursor-pointer items-center gap-2 rounded-full bg-muted/70 px-4 py-2 text-sm font-medium text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Files className="size-3.5" />
|
||||||
|
<span>{notebook.source_count} 个来源</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 输入框(列表视图内联) */}
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => { void handleSend(e); }}
|
||||||
|
className="relative z-10 mt-8 flex w-full max-w-[680px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSourcesOpen(true)}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="问问 AI..."
|
||||||
|
disabled={isStreaming}
|
||||||
|
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
{isStreaming && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
|
||||||
|
{models.length > 0 && (
|
||||||
|
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||||
|
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{models.map((m) => (
|
||||||
|
<SelectItem key={m.name} value={m.name} className="text-xs">
|
||||||
|
{m.display_name || m.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 空状态提示 */}
|
||||||
|
{(!notebookThreads || notebookThreads.length === 0) && (
|
||||||
|
<div className="mt-8 flex items-center gap-2 text-sm text-foreground/40">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
笔记本对话会显示在这里
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 历史线程列表 */}
|
||||||
|
{notebookThreads && notebookThreads.length > 0 && (
|
||||||
|
<div className="mt-8 w-full max-w-[680px] flex flex-col gap-2">
|
||||||
|
<p className="mb-1 text-xs font-medium text-foreground/40">历史对话</p>
|
||||||
|
<div className="flex flex-col gap-2 overflow-y-auto" style={{ maxHeight: "260px" }}>
|
||||||
|
{(threadsExpanded ? notebookThreads : notebookThreads.slice(0, THREADS_LIMIT)).map((t) => {
|
||||||
|
const values = t.values as Record<string, unknown> | undefined;
|
||||||
|
const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined;
|
||||||
|
const firstHuman = msgs?.find((m) => m.type === "human");
|
||||||
|
const rawContent = firstHuman?.content;
|
||||||
|
const preview =
|
||||||
|
typeof rawContent === "string"
|
||||||
|
? rawContent.slice(0, 60)
|
||||||
|
: Array.isArray(rawContent)
|
||||||
|
? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60)
|
||||||
|
: "对话";
|
||||||
|
const title = (values?.title as string | undefined) || preview || "对话";
|
||||||
|
const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", {
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.thread_id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveThreadId(t.thread_id);
|
||||||
|
setThreadId(t.thread_id);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-between rounded-xl bg-muted/50 px-4 py-3 text-left transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
<span className="truncate text-sm font-medium text-foreground">{title}</span>
|
||||||
|
<span className="ml-4 shrink-0 text-xs text-foreground/50">{time}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{notebookThreads.length > THREADS_LIMIT && (
|
||||||
|
<button
|
||||||
|
onClick={() => setThreadsExpanded((v) => !v)}
|
||||||
|
className="mt-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{threadsExpanded ? "收起" : `查看全部 ${notebookThreads.length} 条对话`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-4 max-w-xl text-2xl font-semibold leading-snug text-foreground text-center">
|
|
||||||
{notebook.name}
|
|
||||||
</h1>
|
|
||||||
<button
|
|
||||||
onClick={() => setSourcesOpen(true)}
|
|
||||||
className="mt-3 flex cursor-pointer items-center gap-2 rounded-full bg-muted px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted/70"
|
|
||||||
>
|
|
||||||
<Files className="size-3.5" />
|
|
||||||
<span>{notebook.source_count} 个来源</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 历史线程列表 */}
|
|
||||||
{notebookThreads && notebookThreads.length > 0 && (
|
|
||||||
<div className="mt-8 w-full max-w-lg flex flex-col gap-2">
|
|
||||||
<p className="mb-1 text-xs font-medium text-muted-foreground">历史对话</p>
|
|
||||||
{notebookThreads.map((t) => {
|
|
||||||
const values = t.values as Record<string, unknown> | undefined;
|
|
||||||
const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined;
|
|
||||||
const firstHuman = msgs?.find((m) => m.type === "human");
|
|
||||||
const rawContent = firstHuman?.content;
|
|
||||||
const preview =
|
|
||||||
typeof rawContent === "string"
|
|
||||||
? rawContent.slice(0, 60)
|
|
||||||
: Array.isArray(rawContent)
|
|
||||||
? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60)
|
|
||||||
: "对话";
|
|
||||||
const title = (values?.title as string | undefined) || preview || "对话";
|
|
||||||
const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", {
|
|
||||||
month: "numeric",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={t.thread_id}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveThreadId(t.thread_id);
|
|
||||||
setThreadId(t.thread_id);
|
|
||||||
}}
|
|
||||||
className="flex items-center justify-between rounded-xl bg-muted px-4 py-3 text-left transition-colors hover:bg-muted/70"
|
|
||||||
>
|
|
||||||
<span className="truncate text-sm font-medium text-foreground">{title}</span>
|
|
||||||
<span className="ml-4 shrink-0 text-xs text-muted-foreground">{time}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -471,11 +592,11 @@ export default function StudioNotebookDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 输入框区域 */}
|
{/* 聊天视图的底部输入框 */}
|
||||||
<div className={`shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "pb-12"}`}>
|
<div className={`relative z-10 shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "hidden"}`}>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => { void handleSend(e); }}
|
onSubmit={(e) => { void handleSend(e); }}
|
||||||
className="mx-auto flex w-[560px] items-center gap-3 rounded-2xl bg-card px-5 py-4 shadow-sm ring-1 ring-border"
|
className="mx-auto flex max-w-[720px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -484,9 +605,17 @@ export default function StudioNotebookDetailPage() {
|
|||||||
>
|
>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="问问 AI..."
|
||||||
|
disabled={isStreaming}
|
||||||
|
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
{isStreaming && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
|
||||||
{models.length > 0 && (
|
{models.length > 0 && (
|
||||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||||
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-lg border-0 bg-muted px-2 text-xs text-muted-foreground shadow-none focus:ring-0">
|
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -498,24 +627,6 @@ export default function StudioNotebookDetailPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
<input
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
placeholder="问问 AI..."
|
|
||||||
disabled={isStreaming}
|
|
||||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!input.trim() || isStreaming}
|
|
||||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-30"
|
|
||||||
>
|
|
||||||
{isStreaming ? (
|
|
||||||
<Loader2 className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send className="size-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Files, Loader2, Plus } from "lucide-react";
|
import { Files, Loader2, Plus, MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { useStudioNotebooks } from "@/core/studio/hooks";
|
import { useStudioNotebooks } from "@/core/studio/hooks";
|
||||||
import { createSearchSpace } from "@/core/studio/api";
|
import { createSearchSpace, deleteSearchSpace, renameSearchSpace } from "@/core/studio/api";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import type { StudioNotebook } from "@/core/studio/types";
|
import type { StudioNotebook } from "@/core/studio/types";
|
||||||
import {
|
import {
|
||||||
@@ -15,81 +15,175 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
function NotebookCardSkeleton() {
|
function NotebookCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 rounded-2xl bg-card p-4">
|
<div className="flex flex-col rounded-[20px] bg-[#f2f2f2] p-5" style={{ minHeight: 180 }}>
|
||||||
<div className="size-10 animate-pulse rounded-xl bg-muted" />
|
<div className="size-10 animate-pulse rounded-xl bg-[#e0e0e0]" />
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="mt-auto flex flex-col gap-1.5 pt-8">
|
||||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
<div className="h-4 w-3/4 animate-pulse rounded bg-[#e0e0e0]" />
|
||||||
<div className="h-3 w-1/3 animate-pulse rounded bg-muted" />
|
<div className="h-3 w-1/3 animate-pulse rounded bg-[#e0e0e0]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotebookCard({ nb }: { nb: StudioNotebook }) {
|
function NotebookCard({
|
||||||
|
nb,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
nb: StudioNotebook;
|
||||||
|
onRename: (nb: StudioNotebook) => void;
|
||||||
|
onDelete: (nb: StudioNotebook) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<div className="group relative flex flex-col rounded-[20px] bg-[#f2f2f2] transition-colors hover:bg-[#e8e8e8]" style={{ minHeight: 180 }}>
|
||||||
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
<Link
|
||||||
className="group flex flex-col gap-3 rounded-2xl bg-card p-4 transition-colors hover:bg-muted ring-1 ring-border"
|
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
||||||
>
|
className="flex flex-1 flex-col p-5"
|
||||||
<div className="flex items-start justify-between">
|
>
|
||||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted text-xl group-hover:bg-background">
|
<div className="flex items-start justify-between">
|
||||||
{nb.avatar_url ? (
|
<div className="flex size-11 items-center justify-center text-3xl leading-none select-none">
|
||||||
<img src={nb.avatar_url} alt="" className="size-6 rounded object-contain" />
|
{nb.avatar_url ? (
|
||||||
) : (
|
<img src={nb.avatar_url} alt="" className="size-8 rounded object-contain" />
|
||||||
<Files className="size-5 text-muted-foreground" />
|
) : (
|
||||||
)}
|
<Files className="size-7 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* spacer so title stays below icon */}
|
||||||
|
<div className="size-7" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-auto flex flex-col gap-1 pt-8">
|
||||||
|
<span className="line-clamp-2 text-[15px] font-medium leading-snug text-[#1f1f1f]">
|
||||||
|
{nb.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] text-[#666]">{nb.source_count} 个来源</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* three-dot menu — absolute so it doesn't shift layout */}
|
||||||
|
<div className="absolute right-4 top-4">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="flex size-7 items-center justify-center rounded-full text-[#888] opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/10"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<MoreVertical className="size-4" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-36">
|
||||||
|
<DropdownMenuItem onClick={() => onRename(nb)}>
|
||||||
|
<Pencil className="mr-2 size-4" />
|
||||||
|
重命名
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onDelete(nb)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 size-4" />
|
||||||
|
删除
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
</div>
|
||||||
<span className="line-clamp-2 text-sm font-medium leading-snug text-foreground">
|
|
||||||
{nb.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{nb.source_count} 个来源</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StudioNotebooksPage() {
|
export default function StudioNotebooksPage() {
|
||||||
const { notebooks, isLoading, isError } = useStudioNotebooks();
|
const { notebooks, isLoading, isError } = useStudioNotebooks();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
// ── 新建 ──
|
||||||
const trimmed = name.trim();
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createName, setCreateName] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState("");
|
||||||
|
|
||||||
|
// ── 重命名 ──
|
||||||
|
const [renameTarget, setRenameTarget] = useState<StudioNotebook | null>(null);
|
||||||
|
const [renameName, setRenameName] = useState("");
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [renameError, setRenameError] = useState("");
|
||||||
|
|
||||||
|
// ── 删除 ──
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<StudioNotebook | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState("");
|
||||||
|
|
||||||
|
function getToken() {
|
||||||
|
const email = getStoredAuth()?.email ?? "";
|
||||||
|
const token = queryClient.getQueryData<string>(["studio", "token", email]);
|
||||||
|
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
const trimmed = createName.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setError("");
|
setCreateError("");
|
||||||
try {
|
try {
|
||||||
const email = getStoredAuth()?.email ?? "";
|
await createSearchSpace(trimmed, getToken());
|
||||||
const token = queryClient.getQueryData<string>(["studio", "token", email]);
|
setCreateOpen(false);
|
||||||
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
|
setCreateName("");
|
||||||
await createSearchSpace(trimmed, token);
|
|
||||||
setDialogOpen(false);
|
|
||||||
setName("");
|
|
||||||
// 刷新笔记本列表
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "创建失败");
|
setCreateError(e instanceof Error ? e.message : "创建失败");
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
async function handleRename() {
|
||||||
|
if (!renameTarget) return;
|
||||||
|
const trimmed = renameName.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setRenaming(true);
|
||||||
|
setRenameError("");
|
||||||
|
try {
|
||||||
|
await renameSearchSpace(renameTarget.id, trimmed, getToken());
|
||||||
|
setRenameTarget(null);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
||||||
|
} catch (e) {
|
||||||
|
setRenameError(e instanceof Error ? e.message : "重命名失败");
|
||||||
|
} finally {
|
||||||
|
setRenaming(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
setDeleteError("");
|
||||||
|
try {
|
||||||
|
await deleteSearchSpace(deleteTarget.id, getToken());
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
||||||
|
} catch (e) {
|
||||||
|
setDeleteError(e instanceof Error ? e.message : "删除失败");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full bg-background p-8">
|
<div className="min-h-full bg-background p-8">
|
||||||
|
{/* Header */}
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold text-foreground">笔记本</h1>
|
<h1 className="text-2xl font-semibold text-foreground">笔记本</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
|
onClick={() => { setCreateOpen(true); setCreateError(""); setCreateName(""); }}
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
className="flex items-center gap-1.5 rounded-full bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
新建笔记本
|
新建笔记本
|
||||||
@@ -100,13 +194,22 @@ export default function StudioNotebooksPage() {
|
|||||||
<p className="text-sm text-muted-foreground">加载笔记本失败,请稍后重试。</p>
|
<p className="text-sm text-muted-foreground">加载笔记本失败,请稍后重试。</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
{isLoading
|
{isLoading
|
||||||
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />)
|
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />)
|
||||||
: notebooks.map((nb) => <NotebookCard key={nb.id} nb={nb} />)}
|
: notebooks.map((nb) => (
|
||||||
|
<NotebookCard
|
||||||
|
key={nb.id}
|
||||||
|
nb={nb}
|
||||||
|
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
|
||||||
|
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={(v) => !creating && setDialogOpen(v)}>
|
{/* ── 新建 Dialog ── */}
|
||||||
|
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>新建笔记本</DialogTitle>
|
<DialogTitle>新建笔记本</DialogTitle>
|
||||||
@@ -114,25 +217,78 @@ export default function StudioNotebooksPage() {
|
|||||||
<div className="flex flex-col gap-4 pt-2">
|
<div className="flex flex-col gap-4 pt-2">
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={name}
|
value={createName}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setCreateName(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
|
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
|
||||||
placeholder="笔记本名称"
|
placeholder="笔记本名称"
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{createError && <p className="text-xs text-destructive">{createError}</p>}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" size="sm" disabled={creating} onClick={() => setDialogOpen(false)}>
|
<Button variant="outline" size="sm" disabled={creating} onClick={() => setCreateOpen(false)}>取消</Button>
|
||||||
取消
|
<Button size="sm" disabled={!createName.trim() || creating} onClick={() => void handleCreate()}>
|
||||||
</Button>
|
|
||||||
<Button size="sm" disabled={!name.trim() || creating} onClick={() => void handleCreate()}>
|
|
||||||
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
|
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── 重命名 Dialog ── */}
|
||||||
|
<Dialog open={!!renameTarget} onOpenChange={(v) => !renaming && !v && setRenameTarget(null)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>重命名笔记本</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4 pt-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-12 flex-shrink-0 items-center justify-center text-3xl">
|
||||||
|
{renameTarget?.avatar_url
|
||||||
|
? <img src={renameTarget.avatar_url} alt="" className="size-10 rounded object-contain" />
|
||||||
|
: <Files className="size-8 text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={renameName}
|
||||||
|
onChange={(e) => setRenameName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") void handleRename(); }}
|
||||||
|
disabled={renaming}
|
||||||
|
className="flex-1 rounded-lg border-2 border-foreground bg-background px-3 py-2 text-sm outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{renameError && <p className="text-xs text-destructive">{renameError}</p>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={renaming} onClick={() => setRenameTarget(null)}>取消</Button>
|
||||||
|
<Button size="sm" disabled={!renameName.trim() || renaming} onClick={() => void handleRename()}>
|
||||||
|
{renaming ? <Loader2 className="size-4 animate-spin" /> : "保存"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── 删除确认 Dialog ── */}
|
||||||
|
<Dialog open={!!deleteTarget} onOpenChange={(v) => !deleting && !v && setDeleteTarget(null)}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>删除笔记本</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4 pt-1">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
确定要删除{deleteTarget ? <span className="font-medium text-foreground">「{deleteTarget.name}」</span> : ""}吗?
|
||||||
|
删除后笔记本内所有来源数据将一并移除,且无法恢复。
|
||||||
|
</p>
|
||||||
|
{deleteError && <p className="text-xs text-destructive">{deleteError}</p>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={deleting} onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||||
|
<Button variant="destructive" size="sm" disabled={deleting} onClick={() => void handleDelete()}>
|
||||||
|
{deleting ? <Loader2 className="size-4 animate-spin" /> : "删除"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user