0.2.1 onlyoffice修复
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { buildDocumentTree } from "@/lib/documents";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
|
||||
|
||||
export type MoveEmbedMode = "move" | "embed";
|
||||
|
||||
const DEFAULT_FILTERS: DocumentSearchFilters = {
|
||||
titleOnly: true,
|
||||
exact: false,
|
||||
onlyCurrentPage: false,
|
||||
includeOcr: false,
|
||||
timeRange: "any",
|
||||
timeField: "updated",
|
||||
};
|
||||
|
||||
type PickerItem =
|
||||
| { kind: "root"; id: null; title: string; subtitle?: string }
|
||||
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number; raw?: DocumentSearchResult };
|
||||
|
||||
async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData> {
|
||||
const response = await fetch(`/api/sidebar?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const message = payload?.error ?? "获取页面列表失败";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
interface MoveEmbedPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string | null;
|
||||
defaultMode?: MoveEmbedMode;
|
||||
modes?: MoveEmbedMode[];
|
||||
allowRoot?: boolean;
|
||||
excludeIds?: string[];
|
||||
onPick: (mode: MoveEmbedMode, targetId: string | null) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function MoveEmbedPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
defaultMode = "move",
|
||||
modes = ["move", "embed"],
|
||||
allowRoot = true,
|
||||
excludeIds = [],
|
||||
onPick,
|
||||
}: MoveEmbedPickerDialogProps) {
|
||||
const [mode, setMode] = useState<MoveEmbedMode>(defaultMode);
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlighted, setHighlighted] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setHighlighted(0);
|
||||
return;
|
||||
}
|
||||
// 说明:每次打开对话框时,强制同步到调用方传入的默认模式(移动/嵌入)。
|
||||
setMode(defaultMode);
|
||||
setQuery("");
|
||||
setHighlighted(0);
|
||||
}, [defaultMode, open]);
|
||||
|
||||
const payload = useMemo(() => {
|
||||
if (!workspaceId) return null;
|
||||
return {
|
||||
workspaceId,
|
||||
query,
|
||||
filters: DEFAULT_FILTERS,
|
||||
limit: 30,
|
||||
};
|
||||
}, [query, workspaceId]);
|
||||
|
||||
const trimmed = query.trim();
|
||||
const isEmptyQuery = trimmed.length === 0;
|
||||
|
||||
const sidebarQuery = useQuery({
|
||||
queryKey: ["move-embed-picker-sidebar", workspaceId],
|
||||
queryFn: () => {
|
||||
if (!workspaceId) {
|
||||
throw new Error("缺少 workspaceId");
|
||||
}
|
||||
return fetchSidebarData(workspaceId);
|
||||
},
|
||||
enabled: open && Boolean(workspaceId) && isEmptyQuery,
|
||||
staleTime: 30_000,
|
||||
gcTime: 60_000,
|
||||
});
|
||||
|
||||
const { data, isLoading, error } = useDocumentSearch(payload, open && !isEmptyQuery);
|
||||
|
||||
const items = useMemo<PickerItem[]>(() => {
|
||||
const excluded = new Set(excludeIds);
|
||||
|
||||
const result: PickerItem[] = [];
|
||||
|
||||
if (allowRoot && mode === "move") {
|
||||
result.push({ kind: "root", id: null, title: "根目录", subtitle: "移动到工作空间根目录" });
|
||||
}
|
||||
|
||||
if (isEmptyQuery) {
|
||||
const docs = sidebarQuery.data?.documents ?? [];
|
||||
const tree = buildDocumentTree(docs);
|
||||
|
||||
const flattened: Array<{ id: string; title: string; depth: number }> = [];
|
||||
const walk = (nodes: ReturnType<typeof buildDocumentTree>, depth: number) => {
|
||||
for (const node of nodes) {
|
||||
flattened.push({ id: node.id, title: node.title ?? "无标题", depth });
|
||||
if (node.children?.length) {
|
||||
walk(node.children, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(tree, 0);
|
||||
|
||||
for (const item of flattened) {
|
||||
if (excluded.has(item.id)) continue;
|
||||
result.push({
|
||||
kind: "doc",
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
depth: item.depth,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const rawList = data?.results?.length ? data?.results : data?.recent ?? [];
|
||||
for (const r of rawList) {
|
||||
if (!r || excluded.has(r.id)) continue;
|
||||
result.push({
|
||||
kind: "doc",
|
||||
id: r.id,
|
||||
title: r.title || "无标题",
|
||||
subtitle: r.matchField === "recent" ? "最近打开" : undefined,
|
||||
raw: r,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.documents]);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlighted(0);
|
||||
}, [mode, query, open]);
|
||||
|
||||
const placeholder = mode === "move" ? "移动到..." : "嵌入到...";
|
||||
|
||||
const handlePick = useCallback(
|
||||
async (targetId: string | null) => {
|
||||
await onPick(mode, targetId);
|
||||
onOpenChange(false);
|
||||
},
|
||||
[mode, onOpenChange, onPick],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] w-full max-w-md overflow-hidden border-none bg-white p-0 shadow-xl">
|
||||
<DialogTitle className="sr-only">选择目标页面</DialogTitle>
|
||||
<div className="flex h-[520px] flex-col">
|
||||
<div className="border-b border-[#eef2ff] p-4">
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as MoveEmbedMode)}>
|
||||
<TabsList className="w-full">
|
||||
{modes.includes("move") && (
|
||||
<TabsTrigger value="move" className="flex-1">
|
||||
移动到
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{modes.includes("embed") && (
|
||||
<TabsTrigger value="embed" className="flex-1">
|
||||
嵌入到
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
<TabsContent value={mode} className="mt-4">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-10 rounded-xl border-[#e2e8f0] pl-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
onKeyDown={(event) => {
|
||||
if (!open) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setHighlighted((prev) => Math.max(0, prev - 1));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const picked = items[highlighted];
|
||||
if (!picked) return;
|
||||
void handlePick(picked.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!workspaceId ? (
|
||||
<div className="p-4 text-sm text-gray-500">缺少 workspaceId,无法加载页面列表。</div>
|
||||
) : (isEmptyQuery ? sidebarQuery.isLoading : isLoading) ? (
|
||||
<div className="p-4 text-sm text-gray-400">加载中...</div>
|
||||
) : (isEmptyQuery ? sidebarQuery.error : error) ? (
|
||||
<div className="p-4 text-sm text-red-600">{String(isEmptyQuery ? sidebarQuery.error : error)}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
{items.map((item, idx) => (
|
||||
<button
|
||||
key={item.kind === "root" ? "root" : item.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
|
||||
idx === highlighted && "bg-[#e3ecff]",
|
||||
)}
|
||||
onMouseEnter={() => setHighlighted(idx)}
|
||||
onClick={() => void handlePick(item.id)}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-medium text-gray-900"
|
||||
style={item.kind === "doc" ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
{item.subtitle && <div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { MoveEmbedPickerDialog } from "@/components/documents/move-embed-picker-dialog";
|
||||
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
|
||||
|
||||
export function MoveEmbedPickerHost() {
|
||||
const open = useMoveEmbedPickerStore((s) => s.open);
|
||||
const workspaceId = useMoveEmbedPickerStore((s) => s.workspaceId);
|
||||
const defaultMode = useMoveEmbedPickerStore((s) => s.defaultMode);
|
||||
const modes = useMoveEmbedPickerStore((s) => s.modes);
|
||||
const allowRoot = useMoveEmbedPickerStore((s) => s.allowRoot);
|
||||
const excludeIds = useMoveEmbedPickerStore((s) => s.excludeIds);
|
||||
const onPick = useMoveEmbedPickerStore((s) => s.onPick);
|
||||
const setWorkspaceId = useMoveEmbedPickerStore((s) => s.setWorkspaceId);
|
||||
const close = useMoveEmbedPickerStore((s) => s.close);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (workspaceId) return;
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/sidebar");
|
||||
if (!res.ok) return;
|
||||
const json = (await res.json().catch(() => null)) as { activeWorkspaceId?: string } | null;
|
||||
const nextId = typeof json?.activeWorkspaceId === "string" ? json.activeWorkspaceId : null;
|
||||
if (!cancelled) {
|
||||
setWorkspaceId(nextId);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, setWorkspaceId, workspaceId]);
|
||||
|
||||
const handlePick = useCallback(
|
||||
async (mode: "move" | "embed", targetId: string | null) => {
|
||||
if (onPick) {
|
||||
await onPick(mode, targetId);
|
||||
}
|
||||
close();
|
||||
},
|
||||
[close, onPick],
|
||||
);
|
||||
|
||||
return (
|
||||
<MoveEmbedPickerDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
workspaceId={workspaceId}
|
||||
defaultMode={defaultMode}
|
||||
modes={modes}
|
||||
allowRoot={allowRoot}
|
||||
excludeIds={excludeIds}
|
||||
onPick={handlePick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user