Files
deerflow-code/frontend-web/src/pages/StudioNotebooksPage.tsx
T
xuhdandClaude Sonnet 4.6 42ce20b4df feat(frontend): 来源弹窗加loading + 笔记页暗色适配 + 主题透传
- 来源上传弹窗 A系统基因库 tab 搜索时显示 loading spinner
- NetworkActor 组件初始加载时显示 loading spinner(初始状态设为 true)
- StudioNotebooksPage 笔记卡片支持暗色主题样式
- StudioNotebookDetailPage / NotebookAIWritingPage 跳转时透传 themekey 参数

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 10:07:13 +08:00

329 lines
13 KiB
TypeScript

"use client";
import { useState } from "react";
import { Link } from "react-router-dom";
import { Files, Loader2, Plus, MoreVertical, Pencil, Trash2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useStudioNotebooks } from "@/core/studio/hooks";
import { createSearchSpace, deleteSearchSpace, renameSearchSpace } from "@/core/studio/api";
import { getStoredAuth } from "@/core/auth";
import { useTheme } from "@/contexts/ThemeContext";
import type { StudioNotebook } from "@/core/studio/types";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
function NotebookCardSkeleton({ isDark }: { isDark: boolean }) {
return (
<div
className={`flex flex-col rounded-[20px] p-5 ${isDark ? "bg-[#1e2330]" : "bg-[#f2f2f2]"}`}
style={{ minHeight: 180 }}
>
<div className={`size-10 animate-pulse rounded-xl ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
<div className="mt-auto flex flex-col gap-1.5 pt-8">
<div className={`h-4 w-3/4 animate-pulse rounded ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
<div className={`h-3 w-1/3 animate-pulse rounded ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
</div>
</div>
);
}
function NotebookCard({
nb,
isDark,
onRename,
onDelete,
onOpen,
}: {
nb: StudioNotebook;
isDark: boolean;
onRename: (nb: StudioNotebook) => void;
onDelete: (nb: StudioNotebook) => void;
/** 传入则点击卡片回调(内嵌打开),不再路由跳转。 */
onOpen?: (id: number) => void;
}) {
const inner = (
<>
<div className="flex items-start justify-between">
<div className="flex size-11 items-center justify-center text-3xl leading-none select-none">
{nb.avatar_url ? (
<img src={nb.avatar_url} alt="" className="size-8 rounded object-contain" />
) : (
<Files className="size-7 text-muted-foreground" />
)}
</div>
{/* spacer so title stays below icon */}
<div className="size-7" />
</div>
<div className="mt-auto flex flex-col gap-1 pt-8">
<span className={`line-clamp-2 text-[15px] font-medium leading-snug ${isDark ? "text-[#e8eaf0]" : "text-[#1f1f1f]"}`}>
{nb.name}
</span>
<span className={`text-[13px] ${isDark ? "text-[#8b93a7]" : "text-[#666]"}`}>{nb.source_count} 个来源</span>
</div>
</>
);
return (
<div className={`group relative flex flex-col rounded-[20px] transition-colors ${isDark ? "bg-[#1e2330] hover:bg-[#262c3b]" : "bg-[#f2f2f2] hover:bg-[#e8e8e8]"}`} style={{ minHeight: 180 }}>
{onOpen ? (
<button
type="button"
onClick={() => onOpen(nb.id)}
className="flex flex-1 flex-col p-5 text-left"
>
{inner}
</button>
) : (
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
className="flex flex-1 flex-col p-5"
>
{inner}
</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>
);
}
export default function StudioNotebooksPage({
onOpenNotebook,
}: {
/** 内嵌使用(课题研究右侧)时传入:点击空间卡片回调,不跳路由。 */
onOpenNotebook?: (id: number) => void;
} = {}) {
const { notebooks, isLoading, isError } = useStudioNotebooks();
const { theme } = useTheme();
const isDark = theme === "dark";
const queryClient = useQueryClient();
// ── 新建 ──
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;
setCreating(true);
setCreateError("");
try {
await createSearchSpace(trimmed, getToken());
setCreateOpen(false);
setCreateName("");
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
} catch (e) {
setCreateError(e instanceof Error ? e.message : "创建失败");
} finally {
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 (
<div className="min-h-full bg-background p-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">空间</h1>
<button
onClick={() => { setCreateOpen(true); setCreateError(""); setCreateName(""); }}
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" />
新建空间
</button>
</div>
{isError && (
<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">
{isLoading
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} isDark={isDark} />)
: notebooks.map((nb) => (
<NotebookCard
key={nb.id}
nb={nb}
isDark={isDark}
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
onOpen={onOpenNotebook}
/>
))}
</div>
{/* ── 新建 Dialog ── */}
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>新建空间</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2">
<input
autoFocus
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
placeholder="空间名称"
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"
/>
{createError && <p className="text-xs text-destructive">{createError}</p>}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" disabled={creating} onClick={() => setCreateOpen(false)}>取消</Button>
<Button size="sm" disabled={!createName.trim() || creating} onClick={() => void handleCreate()}>
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
</Button>
</div>
</div>
</DialogContent>
</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>
);
}