0.3.2.1 UI修复3
This commit is contained in:
@@ -783,6 +783,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
|
||||
<CustomSideMenu {...props} currentDocumentId={documentId} workspaceId={workspaceId} />
|
||||
)}
|
||||
floatingOptions={{ placement: "left" }}
|
||||
/>
|
||||
)}
|
||||
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
|
||||
|
||||
@@ -229,9 +229,50 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
editor.updateBlock(block, { props: { linkUrl: next.trim() } });
|
||||
};
|
||||
|
||||
const viewOriginal = () => {
|
||||
if (!fileUrl) return;
|
||||
window.open(fileUrl, "_blank", "noopener,noreferrer");
|
||||
const resolveAssetId = async () => {
|
||||
const direct = (block.props as { assetId?: string })?.assetId;
|
||||
if (direct) return direct;
|
||||
|
||||
// 说明:历史数据/迁移场景下,media block 可能丢失 assetId,导致:
|
||||
// - PDF 打开拿到的不是原文件(旧链接过期/返回 HTML)
|
||||
// - OnlyOffice callback 缺少 assetId,进而“不能保存”
|
||||
// 这里尝试通过 documentId + fileName 在 media_assets 中反查 assetId。
|
||||
const docId = (block.props as { documentId?: string })?.documentId || resolveDocumentId();
|
||||
const name = String(block.props.fileName || block.props.caption || "").trim();
|
||||
if (!docId || !name) return "";
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/media/by-document?documentId=${encodeURIComponent(docId)}&limit=500`,
|
||||
);
|
||||
if (!res.ok) return "";
|
||||
const payload = (await res.json().catch(() => null)) as { items?: Array<{ id?: string; file_name?: string | null }> } | null;
|
||||
const items = Array.isArray(payload?.items) ? payload!.items! : [];
|
||||
const hit = items.find((it) => String(it.file_name || "") === name);
|
||||
return hit?.id ? String(hit.id) : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const resolveLatestFileUrl = async () => {
|
||||
if (!fileUrl) return "";
|
||||
const assetId = await resolveAssetId();
|
||||
if (!assetId) return fileUrl;
|
||||
try {
|
||||
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`);
|
||||
if (!res.ok) return fileUrl;
|
||||
const payload = (await res.json().catch(() => null)) as { signedUrl?: string } | null;
|
||||
return payload?.signedUrl || fileUrl;
|
||||
} catch {
|
||||
return fileUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const viewOriginal = async () => {
|
||||
const url = await resolveLatestFileUrl();
|
||||
if (!url) return;
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const openWithOnlyOffice = async () => {
|
||||
@@ -241,7 +282,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const assetId = (block.props as { assetId?: string })?.assetId;
|
||||
const assetId = await resolveAssetId();
|
||||
const res = assetId
|
||||
? await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`)
|
||||
: await fetch(
|
||||
@@ -258,16 +299,20 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
target.searchParams.set("fileUrl", signedUrl);
|
||||
target.searchParams.set("fileName", displayFileName);
|
||||
target.searchParams.set("fileType", extension || "docx");
|
||||
if (assetId) {
|
||||
target.searchParams.set("assetId", assetId);
|
||||
}
|
||||
window.open(target.toString(), "_blank", "noopener,noreferrer");
|
||||
} catch (error) {
|
||||
window.alert((error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAsset = () => {
|
||||
if (!fileUrl) return;
|
||||
const downloadAsset = async () => {
|
||||
const url = await resolveLatestFileUrl();
|
||||
if (!url) return;
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = fileUrl;
|
||||
anchor.href = url;
|
||||
anchor.download = block.props.fileName || block.props.caption || typeLabel;
|
||||
anchor.click();
|
||||
};
|
||||
@@ -364,44 +409,111 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
return "text-[#9B9A97]";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (isOfficeDoc) {
|
||||
void openWithOnlyOffice();
|
||||
} else {
|
||||
downloadAsset();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (isOfficeDoc) {
|
||||
void openWithOnlyOffice();
|
||||
} else {
|
||||
downloadAsset();
|
||||
void downloadAsset();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="group flex items-center gap-2 px-3 py-2 rounded-[4px] bg-white hover:bg-[#F5F5F5] transition-colors duration-200 cursor-pointer select-none"
|
||||
>
|
||||
<span className={cn("w-5 h-5 shrink-0 flex items-center justify-center", getIconColor())}>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="flex flex-col justify-center gap-0.5 overflow-hidden">
|
||||
<p className="text-[14px] text-[#37352F] font-normal truncate">{displayFileName}</p>
|
||||
{block.props.fileSize ? (
|
||||
<p className="text-[12px] text-[#999999]">{formatFileSize(block.props.fileSize)}</p>
|
||||
) : null}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (isOfficeDoc) {
|
||||
void openWithOnlyOffice();
|
||||
} else {
|
||||
void downloadAsset();
|
||||
}
|
||||
}
|
||||
}}
|
||||
data-testid="wolai-media-file-row"
|
||||
className="group flex h-[26px] items-center gap-2 rounded-[4px] border border-transparent bg-transparent px-2 outline-none transition-colors duration-200 cursor-pointer select-none hover:border-[#E9E9E8] hover:bg-[#F7F7F5] focus:outline-none focus-visible:outline-none"
|
||||
>
|
||||
<span className={cn("w-5 h-5 flex-none flex items-center justify-center", getIconColor())}>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-row items-center gap-2 overflow-hidden">
|
||||
<span className="min-w-0 flex-1 truncate text-[14px] text-[#37352F] font-normal">
|
||||
{displayFileName}
|
||||
</span>
|
||||
{block.props.fileSize ? (
|
||||
<span className="shrink-0 text-[12px] text-[#999999]">{formatFileSize(block.props.fileSize)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wolai-media-file-download"
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F]"
|
||||
aria-label="下载"
|
||||
title="下载"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void downloadAsset();
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wolai-media-file-more"
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F]"
|
||||
aria-label="更多操作"
|
||||
title="更多操作"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={handleChoose}>替换资源</DropdownMenuItem>
|
||||
{!shouldShowCaption && <DropdownMenuItem onClick={enableCaptionEdit}>添加说明</DropdownMenuItem>}
|
||||
<DropdownMenuItem onClick={handleLink}>{block.props.linkUrl ? "编辑链接" : "添加链接"}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}>复制链接</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void viewOriginal();
|
||||
}}
|
||||
>
|
||||
查看原文件
|
||||
</DropdownMenuItem>
|
||||
{isOfficeDoc && <DropdownMenuItem onClick={() => void openWithOnlyOffice()}>使用 ONLYOFFICE 打开</DropdownMenuItem>}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void downloadAsset();
|
||||
}}
|
||||
>
|
||||
下载到本地
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDeleteAsset}>删除</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
|
||||
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
|
||||
};
|
||||
);
|
||||
}
|
||||
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
|
||||
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
|
||||
};
|
||||
|
||||
const figure = (
|
||||
<figure
|
||||
@@ -462,7 +574,9 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
key: "download",
|
||||
label: `下载${typeLabel}`,
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
onClick: downloadAsset,
|
||||
onClick: () => {
|
||||
void downloadAsset();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
@@ -475,17 +589,17 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
|
||||
|
||||
return (
|
||||
<div className="wolai-media" ref={mediaRef}>
|
||||
<div className={cn("wolai-media", assetType === "file" && "wolai-media--file")} ref={mediaRef}>
|
||||
<div
|
||||
className="wolai-media__canvas"
|
||||
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
|
||||
onDoubleClick={() => {
|
||||
if (assetType === "file" && isOfficeDoc) {
|
||||
void openWithOnlyOffice();
|
||||
} else if (assetType === "file") {
|
||||
viewOriginal();
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (assetType === "file" && isOfficeDoc) {
|
||||
void openWithOnlyOffice();
|
||||
} else if (assetType === "file") {
|
||||
void viewOriginal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{block.props.linkUrl ? (
|
||||
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
|
||||
@@ -494,6 +608,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
) : (
|
||||
figure
|
||||
)}
|
||||
{assetType !== "file" && (
|
||||
<div className="wolai-media__quickbar">
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
@@ -532,13 +647,25 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}>复制链接</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={viewOriginal}>查看原文件</DropdownMenuItem>
|
||||
{assetType === "file" && isOfficeDoc && (
|
||||
<DropdownMenuItem onClick={() => void openWithOnlyOffice()}>使用 ONLYOFFICE 打开</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={downloadAsset}>下载到本地</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}>复制链接</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void viewOriginal();
|
||||
}}
|
||||
>
|
||||
查看原文件
|
||||
</DropdownMenuItem>
|
||||
{assetType === "file" && isOfficeDoc && (
|
||||
<DropdownMenuItem onClick={() => void openWithOnlyOffice()}>使用 ONLYOFFICE 打开</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void downloadAsset();
|
||||
}}
|
||||
>
|
||||
下载到本地
|
||||
</DropdownMenuItem>
|
||||
{canTriggerOcr && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -550,6 +677,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
{canResize && (
|
||||
<>
|
||||
<ResizeHandle side="left" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "left")} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import type { Block, PartialBlock } from "@blocknote/core";
|
||||
import {
|
||||
BlockColorsItem,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type DragHandleMenuProps,
|
||||
type SideMenuProps,
|
||||
} from "@blocknote/react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
import { deleteOnlineTable } from "@/lib/online-table";
|
||||
@@ -36,6 +37,15 @@ type CustomDragProps = DragHandleMenuProps<CustomBlockSchema> & {
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
const FourDotHandleIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" {...props}>
|
||||
<circle cx="6" cy="6" r="1.2" />
|
||||
<circle cx="10" cy="6" r="1.2" />
|
||||
<circle cx="6" cy="10" r="1.2" />
|
||||
<circle cx="10" cy="10" r="1.2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const extractText = (block: Block<CustomBlockSchema>) => {
|
||||
const inlineNodes = block.content as InlineNode[] | undefined;
|
||||
const maybeText = inlineNodes?.[0]?.text;
|
||||
@@ -410,15 +420,133 @@ type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
|
||||
const Components = useComponentsContext()!;
|
||||
const { editor, block, blockDragStart, blockDragEnd, freezeMenu, unfreezeMenu, currentDocumentId, workspaceId } = props;
|
||||
const [activeLine, setActiveLine] = useState<null | "top" | "bottom">(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const menuFrozenRef = useRef(false);
|
||||
|
||||
// 说明:Wolai 的附件(file)手柄是在行中线左侧居中显示。
|
||||
// BlockNote 的 SideMenu 默认参考点更偏“块顶部”,因此这里通过扩大 hover 区域并把手柄定位到附件行中线来对齐。
|
||||
const isFileAttachmentRow = block.type === "media" && (block as any)?.props?.assetType === "file";
|
||||
const rowHeightPx = isFileAttachmentRow ? 26 : 24;
|
||||
const hoverPadPx = 14;
|
||||
const lineOffsetPx = 6;
|
||||
const lineGapPx = 5;
|
||||
|
||||
const setFrozen = useCallback(
|
||||
(next: boolean) => {
|
||||
if (menuFrozenRef.current === next) return;
|
||||
menuFrozenRef.current = next;
|
||||
if (next) {
|
||||
freezeMenu();
|
||||
} else {
|
||||
unfreezeMenu();
|
||||
}
|
||||
},
|
||||
[freezeMenu, unfreezeMenu],
|
||||
);
|
||||
|
||||
const insertParagraph = useCallback(
|
||||
(position: "before" | "after") => {
|
||||
const inserted = editor.insertBlocks([{ type: "paragraph" } as any], block as any, position as any)?.[0];
|
||||
if (inserted) {
|
||||
editor.setTextCursorPosition(inserted as any);
|
||||
editor.focus();
|
||||
}
|
||||
},
|
||||
[block, editor],
|
||||
);
|
||||
|
||||
const stop = (e: ReactMouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<Components.Generic.Menu.Root
|
||||
onOpenChange={(open: boolean) => {
|
||||
setMenuOpen(open);
|
||||
setFrozen(open || hovering);
|
||||
}}
|
||||
position={"left"}
|
||||
>
|
||||
<div
|
||||
className="relative w-7 overflow-visible"
|
||||
style={{ height: rowHeightPx + hoverPadPx * 2, marginTop: -hoverPadPx }}
|
||||
onMouseEnter={() => {
|
||||
setHovering(true);
|
||||
setFrozen(menuOpen || true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHovering(false);
|
||||
setActiveLine(null);
|
||||
setFrozen(menuOpen || false);
|
||||
}}
|
||||
>
|
||||
{activeLine !== "bottom" && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wolai-insert-before"
|
||||
title="在上方插入块"
|
||||
className={`absolute left-1/2 z-30 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${hovering ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
||||
style={{ top: hoverPadPx - lineOffsetPx - lineGapPx }}
|
||||
onMouseEnter={() => setActiveLine("top")}
|
||||
onMouseDown={stop}
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
insertParagraph("before");
|
||||
}}
|
||||
>
|
||||
{activeLine === "top" ? <Plus className="h-3.5 w-3.5" /> : <span className="block h-px w-3 bg-[#CFCFCF]" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ top: hoverPadPx + rowHeightPx / 2 }}
|
||||
onMouseEnter={() => setActiveLine(null)}
|
||||
>
|
||||
<Components.Generic.Menu.Trigger>
|
||||
<Components.SideMenu.Button
|
||||
label="块操作"
|
||||
draggable={true}
|
||||
onDragStart={(e) => blockDragStart(e, block)}
|
||||
onDragEnd={blockDragEnd}
|
||||
className={"bn-button bn-drag-handle"}
|
||||
icon={<FourDotHandleIcon className="h-5 w-5" data-test="dragHandle" />}
|
||||
/>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
</div>
|
||||
|
||||
{activeLine !== "top" && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wolai-insert-after"
|
||||
title="在下方插入块"
|
||||
className={`absolute left-1/2 z-30 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${hovering ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
||||
style={{ bottom: hoverPadPx - lineOffsetPx - lineGapPx }}
|
||||
onMouseEnter={() => setActiveLine("bottom")}
|
||||
onMouseDown={stop}
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
insertParagraph("after");
|
||||
}}
|
||||
>
|
||||
{activeLine === "bottom" ? <Plus className="h-3.5 w-3.5" /> : <span className="block h-px w-3 bg-[#CFCFCF]" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CustomDragHandleMenu block={block} currentDocumentId={currentDocumentId} workspaceId={workspaceId} />
|
||||
</Components.Generic.Menu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomSideMenu = (props: CustomSideMenuProps) => (
|
||||
<SideMenu
|
||||
{...props}
|
||||
dragHandleMenu={(dragProps) => (
|
||||
<CustomDragHandleMenu
|
||||
{...(dragProps as DragHandleMenuProps<CustomBlockSchema>)}
|
||||
currentDocumentId={props.currentDocumentId}
|
||||
workspaceId={props.workspaceId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SideMenu {...props}>
|
||||
<WolaiDragHandleWithInsert {...props} />
|
||||
</SideMenu>
|
||||
);
|
||||
|
||||
@@ -16,14 +16,47 @@ export function ConvexClientProvider({ children }: ConvexClientProviderProps) {
|
||||
// 动态获取 Convex URL,默认优先使用环境变量。
|
||||
// 说明:浏览器侧优先用当前 hostname(端口固定 3210),避免通过非 localhost/127 访问前端时出现跨域/白名单不匹配。
|
||||
const getConvexUrl = () => {
|
||||
const normalize = (url: string) => url.replace(/\/+$/, "");
|
||||
const isLocalHost = (host: string) => host === "localhost" || host === "127.0.0.1";
|
||||
const convexProxyPath = "/convex";
|
||||
|
||||
// 默认使用环境变量
|
||||
const envUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||
const envUrlRaw = (process.env.NEXT_PUBLIC_CONVEX_URL ?? "").trim();
|
||||
if (typeof window === "undefined") {
|
||||
return envUrl || "http://127.0.0.1:3210";
|
||||
return normalize(envUrlRaw || "http://127.0.0.1:3210");
|
||||
}
|
||||
// 在浏览器中,使用当前主机名,但端口改为 3210
|
||||
|
||||
const browserProtocol = window.location.protocol;
|
||||
|
||||
// 浏览器侧:优先尊重 NEXT_PUBLIC_CONVEX_URL(例如生产环境的 https://xxx.convex.cloud)。
|
||||
// 仅在 envUrl 是 localhost/127.0.0.1 时,才把 host 改为当前 hostname,避免通过域名访问前端时出现不一致。
|
||||
if (envUrlRaw) {
|
||||
try {
|
||||
const url = new URL(envUrlRaw);
|
||||
const browserHost = window.location.hostname;
|
||||
|
||||
// 当通过 HTTPS(如 frp/nginx)访问前端时,浏览器不允许从 https 页面发起 ws://(不安全)连接。
|
||||
// 若 envUrl 仍是 http://localhost:3210,则走同源反代(由 scripts/dev-server.js 处理 Upgrade)转到本机 3210。
|
||||
if (browserProtocol === "https:" && (url.protocol === "http:" || isLocalHost(url.hostname))) {
|
||||
return normalize(`${window.location.origin}${convexProxyPath}`);
|
||||
}
|
||||
|
||||
if (isLocalHost(url.hostname) && !isLocalHost(browserHost)) {
|
||||
// 非 https 访问时才尝试“借用当前 host:3210”;https 场景已提前走同源反代。
|
||||
url.hostname = browserHost;
|
||||
}
|
||||
|
||||
return normalize(url.toString());
|
||||
} catch {
|
||||
// envUrl 不是合法 URL 时,按原值兜底
|
||||
return normalize(envUrlRaw);
|
||||
}
|
||||
}
|
||||
|
||||
// 未配置 env:使用当前主机名,端口固定 3210,并跟随当前页面协议(http/https)。
|
||||
const hostname = window.location.hostname;
|
||||
return `http://${hostname}:3210`;
|
||||
const protocol = "http:";
|
||||
return normalize(`${protocol}//${hostname}:3210`);
|
||||
};
|
||||
|
||||
const convexUrl = getConvexUrl();
|
||||
|
||||
@@ -542,7 +542,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const officeFileType = officeFileTypeFromAsset(asset.file_name ?? null, asset.mime_type ?? null);
|
||||
if (officeFileType) {
|
||||
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
|
||||
@@ -571,15 +571,34 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = asset.signed_url ?? asset.file_url;
|
||||
if (!url) {
|
||||
window.alert("暂无可用的文件链接");
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(asset.id)}`);
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => null);
|
||||
throw new Error(payload?.error ?? "生成签名链接失败");
|
||||
}
|
||||
const { signedUrl } = (await res.json()) as { signedUrl: string };
|
||||
if (!signedUrl) {
|
||||
throw new Error("暂无可用的文件链接");
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(signedUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
setOpen(false);
|
||||
} catch {
|
||||
const fallback = asset.signed_url ?? asset.file_url;
|
||||
if (!fallback) {
|
||||
window.alert("暂无可用的文件链接");
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(fallback, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
setOpen(false);
|
||||
}
|
||||
})();
|
||||
}, [activeId, editorBridge, router, setOpen]);
|
||||
|
||||
const handleFileTreeBlankMouseDown = useCallback(() => {
|
||||
@@ -846,13 +865,28 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
window.alert("在线表格暂不支持下载(后续可做导出 JSON/Excel)");
|
||||
return;
|
||||
}
|
||||
const url = asset.signed_url ?? asset.file_url;
|
||||
if (!url) {
|
||||
window.alert("暂无可用的下载链接");
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
try {
|
||||
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(asset.id)}`);
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => null);
|
||||
throw new Error(payload?.error ?? "生成签名链接失败");
|
||||
}
|
||||
const { signedUrl } = (await res.json()) as { signedUrl: string };
|
||||
if (!signedUrl) {
|
||||
throw new Error("暂无可用的下载链接");
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(signedUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch {
|
||||
const fallback = asset.signed_url ?? asset.file_url;
|
||||
if (!fallback) {
|
||||
window.alert("暂无可用的下载链接");
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(fallback, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user