feat(kernel): complete tree-first graph tasks 074-080
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect } from "react";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
|
||||
type SearchPaletteProps = {
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
const SearchPaletteRuntime = dynamic<SearchPaletteProps>(
|
||||
() => import("./search-palette.runtime").then((mod) => mod.SearchPalette),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
// 轻量 host:负责首开前热键与懒加载,重型搜索面板首次打开后再进入运行态。
|
||||
export function SearchPaletteHost({ workspaceId }: SearchPaletteProps) {
|
||||
const openSearch = useSearchPaletteStore((state) => state.openSearch);
|
||||
const openReference = useSearchPaletteStore((state) => state.openReference);
|
||||
const activated = useSearchPaletteStore((state) => state.activated);
|
||||
|
||||
useEffect(() => {
|
||||
if (activated) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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);
|
||||
}, [activated, openReference, openSearch]);
|
||||
|
||||
if (!activated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <SearchPaletteRuntime workspaceId={workspaceId} />;
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
"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, DialogDescription, 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-[92vh] w-[min(1000px,96vw)] !max-w-[min(1000px,96vw)] sm:!max-w-[min(1000px,96vw)] overflow-hidden border-none bg-white/95 p-0 shadow-xl">
|
||||
<DialogTitle className="sr-only">{dialogTitle}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
输入关键词在当前工作区搜索页面标题、正文、思维导图、表格内容与附件文件名;可勾选“搜索附件内容”以纳入附件 OCR/解析文本。
|
||||
</DialogDescription>
|
||||
<div className="flex h-[min(78vh,720px)] min-h-[560px] 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" ? "搜索页面标题、正文或附件文件名..." : "选择要引用的页面"}
|
||||
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="搜索附件内容"
|
||||
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" className="w-[min(720px,90vw)] max-w-[720px]">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,657 +1,6 @@
|
||||
"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, DialogDescription, 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";
|
||||
import { SearchPaletteHost } from "./SearchPaletteHost";
|
||||
|
||||
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-[92vh] w-[min(1000px,96vw)] !max-w-[min(1000px,96vw)] sm:!max-w-[min(1000px,96vw)] overflow-hidden border-none bg-white/95 p-0 shadow-xl">
|
||||
<DialogTitle className="sr-only">{dialogTitle}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
输入关键词在当前工作区搜索页面标题、正文、思维导图、表格内容与附件文件名;可勾选“搜索附件内容”以纳入附件 OCR/解析文本。
|
||||
</DialogDescription>
|
||||
<div className="flex h-[min(78vh,720px)] min-h-[560px] 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" ? "搜索页面标题、正文或附件文件名..." : "选择要引用的页面"}
|
||||
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="搜索附件内容"
|
||||
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" className="w-[min(720px,90vw)] max-w-[720px]">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
// 轻量入口:供全局布局接线,真正的搜索运行态由 host 首次打开时按需加载。
|
||||
export const SearchPalette = SearchPaletteHost;
|
||||
|
||||
Reference in New Issue
Block a user