0.4.0 convex及界面修改

This commit is contained in:
liaibo
2026-02-01 08:47:40 +08:00
parent d1f055f51a
commit af92c4b149
636 changed files with 7522 additions and 1815 deletions
+202 -29
View File
@@ -60,7 +60,7 @@ import {
} from "@/lib/file-tree/clipboard";
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 { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
import { api } from "@/lib/convex/api";
@@ -239,6 +239,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
// 删除在线表格后,Convex 订阅刷新存在极短延迟;这里做短暂“乐观隐藏”,避免文件树闪回。
const hiddenTableIdsRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
setTree(() => {
@@ -257,7 +259,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}, [sidebarData.mindmapAssets]);
useEffect(() => {
setTableAssets(sidebarData.tableAssets ?? []);
const hidden = hiddenTableIdsRef.current;
const now = Date.now();
hidden.forEach((ts, id) => {
if (now - ts > 15000) {
hidden.delete(id);
}
});
setTableAssets((sidebarData.tableAssets ?? []).filter((item) => !hidden.has(item.id)));
}, [sidebarData.tableAssets]);
useEffect(() => {
@@ -265,8 +274,23 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}, [activeId, setOpen]);
useEffect(() => {
const onSaved = () => void sidebarQuery.refetch();
const onDeleted = () => void sidebarQuery.refetch();
const onSaved = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
const tableId = typeof detail?.tableId === "string" ? detail.tableId : "";
if (tableId) {
hiddenTableIdsRef.current.delete(tableId);
}
void sidebarQuery.refetch();
};
const onDeleted = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
const tableId = typeof detail?.tableId === "string" ? detail.tableId : "";
if (tableId) {
hiddenTableIdsRef.current.set(tableId, Date.now());
setTableAssets((prev) => prev.filter((item) => item.id !== tableId));
}
void sidebarQuery.refetch();
};
window.addEventListener("online-table-saved", onSaved as EventListener);
window.addEventListener("online-table-deleted", onDeleted as EventListener);
return () => {
@@ -288,7 +312,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const msg = e?.message ?? "加载共享摘要失败";
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`。",
"共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all`。",
);
} else {
setShareSummaryError(msg);
@@ -328,7 +352,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
} 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`。");
setGroupPublicError("群组公开功能后端未部署/未更新:请执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all`。");
} else {
setGroupPublicError(msg);
}
@@ -463,13 +487,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const assets = [
...(sidebarData.trashedMediaAssets ?? []),
...(sidebarData.trashedMindmapAssets ?? []),
...(sidebarData.trashedTableAssets ?? []),
];
const keyword = trashSearch.trim().toLowerCase();
if (!keyword) {
return assets;
}
return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, trashSearch]);
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, sidebarData.trashedTableAssets, trashSearch]);
const mindmapChildrenSnapshot = useMemo(() => {
const mapping = sidebarData.mindmapAssetChildren ?? {};
@@ -703,7 +728,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
if (officeFileType) {
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
if (!officeBase) {
window.alert("缺少 ONLYOFFICE 地址,请在 .env.local 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
window.alert("缺少 ONLYOFFICE 地址,请在 .env.all 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
return;
}
void (async () => {
@@ -1142,15 +1167,19 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
);
const handleDeleteAssets = useCallback(
async (assetIds: string[], assetHint?: MediaAsset) => {
async (assetIds: string[], assetHint?: MediaAsset | MediaAsset[]) => {
const uniqueAssetIds = Array.from(new Set(assetIds));
const assets = uniqueAssetIds
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
.filter(Boolean) as MediaAsset[];
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
assets.unshift(assetHint);
}
const hints = Array.isArray(assetHint) ? assetHint : assetHint ? [assetHint] : [];
hints.forEach((hint) => {
if (!hint) return;
if (!assets.find((item) => item.id === hint.id)) {
assets.unshift(hint);
}
});
const mindmapAssetsToDelete = assets.filter((item) => item.asset_type === "mindmap");
const tableAssetsToDelete = assets.filter((item) => item.asset_type === "luckysheet");
@@ -1231,16 +1260,32 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}
const docText = docIds.length > 0 ? `${docIds.length} 个页面(删除到垃圾桶)` : "";
const selectedAssets = assetIds
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
.filter(Boolean) as MediaAsset[];
const mindmapCount = selectedAssets.filter((item) => item.asset_type === "mindmap").length;
const tableCount = selectedAssets.filter((item) => item.asset_type === "luckysheet").length;
const fileCount = selectedAssets.filter((item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet").length;
// 说明:文件树里可能展示“思维导图子文件”等动态资源(不一定在 mindmapAssets 列表里)。
// 为避免出现“看起来选中了,但删除不生效”,这里优先从可见行里拿到选中资源的完整元数据。
const isAssetRow = (
row: FileTreeRow,
): row is Extract<FileTreeRow, { kind: "asset" | "asset-folder" }> =>
row.kind === "asset" || row.kind === "asset-folder";
const selectedAssetHints = Array.from(
new Map(
fileTreeRows
.filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId))
.filter(isAssetRow)
.map((row) => [row.asset.id, row.asset] as const),
).values(),
);
const mindmapCount = selectedAssetHints.filter((item) => item.asset_type === "mindmap").length;
const tableCount = selectedAssetHints.filter((item) => item.asset_type === "luckysheet").length;
const fileCount = selectedAssetHints.filter(
(item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet",
).length;
const unknownCount = Math.max(0, assetIds.length - mindmapCount - tableCount - fileCount);
const assetTextParts: string[] = [];
if (fileCount > 0) assetTextParts.push(`${fileCount} 个附件(删除,10 分钟内可撤销)`);
if (mindmapCount > 0) assetTextParts.push(`${mindmapCount} 个思维导图(删除`);
if (mindmapCount > 0) assetTextParts.push(`${mindmapCount} 个思维导图(移入垃圾桶,10 分钟内可恢复`);
if (tableCount > 0) assetTextParts.push(`${tableCount} 个在线表格(删除)`);
if (unknownCount > 0) assetTextParts.push(`${unknownCount} 个对象(删除)`);
const assetText = assetTextParts.length > 0 ? assetTextParts.join(" + ") : "";
@@ -1273,7 +1318,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}
if (assetIds.length > 0) {
await handleDeleteAssets(assetIds);
await handleDeleteAssets(assetIds, selectedAssetHints);
}
await refreshTree();
@@ -1842,6 +1887,10 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
if (!confirmTrashAction("确认恢复该附件吗?")) {
return;
}
const assetHint =
(filteredTrashedMediaAssets ?? []).find((a) => a.id === assetId) ??
(sidebarData.trashedMediaAssets ?? []).find((a) => a.id === assetId) ??
null;
const response = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1853,8 +1902,84 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
// 重要:删除附件时会同步移除主编辑区块;恢复后向编辑器广播“恢复事件”,由编辑器决定是否插入。
let assetForInsert = assetHint as any;
if (assetHint?.document_id && String(assetHint.document_id) === String(activeId)) {
const fallback = (assetHint.signed_url ?? assetHint.file_url ?? "").trim();
let fileUrl = fallback;
if (!fileUrl) {
try {
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`);
if (res.ok) {
const payload = (await res.json().catch(() => null)) as any;
fileUrl = String(payload?.signedUrl ?? "").trim();
}
} catch {
// ignore
}
}
if (fileUrl) {
assetForInsert = {
...(assetHint as any),
id: assetId,
file_url: fileUrl,
thumbnail_url: (assetHint.thumbnail_url ?? fileUrl) as any,
} as any;
editorBridge?.insertMediaAsset?.(assetForInsert);
}
}
if (assetHint?.document_id) {
emitAssetsRestored({ docId: String(assetHint.document_id), kind: "media", assetId, asset: assetForInsert });
}
},
[confirmTrashAction, refreshTree, sidebarQuery],
[
activeId,
confirmTrashAction,
editorBridge,
filteredTrashedMediaAssets,
refreshTree,
sidebarData.trashedMediaAssets,
sidebarQuery,
],
);
const handleRestoreTableFromTrash = useCallback(
async (tableId: string) => {
if (!confirmTrashAction("确认恢复该在线表格吗?")) {
return;
}
const tableHint =
(filteredTrashedMediaAssets ?? []).find((a) => a.id === tableId && a.asset_type === "luckysheet") ??
(sidebarData.trashedTableAssets ?? []).find((a) => a.id === tableId) ??
null;
const response = await fetch("/api/tables/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tableId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "恢复在线表格失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
// 重要:删除在线表格会同步移除主编辑区块;恢复后若目标页面正打开,则把表格重新插入主编辑区。
if (tableHint?.document_id && String(tableHint.document_id) === String(activeId)) {
editorBridge?.insertOnlineTableAsset?.({ documentId: String(tableHint.document_id), tableId });
}
if (tableHint?.document_id) {
emitAssetsRestored({ docId: String(tableHint.document_id), kind: "table", tableId });
}
},
[
activeId,
confirmTrashAction,
editorBridge,
filteredTrashedMediaAssets,
refreshTree,
sidebarData.trashedTableAssets,
sidebarQuery,
],
);
const handlePurgeMediaAssetFromTrash = useCallback(
@@ -1877,6 +2002,26 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
[confirmTrashAction, refreshTree, sidebarQuery],
);
const handlePurgeTableFromTrash = useCallback(
async (tableId: string) => {
if (!confirmTrashAction("彻底删除后将无法恢复,是否继续?")) {
return;
}
const response = await fetch("/api/tables/purge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tableId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "彻底删除在线表格失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
},
[confirmTrashAction, refreshTree, sidebarQuery],
);
const handleEmptyMediaTrash = useCallback(async () => {
if (!sidebarData.activeWorkspaceId) {
window.alert("暂无可清空的工作空间");
@@ -1887,7 +2032,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}
setEmptyingTrash(true);
try {
const [mediaResp, mindmapResp] = await Promise.all([
const [mediaResp, mindmapResp, tableResp] = await Promise.all([
fetch("/api/media/empty-trash", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1898,6 +2043,11 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
}),
fetch("/api/tables/empty-trash", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
}),
]);
if (!mediaResp.ok) {
const payload = await mediaResp.json().catch(() => ({}));
@@ -1909,6 +2059,11 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
window.alert(payload?.error ?? "清空思维导图垃圾桶失败,请稍后再试");
return;
}
if (!tableResp.ok) {
const payload = await tableResp.json().catch(() => ({}));
window.alert(payload?.error ?? "清空在线表格垃圾桶失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
} finally {
setEmptyingTrash(false);
@@ -1931,8 +2086,15 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
// 重要:删除思维导图会同步移除主编辑区块;恢复后若目标页面正打开,则把导图重新插入主编辑区。
if (documentId && String(documentId) === String(activeId)) {
editorBridge?.insertMindmapAsset?.({ documentId, mindmapId });
}
if (documentId) {
emitAssetsRestored({ docId: String(documentId), kind: "mindmap", mindmapId });
}
},
[confirmTrashAction, refreshTree, sidebarQuery],
[activeId, confirmTrashAction, editorBridge, refreshTree, sidebarQuery],
);
const handlePurgeMindmapFromTrash = useCallback(
@@ -2387,7 +2549,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
<span className="text-xs text-gray-400">
{sidebarData.trashedDocuments.length +
(sidebarData.trashedMediaAssets?.length ?? 0) +
(sidebarData.trashedMindmapAssets?.length ?? 0)}{" "}
(sidebarData.trashedMindmapAssets?.length ?? 0) +
(sidebarData.trashedTableAssets?.length ?? 0)}{" "}
</span>
</button>
@@ -2509,7 +2672,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
/>
)}
<Drawer open={trashOpen} onOpenChange={setTrashOpen}>
<DrawerContent className="max-h-[90vh]">
<DrawerContent className="max-h-[90vh] overflow-hidden">
<div className="flex max-h-[90vh] flex-col">
<DrawerHeader className="text-left">
<DrawerTitle></DrawerTitle>
{trashTab === "documents" ? (
@@ -2520,6 +2684,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
</p>
)}
</DrawerHeader>
<div className="flex-1 overflow-y-auto">
<div className="space-y-4 px-4 pb-6">
<div className="flex gap-2">
<button
@@ -2543,7 +2708,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
onClick={() => setTrashTab("assets")}
>
(
{(sidebarData.trashedMediaAssets?.length ?? 0) + (sidebarData.trashedMindmapAssets?.length ?? 0)}
{(sidebarData.trashedMediaAssets?.length ?? 0) +
(sidebarData.trashedMindmapAssets?.length ?? 0) +
(sidebarData.trashedTableAssets?.length ?? 0)}
)
</button>
</div>
@@ -2624,7 +2791,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
{item.deleted_at ? new Date(item.deleted_at).toLocaleString() : "-"}
</div>
<div className="text-xs text-gray-400">
{item.mime_type ?? item.asset_type ?? "unknown"}
{item.asset_type === "mindmap" ? "思维导图" : (item.mime_type ?? item.asset_type ?? "unknown")}
</div>
</div>
<div className="flex gap-2">
@@ -2634,7 +2801,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
onClick={() =>
void (item.asset_type === "mindmap"
? handleRestoreMindmapFromTrash(item.document_id, item.id)
: handleRestoreMediaAssetFromTrash(item.id))
: item.asset_type === "luckysheet"
? handleRestoreTableFromTrash(item.id)
: handleRestoreMediaAssetFromTrash(item.id))
}
>
@@ -2645,7 +2814,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
onClick={() =>
void (item.asset_type === "mindmap"
? handlePurgeMindmapFromTrash(item.document_id, item.id)
: handlePurgeMediaAssetFromTrash(item.id))
: item.asset_type === "luckysheet"
? handlePurgeTableFromTrash(item.id)
: handlePurgeMediaAssetFromTrash(item.id))
}
>
@@ -2656,6 +2827,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
)}
</div>
</div>
</div>
</div>
</DrawerContent>
</Drawer>
</>