0.3.2.1 UI修复3

This commit is contained in:
liaibo
2026-01-20 07:24:12 +08:00
parent fa7235b1a7
commit f13de35321
16 changed files with 741 additions and 108 deletions
+151 -5
View File
@@ -19,6 +19,7 @@ const next = require("next");
const { parse: parseUrl } = require("url");
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
const CONVEX_PREFIX = "/convex";
function readArgValue(flag) {
const idx = process.argv.findIndex((x) => x === flag);
@@ -48,10 +49,19 @@ function isOnlyOfficePath(urlString) {
}
}
function buildUpstreamRequestHead(req, targetUrl) {
function isConvexPath(urlString) {
try {
const u = new URL(urlString, "http://localhost");
return u.pathname === CONVEX_PREFIX || u.pathname.startsWith(`${CONVEX_PREFIX}/`);
} catch {
return false;
}
}
function buildUpstreamRequestHead(req, targetUrl, prefix) {
const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/";
const stripped = rawPath === ONLYOFFICE_PREFIX ? "/" : rawPath.slice(ONLYOFFICE_PREFIX.length) || "/";
const stripped = rawPath === prefix ? "/" : rawPath.slice(prefix.length) || "/";
const basePath = String(targetUrl.pathname || "/").replace(/\/+$/, "") || "";
const upstreamPath = `${basePath}${stripped}`.replace(/\/{2,}/g, "/") + (incoming.search || "");
@@ -108,7 +118,7 @@ function buildUpstreamRequestHead(req, targetUrl) {
lines.push(`x-forwarded-host: ${forwardedHost}`);
lines.push(`x-forwarded-proto: ${forwardedProto}`);
lines.push(`x-forwarded-port: ${forwardedPort}`);
lines.push(`x-forwarded-prefix: ${ONLYOFFICE_PREFIX}`);
lines.push(`x-forwarded-prefix: ${prefix}`);
// 说明:Host 必须指向 ONLYOFFICE_INTERNAL_URL,否则上游可能拒绝 Upgrade。
lines.push(`Host: ${targetUrl.host}`);
@@ -123,7 +133,7 @@ function proxyOnlyOfficeUpgrade(req, socket, head) {
const upstream = net.connect({ host: target.hostname, port }, () => {
try {
const reqHead = buildUpstreamRequestHead(req, target);
const reqHead = buildUpstreamRequestHead(req, target, ONLYOFFICE_PREFIX);
upstream.write(reqHead);
if (head && head.length > 0) upstream.write(head);
socket.pipe(upstream);
@@ -156,6 +166,116 @@ function proxyOnlyOfficeUpgrade(req, socket, head) {
socket.on("error", onError);
}
function resolveConvexInternalUrl() {
// 说明:Convex 本地 dev server 默认 3210;对外访问(如 frp https)时,浏览器需要 wss
// 因此这里通过同源反代把 Upgrade 转发到本机 3210。
const raw = (process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210").trim();
try {
return new URL(raw.replace(/\/+$/, "") + "/");
} catch {
return new URL("http://127.0.0.1:3210/");
}
}
function proxyConvexUpgrade(req, socket, head) {
const target = resolveConvexInternalUrl();
const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
const upstream = net.connect({ host: target.hostname, port }, () => {
try {
const reqHead = buildUpstreamRequestHead(req, target, CONVEX_PREFIX);
upstream.write(reqHead);
if (head && head.length > 0) upstream.write(head);
socket.pipe(upstream);
upstream.pipe(socket);
} catch {
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
}
});
const onError = (err) => {
try {
const msg = err && err.message ? String(err.message) : String(err || "");
// eslint-disable-next-line no-console
console.log("[dev-server][convex-ws] proxy error", msg);
} catch {}
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
};
upstream.on("error", onError);
socket.on("error", onError);
}
function proxyConvexHttp(req, res) {
const target = resolveConvexInternalUrl();
const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/";
const stripped = rawPath === CONVEX_PREFIX ? "/" : rawPath.slice(CONVEX_PREFIX.length) || "/";
const upstreamPath = stripped + (incoming.search || "");
const isHttps = target.protocol === "https:";
const mod = isHttps ? require("https") : require("http");
const headers = { ...(req.headers || {}) };
headers.host = target.host;
// 说明:让上游能感知对外协议/域名(主要用于调试;Convex 本身通常不依赖这些头)。
const forwardedHostRaw = String(headers["x-forwarded-host"] || req.headers.host || "");
const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost";
const forwardedProto =
String(headers["x-forwarded-proto"] || "").split(",")[0].trim() ||
(String(req.headers.origin || "").startsWith("https") ? "https" : "http");
const forwardedPortRaw = String(headers["x-forwarded-port"] || "").split(",")[0].trim();
const forwardedPort = (() => {
if (forwardedPortRaw) return forwardedPortRaw;
const hostHasPort = forwardedHost.includes(":") ? forwardedHost.split(":").pop() : "";
if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort;
return forwardedProto === "https" ? "443" : "80";
})();
headers["x-forwarded-host"] = forwardedHost;
headers["x-forwarded-proto"] = forwardedProto;
headers["x-forwarded-port"] = forwardedPort;
headers["x-forwarded-prefix"] = CONVEX_PREFIX;
const upstreamReq = mod.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
method: req.method,
path: upstreamPath,
headers,
},
(upstreamRes) => {
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers || {});
upstreamRes.pipe(res);
},
);
upstreamReq.on("error", (err) => {
try {
// eslint-disable-next-line no-console
console.log("[dev-server][convex-http] proxy error", err && err.message ? err.message : String(err || ""));
} catch {}
try {
res.statusCode = 502;
res.end("Bad Gateway");
} catch {}
});
req.pipe(upstreamReq);
}
async function main() {
const port = resolvePort();
const hostname = resolveHostname();
@@ -172,14 +292,38 @@ async function main() {
try {
res.setHeader("x-mnote-dev-server", "1");
res.setHeader("x-mnote-onlyoffice-ws-proxy", "1");
res.setHeader("x-mnote-convex-ws-proxy", "1");
} catch {
// ignore
}
if (isConvexPath(req.url || "/")) {
proxyConvexHttp(req, res);
return;
}
const parsed = parseUrl(req.url || "/", true);
handle(req, res, parsed);
});
server.on("upgrade", (req, socket, head) => {
if (isConvexPath(req.url || "/")) {
try {
// eslint-disable-next-line no-console
console.log(
"[dev-server][convex-ws] upgrade",
req.url,
"host=",
req.headers.host,
"xfp=",
req.headers["x-forwarded-proto"],
"xfh=",
req.headers["x-forwarded-host"],
);
} catch {}
proxyConvexUpgrade(req, socket, head);
return;
}
if (isOnlyOfficePath(req.url || "/")) {
try {
// eslint-disable-next-line no-console
@@ -210,7 +354,9 @@ async function main() {
server.listen(port, hostname, () => {
// eslint-disable-next-line no-console
console.log(`[dev-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081"})`);
console.log(
`[dev-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081"}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`,
);
});
}
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { HttpError } from "@/lib/auth/authContext";
import { getAuthedConvexClient } from "@/lib/convex/route";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const { auth } = await getAuthedConvexClient();
return NextResponse.json({
userId: auth.userId,
email: auth.email ?? null,
name: auth.name ?? null,
});
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
}
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import type { MediaAsset } from "@/types/media";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = await requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const documentId = searchParams.get("documentId") ?? "";
const limit = Number.parseInt(searchParams.get("limit") ?? "200", 10);
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const client = await getConvexAuthedHttpClient();
const items = await client.query(api.mediaAssets.listByDocument, {
userId: auth.userId,
documentId,
limit: Number.isNaN(limit) ? 200 : limit,
});
return NextResponse.json({ items: (items ?? []) as MediaAsset[] });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -82,7 +82,11 @@ export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
// 说明:Convex 模式下,保存写回 Convex Files,并更新 media_assets.storage_id/file_url。
const userId = String(process.env.DEV_USER_ID || "dev-user").trim() || "dev-user";
// 优先使用前端在 /onlyoffice 页面透传的 userId;避免固定 dev-user 导致 workspace membership 校验失败。
const userId =
String(searchParams.get("userId") || "").trim() ||
String(process.env.DEV_USER_ID || "").trim() ||
"dev-user";
const client = getConvexHttpClient();
const asset = await client.query(api.mediaAssets.getById, { userId, id: assetId });
+14 -2
View File
@@ -97,6 +97,18 @@ export async function GET(request: Request) {
includeDeleted: true,
});
const mediaAssets = await client.query(api.mediaAssets.listByWorkspace, {
userId: auth.userId,
workspaceId: targetWorkspaceId,
limit: 200,
});
const trashedMediaAssets = await client.query(api.mediaAssets.listDeletedByWorkspace, {
userId: auth.userId,
workspaceId: targetWorkspaceId,
limit: 2000,
});
const activeMindmaps = (mindmapRows ?? []).filter((r) => !r.deleted_at);
const trashedMindmaps = (mindmapRows ?? []).filter((r) => !!r.deleted_at);
@@ -166,13 +178,13 @@ export async function GET(request: Request) {
workspaces,
documents,
trashedDocuments,
trashedMediaAssets: [],
trashedMediaAssets: (trashedMediaAssets ?? []) as MediaAsset[],
trashedMindmapAssets,
mindmapDocs,
mindmapAssets,
mindmapAssetChildren,
tableAssets: [],
mediaAssets: [],
mediaAssets: (mediaAssets ?? []) as MediaAsset[],
};
return NextResponse.json(payload);
+22 -2
View File
@@ -343,6 +343,27 @@ body {
position: relative;
margin: 24px 0;
border-radius: 18px;
/* 说明:Wolai 的附件/媒体块在选中时不显示蓝色外框(ProseMirror-selectednode 默认会加 outline)。 */
outline: none !important;
}
/* 说明:文件附件需要表现为“普通行”,不应该出现外层大卡片/大外框 */
.wolai-media--file {
margin: 0;
border-radius: 4px;
}
.wolai-media--file::after {
content: none;
}
.wolai-media--file .wolai-media__canvas {
background: transparent;
box-shadow: none;
border-radius: 0;
overflow: visible;
}
.wolai-media--file .wolai-media__figure {
background: transparent;
border-radius: 0;
overflow: visible;
}
.wolai-media::after {
content: "";
@@ -353,8 +374,7 @@ body {
transition: border-color 0.2s ease;
pointer-events: none;
}
.wolai-media:hover::after,
.wolai-media:focus-within::after {
.wolai-media:hover::after {
border-color: rgba(37, 99, 235, 0.15);
}
.wolai-media--empty {
@@ -279,8 +279,10 @@ export default function OnlyOfficePage() {
const fileType = (params.get("fileType") ?? "docx").toLowerCase();
const mode = (params.get("mode") ?? "edit") as EditorMode;
const assetId = params.get("assetId") ?? "";
const initialUserId = params.get("userId") ?? "";
const channel = (params.get("channel") ?? "").trim().toLowerCase();
const [error, setError] = useState<string | null>(null);
const [authedUserId, setAuthedUserId] = useState<string>(initialUserId);
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
const baseUrlCandidates = useMemo(() => {
const uniq: string[] = [];
@@ -330,6 +332,31 @@ export default function OnlyOfficePage() {
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin;
useEffect(() => {
// 说明:ONLYOFFICE 回调由文档服务器触发,不携带用户 Cookie。
// 为了让 /api/onlyoffice/callback 能以真实用户身份写回存储,
// 这里尽量从当前会话获取 userId,并透传到 callbackUrl。
if (authedUserId) return undefined;
let canceled = false;
fetch("/api/auth/whoami", { method: "GET" })
.then(async (r) => {
if (!r.ok) return null;
return (await r.json().catch(() => null)) as { userId?: string } | null;
})
.then((payload) => {
const uid = String(payload?.userId || "").trim();
if (!uid) return;
if (canceled) return;
setAuthedUserId(uid);
})
.catch(() => {
// ignore
});
return () => {
canceled = true;
};
}, [authedUserId]);
useEffect(() => {
// 说明:在部分 ONLYOFFICE 版本/环境下,编辑器内部会触发 DOMException(NotFoundError: removeChild)
// 该异常会被 Next.js 捕获并显示“客户端异常”大红屏,但实际文档仍可继续使用。
@@ -540,7 +567,7 @@ export default function OnlyOfficePage() {
} catch {
// ignore
}
}, [assetId, baseUrl, callbackOrigin, fileName, fileType, fileUrl, mode, proxyOrigin, resolvedFileUrl]);
}, [assetId, authedUserId, baseUrl, callbackOrigin, fileName, fileType, fileUrl, mode, proxyOrigin, resolvedFileUrl]);
useEffect(() => {
if (!baseUrl) {
@@ -623,6 +650,7 @@ export default function OnlyOfficePage() {
const base = callbackOrigin || proxyOrigin || window.location.origin;
const cb = new URL("/api/onlyoffice/callback", base);
if (assetId) cb.searchParams.set("assetId", assetId);
if (authedUserId) cb.searchParams.set("userId", authedUserId);
return cb.toString();
})(),
customization: {
@@ -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");
}
}
}, []);
@@ -21,6 +21,13 @@ export function useConvexSidebarData(workspaceId: string): {
// 显式转为 boolean,确保类型正确
const shouldFetch = Boolean(isAuthenticated && workspaceId);
const currentUser = useQuery(api.users.currentUser, shouldFetch ? {} : "skip");
const userId =
currentUser && typeof currentUser === "object" && typeof (currentUser as any)._id === "string"
? String((currentUser as any)._id)
: "";
const shouldFetchAuthed = Boolean(shouldFetch && userId);
// 使用 Convex 的 useQuery,自动订阅实时更新
// 后端从 ctx.auth.getUserIdentity() 获取用户身份,无需前端传入 userId
@@ -39,6 +46,16 @@ export function useConvexSidebarData(workspaceId: string): {
shouldFetch ? { workspaceId, includeDeleted: true } : "skip"
);
const mediaAssets = useQuery(
api.mediaAssets.listByWorkspace,
shouldFetchAuthed ? { userId, workspaceId, limit: 200 } : "skip",
);
const trashedMediaAssets = useQuery(
api.mediaAssets.listDeletedByWorkspace,
shouldFetchAuthed ? { userId, workspaceId, limit: 2000 } : "skip",
);
const workspacesResult = useQuery(
api.workspaces.fetchWorkspaceSummaries,
shouldFetch ? undefined : "skip"
@@ -47,9 +64,12 @@ export function useConvexSidebarData(workspaceId: string): {
// 组合数据,格式与 SidebarInitialData 一致
const data: SidebarInitialData | null = useMemo(() => {
// 当 skip 时,返回值是 undefined
if (documents === undefined ||
if (currentUser === undefined ||
documents === undefined ||
trashedDocuments === undefined ||
mindmaps === undefined ||
mediaAssets === undefined ||
trashedMediaAssets === undefined ||
workspacesResult === undefined) {
return null;
}
@@ -115,22 +135,34 @@ export function useConvexSidebarData(workspaceId: string): {
workspaces: workspacesResult.workspaces,
documents,
trashedDocuments,
trashedMediaAssets: [],
trashedMediaAssets: (trashedMediaAssets ?? []) as MediaAsset[],
trashedMindmapAssets,
mindmapDocs,
mindmapAssets,
mindmapAssetChildren: {},
tableAssets: [],
mediaAssets: [],
mediaAssets: (mediaAssets ?? []) as MediaAsset[],
};
}, [documents, trashedDocuments, mindmaps, workspacesResult, workspaceId]);
}, [
currentUser,
documents,
trashedDocuments,
mindmaps,
mediaAssets,
trashedMediaAssets,
workspacesResult,
workspaceId,
]);
// loading 状态:只有当 shouldFetch 为 true 且数据未加载时才算 loading
// 使用 === undefined 判断,因为 skip 时返回 undefined
const isLoading = shouldFetch && (
currentUser === undefined ||
documents === undefined ||
trashedDocuments === undefined ||
mindmaps === undefined ||
mediaAssets === undefined ||
trashedMediaAssets === undefined ||
workspacesResult === undefined
);
const error = null;
+4 -3
View File
@@ -1,4 +1,5 @@
export function isConvexEnabled(): boolean {
return process.env.NEXT_PUBLIC_USE_CONVEX === "1";
}
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
export function isConvexEnabled(): boolean {
return Boolean(getMnoteRuntimeConfig().useConvex);
}
+1 -1
View File
@@ -44,7 +44,7 @@ declare global {
}
const readFromEnv = (): MnoteRuntimeConfig => ({
useConvex: process.env.USE_CONVEX === "1",
useConvex: process.env.USE_CONVEX === "1" || process.env.NEXT_PUBLIC_USE_CONVEX === "1",
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
+3 -1
View File
@@ -11,6 +11,9 @@ const isPublicRoute = createRouteMatcher([
"/auth",
"/login",
"/api/auth(.*)",
// 说明:ONLYOFFICE 文档服务器(容器/远端)拉取文件与回调保存不携带用户态,必须放行。
"/api/onlyoffice/proxy(.*)",
"/api/onlyoffice/callback(.*)",
"/api/health(.*)",
"/_next(.*)",
"/favicon.ico",
@@ -34,4 +37,3 @@ export const config = {
// 说明:排除静态资源,避免无意义的中间件开销。
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};