feat(studio): replace notes source upload with multi-tab popup

- Add document-upload-popup.tsx with tabs for B系统/A系统/Y系统/XWT/upload
- Add network-actor.tsx for XWT search tab
- Add empty.tsx for empty state
- Refactor SourcesDialog: left panel becomes single "上传来源" button that opens new popup
- Wire DocumentUploadDialogProvider around StudioNotebookDetailPage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xuhdandClaude Sonnet 4.6 committed 2026-06-05 13:32:19 +08:00
1 parent 2c2650ff07
commit e6d3226c47
4 files changed
+1565 -131

No files matched your search

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import {
AlignLeft,
@@ -22,7 +22,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
import { fetchDocumentByChunk, uploadSourceFiles, uploadSourceText } from "@/core/studio/api";
import { fetchDocumentByChunk } from "@/core/studio/api";
import type { DocumentWithChunks } from "@/core/studio/api";
import type { SourceItem } from "@/core/studio/types";
import { getStoredAuth } from "@/core/auth";
@@ -38,6 +38,10 @@ import { MessageList } from "@/components/workspace/messages/message-list";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ChatBox } from "@/components/workspace/chats/chat-box";
import { rehypeCitations } from "@/core/rehype";
import {
DocumentUploadDialogProvider,
useDocumentUploadDialog,
} from "@/components/assistant-ui/document-upload-popup";
// ── 来源卡片图标 ────────────────────────────────────────
function SourceIcon({ item }: { item: SourceItem }) {
@@ -102,79 +106,31 @@ function SourceCardSkeleton() {
);
}
const UPLOAD_ACCEPT = ".pdf,.docx,.xlsx,.pptx,.html,.csv,.md,.txt,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.mp3,.mp4,.wav,.webm";
// ── 来源 Dialog ─────────────────────────────────────────
function SourcesDialog({
open,
onClose,
notebookName,
notebookId,
token,
sources,
isLoadingSources,
selectedSourceIds,
onToggle,
onUploadSuccess,
}: {
open: boolean;
onClose: () => void;
notebookName: string;
notebookId: number;
token: string;
sources: SourceItem[];
isLoadingSources: boolean;
selectedSourceIds: number[];
onToggle: (id: number) => void;
onUploadSuccess: () => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
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 files = Array.from(e.target.files ?? []);
if (!files.length) return;
setUploading(true);
setUploadError("");
try {
await uploadSourceFiles(notebookId, files, token);
onUploadSuccess();
} catch (err) {
setUploadError(err instanceof Error ? err.message : "上传失败");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
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);
}
};
const { openDialog: openUploadPopup } = useDocumentUploadDialog();
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent
showCloseButton={false}
className="flex h-[75vh] w-[900px] max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[900px]"
className="flex h-[90vh] w-[75vw] max-w-[75vw] sm:max-w-[75vw] flex-col gap-0 overflow-hidden p-0"
>
<DialogHeader className="flex flex-row items-start justify-between border-b border-border px-6 py-5">
<div>
@@ -194,87 +150,33 @@ function SourcesDialog({
<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">
<input
ref={fileInputRef}
type="file"
multiple
accept={UPLOAD_ACCEPT}
className="hidden"
onChange={(e) => { void handleFileChange(e); }}
/>
<button
disabled={uploading}
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"
onClick={() => openUploadPopup()}
className="flex w-full flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-border bg-muted/40 px-4 py-8 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-primary"
>
{uploading ? <Loader2 className="size-4 shrink-0 animate-spin" /> : <Plus className="size-4 shrink-0" />}
<span className="text-left leading-tight">上传文件</span>
<Plus className="size-8 shrink-0" />
<span className="text-sm font-medium leading-tight text-center">上传来源</span>
</button>
<button
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" />
<span className="text-left leading-tight">复制的文字</span>
</button>
{uploadError && (
<p className="px-3 text-xs text-destructive">{uploadError}</p>
)}
</div>
{/* 右侧内容 */}
{panel === "sources" ? (
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-4 gap-3">
{isLoadingSources
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
: sources.map((src) => (
<SourceCard
key={src.id}
item={src}
selected={selectedSourceIds.includes(src.id)}
onToggle={onToggle}
/>
))}
</div>
{!isLoadingSources && sources.length === 0 && (
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
)}
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-4 gap-3">
{isLoadingSources
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
: sources.map((src) => (
<SourceCard
key={src.id}
item={src}
selected={selectedSourceIds.includes(src.id)}
onToggle={onToggle}
/>
))}
</div>
) : (
<div className="flex flex-1 flex-col gap-3 p-5">
<input
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>
)}
{!isLoadingSources && sources.length === 0 && (
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
@@ -378,6 +280,12 @@ export default function StudioNotebookDetailPage() {
const isChatView = activeThreadId !== null || hasMessages;
return (
<DocumentUploadDialogProvider
searchSpace={{ id: notebook.id }}
onUploadSuccess={() => {
void queryClient.invalidateQueries({ queryKey: ["studio", "sources", notebook.id] });
}}
>
<ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={chatThreadId ?? ""}>
<div className="relative flex h-full flex-col overflow-hidden bg-background">
@@ -667,18 +575,14 @@ export default function StudioNotebookDetailPage() {
open={sourcesOpen}
onClose={() => setSourcesOpen(false)}
notebookName={notebook.name}
notebookId={notebook.id}
token={queryClient.getQueryData<string>(["studio", "token", getStoredAuth()?.email ?? ""]) ?? ""}
sources={sources}
isLoadingSources={isLoadingSources}
selectedSourceIds={selectedSourceIds}
onToggle={handleToggleSource}
onUploadSuccess={() => {
void queryClient.invalidateQueries({ queryKey: ["studio", "sources", notebook.id] });
}}
/>
</div>
</ChatBox>
</ThreadContext.Provider>
</DocumentUploadDialogProvider>
);
}