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
@@ -27,7 +27,7 @@ import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import type { ReferenceTarget } from "@/types/search";
import FullScreenTableEditor from "@/components/online-table/FullScreenTableEditor";
import { ASSETS_CHANGED_EVENT, emitAssetsChanged } from "@/lib/events";
import { ASSETS_CHANGED_EVENT, ASSETS_RESTORED_EVENT, emitAssetsChanged } from "@/lib/events";
interface BlockNoteEditorProps {
documentId: string;
@@ -557,15 +557,32 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
if (!editor) {
return;
}
// 避免重复插入(例如:恢复事件重复触发/多端同时恢复)
const exists = (editor.topLevelBlocks as Block<CustomBlockSchema>[]).some((b) => {
if (b.type !== "media") return false;
const id = (b.props as { assetId?: string })?.assetId;
return Boolean(id && asset.id && String(id) === String(asset.id));
});
if (exists) {
return;
}
const fileUrl = asset.file_url ?? "";
if (!fileUrl) {
return;
}
const cursor = editor.getTextCursorPosition();
const referenceBlock =
let referenceBlock =
cursor?.block ?? (editor.topLevelBlocks[editor.topLevelBlocks.length - 1] as Block<CustomBlockSchema> | undefined);
if (!referenceBlock) {
return;
try {
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
referenceBlock = editor.topLevelBlocks[editor.topLevelBlocks.length - 1] as
| Block<CustomBlockSchema>
| undefined;
} catch {
// ignore
}
if (!referenceBlock) return;
}
editor.insertBlocks(
[
@@ -591,6 +608,101 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
[documentId, editor],
);
const insertMindmapBlock = useCallback(
(args: { documentId: string; mindmapId: string }) => {
if (!editor) return;
// 仅允许插入到当前打开的页面
if (!args.documentId || String(args.documentId) !== String(documentId)) return;
const mindmapId = String(args.mindmapId ?? "").trim();
if (!mindmapId) return;
const exists = (editor.topLevelBlocks as Block<CustomBlockSchema>[]).some(
(b) => b.type === "mindmap" && String(b.id) === mindmapId,
);
if (exists) return;
const cursor = editor.getTextCursorPosition();
let referenceBlock =
cursor?.block ?? (editor.topLevelBlocks[editor.topLevelBlocks.length - 1] as Block<CustomBlockSchema> | undefined);
if (!referenceBlock) {
try {
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
referenceBlock = editor.topLevelBlocks[editor.topLevelBlocks.length - 1] as
| Block<CustomBlockSchema>
| undefined;
} catch {
// ignore
}
if (!referenceBlock) return;
}
editor.insertBlocks(
[
{
id: mindmapId,
type: "mindmap",
props: { docId: documentId },
content: [],
} as any,
],
referenceBlock,
"after",
);
},
[documentId, editor],
);
useEffect(() => {
if (!editor) return undefined;
const handler = (event: Event) => {
const detail = (event as CustomEvent)?.detail as
| { docId?: string; kind?: "media"; assetId?: string; asset?: MediaAsset }
| { docId?: string; kind?: "mindmap"; mindmapId?: string };
if (!detail || !detail.docId) return;
if (String(detail.docId) !== String(documentId)) return;
if ((detail as any).kind === "mindmap") {
const mindmapId = String((detail as any).mindmapId ?? "").trim();
if (!mindmapId) return;
insertMindmapBlock({ documentId: detail.docId, mindmapId });
return;
}
if ((detail as any).kind === "media") {
const assetId = String((detail as any).assetId ?? "").trim();
const asset = ((detail as any).asset ?? null) as MediaAsset | null;
if (!assetId) return;
void (async () => {
// 恢复列表里的 file_url 可能为空/不可用,优先用 sign 接口拿最新可访问链接
let fileUrl = (asset?.file_url ?? "").trim();
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) return;
insertMediaAssetBlock({
...(asset ?? ({} as MediaAsset)),
id: assetId,
document_id: documentId,
file_url: fileUrl,
thumbnail_url: (asset?.thumbnail_url ?? fileUrl) as any,
} as MediaAsset);
})();
}
};
window.addEventListener(ASSETS_RESTORED_EVENT, handler);
return () => window.removeEventListener(ASSETS_RESTORED_EVENT, handler);
}, [documentId, editor, insertMediaAssetBlock, insertMindmapBlock]);
const uploadClipboardMedia = useCallback(
async (file: File) => {
if (!workspaceId) {
@@ -630,6 +742,9 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
insertMediaAsset: (asset: MediaAsset) => {
insertMediaAssetBlock(asset);
},
insertMindmapAsset: (args: { documentId: string; mindmapId: string }) => {
insertMindmapBlock(args);
},
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
editor.focus();
const cursor = editor.getTextCursorPosition();
@@ -682,7 +797,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
};
registerEditorBridge(bridge);
return () => registerEditorBridge(null);
}, [editor, registerEditorBridge]);
}, [editor, insertMediaAssetBlock, insertMindmapBlock, registerEditorBridge]);
useEffect(() => {
if (!editor) {
@@ -237,7 +237,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
const openWithOnlyOffice = async () => {
if (!fileUrl) return;
if (!officeBase) {
window.alert("缺少 ONLYOFFICE 地址,请在 .env.local 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
window.alert("缺少 ONLYOFFICE 地址,请在 .env.all 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
return;
}
try {
@@ -373,6 +373,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
const handleMediaPick = (mediaType: MediaKind) => {
openPicker({
mediaType,
multiple: true,
onSelect: (selection) => {
insertMediaSelection({
...selection,
@@ -8,11 +8,17 @@ interface PickerState {
open: boolean;
defaultTab: PickerTab;
mediaType: MediaKind;
multiple: boolean;
onSelect?: (selection: MediaSelection) => void;
}
interface ImagePickerContextValue {
openPicker: (options: { onSelect: (selection: MediaSelection) => void; defaultTab?: PickerTab; mediaType?: MediaKind }) => void;
openPicker: (options: {
onSelect: (selection: MediaSelection) => void;
defaultTab?: PickerTab;
mediaType?: MediaKind;
multiple?: boolean;
}) => void;
}
const ImagePickerContext = createContext<ImagePickerContextValue | undefined>(undefined);
@@ -28,16 +34,18 @@ export function ImagePickerProvider({ documentId, workspaceId, children }: Image
open: false,
defaultTab: "upload",
mediaType: "image",
multiple: false,
});
const contextValue = useMemo<ImagePickerContextValue>(
() => ({
openPicker: ({ onSelect, defaultTab = "upload", mediaType = "image" }) => {
openPicker: ({ onSelect, defaultTab = "upload", mediaType = "image", multiple = false }) => {
setState({
open: true,
onSelect,
defaultTab,
mediaType,
multiple,
});
},
}),
@@ -50,7 +58,9 @@ export function ImagePickerProvider({ documentId, workspaceId, children }: Image
const handleSelect = (selection: MediaSelection) => {
state.onSelect?.(selection);
setState((prev) => ({ ...prev, open: false }));
if (!state.multiple) {
setState((prev) => ({ ...prev, open: false }));
}
};
return (
@@ -60,6 +70,7 @@ export function ImagePickerProvider({ documentId, workspaceId, children }: Image
open={state.open}
defaultTab={state.defaultTab}
mediaType={state.mediaType}
multiple={state.multiple}
documentId={documentId}
workspaceId={workspaceId}
onClose={handleClose}
@@ -16,6 +16,7 @@ interface ImagePickerDialogProps {
open: boolean;
defaultTab: PickerTab;
mediaType: MediaKind;
multiple?: boolean;
documentId: string;
workspaceId: string;
onClose: () => void;
@@ -92,6 +93,7 @@ export function ImagePickerDialog({
open,
defaultTab,
mediaType,
multiple = false,
documentId,
workspaceId,
onClose,
@@ -141,41 +143,51 @@ export function ImagePickerDialog({
}
}, [open, tab, fetchRecent]);
const handleUpload = useCallback(
const uploadOne = useCallback(
async (file: File) => {
if (!workspaceId || !documentId) {
setError("缺少必要参数");
return;
throw new Error("缺少必要参数");
}
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", documentId);
const response = await fetch("/api/media/upload", {
method: "POST",
body: form,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "上传失败");
}
const payload = (await response.json()) as { asset: MediaAsset };
if (!payload.asset?.file_url) {
throw new Error("返回数据缺少文件地址");
}
emitAssetsChanged(documentId, payload.asset);
return {
assetId: payload.asset.id,
fileUrl: payload.asset.file_url,
thumbnailUrl: payload.asset.thumbnail_url,
assetType: payload.asset.asset_type,
fileName: payload.asset.file_name,
fileSize: payload.asset.file_size,
mimeType: payload.asset.mime_type,
} satisfies MediaSelection;
},
[documentId, workspaceId],
);
const handleUploadMany = useCallback(
async (files: File[]) => {
if (files.length === 0) return;
setUploading(true);
setError(null);
try {
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", documentId);
const response = await fetch("/api/media/upload", {
method: "POST",
body: form,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "上传失败");
for (const file of files) {
const selection = await uploadOne(file);
onSelect(selection);
}
const payload = (await response.json()) as { asset: MediaAsset };
if (!payload.asset?.file_url) {
throw new Error("返回数据缺少文件地址");
}
emitAssetsChanged(documentId, payload.asset);
onSelect({
assetId: payload.asset.id,
fileUrl: payload.asset.file_url,
thumbnailUrl: payload.asset.thumbnail_url,
assetType: payload.asset.asset_type,
fileName: payload.asset.file_name,
fileSize: payload.asset.file_size,
mimeType: payload.asset.mime_type,
});
onClose();
} catch (err) {
setError((err as Error).message);
@@ -183,15 +195,15 @@ export function ImagePickerDialog({
setUploading(false);
}
},
[documentId, onClose, onSelect, workspaceId],
[onClose, onSelect, uploadOne],
);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return;
void handleUpload(acceptedFiles[0]);
void handleUploadMany(multiple ? acceptedFiles : [acceptedFiles[0]]);
},
[handleUpload],
[handleUploadMany, multiple],
);
const acceptConfig = MEDIA_ACCEPTS[mediaType];
@@ -199,7 +211,7 @@ export function ImagePickerDialog({
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: acceptConfig ?? undefined,
maxFiles: 1,
multiple,
});
const handleLinkSubmit = async () => {
@@ -320,7 +332,9 @@ export function ImagePickerDialog({
const label = MEDIA_TITLES[mediaType].replace("选择", "");
switch (tab) {
case "upload":
return `支持拖拽或点击上传${label},单个文件不超过 200MB`;
return multiple
? `支持拖拽或点击上传${label}(可多选),单个文件不超过 200MB`
: `支持拖拽或点击上传${label},单个文件不超过 200MB`;
case "recent":
return `最近 12 个${label},支持一键选取`;
case "link":
@@ -330,7 +344,7 @@ export function ImagePickerDialog({
default:
return "";
}
}, [mediaType, tab]);
}, [mediaType, multiple, tab]);
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
@@ -111,7 +111,9 @@ export function FileTree({
{rows.map((row, index) => {
const label = getFileTreeRowLabel(row);
const selected = selectedRowIds.has(row.rowId);
const active = row.kind !== "asset" && row.docId === activeId;
// 说明:只有“页面本身(doc/index.md)”才需要 active 高亮。
// 否则当你打开某个页面时,它下面的思维导图文件夹/附件会出现“灰色假选中”的视觉误导。
const active = (row.kind === "doc" || row.kind === "index") && row.docId === activeId;
const draggable =
row.kind === "doc" || (row.kind === "asset" && isRealFileAsset(row.asset));
const inDropFeedback =
@@ -56,7 +56,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";
const TOP_BUTTONS = [
{ id: "search", icon: SearchIcon, label: "搜索" },
@@ -899,15 +899,19 @@ export function Sidebar({ initialData }: SidebarProps) {
);
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");
@@ -988,12 +992,26 @@ export function Sidebar({ initialData }: SidebarProps) {
}
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 selectedAssetHints = Array.from(
new Map(
fileTreeRows
.filter(
(row) =>
fileTreeSelection.selectedRowIds.has(row.rowId) &&
(row.kind === "asset" || row.kind === "asset-folder"),
)
.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 分钟内可撤销)`);
@@ -1030,7 +1048,7 @@ export function Sidebar({ initialData }: SidebarProps) {
}
if (assetIds.length > 0) {
await handleDeleteAssets(assetIds);
await handleDeleteAssets(assetIds, selectedAssetHints);
}
await refreshTree();
@@ -1532,6 +1550,10 @@ export function Sidebar({ initialData }: SidebarProps) {
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" },
@@ -1543,8 +1565,45 @@ export function Sidebar({ initialData }: SidebarProps) {
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 handlePurgeMediaAssetFromTrash = useCallback(
@@ -1621,8 +1680,15 @@ export function Sidebar({ initialData }: SidebarProps) {
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(
@@ -3,6 +3,7 @@
*/
export const ASSETS_CHANGED_EVENT = "wolai:assets-changed";
export const DOCUMENTS_CHANGED_EVENT = "wolai:documents-changed";
export const ASSETS_RESTORED_EVENT = "wolai:assets-restored";
type AssetsChangedPayload = {
docId?: string;
@@ -12,6 +13,10 @@ type AssetsChangedPayload = {
mindmapAssetIds?: string[];
};
type AssetsRestoredPayload =
| { docId: string; kind: "media"; assetId: string; asset?: unknown }
| { docId: string; kind: "mindmap"; mindmapId: string };
export function emitAssetsChanged(
docId?: string,
asset?: unknown,
@@ -24,6 +29,11 @@ export function emitAssetsChanged(
window.dispatchEvent(new CustomEvent(ASSETS_CHANGED_EVENT, { detail }));
}
export function emitAssetsRestored(payload: AssetsRestoredPayload) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(ASSETS_RESTORED_EVENT, { detail: payload }));
}
export function emitDocumentsChanged(docId?: string) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(DOCUMENTS_CHANGED_EVENT, { detail: { docId } }));
@@ -17,6 +17,11 @@ export interface EditorReferenceBridge {
* 主要用于侧边栏文件树拖拽上传后,把文件显示到主编辑区。
*/
insertMediaAsset?: (asset: MediaAsset) => void;
/**
* 向当前打开的文档插入一个“思维导图”块。
* 用于:从垃圾桶恢复思维导图后,把导图重新插入主编辑区。
*/
insertMindmapAsset?: (args: { documentId: string; mindmapId: string }) => void;
replaceWithSnapshot: (blocks: Json) => void;
openTableFullScreen?: (tableId: string) => void;
}
File diff suppressed because one or more lines are too long