0.3.5 共享功能修复
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useParams, useSelectedLayoutSegments } from "next/navigation";
|
||||
import { ChevronRight, MoreHorizontal, Star } from "lucide-react";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import { findBreadcrumb } from "@/lib/documents";
|
||||
import { usePageLayoutStore } from "@/store/page-layout";
|
||||
@@ -14,6 +14,7 @@ interface BreadcrumbProps {
|
||||
}
|
||||
|
||||
export function Breadcrumb({ documents }: BreadcrumbProps) {
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const segments = useSelectedLayoutSegments();
|
||||
const params = useParams<{ id?: string }>();
|
||||
const paramId = typeof params?.id === "string" ? params.id : "";
|
||||
@@ -21,7 +22,10 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
||||
const path = findBreadcrumb(documents, activeId);
|
||||
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
||||
const toggleInspector = usePageLayoutStore((state) => state.toggleInspector);
|
||||
const isStarred = useQuery(api.documentStars.isStarred, activeId ? { documentId: activeId } : "skip");
|
||||
const isStarred = useQuery(
|
||||
api.documentStars.isStarred,
|
||||
activeId && isAuthenticated ? { documentId: activeId } : "skip",
|
||||
);
|
||||
const toggleStar = useMutation(api.documentStars.toggle);
|
||||
|
||||
// 新建页面后,侧边栏数据可能还没同步到 layout(SSR)注入的 documents,导致 findBreadcrumb 暂时为空。
|
||||
@@ -60,7 +64,7 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
||||
type="button"
|
||||
className="hover:bg-wolai-bg-hover hover:text-wolai-text-primary rounded-full px-3 py-1 text-sm transition-colors"
|
||||
onClick={() => void toggleStar({ documentId: activeId })}
|
||||
disabled={!activeId}
|
||||
disabled={!activeId || !isAuthenticated}
|
||||
>
|
||||
<Star
|
||||
className={`mr-1 inline h-4 w-4 ${isStarred ? "text-[#f5a623]" : ""}`}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useImagePicker } from "@/components/media/image-picker-context";
|
||||
import type { MediaKind } from "@/types/media";
|
||||
import { emitAssetsChanged } from "@/lib/events";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -281,6 +282,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
window.alert("缺少 ONLYOFFICE 地址,请在 .env.local 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
|
||||
return;
|
||||
}
|
||||
const currentDocReadOnly = useCurrentDocumentStore.getState().readOnly;
|
||||
try {
|
||||
const assetId = await resolveAssetId();
|
||||
const res = assetId
|
||||
@@ -299,16 +301,33 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
target.searchParams.set("fileUrl", signedUrl);
|
||||
target.searchParams.set("fileName", displayFileName);
|
||||
target.searchParams.set("fileType", extension || "docx");
|
||||
const docId = resolveDocumentId();
|
||||
if (docId) {
|
||||
target.searchParams.set("documentId", docId);
|
||||
}
|
||||
if (assetId) {
|
||||
target.searchParams.set("assetId", assetId);
|
||||
}
|
||||
target.searchParams.set("mode", currentDocReadOnly ? "view" : "edit");
|
||||
window.open(target.toString(), "_blank", "noopener,noreferrer");
|
||||
} catch (error) {
|
||||
window.alert((error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const currentDocumentId = useCurrentDocumentStore((state) => state.documentId);
|
||||
const currentDisableDownload = useCurrentDocumentStore((state) => state.disableDownload);
|
||||
const resolvedDocIdForRestriction = resolveDocumentId();
|
||||
const downloadDisabled =
|
||||
Boolean(currentDisableDownload) &&
|
||||
Boolean(resolvedDocIdForRestriction) &&
|
||||
String(currentDocumentId ?? "") === String(resolvedDocIdForRestriction);
|
||||
|
||||
const downloadAsset = async () => {
|
||||
if (downloadDisabled) {
|
||||
window.alert("该页面已禁止下载");
|
||||
return;
|
||||
}
|
||||
const url = await resolveLatestFileUrl();
|
||||
if (!url) return;
|
||||
const anchor = document.createElement("a");
|
||||
@@ -449,8 +468,9 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
type="button"
|
||||
data-testid="wolai-media-file-download"
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F]"
|
||||
aria-label="下载"
|
||||
title="下载"
|
||||
aria-label={downloadDisabled ? "已禁止下载" : "下载"}
|
||||
title={downloadDisabled ? "已禁止下载" : "下载"}
|
||||
disabled={downloadDisabled}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -497,11 +517,12 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
</DropdownMenuItem>
|
||||
{isOfficeDoc && <DropdownMenuItem onClick={() => void openWithOnlyOffice()}>使用 ONLYOFFICE 打开</DropdownMenuItem>}
|
||||
<DropdownMenuItem
|
||||
disabled={downloadDisabled}
|
||||
onClick={() => {
|
||||
void downloadAsset();
|
||||
}}
|
||||
>
|
||||
下载到本地
|
||||
{downloadDisabled ? "已禁止下载" : "下载到本地"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDeleteAsset}>删除</DropdownMenuItem>
|
||||
@@ -570,14 +591,16 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
icon: <LinkIcon className="h-4 w-4" />,
|
||||
onClick: handleLink,
|
||||
},
|
||||
{
|
||||
key: "download",
|
||||
label: `下载${typeLabel}`,
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
void downloadAsset();
|
||||
},
|
||||
},
|
||||
!downloadDisabled
|
||||
? {
|
||||
key: "download",
|
||||
label: `下载${typeLabel}`,
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
void downloadAsset();
|
||||
},
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "delete",
|
||||
label: `删除${typeLabel}`,
|
||||
@@ -657,11 +680,12 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
查看原文件
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={downloadDisabled}
|
||||
onClick={() => {
|
||||
void downloadAsset();
|
||||
}}
|
||||
>
|
||||
下载到本地
|
||||
{downloadDisabled ? "已禁止下载" : "下载到本地"}
|
||||
</DropdownMenuItem>
|
||||
{canTriggerOcr && (
|
||||
<>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
|
||||
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
|
||||
import { PageBacklinksPanel } from "@/components/editor/page-backlinks-panel";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { usePageLayoutStore } from "@/store/page-layout";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { DocumentHistoryDrawer } from "@/components/editor/document-history-drawer";
|
||||
import type { DocumentSnapshot } from "@/types/document";
|
||||
@@ -36,6 +37,8 @@ export interface DocumentContentProps {
|
||||
initialStats: DocumentStats | null;
|
||||
openTableId?: string | null;
|
||||
readOnly?: boolean;
|
||||
disableDownload?: boolean;
|
||||
disableCopy?: boolean;
|
||||
}
|
||||
|
||||
const defaultOptions: PageOptionsState = {
|
||||
@@ -59,7 +62,11 @@ export function DocumentContent({
|
||||
initialStats,
|
||||
openTableId,
|
||||
readOnly = false,
|
||||
disableDownload = false,
|
||||
disableCopy = false,
|
||||
}: DocumentContentProps) {
|
||||
const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent);
|
||||
const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch);
|
||||
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
|
||||
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
|
||||
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
|
||||
@@ -76,6 +83,78 @@ export function DocumentContent({
|
||||
const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingOpenTableRef = useRef<string | null>(null);
|
||||
const latestBlocksRef = useRef<Json | null>(null);
|
||||
const pageRootRef = useRef<HTMLDivElement>(null);
|
||||
const lastCopyBlockedAtRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentDocument(documentId, readOnly, disableDownload, disableCopy);
|
||||
return () => {
|
||||
clearIfMatch(documentId);
|
||||
};
|
||||
}, [clearIfMatch, disableCopy, disableDownload, documentId, readOnly, setCurrentDocument]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disableCopy) return;
|
||||
|
||||
const isEventInsidePage = () => {
|
||||
const root = pageRootRef.current;
|
||||
if (!root) return false;
|
||||
const selection = typeof window !== "undefined" ? window.getSelection() : null;
|
||||
const anchor = selection?.anchorNode ?? null;
|
||||
const focus = selection?.focusNode ?? null;
|
||||
const anchorEl =
|
||||
anchor && "nodeType" in anchor && anchor.nodeType === Node.TEXT_NODE
|
||||
? anchor.parentElement
|
||||
: (anchor as any as Element | null);
|
||||
const focusEl =
|
||||
focus && "nodeType" in focus && focus.nodeType === Node.TEXT_NODE
|
||||
? focus.parentElement
|
||||
: (focus as any as Element | null);
|
||||
return Boolean((anchorEl && root.contains(anchorEl)) || (focusEl && root.contains(focusEl)));
|
||||
};
|
||||
|
||||
const notifyBlocked = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastCopyBlockedAtRef.current < 1200) return;
|
||||
lastCopyBlockedAtRef.current = now;
|
||||
window.alert("该页面已禁止复制");
|
||||
};
|
||||
|
||||
const onCopy = (event: ClipboardEvent) => {
|
||||
if (!isEventInsidePage()) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
notifyBlocked();
|
||||
};
|
||||
|
||||
const onCut = (event: ClipboardEvent) => {
|
||||
if (!isEventInsidePage()) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
notifyBlocked();
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isEventInsidePage()) return;
|
||||
const key = String(event.key ?? "").toLowerCase();
|
||||
const ctrlOrMeta = event.ctrlKey || event.metaKey;
|
||||
if (!ctrlOrMeta) return;
|
||||
if (key === "c" || key === "x" || key === "insert") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
notifyBlocked();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("copy", onCopy, true);
|
||||
document.addEventListener("cut", onCut, true);
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("copy", onCopy, true);
|
||||
document.removeEventListener("cut", onCut, true);
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
};
|
||||
}, [disableCopy]);
|
||||
|
||||
useEffect(() => {
|
||||
const tableId = (openTableId ?? "").trim();
|
||||
@@ -208,7 +287,7 @@ export function DocumentContent({
|
||||
void persistTitle(pageTitle);
|
||||
};
|
||||
|
||||
const handleTitleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
const handleTitleKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
@@ -251,6 +330,10 @@ export function DocumentContent({
|
||||
}, [updatedAt]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
if (disableDownload) {
|
||||
window.alert("该页面已禁止下载");
|
||||
return;
|
||||
}
|
||||
const latest = history[0];
|
||||
if (!latest) {
|
||||
window.alert("暂无可导出的内容");
|
||||
@@ -264,7 +347,7 @@ export function DocumentContent({
|
||||
anchor.download = `${title ?? "未命名页面"}-${new Date(latest.timestamp).toISOString()}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [history, title]);
|
||||
}, [disableDownload, history, title]);
|
||||
|
||||
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
|
||||
latestBlocksRef.current = payload.blocks;
|
||||
@@ -315,7 +398,7 @@ export function DocumentContent({
|
||||
|
||||
return (
|
||||
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
||||
<div className="flex h-full overflow-hidden bg-wolai-bg">
|
||||
<div className="flex h-full overflow-hidden bg-wolai-bg" ref={pageRootRef}>
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b border-wolai-border px-12 pb-6 pt-8">
|
||||
<div className="relative">
|
||||
|
||||
@@ -13,6 +13,7 @@ type GroupRow = {
|
||||
name: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
my_role: "owner" | "member";
|
||||
};
|
||||
|
||||
type MemberRow = {
|
||||
@@ -32,12 +33,42 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
const convex = useConvex();
|
||||
const createGroup = useMutation(api.groups.create);
|
||||
const removeGroup = useMutation(api.groups.remove);
|
||||
const inviteByUsername = useMutation(api.groupMembers.inviteByUsername);
|
||||
const inviteByUsername = useMutation(api.groupInvitations.inviteByUsername);
|
||||
const removeMember = useMutation(api.groupMembers.removeMember);
|
||||
const listMyInvitations = useCallback(async () => {
|
||||
const resp = await convex.query(api.groupInvitations.listMine, {});
|
||||
const rows = Array.isArray(resp) ? (resp as any[]) : [];
|
||||
return rows.map((r) => ({
|
||||
workspaceId: String(r.workspaceId),
|
||||
workspaceName: r.workspaceName ? String(r.workspaceName) : null,
|
||||
groupId: String(r.groupId),
|
||||
groupName: r.groupName ? String(r.groupName) : null,
|
||||
invitedByUserId: String(r.invitedByUserId ?? ""),
|
||||
invitedByUsername: r.invitedByUsername ? String(r.invitedByUsername) : null,
|
||||
createdAt: String(r.createdAt ?? ""),
|
||||
updatedAt: String(r.updatedAt ?? ""),
|
||||
}));
|
||||
}, [convex]);
|
||||
const acceptInvite = useMutation(api.groupInvitations.accept);
|
||||
const declineInvite = useMutation(api.groupInvitations.decline);
|
||||
|
||||
const [groups, setGroups] = useState<GroupRow[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>("");
|
||||
const [members, setMembers] = useState<MemberRow[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<Array<{ id: string; name: string; type: string }>>([]);
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>(workspaceId);
|
||||
const [invitations, setInvitations] = useState<
|
||||
Array<{
|
||||
workspaceId: string;
|
||||
workspaceName: string | null;
|
||||
groupId: string;
|
||||
groupName: string | null;
|
||||
invitedByUserId: string;
|
||||
invitedByUsername: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [inviteUsername, setInviteUsername] = useState("");
|
||||
@@ -50,18 +81,24 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
if (!workspaceId) return;
|
||||
const resp = await convex.query(api.groups.listByWorkspace, { workspaceId });
|
||||
const rows = Array.isArray(resp) ? (resp as any[]) : [];
|
||||
if (!selectedWorkspaceId) return;
|
||||
const resp = await convex.query(api.groups.listMineByWorkspace, { workspaceId: selectedWorkspaceId });
|
||||
const rows = Array.isArray((resp as any)?.groups) ? ((resp as any).groups as any[]) : [];
|
||||
setGroups(
|
||||
rows.map((g) => ({
|
||||
id: String(g.id),
|
||||
name: String(g.name ?? ""),
|
||||
created_by: String(g.created_by ?? ""),
|
||||
created_at: String(g.created_at ?? ""),
|
||||
my_role: g.my_role === "owner" ? "owner" : "member",
|
||||
})),
|
||||
);
|
||||
}, [convex, workspaceId]);
|
||||
}, [convex, selectedWorkspaceId]);
|
||||
|
||||
const loadInvitations = useCallback(async () => {
|
||||
const rows = await listMyInvitations();
|
||||
setInvitations(rows);
|
||||
}, [listMyInvitations]);
|
||||
|
||||
const loadMembers = useCallback(async (groupId: string) => {
|
||||
if (!groupId) {
|
||||
@@ -86,14 +123,54 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
setLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
await loadGroups();
|
||||
const wsResp = await convex.query(api.workspaces.fetchWorkspaceSummaries, {});
|
||||
const wsRows = Array.isArray((wsResp as any)?.workspaces) ? ((wsResp as any).workspaces as any[]) : [];
|
||||
const normalized = wsRows.map((w) => ({
|
||||
id: String(w.id),
|
||||
name: String(w.name ?? ""),
|
||||
type: String(w.type ?? ""),
|
||||
}));
|
||||
setWorkspaces(normalized);
|
||||
|
||||
const desired =
|
||||
workspaceId && normalized.some((w) => w.id === workspaceId)
|
||||
? workspaceId
|
||||
: typeof (wsResp as any)?.activeWorkspaceId === "string" && (wsResp as any).activeWorkspaceId
|
||||
? String((wsResp as any).activeWorkspaceId)
|
||||
: normalized[0]?.id ?? workspaceId;
|
||||
setSelectedWorkspaceId(desired);
|
||||
|
||||
await Promise.all([loadInvitations()]);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "加载群组失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [loadGroups, open]);
|
||||
}, [convex, loadInvitations, open, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!selectedWorkspaceId) {
|
||||
setGroups([]);
|
||||
setSelectedGroupId("");
|
||||
setMembers([]);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
await loadGroups();
|
||||
setSelectedGroupId("");
|
||||
setMembers([]);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "加载群组失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [loadGroups, open, selectedWorkspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -113,9 +190,13 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
setError("请输入群组名称");
|
||||
return;
|
||||
}
|
||||
if (!selectedWorkspaceId) {
|
||||
setError("请先选择一个工作空间");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await createGroup({ id: uuidv4(), workspaceId, name });
|
||||
await createGroup({ id: uuidv4(), workspaceId: selectedWorkspaceId, name });
|
||||
setNewGroupName("");
|
||||
await loadGroups();
|
||||
} catch (e: any) {
|
||||
@@ -149,6 +230,10 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
setError("请先选择一个群组");
|
||||
return;
|
||||
}
|
||||
if (selectedGroup?.my_role !== "owner") {
|
||||
setError("只有群主可以邀请成员");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const username = inviteUsername.trim();
|
||||
if (!username) {
|
||||
@@ -159,7 +244,7 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
try {
|
||||
await inviteByUsername({ groupId: selectedGroupId, username });
|
||||
setInviteUsername("");
|
||||
await loadMembers(selectedGroupId);
|
||||
await loadInvitations();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "邀请失败");
|
||||
} finally {
|
||||
@@ -167,6 +252,34 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcceptInvite = async (inv: { groupId: string; workspaceId: string }) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await acceptInvite({ groupId: inv.groupId });
|
||||
await loadInvitations();
|
||||
setSelectedWorkspaceId(inv.workspaceId);
|
||||
window.alert("已接受邀请:已加入群组与工作空间。共享页面会出现在左侧「共享页面」面板。");
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "接受邀请失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeclineInvite = async (groupId: string) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await declineInvite({ groupId });
|
||||
await loadInvitations();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "拒绝邀请失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
if (!selectedGroupId) return;
|
||||
if (!window.confirm("确认移除该成员吗?")) return;
|
||||
@@ -184,7 +297,7 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>群组管理</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -193,6 +306,78 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
<div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-md border border-gray-200">
|
||||
<div className="border-b border-gray-200 px-3 py-2 text-sm font-medium text-gray-700">工作空间</div>
|
||||
<div className="p-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<select
|
||||
className="h-10 min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700"
|
||||
value={selectedWorkspaceId}
|
||||
onChange={(e) => setSelectedWorkspaceId(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
{workspaces.length === 0 ? (
|
||||
<option value={selectedWorkspaceId || ""}>暂无工作空间</option>
|
||||
) : (
|
||||
workspaces.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}({w.type === "team" ? "团队" : "个人"})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
说明:工作空间切换功能暂时停用;这里的选择仅影响“群组管理”的展示与操作范围。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200">
|
||||
<div className="border-b border-gray-200 px-3 py-2 text-sm font-medium text-gray-700">
|
||||
收到的邀请({invitations.length})
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{invitations.length === 0 ? (
|
||||
<div className="text-sm text-gray-400">暂无邀请</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{invitations.map((inv) => (
|
||||
<div
|
||||
key={`${inv.workspaceId}:${inv.groupId}`}
|
||||
className="flex w-full min-w-0 items-center justify-between gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">
|
||||
群组:{inv.groupName ?? inv.groupId}
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
工作空间:{inv.workspaceName ?? inv.workspaceId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-400">
|
||||
邀请人:{inv.invitedByUsername ?? inv.invitedByUserId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button className="h-8" disabled={loading} onClick={() => void handleAcceptInvite({ groupId: inv.groupId, workspaceId: inv.workspaceId })}>
|
||||
接受
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={loading}
|
||||
onClick={() => void handleDeclineInvite(inv.groupId)}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="rounded-md border border-gray-200">
|
||||
<div className="border-b border-gray-200 px-3 py-2 text-sm font-medium text-gray-700">群组</div>
|
||||
@@ -213,28 +398,52 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
{groups.length === 0 ? (
|
||||
<div className="px-3 py-3 text-sm text-gray-400">暂无群组</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
className={`flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-gray-50 ${selectedGroupId === g.id ? "bg-blue-50" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left"
|
||||
onClick={() => setSelectedGroupId(g.id)}
|
||||
(() => {
|
||||
const owned = groups.filter((g) => g.my_role === "owner");
|
||||
const joined = groups.filter((g) => g.my_role !== "owner");
|
||||
const renderGroupRow = (g: GroupRow) => (
|
||||
<div
|
||||
key={g.id}
|
||||
className={`flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-gray-50 ${selectedGroupId === g.id ? "bg-blue-50" : ""}`}
|
||||
>
|
||||
{g.name}
|
||||
</button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={loading}
|
||||
onClick={() => void handleRemoveGroup(g.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left"
|
||||
onClick={() => setSelectedGroupId(g.id)}
|
||||
>
|
||||
{g.name}
|
||||
<span className="ml-2 text-xs text-gray-400">{g.my_role === "owner" ? "群主" : "成员"}</span>
|
||||
</button>
|
||||
{g.my_role === "owner" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={loading}
|
||||
onClick={() => void handleRemoveGroup(g.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="py-1">
|
||||
<div className="px-3 py-2 text-xs font-medium text-gray-500">我创建的</div>
|
||||
{owned.length === 0 ? (
|
||||
<div className="px-3 pb-2 text-sm text-gray-400">暂无</div>
|
||||
) : (
|
||||
owned.map(renderGroupRow)
|
||||
)}
|
||||
<div className="px-3 py-2 text-xs font-medium text-gray-500">我加入的</div>
|
||||
{joined.length === 0 ? (
|
||||
<div className="px-3 pb-2 text-sm text-gray-400">暂无</div>
|
||||
) : (
|
||||
joined.map(renderGroupRow)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,12 +459,15 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
value={inviteUsername}
|
||||
onChange={(e) => setInviteUsername(e.target.value)}
|
||||
placeholder="输入用户名邀请"
|
||||
disabled={loading}
|
||||
disabled={loading || !selectedGroupId || selectedGroup?.my_role !== "owner"}
|
||||
/>
|
||||
<Button onClick={() => void handleInvite()} disabled={loading}>
|
||||
邀请
|
||||
</Button>
|
||||
</div>
|
||||
{selectedGroupId && selectedGroup?.my_role !== "owner" ? (
|
||||
<div className="text-xs text-gray-400">提示:只有群主可以邀请成员</div>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-60 overflow-auto rounded-md border border-gray-200">
|
||||
{!selectedGroupId ? (
|
||||
@@ -301,4 +513,3 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,9 +54,13 @@ export function ConvexClientProvider({ children }: ConvexClientProviderProps) {
|
||||
}
|
||||
|
||||
// 未配置 env:使用当前主机名,端口固定 3210,并跟随当前页面协议(http/https)。
|
||||
// https 场景下浏览器禁止 ws://,因此默认走同源反代 `/convex`(需要 server 支持 Upgrade 透传)。
|
||||
if (browserProtocol === "https:") {
|
||||
return normalize(`${window.location.origin}${convexProxyPath}`);
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = "http:";
|
||||
return normalize(`${protocol}//${hostname}:3210`);
|
||||
return normalize(`http://${hostname}:3210`);
|
||||
};
|
||||
|
||||
const convexUrl = getConvexUrl();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { DragEvent as ReactDragEvent } from "react";
|
||||
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
|
||||
import { CalendarClock, ChevronsUpDown, Copy, Layers, Search } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { 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";
|
||||
@@ -249,9 +249,12 @@ export function SearchPalette({ workspaceId }: SearchPaletteProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && close()}>
|
||||
<DialogContent className="max-h-[90vh] w-full max-w-3xl overflow-hidden border-none bg-white/95 p-0 shadow-xl">
|
||||
<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>
|
||||
<div className="flex h-[520px] flex-col">
|
||||
<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">
|
||||
@@ -266,7 +269,7 @@ export function SearchPalette({ workspaceId }: SearchPaletteProps) {
|
||||
}
|
||||
setQuery(nextValue);
|
||||
}}
|
||||
placeholder={mode === "search" ? "搜索页面标题、正文或 OCR 内容..." : "选择要引用的页面"}
|
||||
placeholder={mode === "search" ? "搜索页面标题、正文或附件文件名..." : "选择要引用的页面"}
|
||||
className="h-11 w-full rounded-xl border border-[#e2e8f0] bg-white pl-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -321,7 +324,7 @@ export function SearchPalette({ workspaceId }: SearchPaletteProps) {
|
||||
onClick={() => toggleFilter("onlyCurrentPage")}
|
||||
/>
|
||||
<FilterToggle
|
||||
label="图片 OCR"
|
||||
label="搜索附件内容"
|
||||
active={filters.includeOcr}
|
||||
onClick={() => toggleFilter("includeOcr")}
|
||||
/>
|
||||
@@ -569,7 +572,7 @@ function ResultRow({
|
||||
return (
|
||||
<HoverCard openDelay={250}>
|
||||
<HoverCardTrigger asChild>{content}</HoverCardTrigger>
|
||||
<HoverCardContent align="start">
|
||||
<HoverCardContent align="start" className="w-[min(720px,90vw)] max-w-[720px]">
|
||||
<PageHoverCard result={result} onPreview={onOpen} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
@@ -32,6 +32,8 @@ export function DocumentShareDialog({
|
||||
const [username, setUsername] = useState("");
|
||||
const [permission, setPermission] = useState<SharePermission>("read");
|
||||
const [includeDescendants, setIncludeDescendants] = useState(false);
|
||||
const [disableDownload, setDisableDownload] = useState(false);
|
||||
const [disableCopy, setDisableCopy] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [shares, setShares] = useState<any[] | null>(null);
|
||||
@@ -40,6 +42,8 @@ export function DocumentShareDialog({
|
||||
const [groupShares, setGroupShares] = useState<any[] | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState("");
|
||||
const [groupIncludeDescendants, setGroupIncludeDescendants] = useState(false);
|
||||
const [groupDisableDownload, setGroupDisableDownload] = useState(false);
|
||||
const [groupDisableCopy, setGroupDisableCopy] = useState(false);
|
||||
const [groupMembers, setGroupMembers] = useState<Array<{ userId: string; username: string | null; role: string }>>(
|
||||
[],
|
||||
);
|
||||
@@ -121,6 +125,8 @@ export function DocumentShareDialog({
|
||||
setUsername("");
|
||||
setPermission("read");
|
||||
setIncludeDescendants(false);
|
||||
setDisableDownload(false);
|
||||
setDisableCopy(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
setShares(null);
|
||||
@@ -129,6 +135,8 @@ export function DocumentShareDialog({
|
||||
setGroupShares(null);
|
||||
setSelectedGroupId("");
|
||||
setGroupIncludeDescendants(false);
|
||||
setGroupDisableDownload(false);
|
||||
setGroupDisableCopy(false);
|
||||
setGroupMembers([]);
|
||||
setGroupEditableUserIds(new Set());
|
||||
return;
|
||||
@@ -150,10 +158,14 @@ export function DocumentShareDialog({
|
||||
const existing = rows.find((r: any) => String(r.groupId) === String(selectedGroupId));
|
||||
if (existing) {
|
||||
setGroupIncludeDescendants(Boolean(existing.includeDescendants));
|
||||
setGroupDisableDownload(Boolean(existing.disableDownload));
|
||||
setGroupDisableCopy(Boolean(existing.disableCopy));
|
||||
const editable = new Set<string>(Array.isArray(existing.editableUserIds) ? existing.editableUserIds.map(String) : []);
|
||||
setGroupEditableUserIds(editable);
|
||||
} else {
|
||||
setGroupIncludeDescendants(false);
|
||||
setGroupDisableDownload(false);
|
||||
setGroupDisableCopy(false);
|
||||
setGroupEditableUserIds(new Set());
|
||||
}
|
||||
}, [groupShares, open, selectedGroupId]);
|
||||
@@ -177,12 +189,40 @@ export function DocumentShareDialog({
|
||||
username: u,
|
||||
permission,
|
||||
includeDescendants: allowIncludeDescendants ? includeDescendants : false,
|
||||
disableDownload,
|
||||
disableCopy,
|
||||
});
|
||||
setUsername("");
|
||||
await loadShares();
|
||||
await onChanged?.();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "共享失败,请重试");
|
||||
const msg = e?.message ?? "共享失败,请重试";
|
||||
if (
|
||||
String(msg).includes("ArgumentValidationError") &&
|
||||
(String(msg).includes("disableCopy") || String(msg).includes("disableDownload"))
|
||||
) {
|
||||
// 兼容旧后端:先用旧参数重试,避免用户完全无法共享。
|
||||
try {
|
||||
await upsertShare({
|
||||
documentId,
|
||||
username: u,
|
||||
permission,
|
||||
includeDescendants: allowIncludeDescendants ? includeDescendants : false,
|
||||
} as any);
|
||||
setUsername("");
|
||||
await loadShares();
|
||||
await onChanged?.();
|
||||
setError(
|
||||
"已共享,但“禁止下载/禁止复制”未生效(当前 Convex 后端未更新)。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后再设置限制。",
|
||||
);
|
||||
} catch {
|
||||
setError(
|
||||
"共享功能已升级(新增“禁止下载/禁止复制”),但当前 Convex 后端未更新。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后刷新页面再试。",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -216,11 +256,37 @@ export function DocumentShareDialog({
|
||||
groupId,
|
||||
includeDescendants: allowIncludeDescendants ? groupIncludeDescendants : false,
|
||||
editableUserIds: Array.from(groupEditableUserIds),
|
||||
disableDownload: groupDisableDownload,
|
||||
disableCopy: groupDisableCopy,
|
||||
});
|
||||
await loadGroupShares();
|
||||
await onChanged?.();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "公开失败,请重试");
|
||||
const msg = e?.message ?? "公开失败,请重试";
|
||||
if (
|
||||
String(msg).includes("ArgumentValidationError") &&
|
||||
(String(msg).includes("disableCopy") || String(msg).includes("disableDownload"))
|
||||
) {
|
||||
try {
|
||||
await upsertGroupShare({
|
||||
documentId,
|
||||
groupId,
|
||||
includeDescendants: allowIncludeDescendants ? groupIncludeDescendants : false,
|
||||
editableUserIds: Array.from(groupEditableUserIds),
|
||||
} as any);
|
||||
await loadGroupShares();
|
||||
await onChanged?.();
|
||||
setError(
|
||||
"已公开,但“禁止下载/禁止复制”未生效(当前 Convex 后端未更新)。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后再设置限制。",
|
||||
);
|
||||
} catch {
|
||||
setError(
|
||||
"共享功能已升级(新增“禁止下载/禁止复制”),但当前 Convex 后端未更新。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后刷新页面再试。",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -294,6 +360,24 @@ export function DocumentShareDialog({
|
||||
包含子页面(共享文件夹)
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disableDownload}
|
||||
onChange={(e) => setDisableDownload(e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
禁止下载
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disableCopy}
|
||||
onChange={(e) => setDisableCopy(e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
禁止复制
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -325,6 +409,24 @@ export function DocumentShareDialog({
|
||||
包含子页面(公开文件夹)
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupDisableDownload}
|
||||
onChange={(e) => setGroupDisableDownload(e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
禁止下载
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupDisableCopy}
|
||||
onChange={(e) => setGroupDisableCopy(e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
禁止复制
|
||||
</label>
|
||||
<Button onClick={() => void handleUpsertGroupShare()} disabled={submitting || !selectedGroupId}>
|
||||
{submitting ? "处理中..." : "公开/更新"}
|
||||
</Button>
|
||||
@@ -396,6 +498,14 @@ export function DocumentShareDialog({
|
||||
{r.includeDescendants ? "包含子页面" : "仅当前页面"}
|
||||
{" · "}
|
||||
可编辑:{Array.isArray(r.editableUserIds) ? r.editableUserIds.length : 0} 人
|
||||
{(r.disableDownload || r.disableCopy) ? (
|
||||
<>
|
||||
{" · "}
|
||||
{r.disableDownload ? "禁止下载" : ""}
|
||||
{r.disableDownload && r.disableCopy ? " / " : ""}
|
||||
{r.disableCopy ? "禁止复制" : ""}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -440,6 +550,14 @@ export function DocumentShareDialog({
|
||||
<div className="text-xs text-gray-500">
|
||||
权限:{row.permission === "edit" ? "可编辑" : "只读"}
|
||||
{row.includeDescendants ? " · 包含子页面" : ""}
|
||||
{(row.disableDownload || row.disableCopy) ? (
|
||||
<>
|
||||
{" · "}
|
||||
{row.disableDownload ? "禁止下载" : ""}
|
||||
{row.disableDownload && row.disableCopy ? " / " : ""}
|
||||
{row.disableCopy ? "禁止复制" : ""}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -44,7 +44,17 @@ export function PrivateTree({
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||
|
||||
const flatNodes = useMemo(() => flattenDocumentTree(nodes, expanded), [nodes, expanded]);
|
||||
const flatNodes = useMemo(() => {
|
||||
const flattened = flattenDocumentTree(nodes, expanded);
|
||||
const seen = new Set<string>();
|
||||
const deduped: typeof flattened = [];
|
||||
for (const item of flattened) {
|
||||
if (seen.has(item.node.id)) continue;
|
||||
seen.add(item.node.id);
|
||||
deduped.push(item);
|
||||
}
|
||||
return deduped;
|
||||
}, [nodes, expanded]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
|
||||
@@ -42,6 +42,7 @@ import { PrivateTree } from "@/components/sidebar/private-tree";
|
||||
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
import { FileTree } from "@/components/sidebar/file-tree";
|
||||
import { buildVisibleRows } from "@/lib/file-tree/rows";
|
||||
import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
|
||||
@@ -179,16 +180,23 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const [topPanel, setTopPanel] = useState<"starred" | "public" | "shared" | "templates" | null>(null);
|
||||
const [shareSummary, setShareSummary] = useState<{
|
||||
incoming: Array<{
|
||||
workspaceId: string;
|
||||
workspaceName: string | null;
|
||||
documentId: string;
|
||||
documentTitle: string | null;
|
||||
permission: "read" | "edit";
|
||||
includeDescendants: boolean;
|
||||
createdBy: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
outgoing: Array<{
|
||||
workspaceId: string;
|
||||
workspaceName: string | null;
|
||||
documentId: string;
|
||||
documentTitle: string | null;
|
||||
includeDescendants: boolean;
|
||||
sharedWithCount: number;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
} | null>(null);
|
||||
const [shareSummaryError, setShareSummaryError] = useState<string | null>(null);
|
||||
@@ -209,7 +217,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
allowIncludeDescendants: boolean;
|
||||
} | null>(null);
|
||||
const [groupManagerOpen, setGroupManagerOpen] = useState(false);
|
||||
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [trashOpen, setTrashOpen] = useState(false);
|
||||
const [trashSearch, setTrashSearch] = useState("");
|
||||
@@ -230,8 +237,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
focusedRowId: null,
|
||||
}));
|
||||
|
||||
const workspaceMenuRef = useRef<HTMLDivElement>(null);
|
||||
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setTree(() => {
|
||||
@@ -261,38 +268,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
setOpen(false);
|
||||
}, [activeId, setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const custom = event as CustomEvent<{ docId?: string; asset?: MediaAsset }>;
|
||||
const asset = custom.detail?.asset as MediaAsset | undefined;
|
||||
if (asset?.id) {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
setMindmapAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
} else if (asset.asset_type === "luckysheet") {
|
||||
setTableAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
} else {
|
||||
setMediaAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
}
|
||||
}
|
||||
void sidebarQuery.refetch();
|
||||
};
|
||||
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
|
||||
window.addEventListener(DOCUMENTS_CHANGED_EVENT, handler);
|
||||
return () => {
|
||||
window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
|
||||
window.removeEventListener(DOCUMENTS_CHANGED_EVENT, handler);
|
||||
};
|
||||
}, [sidebarQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const onSaved = () => void sidebarQuery.refetch();
|
||||
const onDeleted = () => void sidebarQuery.refetch();
|
||||
@@ -309,27 +284,22 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}, [sidebarQuery]);
|
||||
|
||||
const refreshShareSummary = useCallback(async () => {
|
||||
const workspaceId = sidebarData.activeWorkspaceId;
|
||||
if (!workspaceId) {
|
||||
setShareSummary(null);
|
||||
setShareSummaryError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await convex.query(api.documentShares.listShareRootsByWorkspace, { workspaceId });
|
||||
const resp = await convex.query(api.documentShares.listMyShareRoots, {});
|
||||
setShareSummary(resp as any);
|
||||
setShareSummaryError(null);
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? "加载共享摘要失败";
|
||||
if (String(msg).includes("Could not find public function for 'documentShares:listShareRootsByWorkspace'")) {
|
||||
setShareSummaryError("共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local`。");
|
||||
if (String(msg).includes("Could not find public function for 'documentShares:listMyShareRoots'")) {
|
||||
setShareSummaryError(
|
||||
"共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local`。",
|
||||
);
|
||||
} else {
|
||||
setShareSummaryError(msg);
|
||||
}
|
||||
setShareSummary(null);
|
||||
}
|
||||
}, [convex, sidebarData.activeWorkspaceId]);
|
||||
}, [convex]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshShareSummary();
|
||||
@@ -374,6 +344,70 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
void refreshGroupPublicSummary();
|
||||
}, [refreshGroupPublicSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const custom = event as CustomEvent<{ docId?: string; asset?: MediaAsset }>;
|
||||
const asset = custom.detail?.asset as MediaAsset | undefined;
|
||||
if (asset?.id) {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
setMindmapAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
} else if (asset.asset_type === "luckysheet") {
|
||||
setTableAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
} else {
|
||||
setMediaAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
}
|
||||
}
|
||||
void sidebarQuery.refetch();
|
||||
// 同步刷新共享/公共摘要,避免跨页面操作后出现“幽灵共享条目”(点开 404 / 无标题)。
|
||||
void refreshShareSummary();
|
||||
void refreshGroupPublicSummary();
|
||||
};
|
||||
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
|
||||
window.addEventListener(DOCUMENTS_CHANGED_EVENT, handler);
|
||||
return () => {
|
||||
window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
|
||||
window.removeEventListener(DOCUMENTS_CHANGED_EVENT, handler);
|
||||
};
|
||||
}, [sidebarQuery, refreshShareSummary, refreshGroupPublicSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
// 说明:shareSummary/groupPublicSummary 目前走的是一次性 query + 本地 state,
|
||||
// 为了让 A 侧删除/清空回收站后,B 侧能自动消失(而不是保留 404 幽灵项),这里做轻量轮询刷新。
|
||||
if (topPanel !== "shared" && topPanel !== "public") {
|
||||
return;
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
if (topPanel === "shared") void refreshShareSummary();
|
||||
if (topPanel === "public") void refreshGroupPublicSummary();
|
||||
};
|
||||
|
||||
refresh();
|
||||
const intervalId = window.setInterval(refresh, 2500);
|
||||
|
||||
const onFocus = () => {
|
||||
if (document.visibilityState && document.visibilityState !== "visible") return;
|
||||
refresh();
|
||||
};
|
||||
|
||||
window.addEventListener("focus", onFocus);
|
||||
document.addEventListener("visibilitychange", onFocus);
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
document.removeEventListener("visibilitychange", onFocus);
|
||||
};
|
||||
}, [topPanel, refreshShareSummary, refreshGroupPublicSummary]);
|
||||
|
||||
const sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
|
||||
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
|
||||
const publicNodes = useMemo(() => sections.find((section) => section.id === "public")?.nodes ?? [], [sections]);
|
||||
@@ -395,19 +429,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
return map;
|
||||
}, [tree]);
|
||||
|
||||
const outgoingSharedRootNodes = useMemo(() => {
|
||||
const ids = new Set((shareSummary?.outgoing ?? []).map((o) => o.documentId));
|
||||
const nodes: DocumentNode[] = [];
|
||||
for (const id of ids) {
|
||||
const node = nodeById.get(id);
|
||||
if (node) nodes.push(node);
|
||||
}
|
||||
// 说明:同一个页面被共享给多个用户时,只展示一份。
|
||||
const uniq = new Map<string, DocumentNode>();
|
||||
nodes.forEach((n) => uniq.set(n.id, n));
|
||||
return Array.from(uniq.values());
|
||||
}, [nodeById, shareSummary?.outgoing]);
|
||||
|
||||
const publicGroupNodesByGroupId = useMemo(() => {
|
||||
const map = new Map<string, DocumentNode[]>();
|
||||
for (const g of groupPublicSummary) {
|
||||
@@ -698,6 +719,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
target.searchParams.set("fileName", asset.file_name ?? `未命名.${officeFileType}`);
|
||||
target.searchParams.set("fileType", officeFileType);
|
||||
target.searchParams.set("assetId", asset.id);
|
||||
target.searchParams.set("documentId", asset.document_id);
|
||||
target.searchParams.set("mode", "edit");
|
||||
window.open(target.toString(), "_blank", "noopener,noreferrer");
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
@@ -978,7 +1001,17 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
await copyText(path, "存储路径已复制");
|
||||
}, []);
|
||||
|
||||
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
|
||||
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
|
||||
const current = useCurrentDocumentStore.getState();
|
||||
if (
|
||||
current.disableDownload &&
|
||||
current.documentId &&
|
||||
asset.document_id &&
|
||||
String(asset.document_id) === String(current.documentId)
|
||||
) {
|
||||
window.alert("该页面已禁止下载");
|
||||
return;
|
||||
}
|
||||
if (asset.asset_type === "mindmap") {
|
||||
const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`);
|
||||
if (!resp.ok) {
|
||||
@@ -1284,6 +1317,12 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (parentId: string | null) => {
|
||||
const creatingKey = parentId ?? "__root__";
|
||||
if (creatingDocumentUnderParentRef.current.has(creatingKey)) {
|
||||
return;
|
||||
}
|
||||
creatingDocumentUnderParentRef.current.add(creatingKey);
|
||||
try {
|
||||
const response = await fetch("/api/documents/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -1306,7 +1345,18 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
children: [],
|
||||
};
|
||||
|
||||
setTree((prev) => insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode));
|
||||
setTree((prev) => {
|
||||
// 防止同一节点被插入多次(会导致重复 key / 列表出现“同一个页面两条记录”)
|
||||
const exists = (nodes: DocumentNode[]): boolean => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nextNode.id) return true;
|
||||
if (node.children.length > 0 && exists(node.children)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (exists(prev)) return prev;
|
||||
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
|
||||
});
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (parentId) {
|
||||
@@ -1320,6 +1370,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
|
||||
await refreshTree();
|
||||
router.push(`/documents/${nextNode.id}`);
|
||||
} finally {
|
||||
creatingDocumentUnderParentRef.current.delete(creatingKey);
|
||||
}
|
||||
},
|
||||
[refreshTree, router],
|
||||
);
|
||||
@@ -1847,23 +1900,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
);
|
||||
|
||||
const handleWorkspaceSwitch = useCallback(
|
||||
async (workspaceId: string) => {
|
||||
if (workspaceId === sidebarData.activeWorkspaceId) {
|
||||
setWorkspaceMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
await fetch("/api/workspaces/switch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
});
|
||||
setWorkspaceMenuOpen(false);
|
||||
await sidebarQuery.refetch();
|
||||
},
|
||||
[sidebarData.activeWorkspaceId, sidebarQuery],
|
||||
);
|
||||
|
||||
const handleSignOut = useCallback(async () => {
|
||||
if (signingOut) {
|
||||
return;
|
||||
@@ -1874,7 +1910,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
setSigningOut(true);
|
||||
try {
|
||||
await signOut();
|
||||
setWorkspaceMenuOpen(false);
|
||||
router.replace("/auth");
|
||||
router.refresh();
|
||||
} catch (error: any) {
|
||||
@@ -1941,22 +1976,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
return () => window.removeEventListener("click", closeMenu);
|
||||
}, [contextMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceMenuOpen) {
|
||||
return;
|
||||
}
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
workspaceMenuRef.current &&
|
||||
!workspaceMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setWorkspaceMenuOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("click", handleClickOutside);
|
||||
return () => window.removeEventListener("click", handleClickOutside);
|
||||
}, [workspaceMenuOpen]);
|
||||
|
||||
const sidebarBody = (
|
||||
<div className="flex h-full min-w-0 flex-col">
|
||||
<div className="border-b border-[#f1f1f1] p-3">
|
||||
@@ -1966,51 +1985,30 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
<div className="text-base font-semibold text-gray-900">{activeWorkspace?.name ?? "我的空间"}</div>
|
||||
<div className="text-xs text-gray-500">{activeWorkspace?.type === "team" ? "团队空间" : "个人空间"}</div>
|
||||
</div>
|
||||
<div className="relative" ref={workspaceMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-[#e1e1e1] px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setWorkspaceMenuOpen((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
切换
|
||||
</button>
|
||||
{workspaceMenuOpen && (
|
||||
<div className="absolute right-0 z-20 mt-2 w-56 rounded-md border border-[#eaeaea] bg-white shadow-lg">
|
||||
{sidebarData.workspaces.map((workspace) => (
|
||||
<button
|
||||
type="button"
|
||||
key={workspace.id}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-gray-50",
|
||||
workspace.id === activeWorkspace?.id && "bg-[#f5f7fb]",
|
||||
)}
|
||||
onClick={() => void handleWorkspaceSwitch(workspace.id)}
|
||||
>
|
||||
<span>{workspace.name}</span>
|
||||
{workspace.id === activeWorkspace?.id ? (
|
||||
<span className="text-xs text-[#2563eb]">当前</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">{workspace.memberCount} 人</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-[#f1f1f1] p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => void handleSignOut()}
|
||||
disabled={signingOut}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>{signingOut ? "正在退出..." : "退出登录"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-[#e1e1e1] px-2 py-1 text-xs text-gray-400"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
window.alert("工作空间切换功能暂时停用(正在修复中)。");
|
||||
}}
|
||||
>
|
||||
切换(暂停)
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
提示:当前工作空间切换暂时停用;共享/群组相关内容会在「共享页面」与「成员」里跨工作空间展示。
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1 text-xs text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => void handleSignOut()}
|
||||
disabled={signingOut}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>{signingOut ? "正在退出..." : "退出登录"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2067,6 +2065,70 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
};
|
||||
|
||||
if (topPanel === "shared") {
|
||||
const groupByWorkspace = (
|
||||
rows: Array<{
|
||||
workspaceId: string;
|
||||
workspaceName: string | null;
|
||||
documentId: string;
|
||||
documentTitle: string | null;
|
||||
includeDescendants: boolean;
|
||||
permission?: "read" | "edit";
|
||||
sharedWithCount?: number;
|
||||
}>,
|
||||
) => {
|
||||
const map = new Map<string, { workspaceName: string | null; rows: typeof rows }>();
|
||||
for (const r of rows) {
|
||||
const existing = map.get(r.workspaceId);
|
||||
if (!existing) {
|
||||
map.set(r.workspaceId, { workspaceName: r.workspaceName ?? null, rows: [r] });
|
||||
} else {
|
||||
existing.rows.push(r);
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries()).map(([workspaceId, v]) => ({
|
||||
workspaceId,
|
||||
workspaceName: v.workspaceName,
|
||||
rows: v.rows,
|
||||
}));
|
||||
};
|
||||
|
||||
const renderShareRows = (rows: Array<any>) => {
|
||||
if (!rows || rows.length === 0) {
|
||||
return <div className="px-2 py-2 text-xs text-gray-400">暂无内容</div>;
|
||||
}
|
||||
const groups = groupByWorkspace(rows);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{groups.map((g) => (
|
||||
<div key={g.workspaceId}>
|
||||
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
|
||||
{g.workspaceName ?? g.workspaceId}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{g.rows.map((r) => (
|
||||
<Link
|
||||
key={`${r.workspaceId}:${r.documentId}`}
|
||||
href={`/documents/${r.documentId}`}
|
||||
className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
|
||||
>
|
||||
{r.documentTitle || "无标题"}
|
||||
{typeof r.permission === "string" ? (
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
{r.permission === "edit" ? "可编辑" : "只读"}
|
||||
</span>
|
||||
) : null}
|
||||
{typeof r.sharedWithCount === "number" ? (
|
||||
<span className="ml-2 text-xs text-gray-400">{r.sharedWithCount} 人</span>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{shareSummaryError ? (
|
||||
@@ -2077,14 +2139,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
|
||||
共享给我的({shareSummary?.incoming?.length ?? 0})
|
||||
</div>
|
||||
{renderList(sharedNodes)}
|
||||
{renderShareRows(shareSummary?.incoming ?? [])}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#f1f1f1] pt-2">
|
||||
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
|
||||
我共享出去的({shareSummary?.outgoing?.length ?? 0})
|
||||
</div>
|
||||
{renderList(outgoingSharedRootNodes)}
|
||||
{renderShareRows(shareSummary?.outgoing ?? [])}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2334,13 +2396,11 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{sidebarData.activeWorkspaceId ? (
|
||||
<GroupManagerDialog
|
||||
open={groupManagerOpen}
|
||||
onOpenChange={setGroupManagerOpen}
|
||||
workspaceId={sidebarData.activeWorkspaceId}
|
||||
/>
|
||||
) : null}
|
||||
<GroupManagerDialog
|
||||
open={groupManagerOpen}
|
||||
onOpenChange={setGroupManagerOpen}
|
||||
workspaceId={sidebarData.activeWorkspaceId || ""}
|
||||
/>
|
||||
<MoveEmbedPickerDialog
|
||||
open={moveEmbedOpen}
|
||||
onOpenChange={setMoveEmbedOpen}
|
||||
|
||||
Reference in New Issue
Block a user