0.3.4 小图标功能增加

This commit is contained in:
liaibo
2026-01-22 18:53:20 +08:00
parent 71de56850b
commit 25923f308c
25 changed files with 2781 additions and 141 deletions
+333 -72
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { useAuthActions } from "@convex-dev/auth/react";
import { useConvex } from "convex/react";
import {
ArrowRightLeft,
ArrowUpRight,
@@ -36,7 +37,6 @@ import type { DocumentNode } from "@/lib/documents";
import { buildDocumentTree } from "@/lib/documents";
import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
import { useSidebarData } from "@/hooks/use-sidebar-data";
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
import { PrivateTree } from "@/components/sidebar/private-tree";
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
@@ -61,6 +61,9 @@ import type { MediaAsset } from "@/types/media";
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
import { api } from "@/lib/convex/api";
import { GroupManagerDialog } from "@/components/groups/group-manager-dialog";
const TOP_BUTTONS = [
{ id: "search", icon: SearchIcon, label: "搜索" },
@@ -128,15 +131,8 @@ interface ContextMenuState {
y: number;
}
// 主 Sidebar 组件:根据模式路由到不同的子组件
export function Sidebar({ initialData }: SidebarProps) {
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
// 根据模式渲染不同的子组件,确保 Supabase 模式完全不调用 Convex hooks
if (useConvex) {
return <SidebarConvex initialData={initialData} />;
}
return <SidebarSupabase initialData={initialData} />;
return <SidebarConvex initialData={initialData} />;
}
// Convex 模式专用组件 - 只调用 Convex hooks
@@ -145,12 +141,6 @@ function SidebarConvex({ initialData }: SidebarProps) {
return <SidebarContent initialData={initialData} sidebarQuery={convexData} />;
}
// Supabase 模式专用组件 - 只调用 Supabase hooks
function SidebarSupabase({ initialData }: SidebarProps) {
const supabaseData = useSidebarData(initialData);
return <SidebarContent initialData={initialData} sidebarQuery={supabaseData} />;
}
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
interface SidebarContentProps {
initialData: SidebarInitialData;
@@ -162,11 +152,9 @@ interface SidebarContentProps {
}
function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, setSectionCollapsed, trashConfirm, setTrashConfirm } =
const convex = useConvex();
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
useSidebarStore();
const sectionsTrayOpen = useSidebarStore((state) => state.sectionsTrayOpen);
const toggleSectionsTray = useSidebarStore((state) => state.toggleSectionsTray);
const setSectionsTrayOpen = useSidebarStore((state) => state.setSectionsTrayOpen);
const viewMode = useSidebarStore((state) => state.viewMode);
const setViewMode = useSidebarStore((state) => state.setViewMode);
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
@@ -188,6 +176,39 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const [filter, setFilter] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(() => collectNodeIds(tree));
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [topPanel, setTopPanel] = useState<"starred" | "public" | "shared" | "templates" | null>(null);
const [shareSummary, setShareSummary] = useState<{
incoming: Array<{
documentId: string;
permission: "read" | "edit";
includeDescendants: boolean;
createdBy: string;
updatedAt: string;
}>;
outgoing: Array<{
documentId: string;
includeDescendants: boolean;
sharedWithCount: number;
}>;
} | null>(null);
const [shareSummaryError, setShareSummaryError] = useState<string | null>(null);
const [groupPublicSummary, setGroupPublicSummary] = useState<
Array<{
groupId: string;
groupName: string | null;
documents: Array<{ documentId: string; includeDescendants: boolean }>;
}>
>([]);
const [groupPublicError, setGroupPublicError] = useState<string | null>(null);
const [openPublicGroups, setOpenPublicGroups] = useState<Set<string>>(() => new Set());
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [shareTarget, setShareTarget] = useState<{
id: string;
title: string | null;
workspaceId: string;
allowIncludeDescendants: boolean;
} | null>(null);
const [groupManagerOpen, setGroupManagerOpen] = useState(false);
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
const [signingOut, setSigningOut] = useState(false);
const [trashOpen, setTrashOpen] = useState(false);
@@ -287,12 +308,122 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
await sidebarQuery.refetch();
}, [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 });
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`。");
} else {
setShareSummaryError(msg);
}
setShareSummary(null);
}
}, [convex, sidebarData.activeWorkspaceId]);
useEffect(() => {
void refreshShareSummary();
}, [refreshShareSummary]);
const refreshGroupPublicSummary = useCallback(async () => {
const workspaceId = sidebarData.activeWorkspaceId;
if (!workspaceId) {
setGroupPublicSummary([]);
setGroupPublicError(null);
return;
}
try {
const resp = await convex.query(api.documentGroupShares.listPublicByWorkspace, { workspaceId });
const rows = Array.isArray(resp) ? (resp as any[]) : [];
setGroupPublicSummary(
rows.map((r) => ({
groupId: String(r.groupId),
groupName: r.groupName ? String(r.groupName) : null,
documents: Array.isArray(r.documents)
? r.documents.map((d: any) => ({
documentId: String(d.documentId),
includeDescendants: Boolean(d.includeDescendants),
}))
: [],
})),
);
setGroupPublicError(null);
} catch (e: any) {
const msg = e?.message ?? "加载群组公开摘要失败";
if (String(msg).includes("Could not find public function for 'documentGroupShares:listPublicByWorkspace'")) {
setGroupPublicError("群组公开功能后端未部署/未更新:请执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local`。");
} else {
setGroupPublicError(msg);
}
setGroupPublicSummary([]);
}
}, [convex, sidebarData.activeWorkspaceId]);
useEffect(() => {
void refreshGroupPublicSummary();
}, [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]);
const sharedNodes = useMemo(() => sections.find((section) => section.id === "shared")?.nodes ?? [], [sections]);
const templateNodes = useMemo(() => sections.find((section) => section.id === "templates")?.nodes ?? [], [sections]);
const privateTree = useMemo(() => sections.find((section) => section.id === "private")?.nodes ?? [], [sections]);
const nodeById = useMemo(() => {
const map = new Map<string, DocumentNode>();
const walk = (nodes: DocumentNode[]) => {
nodes.forEach((node) => {
map.set(node.id, node);
if (node.children.length > 0) {
walk(node.children);
}
});
};
walk(tree);
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) {
const nodes: DocumentNode[] = [];
const uniq = new Map<string, DocumentNode>();
for (const d of g.documents ?? []) {
const node = nodeById.get(d.documentId);
if (node) {
uniq.set(node.id, node);
}
}
uniq.forEach((v) => nodes.push(v));
map.set(g.groupId, nodes);
}
return map;
}, [groupPublicSummary, nodeById]);
const filteredPrivateTree = useMemo(
() => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree),
[filter, privateTree],
@@ -1763,6 +1894,16 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
});
}, []);
const openShareDialog = useCallback((node: DocumentNode) => {
setShareTarget({
id: node.id,
title: node.title ?? null,
workspaceId: node.workspace_id,
allowIncludeDescendants: (node.children?.length ?? 0) > 0,
});
setShareDialogOpen(true);
}, []);
const handleTopButtonClick = useCallback(
(buttonId: (typeof TOP_BUTTONS)[number]["id"]) => {
if (buttonId === "search") {
@@ -1776,13 +1917,19 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
buttonId === "templates"
) {
setViewMode("section");
setSectionsTrayOpen(true);
setSectionCollapsed(buttonId, false);
setTopPanel((prev) => (prev === buttonId ? null : buttonId));
return;
}
if (buttonId === "members") {
setGroupManagerOpen(true);
return;
}
window.alert("该功能即将上线,敬请期待");
},
[openSearchPalette, setSectionCollapsed, setSectionsTrayOpen, setViewMode],
[
openSearchPalette,
setViewMode,
],
);
useEffect(() => {
@@ -1883,6 +2030,132 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
))}
</div>
{topPanel ? (
<div className="border-b border-[#f1f1f1] bg-white px-3 pb-3">
<div className="flex items-center gap-2 px-1 py-2 text-sm font-medium text-gray-600">
{SECTION_ICONS[topPanel]}
{topPanel === "starred"
? "星标置顶"
: topPanel === "public"
? "公共页面"
: topPanel === "shared"
? "共享页面"
: "模板中心"}
<span className="ml-auto text-xs text-gray-400"></span>
</div>
<div className="max-h-52 overflow-auto rounded-md border border-[#eff2f6] bg-white px-2 py-2">
{(() => {
const renderList = (nodes: DocumentNode[]) => {
const flat = flattenDocumentTree(nodes, collectNodeIds(nodes));
if (flat.length === 0) {
return <div className="px-2 py-2 text-xs text-gray-400"></div>;
}
return (
<div className="space-y-1">
{flat.map(({ node, depth }) => (
<Link
key={node.id}
href={`/documents/${node.id}`}
className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
style={{ paddingLeft: 8 + depth * 12 }}
>
{node.title || "无标题"}
</Link>
))}
</div>
);
};
if (topPanel === "shared") {
return (
<div className="space-y-3">
{shareSummaryError ? (
<div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">{shareSummaryError}</div>
) : null}
<div>
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
{shareSummary?.incoming?.length ?? 0}
</div>
{renderList(sharedNodes)}
</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)}
</div>
</div>
);
}
if (topPanel === "public") {
const toggleGroup = (groupId: string) => {
setOpenPublicGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId);
else next.add(groupId);
return next;
});
};
return (
<div className="space-y-3">
{groupPublicError ? (
<div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">{groupPublicError}</div>
) : null}
<div>
<div className="px-2 pb-1 text-xs font-medium text-gray-500"></div>
{renderList(publicNodes)}
</div>
<div className="border-t border-[#f1f1f1] pt-2">
<div className="px-2 pb-1 text-xs font-medium text-gray-500"></div>
{groupPublicSummary.length === 0 ? (
<div className="px-2 py-2 text-xs text-gray-400"></div>
) : (
<div className="space-y-1">
{groupPublicSummary.map((g) => {
const isOpen = openPublicGroups.has(g.groupId);
const nodes = publicGroupNodesByGroupId.get(g.groupId) ?? [];
return (
<div key={g.groupId} className="rounded-md border border-[#eff2f6]">
<button
type="button"
className="flex w-full items-center justify-between px-2 py-2 text-sm text-gray-600 hover:bg-gray-50"
onClick={() => toggleGroup(g.groupId)}
>
<span className="flex min-w-0 items-center gap-2">
<ChevronRight
className={cn(
"h-4 w-4 text-gray-400 transition-transform",
isOpen && "rotate-90",
)}
/>
<span className="truncate">{g.groupName ?? "未命名群组"}</span>
</span>
<span className="text-xs text-gray-400">{g.documents.length}</span>
</button>
{isOpen ? <div className="px-1 pb-2">{renderList(nodes)}</div> : null}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
const nodes = topPanel === "starred" ? starredNodes : templateNodes;
return renderList(nodes);
})()}
</div>
</div>
) : null}
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex h-full min-w-0 flex-col overflow-y-auto overflow-x-hidden">
<div className="border-b border-[#f1f1f1] p-3">
@@ -1924,56 +2197,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
{viewMode === "section" ? (
<>
<button
type="button"
className="flex w-full items-center justify-between border-b border-[#f1f1f1] px-4 py-3 text-sm font-medium text-gray-600 hover:bg-gray-50"
onClick={toggleSectionsTray}
>
<span className="flex items-center gap-2">
<Star className="h-4 w-4 text-[#f5a623]" />
/ / /
</span>
<ChevronRight
className={cn(
"h-4 w-4 text-gray-400 transition-transform",
sectionsTrayOpen && "rotate-90",
)}
/>
</button>
{sectionsTrayOpen ? (
<>
<SectionList
label="星标置顶"
icon={SECTION_ICONS.starred}
nodes={starredNodes}
collapsed={collapsedSections.starred}
onToggle={() => toggleSection("starred")}
/>
<SectionList
label="公共页面"
icon={SECTION_ICONS.public}
nodes={publicNodes}
collapsed={collapsedSections.public}
onToggle={() => toggleSection("public")}
/>
<SectionList
label="共享页面"
icon={SECTION_ICONS.shared}
nodes={sharedNodes}
collapsed={collapsedSections.shared}
onToggle={() => toggleSection("shared")}
/>
<SectionList
label="模板中心"
icon={SECTION_ICONS.templates}
nodes={templateNodes}
collapsed={collapsedSections.templates}
onToggle={() => toggleSection("templates")}
/>
</>
) : null}
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
<button
type="button"
@@ -2078,6 +2301,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
contextMenu={contextMenu}
onClose={() => setContextMenu(null)}
onOpenRight={(node) => handleOpenDocument(node.id, "sidebar")}
onShare={openShareDialog}
onMove={handleMovePrompt}
onEmbed={handleEmbedPrompt}
onCopyLink={handleCopyLink}
@@ -2090,6 +2314,33 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
onDelete={() => void handleDeleteFileTreeSelection()}
/>
)}
{shareTarget && (
<DocumentShareDialog
open={shareDialogOpen}
onOpenChange={(nextOpen) => {
setShareDialogOpen(nextOpen);
if (!nextOpen) {
setShareTarget(null);
}
}}
documentId={shareTarget.id}
documentTitle={shareTarget.title}
workspaceId={shareTarget.workspaceId}
allowIncludeDescendants={shareTarget.allowIncludeDescendants}
onChanged={async () => {
await refreshTree();
await refreshShareSummary();
await refreshGroupPublicSummary();
}}
/>
)}
{sidebarData.activeWorkspaceId ? (
<GroupManagerDialog
open={groupManagerOpen}
onOpenChange={setGroupManagerOpen}
workspaceId={sidebarData.activeWorkspaceId}
/>
) : null}
<MoveEmbedPickerDialog
open={moveEmbedOpen}
onOpenChange={setMoveEmbedOpen}
@@ -2343,6 +2594,7 @@ interface ContextMenuProps {
contextMenu: ContextMenuState;
onClose: () => void;
onOpenRight: (node: DocumentNode) => void;
onShare: (node: DocumentNode) => void;
onMove: (node: DocumentNode) => void;
onEmbed: (node: DocumentNode) => void;
onCopyLink: (node: DocumentNode, withTitle?: boolean) => void;
@@ -2359,6 +2611,7 @@ function ContextMenu({
contextMenu,
onClose,
onOpenRight,
onShare,
onMove,
onEmbed,
onCopyLink,
@@ -2417,6 +2670,14 @@ function ContextMenu({
<span></span>
<span className="ml-auto text-[11px] text-gray-400">Alt + O</span>
</button>
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onShare(node))}
>
<Share2 className="h-4 w-4 text-gray-500" />
<span>...</span>
</button>
<button
type="button"
className={buttonClass}