0.3.4 小图标功能增加
This commit is contained in:
@@ -3,9 +3,11 @@
|
||||
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 type { DocumentRecord } from "@/lib/documents";
|
||||
import { findBreadcrumb } from "@/lib/documents";
|
||||
import { usePageLayoutStore } from "@/store/page-layout";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
interface BreadcrumbProps {
|
||||
documents: DocumentRecord[];
|
||||
@@ -19,6 +21,8 @@ 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 toggleStar = useMutation(api.documentStars.toggle);
|
||||
|
||||
// 新建页面后,侧边栏数据可能还没同步到 layout(SSR)注入的 documents,导致 findBreadcrumb 暂时为空。
|
||||
// 这里用 activeId 兜底,避免右上角“收藏/更多”按钮消失。
|
||||
@@ -55,8 +59,13 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
||||
<button
|
||||
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}
|
||||
>
|
||||
<Star className="mr-1 inline h-4 w-4" />
|
||||
<Star
|
||||
className={`mr-1 inline h-4 w-4 ${isStarred ? "text-[#f5a623]" : ""}`}
|
||||
fill={isStarred ? "currentColor" : "none"}
|
||||
/>
|
||||
收藏
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -36,6 +36,7 @@ interface BlockNoteEditorProps {
|
||||
workspaceId: string;
|
||||
initialContent: unknown;
|
||||
pageOptions: PageOptionsState;
|
||||
readOnly?: boolean;
|
||||
onStatsChange?: (stats: DocumentStats) => void;
|
||||
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
|
||||
}
|
||||
@@ -168,6 +169,7 @@ export function BlockNoteEditor({
|
||||
workspaceId,
|
||||
initialContent,
|
||||
pageOptions,
|
||||
readOnly = false,
|
||||
onStatsChange,
|
||||
onSnapshot,
|
||||
}: BlockNoteEditorProps) {
|
||||
@@ -776,7 +778,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
theme="light"
|
||||
slashMenu={false}
|
||||
data-heading-numbering={pageOptions.showHeadingNumbers ? "true" : "false"}
|
||||
editable={!pageOptions.protectEditing}
|
||||
editable={!pageOptions.protectEditing && !readOnly}
|
||||
className={blocknoteClass}
|
||||
>
|
||||
{!isFullScreenTableOpen && (
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface DocumentContentProps {
|
||||
initialOptions: PageOptionsState;
|
||||
initialStats: DocumentStats | null;
|
||||
openTableId?: string | null;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const defaultOptions: PageOptionsState = {
|
||||
@@ -57,6 +58,7 @@ export function DocumentContent({
|
||||
initialOptions,
|
||||
initialStats,
|
||||
openTableId,
|
||||
readOnly = false,
|
||||
}: DocumentContentProps) {
|
||||
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
|
||||
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
|
||||
@@ -179,6 +181,7 @@ export function DocumentContent({
|
||||
|
||||
const persistTitle = useCallback(
|
||||
async (nextTitle: string) => {
|
||||
if (readOnly) return;
|
||||
const payload = nextTitle.trim() || "无标题";
|
||||
await fetch("/api/documents/title", {
|
||||
method: "POST",
|
||||
@@ -186,7 +189,7 @@ export function DocumentContent({
|
||||
body: JSON.stringify({ documentId, title: payload }),
|
||||
});
|
||||
},
|
||||
[documentId],
|
||||
[documentId, readOnly],
|
||||
);
|
||||
|
||||
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
|
||||
@@ -194,12 +197,14 @@ export function DocumentContent({
|
||||
}, 600);
|
||||
|
||||
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (readOnly) return;
|
||||
const value = event.target.value;
|
||||
setPageTitle(value);
|
||||
debouncedPersistTitle(value);
|
||||
};
|
||||
|
||||
const handleTitleBlur = () => {
|
||||
if (readOnly) return;
|
||||
void persistTitle(pageTitle);
|
||||
};
|
||||
|
||||
@@ -212,6 +217,7 @@ export function DocumentContent({
|
||||
|
||||
const persistOptions = useCallback(
|
||||
async (patch: Partial<PageOptionsState>) => {
|
||||
if (readOnly) return;
|
||||
try {
|
||||
const response = await fetch("/api/documents/options", {
|
||||
method: "POST",
|
||||
@@ -226,10 +232,11 @@ export function DocumentContent({
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
[documentId],
|
||||
[documentId, readOnly],
|
||||
);
|
||||
|
||||
const toggleOption = (key: keyof PageOptionsState) => {
|
||||
if (readOnly) return;
|
||||
setOptions((prev) => {
|
||||
const nextValue = !prev[key];
|
||||
const next = { ...prev, [key]: nextValue };
|
||||
@@ -320,12 +327,14 @@ export function DocumentContent({
|
||||
placeholder="无标题"
|
||||
className="w-full border-none bg-transparent text-3xl font-semibold text-wolai-text-primary outline-none focus:ring-0"
|
||||
aria-label="页面标题"
|
||||
disabled={options.protectEditing}
|
||||
disabled={options.protectEditing || readOnly}
|
||||
/>
|
||||
</div>
|
||||
{options.protectEditing && (
|
||||
{readOnly ? (
|
||||
<p className="mt-1 text-sm text-gray-500">该页面为只读共享,无法编辑。</p>
|
||||
) : options.protectEditing ? (
|
||||
<p className="mt-1 text-sm text-[#b91c1c]">当前页面已开启编辑保护,关闭后方可修改内容。</p>
|
||||
)}
|
||||
) : null}
|
||||
<p className="text-sm text-wolai-text-secondary">最近更新:{formattedUpdatedAt}</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-12 py-6">
|
||||
@@ -358,6 +367,7 @@ export function DocumentContent({
|
||||
workspaceId={workspaceId}
|
||||
initialContent={content}
|
||||
pageOptions={options}
|
||||
readOnly={readOnly}
|
||||
onStatsChange={handleStatsChange}
|
||||
onSnapshot={handleSnapshot}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useConvex, useMutation } from "convex/react";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
type GroupRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type MemberRow = {
|
||||
userId: string;
|
||||
username: string | null;
|
||||
role: "owner" | "member";
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export interface GroupManagerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupManagerDialogProps) {
|
||||
const convex = useConvex();
|
||||
const createGroup = useMutation(api.groups.create);
|
||||
const removeGroup = useMutation(api.groups.remove);
|
||||
const inviteByUsername = useMutation(api.groupMembers.inviteByUsername);
|
||||
const removeMember = useMutation(api.groupMembers.removeMember);
|
||||
|
||||
const [groups, setGroups] = useState<GroupRow[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>("");
|
||||
const [members, setMembers] = useState<MemberRow[]>([]);
|
||||
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [inviteUsername, setInviteUsername] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedGroup = useMemo(
|
||||
() => groups.find((g) => g.id === selectedGroupId) ?? null,
|
||||
[groups, selectedGroupId],
|
||||
);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
if (!workspaceId) return;
|
||||
const resp = await convex.query(api.groups.listByWorkspace, { workspaceId });
|
||||
const rows = Array.isArray(resp) ? (resp 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 ?? ""),
|
||||
})),
|
||||
);
|
||||
}, [convex, workspaceId]);
|
||||
|
||||
const loadMembers = useCallback(async (groupId: string) => {
|
||||
if (!groupId) {
|
||||
setMembers([]);
|
||||
return;
|
||||
}
|
||||
const resp = await convex.query(api.groupMembers.listByGroup, { groupId });
|
||||
const rows = Array.isArray(resp) ? (resp as any[]) : [];
|
||||
setMembers(
|
||||
rows.map((m) => ({
|
||||
userId: String(m.userId),
|
||||
username: m.username ? String(m.username) : null,
|
||||
role: m.role === "owner" ? "owner" : "member",
|
||||
createdAt: String(m.createdAt ?? ""),
|
||||
})),
|
||||
);
|
||||
}, [convex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
await loadGroups();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "加载群组失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [loadGroups, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void (async () => {
|
||||
try {
|
||||
await loadMembers(selectedGroupId);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "加载群组成员失败");
|
||||
}
|
||||
})();
|
||||
}, [loadMembers, open, selectedGroupId]);
|
||||
|
||||
const handleCreateGroup = async () => {
|
||||
setError(null);
|
||||
const name = newGroupName.trim();
|
||||
if (!name) {
|
||||
setError("请输入群组名称");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await createGroup({ id: uuidv4(), workspaceId, name });
|
||||
setNewGroupName("");
|
||||
await loadGroups();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "创建群组失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveGroup = async (groupId: string) => {
|
||||
if (!groupId) return;
|
||||
if (!window.confirm("确认删除该群组吗?")) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await removeGroup({ groupId });
|
||||
if (selectedGroupId === groupId) {
|
||||
setSelectedGroupId("");
|
||||
setMembers([]);
|
||||
}
|
||||
await loadGroups();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "删除群组失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!selectedGroupId) {
|
||||
setError("请先选择一个群组");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const username = inviteUsername.trim();
|
||||
if (!username) {
|
||||
setError("请输入用户名");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await inviteByUsername({ groupId: selectedGroupId, username });
|
||||
setInviteUsername("");
|
||||
await loadMembers(selectedGroupId);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "邀请失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
if (!selectedGroupId) return;
|
||||
if (!window.confirm("确认移除该成员吗?")) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await removeMember({ groupId: selectedGroupId, userId });
|
||||
await loadMembers(selectedGroupId);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "移除成员失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>群组管理</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</div>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder="新群组名称"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button onClick={() => void handleCreateGroup()} disabled={loading}>
|
||||
创建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-auto rounded-md border border-gray-200">
|
||||
{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)}
|
||||
>
|
||||
{g.name}
|
||||
</button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={loading}
|
||||
onClick={() => void handleRemoveGroup(g.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</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">
|
||||
成员{selectedGroup ? `:${selectedGroup.name}` : ""}
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={inviteUsername}
|
||||
onChange={(e) => setInviteUsername(e.target.value)}
|
||||
placeholder="输入用户名邀请"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button onClick={() => void handleInvite()} disabled={loading}>
|
||||
邀请
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-auto rounded-md border border-gray-200">
|
||||
{!selectedGroupId ? (
|
||||
<div className="px-3 py-3 text-sm text-gray-400">请先选择一个群组</div>
|
||||
) : members.length === 0 ? (
|
||||
<div className="px-3 py-3 text-sm text-gray-400">暂无成员</div>
|
||||
) : (
|
||||
members.map((m) => (
|
||||
<div
|
||||
key={m.userId}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-gray-50"
|
||||
>
|
||||
<div className="min-w-0 flex-1 truncate">
|
||||
{m.username ?? m.userId}
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
{m.role === "owner" ? "群主" : "成员"}
|
||||
</span>
|
||||
</div>
|
||||
{m.role !== "owner" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={loading}
|
||||
onClick={() => void handleRemoveMember(m.userId)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useConvex, useMutation } from "convex/react";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
type SharePermission = "read" | "edit";
|
||||
|
||||
export interface DocumentShareDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
documentId: string;
|
||||
documentTitle?: string | null;
|
||||
workspaceId: string;
|
||||
allowIncludeDescendants?: boolean;
|
||||
onChanged?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function DocumentShareDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentId,
|
||||
documentTitle,
|
||||
workspaceId,
|
||||
allowIncludeDescendants = false,
|
||||
onChanged,
|
||||
}: DocumentShareDialogProps) {
|
||||
const convex = useConvex();
|
||||
const [username, setUsername] = useState("");
|
||||
const [permission, setPermission] = useState<SharePermission>("read");
|
||||
const [includeDescendants, setIncludeDescendants] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [shares, setShares] = useState<any[] | null>(null);
|
||||
const [sharesLoading, setSharesLoading] = useState(false);
|
||||
const [groups, setGroups] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [groupShares, setGroupShares] = useState<any[] | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState("");
|
||||
const [groupIncludeDescendants, setGroupIncludeDescendants] = useState(false);
|
||||
const [groupMembers, setGroupMembers] = useState<Array<{ userId: string; username: string | null; role: string }>>(
|
||||
[],
|
||||
);
|
||||
const [groupEditableUserIds, setGroupEditableUserIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const upsertShare = useMutation(api.documentShares.upsert);
|
||||
const removeShare = useMutation(api.documentShares.remove);
|
||||
const upsertGroupShare = useMutation(api.documentGroupShares.upsert);
|
||||
const removeGroupShare = useMutation(api.documentGroupShares.remove);
|
||||
|
||||
const loadShares = useCallback(async () => {
|
||||
if (!open) return;
|
||||
if (!documentId) {
|
||||
setShares([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSharesLoading(true);
|
||||
try {
|
||||
const resp = await convex.query(api.documentShares.listByDocument, { documentId });
|
||||
setShares(Array.isArray(resp) ? resp : []);
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? "加载共享者失败,请重试";
|
||||
// 说明:这类报错通常是 Convex functions 没有部署到当前 backend(尤其是自托管场景)。
|
||||
if (String(msg).includes("Could not find public function for 'documentShares:listByDocument'")) {
|
||||
setError("共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后重试。");
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
setShares([]);
|
||||
} finally {
|
||||
setSharesLoading(false);
|
||||
}
|
||||
}, [convex, documentId, open]);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
if (!open) return;
|
||||
if (!workspaceId) {
|
||||
setGroups([]);
|
||||
return;
|
||||
}
|
||||
const resp = await convex.query(api.groups.listByWorkspace, { workspaceId });
|
||||
const rows = Array.isArray(resp) ? (resp as any[]) : [];
|
||||
setGroups(rows.map((g) => ({ id: String(g.id), name: String(g.name ?? "") })));
|
||||
}, [convex, open, workspaceId]);
|
||||
|
||||
const loadGroupShares = useCallback(async () => {
|
||||
if (!open) return;
|
||||
if (!documentId) {
|
||||
setGroupShares([]);
|
||||
return;
|
||||
}
|
||||
const resp = await convex.query(api.documentGroupShares.listByDocument, { documentId });
|
||||
setGroupShares(Array.isArray(resp) ? resp : []);
|
||||
}, [convex, documentId, open]);
|
||||
|
||||
const loadGroupMembers = useCallback(
|
||||
async (groupId: string) => {
|
||||
if (!open) return;
|
||||
if (!groupId) {
|
||||
setGroupMembers([]);
|
||||
return;
|
||||
}
|
||||
const resp = await convex.query(api.groupMembers.listByGroup, { groupId });
|
||||
const rows = Array.isArray(resp) ? (resp as any[]) : [];
|
||||
setGroupMembers(
|
||||
rows.map((m) => ({
|
||||
userId: String(m.userId),
|
||||
username: m.username ? String(m.username) : null,
|
||||
role: String(m.role ?? ""),
|
||||
})),
|
||||
);
|
||||
},
|
||||
[convex, open],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setUsername("");
|
||||
setPermission("read");
|
||||
setIncludeDescendants(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
setShares(null);
|
||||
setSharesLoading(false);
|
||||
setGroups([]);
|
||||
setGroupShares(null);
|
||||
setSelectedGroupId("");
|
||||
setGroupIncludeDescendants(false);
|
||||
setGroupMembers([]);
|
||||
setGroupEditableUserIds(new Set());
|
||||
return;
|
||||
}
|
||||
void loadShares();
|
||||
void loadGroups();
|
||||
void loadGroupShares();
|
||||
}, [loadGroups, loadGroupShares, loadShares, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void loadGroupMembers(selectedGroupId);
|
||||
}, [loadGroupMembers, open, selectedGroupId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!selectedGroupId) return;
|
||||
const rows = Array.isArray(groupShares) ? groupShares : [];
|
||||
const existing = rows.find((r: any) => String(r.groupId) === String(selectedGroupId));
|
||||
if (existing) {
|
||||
setGroupIncludeDescendants(Boolean(existing.includeDescendants));
|
||||
const editable = new Set<string>(Array.isArray(existing.editableUserIds) ? existing.editableUserIds.map(String) : []);
|
||||
setGroupEditableUserIds(editable);
|
||||
} else {
|
||||
setGroupIncludeDescendants(false);
|
||||
setGroupEditableUserIds(new Set());
|
||||
}
|
||||
}, [groupShares, open, selectedGroupId]);
|
||||
|
||||
const shareRows = useMemo(() => {
|
||||
if (!Array.isArray(shares)) return [];
|
||||
return shares;
|
||||
}, [shares]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null);
|
||||
const u = username.trim();
|
||||
if (!u) {
|
||||
setError("请输入对方用户名");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await upsertShare({
|
||||
documentId,
|
||||
username: u,
|
||||
permission,
|
||||
includeDescendants: allowIncludeDescendants ? includeDescendants : false,
|
||||
});
|
||||
setUsername("");
|
||||
await loadShares();
|
||||
await onChanged?.();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "共享失败,请重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (sharedWithUserId: string) => {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await removeShare({ documentId, sharedWithUserId });
|
||||
await loadShares();
|
||||
await onChanged?.();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "移除失败,请重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpsertGroupShare = async () => {
|
||||
setError(null);
|
||||
const groupId = selectedGroupId;
|
||||
if (!groupId) {
|
||||
setError("请选择群组");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await upsertGroupShare({
|
||||
documentId,
|
||||
groupId,
|
||||
includeDescendants: allowIncludeDescendants ? groupIncludeDescendants : false,
|
||||
editableUserIds: Array.from(groupEditableUserIds),
|
||||
});
|
||||
await loadGroupShares();
|
||||
await onChanged?.();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "公开失败,请重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveGroupShare = async (groupId: string) => {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await removeGroupShare({ documentId, groupId });
|
||||
await loadGroupShares();
|
||||
await onChanged?.();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "取消公开失败,请重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>共享:{documentTitle ?? "无标题"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-600">输入用户名进行共享</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="对方用户名"
|
||||
disabled={submitting}
|
||||
/>
|
||||
<Button onClick={() => void handleSubmit()} disabled={submitting}>
|
||||
{submitting ? "处理中..." : "共享/更新"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-700">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="permission"
|
||||
checked={permission === "read"}
|
||||
onChange={() => setPermission("read")}
|
||||
disabled={submitting}
|
||||
/>
|
||||
只读
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="permission"
|
||||
checked={permission === "edit"}
|
||||
onChange={() => setPermission("edit")}
|
||||
disabled={submitting}
|
||||
/>
|
||||
可编辑
|
||||
</label>
|
||||
{allowIncludeDescendants && (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeDescendants}
|
||||
onChange={(e) => setIncludeDescendants(e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
包含子页面(共享文件夹)
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200 p-3">
|
||||
<div className="text-sm font-medium text-gray-700">公开到群组(群组内所有人可见)</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm"
|
||||
value={selectedGroupId}
|
||||
onChange={(e) => setSelectedGroupId(e.target.value)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="">请选择群组</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{allowIncludeDescendants && (
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupIncludeDescendants}
|
||||
onChange={(e) => setGroupIncludeDescendants(e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
包含子页面(公开文件夹)
|
||||
</label>
|
||||
)}
|
||||
<Button onClick={() => void handleUpsertGroupShare()} disabled={submitting || !selectedGroupId}>
|
||||
{submitting ? "处理中..." : "公开/更新"}
|
||||
</Button>
|
||||
</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">
|
||||
群组成员权限(默认只读,可单独设为可编辑)
|
||||
</div>
|
||||
<div className="max-h-44 overflow-auto p-2">
|
||||
{!selectedGroupId ? (
|
||||
<div className="px-2 py-2 text-sm text-gray-400">请选择群组</div>
|
||||
) : groupMembers.length === 0 ? (
|
||||
<div className="px-2 py-2 text-sm text-gray-400">暂无成员</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{groupMembers.map((m) => {
|
||||
const checked = groupEditableUserIds.has(m.userId);
|
||||
return (
|
||||
<label
|
||||
key={m.userId}
|
||||
className="flex items-center justify-between gap-2 rounded-md px-2 py-2 text-sm hover:bg-gray-50"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-gray-900">
|
||||
{m.username ?? m.userId}
|
||||
<span className="ml-2 text-xs text-gray-400">{m.role === "owner" ? "群主" : "成员"}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<span>{checked ? "可编辑" : "只读"}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
const next = new Set(groupEditableUserIds);
|
||||
if (e.target.checked) next.add(m.userId);
|
||||
else next.delete(m.userId);
|
||||
setGroupEditableUserIds(next);
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
已公开到群组
|
||||
</div>
|
||||
<div className="max-h-44 overflow-auto p-2">
|
||||
{groupShares === null ? (
|
||||
<div className="px-2 py-2 text-sm text-gray-400">加载中...</div>
|
||||
) : (groupShares ?? []).length === 0 ? (
|
||||
<div className="px-2 py-2 text-sm text-gray-400">暂无公开群组</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{(groupShares ?? []).map((r: any) => (
|
||||
<div
|
||||
key={String(r.groupId)}
|
||||
className="flex items-center justify-between gap-2 rounded-md px-2 py-2 text-sm hover:bg-gray-50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-gray-900">{r.groupName ?? r.groupId}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{r.includeDescendants ? "包含子页面" : "仅当前页面"}
|
||||
{" · "}
|
||||
可编辑:{Array.isArray(r.editableUserIds) ? r.editableUserIds.length : 0} 人
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={submitting}
|
||||
onClick={() => void handleRemoveGroupShare(String(r.groupId))}
|
||||
>
|
||||
取消公开
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</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">
|
||||
已共享给
|
||||
</div>
|
||||
<div className="max-h-60 overflow-auto p-2">
|
||||
{sharesLoading && shares === null ? (
|
||||
<div className="px-2 py-2 text-sm text-gray-400">加载中...</div>
|
||||
) : shareRows.length === 0 ? (
|
||||
<div className="px-2 py-2 text-sm text-gray-400">暂无共享者</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{shareRows.map((row: any) => (
|
||||
<div
|
||||
key={row.userId}
|
||||
className="flex items-center justify-between gap-2 rounded-md px-2 py-2 text-sm hover:bg-gray-50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-gray-900">
|
||||
{row.username ?? row.userId}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
权限:{row.permission === "edit" ? "可编辑" : "只读"}
|
||||
{row.includeDescendants ? " · 包含子页面" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={submitting}
|
||||
onClick={() => void handleRemove(String(row.userId))}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user