0.3.2.1 UI修复3
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).*)"],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user