655 lines
23 KiB
TypeScript
655 lines
23 KiB
TypeScript
"use client";
|
|||
|
|
|
||
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
|
import type { DragEvent as ReactDragEvent } from "react";
|
||
|
|
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
|
||
|
|
import { CalendarClock, ChevronsUpDown, Copy, Layers, Search } from "lucide-react";
|
||
|
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import {
|
||
|
|
DropdownMenu,
|
||
|
|
DropdownMenuContent,
|
||
|
|
DropdownMenuItem,
|
||
|
|
DropdownMenuTrigger,
|
||
|
|
} from "@/components/ui/dropdown-menu";
|
||
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import { useSearchPaletteStore, type ReferenceInsertMode, type SearchPaletteMode } from "@/store/search-palette";
|
||
|
|
import type { DocumentSearchResult, DocumentSearchRequest, DocumentSearchTimeRange } from "@/types/search";
|
||
|
|
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||
|
|
import { useReferenceComposer } from "@/hooks/use-reference-composer";
|
||
|
|
import { recordRecentPage } from "@/lib/search/record-recent";
|
||
|
|
import { PageHoverCard } from "@/components/reference/page-hover-card";
|
||
|
|
import { buildSearchRequest } from "@/lib/search/request";
|
||
|
|
import { resolveSearchOpenMode } from "@/lib/search/shortcuts";
|
||
|
|
|
||
|
|
interface SearchPaletteProps {
|
||
|
|
workspaceId: string | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const TIME_RANGE_LABEL: Record<DocumentSearchTimeRange, string> = {
|
||
|
|
any: "全部时间",
|
||
|
|
"7d": "最近 7 天",
|
||
|
|
"30d": "最近 30 天",
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatDate = (value: string | null) => {
|
||
|
|
if (!value) return "未知时间";
|
||
|
|
const date = new Date(value);
|
||
|
|
return date.toLocaleString();
|
||
|
|
};
|
||
|
|
|
||
|
|
export function SearchPalette({ workspaceId }: SearchPaletteProps) {
|
||
|
|
const router = useRouter();
|
||
|
|
const segments = useSelectedLayoutSegments();
|
||
|
|
const activeDocumentId = segments?.[1] ?? null;
|
||
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
|
const {
|
||
|
|
open,
|
||
|
|
mode,
|
||
|
|
query,
|
||
|
|
filters,
|
||
|
|
timeRange,
|
||
|
|
referenceMode,
|
||
|
|
alias,
|
||
|
|
recent,
|
||
|
|
setRecent,
|
||
|
|
openSearch,
|
||
|
|
openReference,
|
||
|
|
close,
|
||
|
|
setQuery,
|
||
|
|
toggleFilter,
|
||
|
|
setTimeRange,
|
||
|
|
setTimeField,
|
||
|
|
setCustomRange,
|
||
|
|
setReferenceMode,
|
||
|
|
setAlias,
|
||
|
|
rememberResult,
|
||
|
|
} = useSearchPaletteStore();
|
||
|
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||
|
|
const { insertReference } = useReferenceComposer({
|
||
|
|
workspaceId,
|
||
|
|
sourcePageId: activeDocumentId ?? null,
|
||
|
|
});
|
||
|
|
const [resultTab, setResultTab] = useState<"all" | "recent">(recent.length > 0 ? "recent" : "all");
|
||
|
|
const [referenceFiltersOpen, setReferenceFiltersOpen] = useState(mode === "reference");
|
||
|
|
const updateCustomRange = useCallback(
|
||
|
|
(patch: { from?: string; to?: string }) => {
|
||
|
|
const next = { ...(filters.customRange ?? {}), ...patch };
|
||
|
|
if (!next.from && !next.to) {
|
||
|
|
setCustomRange(null);
|
||
|
|
} else {
|
||
|
|
setCustomRange(next);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[filters.customRange, setCustomRange],
|
||
|
|
);
|
||
|
|
const clearCustomRange = useCallback(() => setCustomRange(null), [setCustomRange]);
|
||
|
|
|
||
|
|
const requestPayload = useMemo<DocumentSearchRequest | null>(
|
||
|
|
() =>
|
||
|
|
buildSearchRequest({
|
||
|
|
workspaceId,
|
||
|
|
activeDocumentId,
|
||
|
|
query,
|
||
|
|
filters,
|
||
|
|
timeRange,
|
||
|
|
}),
|
||
|
|
[workspaceId, activeDocumentId, query, filters, timeRange],
|
||
|
|
);
|
||
|
|
|
||
|
|
const searchQueryEnabled = Boolean(open && workspaceId);
|
||
|
|
const { data, isLoading, isFetching, error } = useDocumentSearch(requestPayload, searchQueryEnabled);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (data?.recent) {
|
||
|
|
setRecent(data.recent);
|
||
|
|
}
|
||
|
|
}, [data?.recent, setRecent]);
|
||
|
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||
|
|
useEffect(() => {
|
||
|
|
if (mode === "reference") {
|
||
|
|
setReferenceFiltersOpen(true);
|
||
|
|
} else {
|
||
|
|
setReferenceFiltersOpen(false);
|
||
|
|
}
|
||
|
|
}, [mode]);
|
||
|
|
/* eslint-enable react-hooks/set-state-in-effect */
|
||
|
|
const searchResults = data?.results;
|
||
|
|
const remoteResults = useMemo(() => searchResults ?? [], [searchResults]);
|
||
|
|
const showRecent = !query.trim() && recent.length > 0;
|
||
|
|
const enableRecentTab = showRecent;
|
||
|
|
const effectiveTab = enableRecentTab ? resultTab : "all";
|
||
|
|
const activeResults = effectiveTab === "recent" ? recent : remoteResults;
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open) {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
inputRef.current?.focus();
|
||
|
|
const frame = requestAnimationFrame(() => setHighlightedIndex(0));
|
||
|
|
return () => cancelAnimationFrame(frame);
|
||
|
|
}, [open]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (highlightedIndex >= activeResults.length) {
|
||
|
|
const frame = requestAnimationFrame(() =>
|
||
|
|
setHighlightedIndex(activeResults.length > 0 ? activeResults.length - 1 : 0),
|
||
|
|
);
|
||
|
|
return () => cancelAnimationFrame(frame);
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}, [activeResults.length, highlightedIndex]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const handleGlobalHotkey = (event: KeyboardEvent) => {
|
||
|
|
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "p") {
|
||
|
|
event.preventDefault();
|
||
|
|
openSearch();
|
||
|
|
}
|
||
|
|
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "r") {
|
||
|
|
event.preventDefault();
|
||
|
|
openReference();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
window.addEventListener("keydown", handleGlobalHotkey);
|
||
|
|
return () => window.removeEventListener("keydown", handleGlobalHotkey);
|
||
|
|
}, [openSearch, openReference]);
|
||
|
|
|
||
|
|
const handleOpenResult = useCallback(
|
||
|
|
(result: DocumentSearchResult, openMode: "main" | "new-window" | "sidebar") => {
|
||
|
|
if (openMode === "new-window") {
|
||
|
|
window.open(result.publicPath, "_blank", "noopener,noreferrer");
|
||
|
|
} else if (openMode === "sidebar") {
|
||
|
|
window.open(`${result.publicPath}?preview=sidebar`, "_blank", "noopener,noreferrer");
|
||
|
|
} else {
|
||
|
|
router.push(result.publicPath);
|
||
|
|
}
|
||
|
|
void recordRecentPage(workspaceId, result.id);
|
||
|
|
rememberResult(result);
|
||
|
|
close();
|
||
|
|
},
|
||
|
|
[router, workspaceId, rememberResult, close],
|
||
|
|
);
|
||
|
|
|
||
|
|
const handleInsertReference = useCallback(
|
||
|
|
(result: DocumentSearchResult, overrideMode?: ReferenceInsertMode) => {
|
||
|
|
const run = async () => {
|
||
|
|
try {
|
||
|
|
const effectiveMode = overrideMode ?? referenceMode;
|
||
|
|
await insertReference(result, {
|
||
|
|
mode: effectiveMode,
|
||
|
|
alias: effectiveMode === "inline" ? alias : undefined,
|
||
|
|
});
|
||
|
|
rememberResult(result);
|
||
|
|
close();
|
||
|
|
} catch (error) {
|
||
|
|
console.error(error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
void run();
|
||
|
|
},
|
||
|
|
[alias, close, insertReference, referenceMode, rememberResult],
|
||
|
|
);
|
||
|
|
|
||
|
|
const handleCopyReference = useCallback((result: DocumentSearchResult) => {
|
||
|
|
if (typeof window === "undefined") {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const payload = `((${result.id}))`;
|
||
|
|
if (navigator?.clipboard) {
|
||
|
|
navigator.clipboard
|
||
|
|
.writeText(payload)
|
||
|
|
.then(() => window.alert("块引用已复制"))
|
||
|
|
.catch(() => {
|
||
|
|
window.prompt("复制失败,请手动复制引用内容", payload);
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
window.prompt("复制块引用", payload);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const handleKey = (event: KeyboardEvent) => {
|
||
|
|
if (event.key === "ArrowDown") {
|
||
|
|
event.preventDefault();
|
||
|
|
setHighlightedIndex((prev) => Math.min(activeResults.length - 1, prev + 1));
|
||
|
|
} else if (event.key === "ArrowUp") {
|
||
|
|
event.preventDefault();
|
||
|
|
setHighlightedIndex((prev) => Math.max(0, prev - 1));
|
||
|
|
} else if (event.key === "Enter") {
|
||
|
|
event.preventDefault();
|
||
|
|
if (activeResults.length === 0) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const result = activeResults[highlightedIndex] ?? activeResults[0];
|
||
|
|
if (!result) return;
|
||
|
|
if (mode === "search") {
|
||
|
|
const openMode = resolveSearchOpenMode(event);
|
||
|
|
handleOpenResult(result, openMode);
|
||
|
|
} else {
|
||
|
|
handleInsertReference(result);
|
||
|
|
}
|
||
|
|
} else if (event.key === "Escape") {
|
||
|
|
event.preventDefault();
|
||
|
|
close();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
window.addEventListener("keydown", handleKey);
|
||
|
|
return () => window.removeEventListener("keydown", handleKey);
|
||
|
|
}, [open, activeResults, highlightedIndex, mode, handleOpenResult, handleInsertReference, close]);
|
||
|
|
|
||
|
|
const pending = isLoading || isFetching;
|
||
|
|
const dialogTitle = mode === "search" ? "页面搜索面板" : "引用选择面板";
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Dialog open={open} onOpenChange={(next) => !next && close()}>
|
||
|
|
<DialogContent className="max-h-[90vh] w-full max-w-3xl overflow-hidden border-none bg-white/95 p-0 shadow-xl">
|
||
|
|
<DialogTitle className="sr-only">{dialogTitle}</DialogTitle>
|
||
|
|
<div className="flex h-[520px] flex-col">
|
||
|
|
<div className="border-b border-[#eef2ff] p-4">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<div className="relative flex-1">
|
||
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||
|
|
<Input
|
||
|
|
ref={inputRef}
|
||
|
|
value={query}
|
||
|
|
onChange={(event) => {
|
||
|
|
const nextValue = event.target.value;
|
||
|
|
if (nextValue.trim().length > 0 && resultTab !== "all") {
|
||
|
|
setResultTab("all");
|
||
|
|
}
|
||
|
|
setQuery(nextValue);
|
||
|
|
}}
|
||
|
|
placeholder={mode === "search" ? "搜索页面标题、正文或 OCR 内容..." : "选择要引用的页面"}
|
||
|
|
className="h-11 w-full rounded-xl border border-[#e2e8f0] bg-white pl-9 text-sm"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<DropdownMenu>
|
||
|
|
<DropdownMenuTrigger asChild>
|
||
|
|
<Button variant="ghost" className="gap-2 text-xs text-gray-600">
|
||
|
|
<CalendarClock className="h-4 w-4" />
|
||
|
|
{TIME_RANGE_LABEL[timeRange]}
|
||
|
|
</Button>
|
||
|
|
</DropdownMenuTrigger>
|
||
|
|
<DropdownMenuContent align="end" className="w-40">
|
||
|
|
{(Object.keys(TIME_RANGE_LABEL) as DocumentSearchTimeRange[]).map((range) => (
|
||
|
|
<DropdownMenuItem
|
||
|
|
key={range}
|
||
|
|
onClick={() => setTimeRange(range)}
|
||
|
|
className={cn(range === timeRange && "bg-[#eef2ff] text-[#2563eb]")}
|
||
|
|
>
|
||
|
|
{TIME_RANGE_LABEL[range]}
|
||
|
|
</DropdownMenuItem>
|
||
|
|
))}
|
||
|
|
</DropdownMenuContent>
|
||
|
|
</DropdownMenu>
|
||
|
|
<DropdownMenu>
|
||
|
|
<DropdownMenuTrigger asChild>
|
||
|
|
<Button variant="ghost" className="text-xs text-gray-600">
|
||
|
|
{filters.timeField === "updated" ? "按编辑时间" : "按创建时间"}
|
||
|
|
</Button>
|
||
|
|
</DropdownMenuTrigger>
|
||
|
|
<DropdownMenuContent align="end" className="w-32">
|
||
|
|
<DropdownMenuItem
|
||
|
|
onClick={() => setTimeField("updated")}
|
||
|
|
className={cn(filters.timeField === "updated" && "bg-[#eef2ff] text-[#2563eb]")}
|
||
|
|
>
|
||
|
|
按编辑时间
|
||
|
|
</DropdownMenuItem>
|
||
|
|
<DropdownMenuItem
|
||
|
|
onClick={() => setTimeField("created")}
|
||
|
|
className={cn(filters.timeField === "created" && "bg-[#eef2ff] text-[#2563eb]")}
|
||
|
|
>
|
||
|
|
按创建时间
|
||
|
|
</DropdownMenuItem>
|
||
|
|
</DropdownMenuContent>
|
||
|
|
</DropdownMenu>
|
||
|
|
</div>
|
||
|
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||
|
|
<FilterToggle label="仅标题" active={filters.titleOnly} onClick={() => toggleFilter("titleOnly")} />
|
||
|
|
<FilterToggle label="精确匹配" active={filters.exact} onClick={() => toggleFilter("exact")} />
|
||
|
|
<FilterToggle
|
||
|
|
label="当前页面"
|
||
|
|
active={filters.onlyCurrentPage && Boolean(activeDocumentId)}
|
||
|
|
disabled={!activeDocumentId}
|
||
|
|
onClick={() => toggleFilter("onlyCurrentPage")}
|
||
|
|
/>
|
||
|
|
<FilterToggle
|
||
|
|
label="图片 OCR"
|
||
|
|
active={filters.includeOcr}
|
||
|
|
onClick={() => toggleFilter("includeOcr")}
|
||
|
|
/>
|
||
|
|
{mode === "reference" && (
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="sm"
|
||
|
|
variant="ghost"
|
||
|
|
className="ml-auto flex items-center gap-1 rounded-full text-xs text-gray-600"
|
||
|
|
onClick={() => setReferenceFiltersOpen((prev) => !prev)}
|
||
|
|
>
|
||
|
|
<ChevronsUpDown className="h-3 w-3" />
|
||
|
|
{referenceFiltersOpen ? "隐藏引用筛选" : "展开引用筛选"}
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{mode === "reference" && referenceFiltersOpen && (
|
||
|
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||
|
|
<ReferenceModeToggle
|
||
|
|
label="行内引用"
|
||
|
|
active={referenceMode === "inline"}
|
||
|
|
onClick={() => setReferenceMode("inline")}
|
||
|
|
/>
|
||
|
|
<ReferenceModeToggle
|
||
|
|
label="嵌入块"
|
||
|
|
active={referenceMode === "embed"}
|
||
|
|
onClick={() => setReferenceMode("embed")}
|
||
|
|
/>
|
||
|
|
{referenceMode === "inline" && (
|
||
|
|
<Input
|
||
|
|
value={alias}
|
||
|
|
onChange={(event) => setAlias(event.target.value)}
|
||
|
|
placeholder="引用别名"
|
||
|
|
className="h-8 w-40 text-xs"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
<span className="text-[11px] text-gray-400">引用模式将同步到 [[ / # 快捷键</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||
|
|
<span>自定义时间</span>
|
||
|
|
<Input
|
||
|
|
type="date"
|
||
|
|
value={filters.customRange?.from ?? ""}
|
||
|
|
onChange={(event) => updateCustomRange({ from: event.target.value || undefined })}
|
||
|
|
className="h-8 w-36 text-xs"
|
||
|
|
/>
|
||
|
|
<Input
|
||
|
|
type="date"
|
||
|
|
value={filters.customRange?.to ?? ""}
|
||
|
|
onChange={(event) => updateCustomRange({ to: event.target.value || undefined })}
|
||
|
|
className="h-8 w-36 text-xs"
|
||
|
|
/>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
className="text-xs text-gray-500"
|
||
|
|
onClick={clearCustomRange}
|
||
|
|
disabled={!filters.customRange?.from && !filters.customRange?.to}
|
||
|
|
>
|
||
|
|
清除
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex-1 overflow-hidden">
|
||
|
|
{enableRecentTab && (
|
||
|
|
<div className="flex items-center gap-2 border-b border-[#eef2ff] px-4 py-2 text-xs">
|
||
|
|
<TabButton
|
||
|
|
label="最近访问"
|
||
|
|
active={resultTab === "recent"}
|
||
|
|
onClick={() => setResultTab("recent")}
|
||
|
|
disabled={!enableRecentTab}
|
||
|
|
/>
|
||
|
|
<TabButton label="全部页面" active={resultTab === "all"} onClick={() => setResultTab("all")} />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{pending ? (
|
||
|
|
<div className="flex h-full items-center justify-center text-sm text-gray-500">
|
||
|
|
检索中,请稍候...
|
||
|
|
</div>
|
||
|
|
) : error ? (
|
||
|
|
<div className="flex h-full items-center justify-center text-sm text-red-500">
|
||
|
|
{(error as Error).message}
|
||
|
|
</div>
|
||
|
|
) : activeResults.length === 0 ? (
|
||
|
|
<div className="flex h-full items-center justify-center text-sm text-gray-400">
|
||
|
|
{effectiveTab === "recent" ? "暂无最近访问记录" : "暂无匹配结果"}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="h-full divide-y divide-[#f1f5f9] overflow-y-auto">
|
||
|
|
{activeResults.map((result, index) => (
|
||
|
|
<ResultRow
|
||
|
|
key={`result-${result.id}-${index}-${resultTab}`}
|
||
|
|
result={result}
|
||
|
|
highlighted={highlightedIndex === index}
|
||
|
|
mode={mode}
|
||
|
|
onOpen={() => handleOpenResult(result, "main")}
|
||
|
|
onReference={() => handleInsertReference(result)}
|
||
|
|
onEmbed={() => handleInsertReference(result, "embed")}
|
||
|
|
onCopy={() => handleCopyReference(result)}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="border-t border-[#eef2ff] px-4 py-2 text-xs text-gray-500">
|
||
|
|
{mode === "search" ? (
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<span>Enter 打开 · Ctrl/Cmd+Enter 新窗口 · Alt+Enter 右侧预览</span>
|
||
|
|
<span>Esc 关闭</span>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<span>
|
||
|
|
{referenceMode === "inline" ? "插入行内引用(链接高亮)" : "插入一个嵌入的页面块"},可先输入别名
|
||
|
|
</span>
|
||
|
|
<span>Esc 关闭</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ResultRow({
|
||
|
|
result,
|
||
|
|
highlighted,
|
||
|
|
mode,
|
||
|
|
onOpen,
|
||
|
|
onReference,
|
||
|
|
onEmbed,
|
||
|
|
onCopy,
|
||
|
|
}: {
|
||
|
|
result: DocumentSearchResult;
|
||
|
|
highlighted: boolean;
|
||
|
|
mode: SearchPaletteMode;
|
||
|
|
onOpen: () => void;
|
||
|
|
onReference: () => void;
|
||
|
|
onEmbed: () => void;
|
||
|
|
onCopy: () => void;
|
||
|
|
}) {
|
||
|
|
const handleActivate = () => {
|
||
|
|
if (mode === "search") {
|
||
|
|
onOpen();
|
||
|
|
} else {
|
||
|
|
onReference();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDragStart = (event: ReactDragEvent<HTMLDivElement>) => {
|
||
|
|
if (mode !== "reference") return;
|
||
|
|
if (event.dataTransfer) {
|
||
|
|
event.dataTransfer.setData("text/plain", `((${result.id}))`);
|
||
|
|
event.dataTransfer.effectAllowed = "copy";
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const content = (
|
||
|
|
<div
|
||
|
|
role="button"
|
||
|
|
tabIndex={0}
|
||
|
|
onClick={handleActivate}
|
||
|
|
onKeyDown={(event) => {
|
||
|
|
if (event.key === "Enter") {
|
||
|
|
event.preventDefault();
|
||
|
|
handleActivate();
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
className={cn(
|
||
|
|
"cursor-pointer px-4 py-3 transition-colors hover:bg-[#eef2ff]",
|
||
|
|
highlighted && "bg-[#e3ecff]",
|
||
|
|
)}
|
||
|
|
draggable={mode === "reference"}
|
||
|
|
onDragStart={handleDragStart}
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||
|
|
{result.title || "无标题"}
|
||
|
|
{result.matchField !== "recent" && (
|
||
|
|
<Badge variant="secondary" className="bg-[#edf2ff] text-xs text-[#2563eb]">
|
||
|
|
{result.matchField === "title" ? "标题匹配" : "正文匹配"}
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
{result.hasOcr && (
|
||
|
|
<Badge variant="outline" className="text-[10px] text-[#475569]">
|
||
|
|
OCR
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="mt-1 text-xs text-gray-500" dangerouslySetInnerHTML={{ __html: result.snippet }} />
|
||
|
|
<div className="mt-2 text-[11px] text-gray-400">
|
||
|
|
最近编辑:{formatDate(result.updatedAt)} · 创建时间:{formatDate(result.createdAt)}
|
||
|
|
</div>
|
||
|
|
{mode === "reference" && (
|
||
|
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="sm"
|
||
|
|
variant="ghost"
|
||
|
|
className="h-7 rounded-full px-3"
|
||
|
|
onClick={(event) => {
|
||
|
|
event.preventDefault();
|
||
|
|
event.stopPropagation();
|
||
|
|
onReference();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
行内引用
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="sm"
|
||
|
|
variant="ghost"
|
||
|
|
className="flex h-7 items-center gap-1 rounded-full px-3"
|
||
|
|
onClick={(event) => {
|
||
|
|
event.preventDefault();
|
||
|
|
event.stopPropagation();
|
||
|
|
onEmbed();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Layers className="h-3 w-3" />
|
||
|
|
嵌入到...
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="sm"
|
||
|
|
variant="ghost"
|
||
|
|
className="flex h-7 items-center gap-1 rounded-full px-3"
|
||
|
|
onClick={(event) => {
|
||
|
|
event.preventDefault();
|
||
|
|
event.stopPropagation();
|
||
|
|
onCopy();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Copy className="h-3 w-3" />
|
||
|
|
复制块引用
|
||
|
|
</Button>
|
||
|
|
<span className="ml-auto text-[10px] text-gray-400">也可直接拖拽引用到编辑器</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<HoverCard openDelay={250}>
|
||
|
|
<HoverCardTrigger asChild>{content}</HoverCardTrigger>
|
||
|
|
<HoverCardContent align="start">
|
||
|
|
<PageHoverCard result={result} onPreview={onOpen} />
|
||
|
|
</HoverCardContent>
|
||
|
|
</HoverCard>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
interface FilterToggleProps {
|
||
|
|
label: string;
|
||
|
|
active: boolean;
|
||
|
|
onClick: () => void;
|
||
|
|
disabled?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
function FilterToggle({ label, active, onClick, disabled }: FilterToggleProps) {
|
||
|
|
return (
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="sm"
|
||
|
|
variant={active ? "default" : "ghost"}
|
||
|
|
disabled={disabled}
|
||
|
|
className={cn(
|
||
|
|
"h-7 rounded-full border px-3 text-xs",
|
||
|
|
active
|
||
|
|
? "border-[#2563eb] bg-[#2563eb] text-white"
|
||
|
|
: "border-transparent text-gray-500 hover:bg-gray-100",
|
||
|
|
)}
|
||
|
|
onClick={onClick}
|
||
|
|
>
|
||
|
|
{label}
|
||
|
|
</Button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
interface TabButtonProps {
|
||
|
|
label: string;
|
||
|
|
active: boolean;
|
||
|
|
onClick: () => void;
|
||
|
|
disabled?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
function TabButton({ label, active, onClick, disabled }: TabButtonProps) {
|
||
|
|
return (
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="sm"
|
||
|
|
variant={active ? "default" : "ghost"}
|
||
|
|
disabled={disabled}
|
||
|
|
className={cn(
|
||
|
|
"rounded-full px-4 text-xs",
|
||
|
|
active ? "bg-[#2563eb] text-white" : "text-gray-600 hover:bg-[#eef2ff]",
|
||
|
|
)}
|
||
|
|
onClick={onClick}
|
||
|
|
>
|
||
|
|
{label}
|
||
|
|
</Button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ReferenceModeToggleProps {
|
||
|
|
label: string;
|
||
|
|
active: boolean;
|
||
|
|
onClick: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
function ReferenceModeToggle({ label, active, onClick }: ReferenceModeToggleProps) {
|
||
|
|
return (
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="sm"
|
||
|
|
variant={active ? "default" : "ghost"}
|
||
|
|
className={cn(
|
||
|
|
"h-7 rounded-full border px-3 text-[11px]",
|
||
|
|
active
|
||
|
|
? "border-[#2563eb] bg-[#2563eb] text-white"
|
||
|
|
: "border-transparent text-gray-500 hover:bg-gray-100",
|
||
|
|
)}
|
||
|
|
onClick={onClick}
|
||
|
|
>
|
||
|
|
{label}
|
||
|
|
</Button>
|
||
|
|
);
|
||
|
|
}
|