diff --git a/wolai-frontend/scripts/dev-server.js b/wolai-frontend/scripts/dev-server.js index 88fde1e7..7211b0ba 100644 --- a/wolai-frontend/scripts/dev-server.js +++ b/wolai-frontend/scripts/dev-server.js @@ -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"})`, + ); }); } diff --git a/wolai-frontend/src/app/api/auth/whoami/route.ts b/wolai-frontend/src/app/api/auth/whoami/route.ts new file mode 100644 index 00000000..c6c319a8 --- /dev/null +++ b/wolai-frontend/src/app/api/auth/whoami/route.ts @@ -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 }); + } +} + diff --git a/wolai-frontend/src/app/api/media/by-document/route.ts b/wolai-frontend/src/app/api/media/by-document/route.ts new file mode 100644 index 00000000..e7261046 --- /dev/null +++ b/wolai-frontend/src/app/api/media/by-document/route.ts @@ -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 }); +} + diff --git a/wolai-frontend/src/app/api/onlyoffice/callback/route.ts b/wolai-frontend/src/app/api/onlyoffice/callback/route.ts index 10778df3..627785f8 100644 --- a/wolai-frontend/src/app/api/onlyoffice/callback/route.ts +++ b/wolai-frontend/src/app/api/onlyoffice/callback/route.ts @@ -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 }); diff --git a/wolai-frontend/src/app/api/sidebar/route.ts b/wolai-frontend/src/app/api/sidebar/route.ts index 71ca68a3..a8fdc480 100644 --- a/wolai-frontend/src/app/api/sidebar/route.ts +++ b/wolai-frontend/src/app/api/sidebar/route.ts @@ -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); diff --git a/wolai-frontend/src/app/globals.css b/wolai-frontend/src/app/globals.css index bd1884c7..6f0fec32 100644 --- a/wolai-frontend/src/app/globals.css +++ b/wolai-frontend/src/app/globals.css @@ -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 { diff --git a/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx b/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx index e4b004d6..9c78e8db 100644 --- a/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx +++ b/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx @@ -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(null); + const [authedUserId, setAuthedUserId] = useState(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: { diff --git a/wolai-frontend/src/components/editor/blocknote-editor.tsx b/wolai-frontend/src/components/editor/blocknote-editor.tsx index 9c141be8..77f61d60 100644 --- a/wolai-frontend/src/components/editor/blocknote-editor.tsx +++ b/wolai-frontend/src/components/editor/blocknote-editor.tsx @@ -783,6 +783,7 @@ const computeDocumentStats = (blocks: Block[]): DocumentStats sideMenu={(props: SideMenuProps) => ( )} + floatingOptions={{ placement: "left" }} /> )} diff --git a/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx b/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx index 7470c270..b27f9c33 100644 --- a/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx +++ b/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx @@ -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 ( -
{ - if (isOfficeDoc) { - void openWithOnlyOffice(); - } else { - downloadAsset(); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); + return ( +
{ 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" - > - - - -
-

{displayFileName}

- {block.props.fileSize ? ( -

{formatFileSize(block.props.fileSize)}

- ) : 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" + > + + + +
+ + {displayFileName} + + {block.props.fileSize ? ( + {formatFileSize(block.props.fileSize)} + ) : null} +
+
+ + + + + + + 替换资源 + {!shouldShowCaption && 添加说明} + {block.props.linkUrl ? "编辑链接" : "添加链接"} + handleCopyLink(fileUrl)}>复制链接 + { + void viewOriginal(); + }} + > + 查看原文件 + + {isOfficeDoc && void openWithOnlyOffice()}>使用 ONLYOFFICE 打开} + { + void downloadAsset(); + }} + > + 下载到本地 + + + 删除 + + +
-
- ); - } - const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined; - return {block.props.caption; - }; + ); + } + const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined; + return {block.props.caption; + }; const figure = (
{ key: "download", label: `下载${typeLabel}`, icon: , - onClick: downloadAsset, + onClick: () => { + void downloadAsset(); + }, }, { key: "delete", @@ -475,17 +589,17 @@ const MediaBlockContent = ({ block, editor }: any) => { const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接"; return ( -
+
{ - 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 ? ( @@ -494,6 +608,7 @@ const MediaBlockContent = ({ block, editor }: any) => { ) : ( figure )} + {assetType !== "file" && (
{quickActions.map((action) => (
+ )} {canResize && ( <> handleResizeStart(event, "left")} /> diff --git a/wolai-frontend/src/components/editor/menus/CustomSideMenu.tsx b/wolai-frontend/src/components/editor/menus/CustomSideMenu.tsx index 4b069cee..a51799c9 100644 --- a/wolai-frontend/src/components/editor/menus/CustomSideMenu.tsx +++ b/wolai-frontend/src/components/editor/menus/CustomSideMenu.tsx @@ -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 & { workspaceId: string | null; }; +const FourDotHandleIcon = (props: React.SVGProps) => ( + +); + const extractText = (block: Block) => { const inlineNodes = block.content as InlineNode[] | undefined; const maybeText = inlineNodes?.[0]?.text; @@ -410,15 +420,133 @@ type CustomSideMenuProps = SideMenuProps & { 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); + 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 ( + { + setMenuOpen(open); + setFrozen(open || hovering); + }} + position={"left"} + > +
{ + setHovering(true); + setFrozen(menuOpen || true); + }} + onMouseLeave={() => { + setHovering(false); + setActiveLine(null); + setFrozen(menuOpen || false); + }} + > + {activeLine !== "bottom" && ( + + )} + +
setActiveLine(null)} + > + + blockDragStart(e, block)} + onDragEnd={blockDragEnd} + className={"bn-button bn-drag-handle"} + icon={} + /> + +
+ + {activeLine !== "top" && ( + + )} +
+ + +
+ ); +}; + export const CustomSideMenu = (props: CustomSideMenuProps) => ( - ( - )} - currentDocumentId={props.currentDocumentId} - workspaceId={props.workspaceId} - /> - )} - /> + + + ); diff --git a/wolai-frontend/src/components/providers/convex-provider.tsx b/wolai-frontend/src/components/providers/convex-provider.tsx index 452e9a09..1b31818b 100644 --- a/wolai-frontend/src/components/providers/convex-provider.tsx +++ b/wolai-frontend/src/components/providers/convex-provider.tsx @@ -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(); diff --git a/wolai-frontend/src/components/sidebar/sidebar.tsx b/wolai-frontend/src/components/sidebar/sidebar.tsx index 82855d3e..a6e3fa55 100644 --- a/wolai-frontend/src/components/sidebar/sidebar.tsx +++ b/wolai-frontend/src/components/sidebar/sidebar.tsx @@ -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"); + } } }, []); diff --git a/wolai-frontend/src/hooks/use-convex-sidebar-data.ts b/wolai-frontend/src/hooks/use-convex-sidebar-data.ts index 60a29690..ee69510b 100644 --- a/wolai-frontend/src/hooks/use-convex-sidebar-data.ts +++ b/wolai-frontend/src/hooks/use-convex-sidebar-data.ts @@ -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; diff --git a/wolai-frontend/src/lib/convex/enabled.ts b/wolai-frontend/src/lib/convex/enabled.ts index 6f3dc2ce..53b63ef6 100644 --- a/wolai-frontend/src/lib/convex/enabled.ts +++ b/wolai-frontend/src/lib/convex/enabled.ts @@ -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); +} diff --git a/wolai-frontend/src/lib/runtime-config.ts b/wolai-frontend/src/lib/runtime-config.ts index 36d29603..d628e0fb 100644 --- a/wolai-frontend/src/lib/runtime-config.ts +++ b/wolai-frontend/src/lib/runtime-config.ts @@ -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, diff --git a/wolai-frontend/src/middleware.ts b/wolai-frontend/src/middleware.ts index a8c92175..b02944aa 100644 --- a/wolai-frontend/src/middleware.ts +++ b/wolai-frontend/src/middleware.ts @@ -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).*)"], }; -