- 来源上传弹窗 A系统基因库 tab 搜索时显示 loading spinner - NetworkActor 组件初始加载时显示 loading spinner(初始状态设为 true) - StudioNotebooksPage 笔记卡片支持暗色主题样式 - StudioNotebookDetailPage / NotebookAIWritingPage 跳转时透传 themekey 参数 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
329 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|