711 lines
26 KiB
TypeScript
711 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||
import {
|
||
ArrowDownWideNarrow,
|
||
Bot,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Download,
|
||
FileText,
|
||
FileX2,
|
||
Library,
|
||
Loader2,
|
||
MessagesSquare,
|
||
MessageSquareShare,
|
||
RefreshCw,
|
||
Search,
|
||
Sparkles,
|
||
} from "lucide-react";
|
||
import Link from "next/link";
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Input } from "@/components/ui/input";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import {
|
||
WorkspaceBody,
|
||
WorkspaceContainer,
|
||
WorkspaceHeader,
|
||
} from "@/components/workspace/workspace-container";
|
||
import {
|
||
downloadArtifactFile,
|
||
downloadArtifactMarkdownAsWord,
|
||
downloadStrategyProjectMarkdown,
|
||
downloadStrategyProjectMarkdownAsWord,
|
||
} from "@/core/artifacts/download";
|
||
import {
|
||
fetchArtifactLibrary,
|
||
type ArtifactLibraryItem,
|
||
type ArtifactLibraryKind,
|
||
type ArtifactLibrarySource,
|
||
} from "@/core/artifacts/library";
|
||
import { useI18n } from "@/core/i18n/hooks";
|
||
import {
|
||
useAppSystemName,
|
||
usePageDescriptionsSettings,
|
||
} from "@/core/system-settings/hooks";
|
||
import { bootstrapResumePath, pathOfThread } from "@/core/threads/utils";
|
||
import { formatDateTime } from "@/core/utils/datetime";
|
||
import {
|
||
getFileExtensionDisplayName,
|
||
getFileIcon,
|
||
} from "@/core/utils/files";
|
||
import { desensitize } from "@/lib/desensitize";
|
||
import { cn } from "@/lib/utils";
|
||
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
|
||
|
||
const PAGE_SIZE = 50;
|
||
|
||
const FILTERS: Array<{ value: ArtifactLibraryKind; label: string }> = [
|
||
{ value: "all", label: "全部" },
|
||
{ value: "document", label: "文档" },
|
||
{ value: "spreadsheet", label: "表格" },
|
||
{ value: "text_data", label: "文本与数据" },
|
||
{ value: "archive", label: "压缩包" },
|
||
{ value: "other", label: "其他" },
|
||
];
|
||
|
||
const SOURCE_FILTERS: Array<{
|
||
value: ArtifactLibrarySource;
|
||
label: string;
|
||
}> = [
|
||
{ value: "all", label: "全部来源" },
|
||
{ value: "general_qa", label: "通用问答" },
|
||
{ value: "agent", label: "智能体" },
|
||
{ value: "strategy", label: "策略生成" },
|
||
];
|
||
|
||
type DownloadFormat = "original" | "word";
|
||
|
||
function isMarkdownArtifact(name: string): boolean {
|
||
return /\.(md|markdown)$/i.test(name);
|
||
}
|
||
|
||
function artifactDownloadKey(
|
||
item: ArtifactLibraryItem,
|
||
format: DownloadFormat,
|
||
): string {
|
||
return `${item.threadId}:${item.path}:${format}`;
|
||
}
|
||
|
||
function formatBytes(bytes: number): string {
|
||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||
const units = ["B", "KB", "MB", "GB"];
|
||
const index = Math.min(
|
||
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||
units.length - 1,
|
||
);
|
||
const value = bytes / 1024 ** index;
|
||
const digits = index === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
||
return `${value.toFixed(digits)} ${units[index]}`;
|
||
}
|
||
|
||
function formatSyncStatus(status: string | null | undefined): string | null {
|
||
const value = status?.trim();
|
||
if (!value) return null;
|
||
const labels: Record<string, string> = {
|
||
idle: "空闲",
|
||
queued: "等待同步",
|
||
pending: "等待同步",
|
||
running: "同步中",
|
||
syncing: "同步中",
|
||
ready: "已同步",
|
||
success: "已同步",
|
||
succeeded: "已同步",
|
||
completed: "已同步",
|
||
failed: "同步失败",
|
||
error: "同步失败",
|
||
};
|
||
return labels[value.toLowerCase()] ?? value;
|
||
}
|
||
|
||
function sourceConversationPath(item: ArtifactLibraryItem): string {
|
||
if (
|
||
item.strategySourceKind === "strategy_selection" &&
|
||
item.strategyProjectId
|
||
) {
|
||
return strategyProjectWorkbenchPath(
|
||
"/page/strategy/qa/projects",
|
||
item.strategyProjectId,
|
||
"solution",
|
||
);
|
||
}
|
||
const basePath =
|
||
item.threadType === "bootstrap"
|
||
? bootstrapResumePath(item.threadId)
|
||
: pathOfThread({
|
||
thread_id: item.threadId,
|
||
metadata: item.agentId ? { agent_id: item.agentId } : undefined,
|
||
});
|
||
const search = new URLSearchParams({ artifact: item.path });
|
||
return `${basePath}?${search.toString()}`;
|
||
}
|
||
|
||
function FileRow({
|
||
item,
|
||
downloading,
|
||
onDownload,
|
||
}: {
|
||
item: ArtifactLibraryItem;
|
||
downloading: DownloadFormat | null;
|
||
onDownload: (item: ArtifactLibraryItem, format: DownloadFormat) => void;
|
||
}) {
|
||
const sourcePath = sourceConversationPath(item);
|
||
const displayName = desensitize(item.name);
|
||
const sourceTitle = desensitize(item.threadTitle || "新对话");
|
||
const sourceLabel =
|
||
item.sourceType === "strategy"
|
||
? "策略生成"
|
||
: item.sourceType === "agent"
|
||
? "智能体"
|
||
: "通用问答";
|
||
const sourceName =
|
||
item.sourceType === "strategy"
|
||
? desensitize(item.strategyName || sourceTitle || "未命名策略")
|
||
: item.sourceType === "agent"
|
||
? desensitize(item.agentName || item.agentId || "未知智能体")
|
||
: sourceLabel;
|
||
const SourceIcon =
|
||
item.sourceType === "strategy"
|
||
? Sparkles
|
||
: item.sourceType === "agent"
|
||
? Bot
|
||
: MessagesSquare;
|
||
const busy = downloading !== null;
|
||
const sourceTargetLabel =
|
||
item.strategySourceKind === "strategy_selection" ? "来源策略项目" : "原对话";
|
||
|
||
return (
|
||
<tr className="group border-b transition-colors hover:bg-muted/35">
|
||
<td className="min-w-0 py-3 pr-4 pl-1">
|
||
<Link
|
||
href={sourcePath}
|
||
className="flex min-w-0 items-center gap-3 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
title={`打开${sourceTargetLabel}并查看 ${displayName}`}
|
||
>
|
||
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground/75">
|
||
{getFileIcon(item.name, "size-5")}
|
||
</span>
|
||
<span className="min-w-0">
|
||
<span className="block truncate text-sm font-medium text-foreground group-hover:text-primary">
|
||
{displayName}
|
||
</span>
|
||
{item.sourceType === "strategy" && (
|
||
<span className="mt-1 flex min-w-0 items-center gap-1.5">
|
||
<Badge
|
||
variant="outline"
|
||
className="h-5 max-w-full gap-1 truncate border-violet-300 bg-violet-50 px-1.5 text-[10px] font-normal text-violet-700 dark:border-violet-400/25 dark:bg-violet-400/10 dark:text-violet-200"
|
||
title={`${sourceLabel} · ${sourceName}`}
|
||
>
|
||
<Sparkles className="size-3 shrink-0" />
|
||
<span className="truncate">{sourceLabel} · {sourceName}</span>
|
||
{item.strategyStale ? " · 历史方案" : ""}
|
||
</Badge>
|
||
</span>
|
||
)}
|
||
<span className="mt-1 block truncate text-xs text-muted-foreground lg:hidden">
|
||
{getFileExtensionDisplayName(item.name)} · {sourceName} · 来自「
|
||
{sourceTitle}」
|
||
</span>
|
||
<span className="mt-1 hidden text-xs text-muted-foreground lg:block">
|
||
{getFileExtensionDisplayName(item.name)} 文件
|
||
</span>
|
||
</span>
|
||
</Link>
|
||
</td>
|
||
<td className="hidden min-w-0 px-3 py-3 lg:table-cell">
|
||
<div className="flex min-w-0 items-center gap-2.5">
|
||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||
<SourceIcon className="size-4" />
|
||
</span>
|
||
<span className="min-w-0">
|
||
<span className="block truncate text-sm font-medium text-foreground">
|
||
{sourceName}
|
||
</span>
|
||
<Link
|
||
href={sourcePath}
|
||
className="block truncate text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||
title={`打开${sourceTargetLabel}:${sourceTitle}`}
|
||
>
|
||
{item.sourceType === "agent"
|
||
? "智能体 · "
|
||
: item.sourceType === "strategy"
|
||
? "策略项目 · "
|
||
: ""}
|
||
{sourceTitle}
|
||
</Link>
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className="whitespace-nowrap px-3 py-3 text-sm text-muted-foreground">
|
||
{formatDateTime(item.modifiedAt)}
|
||
</td>
|
||
<td className="hidden whitespace-nowrap px-3 py-3 text-sm text-muted-foreground md:table-cell">
|
||
{formatBytes(item.sizeBytes)}
|
||
</td>
|
||
<td className="py-3 pr-1 pl-3 text-right">
|
||
<div className="flex items-center justify-end gap-1">
|
||
<Button
|
||
type="button"
|
||
size="icon"
|
||
variant="ghost"
|
||
className="size-8"
|
||
disabled={busy}
|
||
onClick={() => onDownload(item, "original")}
|
||
title={`下载原文件:${displayName}`}
|
||
aria-label={`下载原文件:${displayName}`}
|
||
>
|
||
{downloading === "original" ? (
|
||
<Loader2 className="size-4 animate-spin" />
|
||
) : (
|
||
<Download className="size-4" />
|
||
)}
|
||
</Button>
|
||
{isMarkdownArtifact(item.name) && (
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-8 gap-1 px-2 text-xs"
|
||
disabled={busy}
|
||
onClick={() => onDownload(item, "word")}
|
||
title={`下载为 Word:${displayName}`}
|
||
aria-label={`下载为 Word:${displayName}`}
|
||
>
|
||
{downloading === "word" ? (
|
||
<Loader2 className="size-4 animate-spin" />
|
||
) : (
|
||
<FileText className="size-4" />
|
||
)}
|
||
Word
|
||
</Button>
|
||
)}
|
||
<Button
|
||
asChild
|
||
size="icon"
|
||
variant="ghost"
|
||
className="size-8"
|
||
title={`打开${sourceTargetLabel}:${sourceTitle}`}
|
||
>
|
||
<Link href={sourcePath} aria-label={`打开${sourceTargetLabel}:${sourceTitle}`}>
|
||
<MessageSquareShare className="size-4" />
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
function LoadingRows() {
|
||
return Array.from({ length: 5 }, (_, index) => (
|
||
<tr className="border-b" key={index}>
|
||
<td className="py-3 pr-4 pl-1">
|
||
<div className="flex items-center gap-3">
|
||
<Skeleton className="size-10 shrink-0" />
|
||
<div className="min-w-0 flex-1 space-y-2">
|
||
<Skeleton className="h-4 w-2/3" />
|
||
<Skeleton className="h-3 w-1/3" />
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="hidden px-3 lg:table-cell"><Skeleton className="h-4 w-3/4" /></td>
|
||
<td className="px-3"><Skeleton className="h-4 w-24" /></td>
|
||
<td className="hidden px-3 md:table-cell"><Skeleton className="h-4 w-16" /></td>
|
||
<td className="pr-1 pl-3"><Skeleton className="ml-auto size-8" /></td>
|
||
</tr>
|
||
));
|
||
}
|
||
|
||
export default function ArtifactLibraryPage() {
|
||
const { t } = useI18n();
|
||
const appName = useAppSystemName(t.pages.appName);
|
||
const { data: pageDescriptions } = usePageDescriptionsSettings();
|
||
const configuredDescription = pageDescriptions?.knowledge_base;
|
||
const pageTitle = configuredDescription?.title || "资料库";
|
||
const pageDescription =
|
||
configuredDescription?.description ||
|
||
"汇总当前账号在对话和策略优选中生成的文件,不包含上传文件、图片、音频和视频。";
|
||
const [search, setSearch] = useState("");
|
||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||
const [fileKind, setFileKind] = useState<ArtifactLibraryKind>("all");
|
||
const [sourceType, setSourceType] = useState<ArtifactLibrarySource>("all");
|
||
const [page, setPage] = useState(1);
|
||
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
||
const forceRefreshRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
document.title = `${pageTitle} - ${appName}`;
|
||
}, [appName, pageTitle]);
|
||
|
||
useEffect(() => {
|
||
const timer = window.setTimeout(() => {
|
||
setPage(1);
|
||
setDebouncedSearch(search.trim());
|
||
}, 300);
|
||
return () => window.clearTimeout(timer);
|
||
}, [search]);
|
||
|
||
const offset = (page - 1) * PAGE_SIZE;
|
||
const libraryQuery = useQuery({
|
||
queryKey: ["artifact-library", debouncedSearch, fileKind, sourceType, page],
|
||
queryFn: async ({ signal }) => {
|
||
const refresh = forceRefreshRef.current;
|
||
try {
|
||
return await fetchArtifactLibrary({
|
||
query: debouncedSearch,
|
||
fileKind,
|
||
sourceType,
|
||
offset,
|
||
limit: PAGE_SIZE,
|
||
refresh,
|
||
signal,
|
||
});
|
||
} finally {
|
||
forceRefreshRef.current = false;
|
||
}
|
||
},
|
||
placeholderData: keepPreviousData,
|
||
staleTime: 15_000,
|
||
refetchOnWindowFocus: false,
|
||
refetchInterval: (query) =>
|
||
query.state.data?.syncing === true ? 1_000 : false,
|
||
retry: false,
|
||
});
|
||
|
||
const total = libraryQuery.data?.total ?? 0;
|
||
const items = libraryQuery.data?.items ?? [];
|
||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||
const hasPrevious = page > 1;
|
||
const hasNext = page < totalPages;
|
||
const isSyncing = libraryQuery.data?.syncing === true;
|
||
const syncStatus = formatSyncStatus(libraryQuery.data?.syncStatus);
|
||
const syncError = libraryQuery.data?.syncError?.trim() || null;
|
||
const indexedAt = libraryQuery.data?.indexedAt;
|
||
const syncNotice = isSyncing
|
||
? syncStatus === "等待同步"
|
||
? "资料索引正在等待后台同步,当前展示已有结果"
|
||
: "后台正在同步资料索引,当前展示已有结果"
|
||
: syncError
|
||
? `资料索引同步失败:${syncError}`
|
||
: indexedAt
|
||
? `资料索引更新于 ${formatDateTime(indexedAt)}${syncStatus ? ` · ${syncStatus}` : ""}`
|
||
: syncStatus
|
||
? `资料索引状态:${syncStatus}`
|
||
: null;
|
||
|
||
useEffect(() => {
|
||
if (
|
||
libraryQuery.isSuccess &&
|
||
!libraryQuery.isPlaceholderData &&
|
||
page > totalPages
|
||
) {
|
||
setPage(totalPages);
|
||
}
|
||
}, [libraryQuery.isPlaceholderData, libraryQuery.isSuccess, page, totalPages]);
|
||
|
||
const itemKeys = useMemo(
|
||
() =>
|
||
new Set(
|
||
items.flatMap((item) => [
|
||
artifactDownloadKey(item, "original"),
|
||
artifactDownloadKey(item, "word"),
|
||
]),
|
||
),
|
||
[items],
|
||
);
|
||
useEffect(() => {
|
||
if (downloadingKey && !itemKeys.has(downloadingKey)) setDownloadingKey(null);
|
||
}, [downloadingKey, itemKeys]);
|
||
|
||
const handleDownload = async (
|
||
item: ArtifactLibraryItem,
|
||
format: DownloadFormat,
|
||
) => {
|
||
const key = artifactDownloadKey(item, format);
|
||
if (downloadingKey) return;
|
||
setDownloadingKey(key);
|
||
try {
|
||
if (
|
||
item.strategySourceKind === "strategy_selection" &&
|
||
item.strategyProjectId
|
||
) {
|
||
if (format === "word") {
|
||
await downloadStrategyProjectMarkdownAsWord({
|
||
projectId: item.strategyProjectId,
|
||
});
|
||
toast.success("Word 文件已生成");
|
||
} else {
|
||
await downloadStrategyProjectMarkdown({
|
||
projectId: item.strategyProjectId,
|
||
});
|
||
}
|
||
} else if (format === "word") {
|
||
await downloadArtifactMarkdownAsWord({
|
||
filepath: item.path,
|
||
threadId: item.threadId,
|
||
});
|
||
toast.success("Word 文件已生成");
|
||
} else {
|
||
await downloadArtifactFile({
|
||
filepath: item.path,
|
||
threadId: item.threadId,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
toast.error(error instanceof Error ? error.message : "下载失败");
|
||
} finally {
|
||
setDownloadingKey(null);
|
||
}
|
||
};
|
||
|
||
const handleRefresh = () => {
|
||
forceRefreshRef.current = true;
|
||
void libraryQuery.refetch();
|
||
};
|
||
|
||
return (
|
||
<WorkspaceContainer>
|
||
<WorkspaceHeader />
|
||
<WorkspaceBody className="items-stretch">
|
||
<div className="flex min-h-0 w-full flex-1 flex-col">
|
||
<header className="shrink-0 border-b bg-card/40 px-5 py-6 sm:px-8">
|
||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5">
|
||
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<Library className="size-6 text-primary" />
|
||
<h1 className="text-2xl font-semibold tracking-tight">{pageTitle}</h1>
|
||
</div>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
{pageDescription}
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-col items-start gap-1.5 sm:items-end">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleRefresh}
|
||
disabled={libraryQuery.isFetching || isSyncing}
|
||
>
|
||
<RefreshCw
|
||
className={cn(
|
||
"size-4",
|
||
(libraryQuery.isFetching || isSyncing) && "animate-spin",
|
||
)}
|
||
/>
|
||
{isSyncing
|
||
? "后台同步中"
|
||
: libraryQuery.isFetching
|
||
? "刷新中"
|
||
: "刷新"}
|
||
</Button>
|
||
{syncNotice && (
|
||
<p
|
||
className={cn(
|
||
"max-w-sm text-xs text-muted-foreground",
|
||
syncError && "text-destructive",
|
||
)}
|
||
title={
|
||
syncError ?? libraryQuery.data?.syncStatus ?? undefined
|
||
}
|
||
>
|
||
{syncNotice}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative max-w-xl">
|
||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
type="search"
|
||
className="h-11 pl-9"
|
||
placeholder="搜索文件名、来源对话、智能体或策略名称"
|
||
value={search}
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<div className="flex flex-wrap gap-1" aria-label="文件类型筛选">
|
||
{FILTERS.map((filter) => (
|
||
<Button
|
||
key={filter.value}
|
||
type="button"
|
||
size="sm"
|
||
variant={fileKind === filter.value ? "secondary" : "ghost"}
|
||
aria-pressed={fileKind === filter.value}
|
||
onClick={() => {
|
||
if (filter.value === fileKind) return;
|
||
setPage(1);
|
||
setFileKind(filter.value);
|
||
}}
|
||
>
|
||
{filter.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
<Select
|
||
value={sourceType}
|
||
onValueChange={(value) => {
|
||
const nextSourceType = value as ArtifactLibrarySource;
|
||
if (nextSourceType === sourceType) return;
|
||
setPage(1);
|
||
setSourceType(nextSourceType);
|
||
}}
|
||
>
|
||
<SelectTrigger
|
||
size="sm"
|
||
className="w-32"
|
||
aria-label="筛选文件来源"
|
||
>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{SOURCE_FILTERS.map((filter) => (
|
||
<SelectItem key={filter.value} value={filter.value}>
|
||
{filter.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<ArrowDownWideNarrow className="size-4" />
|
||
最近生成
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<ScrollArea className="min-h-0 flex-1">
|
||
<main className="mx-auto w-full max-w-6xl px-5 py-5 sm:px-8">
|
||
<div className="mb-2 text-xs text-muted-foreground tabular-nums">
|
||
{libraryQuery.isLoading ? "正在统计文件…" : `共 ${total} 个文件`}
|
||
</div>
|
||
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full table-fixed border-collapse">
|
||
<thead>
|
||
<tr className="border-b text-left text-xs font-normal text-muted-foreground">
|
||
<th className="w-[48%] py-3 pr-4 pl-1 font-normal lg:w-[35%]">文件名</th>
|
||
<th className="hidden w-[25%] px-3 py-3 font-normal lg:table-cell">来源</th>
|
||
<th className="w-[34%] px-3 py-3 font-normal md:w-[22%] lg:w-[16%]">生成时间</th>
|
||
<th className="hidden w-[13%] px-3 py-3 font-normal lg:w-[10%] md:table-cell">大小</th>
|
||
<th className="w-[18%] py-3 pr-1 pl-3 text-right font-normal md:w-[17%] lg:w-[14%]">
|
||
<span className="sr-only">操作</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{libraryQuery.isLoading ? (
|
||
<LoadingRows />
|
||
) : (
|
||
items.map((item) => {
|
||
const key = `${item.threadId}:${item.path}`;
|
||
const activeDownload = downloadingKey?.startsWith(`${key}:`)
|
||
? downloadingKey.endsWith(":word")
|
||
? "word"
|
||
: "original"
|
||
: null;
|
||
return (
|
||
<FileRow
|
||
key={key}
|
||
item={item}
|
||
downloading={activeDownload}
|
||
onDownload={(target, format) =>
|
||
void handleDownload(target, format)
|
||
}
|
||
/>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{!libraryQuery.isLoading && libraryQuery.isError && (
|
||
<div className="flex flex-col items-center gap-3 py-16 text-center">
|
||
<FileX2 className="size-9 text-muted-foreground" />
|
||
<div>
|
||
<p className="text-sm font-medium">资料库加载失败</p>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{libraryQuery.error instanceof Error
|
||
? libraryQuery.error.message
|
||
: "请稍后重试"}
|
||
</p>
|
||
</div>
|
||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||
重新加载
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{!libraryQuery.isLoading && !libraryQuery.isError && items.length === 0 && (
|
||
<div className="flex flex-col items-center gap-3 py-20 text-center">
|
||
<FileX2 className="size-9 text-muted-foreground" />
|
||
<div>
|
||
<p className="text-sm font-medium">
|
||
{isSyncing && !indexedAt
|
||
? "正在建立资料索引"
|
||
: debouncedSearch || fileKind !== "all" || sourceType !== "all"
|
||
? "没有找到匹配的文件"
|
||
: "还没有生成文件"}
|
||
</p>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{isSyncing && !indexedAt
|
||
? "后台同步完成后会自动显示,期间不影响其他功能。"
|
||
: "对话文件与已生成的策略优选方案会自动出现在这里。"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!libraryQuery.isLoading && !libraryQuery.isError && totalPages > 1 && (
|
||
<div className="flex items-center justify-center gap-3 py-7">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={!hasPrevious || libraryQuery.isPlaceholderData}
|
||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||
>
|
||
<ChevronLeft className="size-4" />
|
||
上一页
|
||
</Button>
|
||
<span className="text-sm text-muted-foreground tabular-nums">
|
||
{page} / {totalPages}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={!hasNext || libraryQuery.isPlaceholderData}
|
||
onClick={() => setPage((current) => current + 1)}
|
||
>
|
||
下一页
|
||
<ChevronRight className="size-4" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</ScrollArea>
|
||
</div>
|
||
</WorkspaceBody>
|
||
</WorkspaceContainer>
|
||
);
|
||
}
|