feat(kernel): complete tree-first graph tasks 074-080

This commit is contained in:
lix-2026
2026-04-16 22:01:51 +08:00
parent 2ff10fa86c
commit b1d5d97142
65 changed files with 11579 additions and 4606 deletions
@@ -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;