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 { parse: parseUrl } = require("url");
|
||||||
|
|
||||||
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
|
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
|
||||||
|
const CONVEX_PREFIX = "/convex";
|
||||||
|
|
||||||
function readArgValue(flag) {
|
function readArgValue(flag) {
|
||||||
const idx = process.argv.findIndex((x) => x === 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 incoming = new URL(req.url || "/", "http://localhost");
|
||||||
const rawPath = incoming.pathname || "/";
|
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 basePath = String(targetUrl.pathname || "/").replace(/\/+$/, "") || "";
|
||||||
const upstreamPath = `${basePath}${stripped}`.replace(/\/{2,}/g, "/") + (incoming.search || "");
|
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-host: ${forwardedHost}`);
|
||||||
lines.push(`x-forwarded-proto: ${forwardedProto}`);
|
lines.push(`x-forwarded-proto: ${forwardedProto}`);
|
||||||
lines.push(`x-forwarded-port: ${forwardedPort}`);
|
lines.push(`x-forwarded-port: ${forwardedPort}`);
|
||||||
lines.push(`x-forwarded-prefix: ${ONLYOFFICE_PREFIX}`);
|
lines.push(`x-forwarded-prefix: ${prefix}`);
|
||||||
|
|
||||||
// 说明:Host 必须指向 ONLYOFFICE_INTERNAL_URL,否则上游可能拒绝 Upgrade。
|
// 说明:Host 必须指向 ONLYOFFICE_INTERNAL_URL,否则上游可能拒绝 Upgrade。
|
||||||
lines.push(`Host: ${targetUrl.host}`);
|
lines.push(`Host: ${targetUrl.host}`);
|
||||||
@@ -123,7 +133,7 @@ function proxyOnlyOfficeUpgrade(req, socket, head) {
|
|||||||
|
|
||||||
const upstream = net.connect({ host: target.hostname, port }, () => {
|
const upstream = net.connect({ host: target.hostname, port }, () => {
|
||||||
try {
|
try {
|
||||||
const reqHead = buildUpstreamRequestHead(req, target);
|
const reqHead = buildUpstreamRequestHead(req, target, ONLYOFFICE_PREFIX);
|
||||||
upstream.write(reqHead);
|
upstream.write(reqHead);
|
||||||
if (head && head.length > 0) upstream.write(head);
|
if (head && head.length > 0) upstream.write(head);
|
||||||
socket.pipe(upstream);
|
socket.pipe(upstream);
|
||||||
@@ -156,6 +166,116 @@ function proxyOnlyOfficeUpgrade(req, socket, head) {
|
|||||||
socket.on("error", onError);
|
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() {
|
async function main() {
|
||||||
const port = resolvePort();
|
const port = resolvePort();
|
||||||
const hostname = resolveHostname();
|
const hostname = resolveHostname();
|
||||||
@@ -172,14 +292,38 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
res.setHeader("x-mnote-dev-server", "1");
|
res.setHeader("x-mnote-dev-server", "1");
|
||||||
res.setHeader("x-mnote-onlyoffice-ws-proxy", "1");
|
res.setHeader("x-mnote-onlyoffice-ws-proxy", "1");
|
||||||
|
res.setHeader("x-mnote-convex-ws-proxy", "1");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isConvexPath(req.url || "/")) {
|
||||||
|
proxyConvexHttp(req, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = parseUrl(req.url || "/", true);
|
const parsed = parseUrl(req.url || "/", true);
|
||||||
handle(req, res, parsed);
|
handle(req, res, parsed);
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on("upgrade", (req, socket, head) => {
|
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 || "/")) {
|
if (isOnlyOfficePath(req.url || "/")) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -210,7 +354,9 @@ async function main() {
|
|||||||
|
|
||||||
server.listen(port, hostname, () => {
|
server.listen(port, hostname, () => {
|
||||||
// eslint-disable-next-line no-console
|
// 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()) {
|
if (isConvexEnabled()) {
|
||||||
try {
|
try {
|
||||||
// 说明:Convex 模式下,保存写回 Convex Files,并更新 media_assets.storage_id/file_url。
|
// 说明: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 client = getConvexHttpClient();
|
||||||
|
|
||||||
const asset = await client.query(api.mediaAssets.getById, { userId, id: assetId });
|
const asset = await client.query(api.mediaAssets.getById, { userId, id: assetId });
|
||||||
|
|||||||
@@ -97,6 +97,18 @@ export async function GET(request: Request) {
|
|||||||
includeDeleted: true,
|
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 activeMindmaps = (mindmapRows ?? []).filter((r) => !r.deleted_at);
|
||||||
const trashedMindmaps = (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,
|
workspaces,
|
||||||
documents,
|
documents,
|
||||||
trashedDocuments,
|
trashedDocuments,
|
||||||
trashedMediaAssets: [],
|
trashedMediaAssets: (trashedMediaAssets ?? []) as MediaAsset[],
|
||||||
trashedMindmapAssets,
|
trashedMindmapAssets,
|
||||||
mindmapDocs,
|
mindmapDocs,
|
||||||
mindmapAssets,
|
mindmapAssets,
|
||||||
mindmapAssetChildren,
|
mindmapAssetChildren,
|
||||||
tableAssets: [],
|
tableAssets: [],
|
||||||
mediaAssets: [],
|
mediaAssets: (mediaAssets ?? []) as MediaAsset[],
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(payload);
|
return NextResponse.json(payload);
|
||||||
|
|||||||
@@ -343,6 +343,27 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
border-radius: 18px;
|
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 {
|
.wolai-media::after {
|
||||||
content: "";
|
content: "";
|
||||||
@@ -353,8 +374,7 @@ body {
|
|||||||
transition: border-color 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.wolai-media:hover::after,
|
.wolai-media:hover::after {
|
||||||
.wolai-media:focus-within::after {
|
|
||||||
border-color: rgba(37, 99, 235, 0.15);
|
border-color: rgba(37, 99, 235, 0.15);
|
||||||
}
|
}
|
||||||
.wolai-media--empty {
|
.wolai-media--empty {
|
||||||
|
|||||||
@@ -279,8 +279,10 @@ export default function OnlyOfficePage() {
|
|||||||
const fileType = (params.get("fileType") ?? "docx").toLowerCase();
|
const fileType = (params.get("fileType") ?? "docx").toLowerCase();
|
||||||
const mode = (params.get("mode") ?? "edit") as EditorMode;
|
const mode = (params.get("mode") ?? "edit") as EditorMode;
|
||||||
const assetId = params.get("assetId") ?? "";
|
const assetId = params.get("assetId") ?? "";
|
||||||
|
const initialUserId = params.get("userId") ?? "";
|
||||||
const channel = (params.get("channel") ?? "").trim().toLowerCase();
|
const channel = (params.get("channel") ?? "").trim().toLowerCase();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [authedUserId, setAuthedUserId] = useState<string>(initialUserId);
|
||||||
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||||
const baseUrlCandidates = useMemo(() => {
|
const baseUrlCandidates = useMemo(() => {
|
||||||
const uniq: string[] = [];
|
const uniq: string[] = [];
|
||||||
@@ -330,6 +332,31 @@ export default function OnlyOfficePage() {
|
|||||||
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
|
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
|
||||||
const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin;
|
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(() => {
|
useEffect(() => {
|
||||||
// 说明:在部分 ONLYOFFICE 版本/环境下,编辑器内部会触发 DOMException(NotFoundError: removeChild),
|
// 说明:在部分 ONLYOFFICE 版本/环境下,编辑器内部会触发 DOMException(NotFoundError: removeChild),
|
||||||
// 该异常会被 Next.js 捕获并显示“客户端异常”大红屏,但实际文档仍可继续使用。
|
// 该异常会被 Next.js 捕获并显示“客户端异常”大红屏,但实际文档仍可继续使用。
|
||||||
@@ -540,7 +567,7 @@ export default function OnlyOfficePage() {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, [assetId, baseUrl, callbackOrigin, fileName, fileType, fileUrl, mode, proxyOrigin, resolvedFileUrl]);
|
}, [assetId, authedUserId, baseUrl, callbackOrigin, fileName, fileType, fileUrl, mode, proxyOrigin, resolvedFileUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
@@ -623,6 +650,7 @@ export default function OnlyOfficePage() {
|
|||||||
const base = callbackOrigin || proxyOrigin || window.location.origin;
|
const base = callbackOrigin || proxyOrigin || window.location.origin;
|
||||||
const cb = new URL("/api/onlyoffice/callback", base);
|
const cb = new URL("/api/onlyoffice/callback", base);
|
||||||
if (assetId) cb.searchParams.set("assetId", assetId);
|
if (assetId) cb.searchParams.set("assetId", assetId);
|
||||||
|
if (authedUserId) cb.searchParams.set("userId", authedUserId);
|
||||||
return cb.toString();
|
return cb.toString();
|
||||||
})(),
|
})(),
|
||||||
customization: {
|
customization: {
|
||||||
|
|||||||
@@ -783,6 +783,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
|||||||
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
|
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
|
||||||
<CustomSideMenu {...props} currentDocumentId={documentId} workspaceId={workspaceId} />
|
<CustomSideMenu {...props} currentDocumentId={documentId} workspaceId={workspaceId} />
|
||||||
)}
|
)}
|
||||||
|
floatingOptions={{ placement: "left" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
|
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
|
||||||
|
|||||||
@@ -229,9 +229,50 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
|||||||
editor.updateBlock(block, { props: { linkUrl: next.trim() } });
|
editor.updateBlock(block, { props: { linkUrl: next.trim() } });
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewOriginal = () => {
|
const resolveAssetId = async () => {
|
||||||
if (!fileUrl) return;
|
const direct = (block.props as { assetId?: string })?.assetId;
|
||||||
window.open(fileUrl, "_blank", "noopener,noreferrer");
|
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 () => {
|
const openWithOnlyOffice = async () => {
|
||||||
@@ -241,7 +282,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const assetId = (block.props as { assetId?: string })?.assetId;
|
const assetId = await resolveAssetId();
|
||||||
const res = assetId
|
const res = assetId
|
||||||
? await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`)
|
? await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`)
|
||||||
: await fetch(
|
: await fetch(
|
||||||
@@ -258,16 +299,20 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
|||||||
target.searchParams.set("fileUrl", signedUrl);
|
target.searchParams.set("fileUrl", signedUrl);
|
||||||
target.searchParams.set("fileName", displayFileName);
|
target.searchParams.set("fileName", displayFileName);
|
||||||
target.searchParams.set("fileType", extension || "docx");
|
target.searchParams.set("fileType", extension || "docx");
|
||||||
|
if (assetId) {
|
||||||
|
target.searchParams.set("assetId", assetId);
|
||||||
|
}
|
||||||
window.open(target.toString(), "_blank", "noopener,noreferrer");
|
window.open(target.toString(), "_blank", "noopener,noreferrer");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert((error as Error).message);
|
window.alert((error as Error).message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadAsset = () => {
|
const downloadAsset = async () => {
|
||||||
if (!fileUrl) return;
|
const url = await resolveLatestFileUrl();
|
||||||
|
if (!url) return;
|
||||||
const anchor = document.createElement("a");
|
const anchor = document.createElement("a");
|
||||||
anchor.href = fileUrl;
|
anchor.href = url;
|
||||||
anchor.download = block.props.fileName || block.props.caption || typeLabel;
|
anchor.download = block.props.fileName || block.props.caption || typeLabel;
|
||||||
anchor.click();
|
anchor.click();
|
||||||
};
|
};
|
||||||
@@ -364,44 +409,111 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
|||||||
return "text-[#9B9A97]";
|
return "text-[#9B9A97]";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isOfficeDoc) {
|
|
||||||
void openWithOnlyOffice();
|
|
||||||
} else {
|
|
||||||
downloadAsset();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (isOfficeDoc) {
|
if (isOfficeDoc) {
|
||||||
void openWithOnlyOffice();
|
void openWithOnlyOffice();
|
||||||
} else {
|
} else {
|
||||||
downloadAsset();
|
void downloadAsset();
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
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"
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
>
|
e.preventDefault();
|
||||||
<span className={cn("w-5 h-5 shrink-0 flex items-center justify-center", getIconColor())}>
|
if (isOfficeDoc) {
|
||||||
<Paperclip className="h-4 w-4" />
|
void openWithOnlyOffice();
|
||||||
</span>
|
} else {
|
||||||
<div className="flex flex-col justify-center gap-0.5 overflow-hidden">
|
void downloadAsset();
|
||||||
<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}
|
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>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
}
|
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
|
||||||
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
|
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
|
||||||
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const figure = (
|
const figure = (
|
||||||
<figure
|
<figure
|
||||||
@@ -462,7 +574,9 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
|||||||
key: "download",
|
key: "download",
|
||||||
label: `下载${typeLabel}`,
|
label: `下载${typeLabel}`,
|
||||||
icon: <Download className="h-4 w-4" />,
|
icon: <Download className="h-4 w-4" />,
|
||||||
onClick: downloadAsset,
|
onClick: () => {
|
||||||
|
void downloadAsset();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "delete",
|
key: "delete",
|
||||||
@@ -475,17 +589,17 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
|||||||
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
|
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="wolai-media" ref={mediaRef}>
|
<div className={cn("wolai-media", assetType === "file" && "wolai-media--file")} ref={mediaRef}>
|
||||||
<div
|
<div
|
||||||
className="wolai-media__canvas"
|
className="wolai-media__canvas"
|
||||||
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
|
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
if (assetType === "file" && isOfficeDoc) {
|
if (assetType === "file" && isOfficeDoc) {
|
||||||
void openWithOnlyOffice();
|
void openWithOnlyOffice();
|
||||||
} else if (assetType === "file") {
|
} else if (assetType === "file") {
|
||||||
viewOriginal();
|
void viewOriginal();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{block.props.linkUrl ? (
|
{block.props.linkUrl ? (
|
||||||
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
|
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
|
||||||
@@ -494,6 +608,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
|||||||
) : (
|
) : (
|
||||||
figure
|
figure
|
||||||
)}
|
)}
|
||||||
|
{assetType !== "file" && (
|
||||||
<div className="wolai-media__quickbar">
|
<div className="wolai-media__quickbar">
|
||||||
{quickActions.map((action) => (
|
{quickActions.map((action) => (
|
||||||
<button
|
<button
|
||||||
@@ -532,13 +647,25 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
|
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}>复制链接</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}>复制链接</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={viewOriginal}>查看原文件</DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
{assetType === "file" && isOfficeDoc && (
|
onClick={() => {
|
||||||
<DropdownMenuItem onClick={() => void openWithOnlyOffice()}>使用 ONLYOFFICE 打开</DropdownMenuItem>
|
void viewOriginal();
|
||||||
)}
|
}}
|
||||||
<DropdownMenuItem onClick={downloadAsset}>下载到本地</DropdownMenuItem>
|
>
|
||||||
|
查看原文件
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{assetType === "file" && isOfficeDoc && (
|
||||||
|
<DropdownMenuItem onClick={() => void openWithOnlyOffice()}>使用 ONLYOFFICE 打开</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
void downloadAsset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
下载到本地
|
||||||
|
</DropdownMenuItem>
|
||||||
{canTriggerOcr && (
|
{canTriggerOcr && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -550,6 +677,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{canResize && (
|
{canResize && (
|
||||||
<>
|
<>
|
||||||
<ResizeHandle side="left" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "left")} />
|
<ResizeHandle side="left" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "left")} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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 type { Block, PartialBlock } from "@blocknote/core";
|
||||||
import {
|
import {
|
||||||
BlockColorsItem,
|
BlockColorsItem,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type DragHandleMenuProps,
|
type DragHandleMenuProps,
|
||||||
type SideMenuProps,
|
type SideMenuProps,
|
||||||
} from "@blocknote/react";
|
} from "@blocknote/react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { CustomBlockSchema } from "../schema";
|
import type { CustomBlockSchema } from "../schema";
|
||||||
import { deleteOnlineTable } from "@/lib/online-table";
|
import { deleteOnlineTable } from "@/lib/online-table";
|
||||||
@@ -36,6 +37,15 @@ type CustomDragProps = DragHandleMenuProps<CustomBlockSchema> & {
|
|||||||
workspaceId: string | null;
|
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 extractText = (block: Block<CustomBlockSchema>) => {
|
||||||
const inlineNodes = block.content as InlineNode[] | undefined;
|
const inlineNodes = block.content as InlineNode[] | undefined;
|
||||||
const maybeText = inlineNodes?.[0]?.text;
|
const maybeText = inlineNodes?.[0]?.text;
|
||||||
@@ -410,15 +420,133 @@ type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
|
|||||||
workspaceId: string | null;
|
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) => (
|
export const CustomSideMenu = (props: CustomSideMenuProps) => (
|
||||||
<SideMenu
|
<SideMenu {...props}>
|
||||||
{...props}
|
<WolaiDragHandleWithInsert {...props} />
|
||||||
dragHandleMenu={(dragProps) => (
|
</SideMenu>
|
||||||
<CustomDragHandleMenu
|
|
||||||
{...(dragProps as DragHandleMenuProps<CustomBlockSchema>)}
|
|
||||||
currentDocumentId={props.currentDocumentId}
|
|
||||||
workspaceId={props.workspaceId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,14 +16,47 @@ export function ConvexClientProvider({ children }: ConvexClientProviderProps) {
|
|||||||
// 动态获取 Convex URL,默认优先使用环境变量。
|
// 动态获取 Convex URL,默认优先使用环境变量。
|
||||||
// 说明:浏览器侧优先用当前 hostname(端口固定 3210),避免通过非 localhost/127 访问前端时出现跨域/白名单不匹配。
|
// 说明:浏览器侧优先用当前 hostname(端口固定 3210),避免通过非 localhost/127 访问前端时出现跨域/白名单不匹配。
|
||||||
const getConvexUrl = () => {
|
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") {
|
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;
|
const hostname = window.location.hostname;
|
||||||
return `http://${hostname}:3210`;
|
const protocol = "http:";
|
||||||
|
return normalize(`${protocol}//${hostname}:3210`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const convexUrl = getConvexUrl();
|
const convexUrl = getConvexUrl();
|
||||||
|
|||||||
@@ -542,7 +542,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const officeFileType = officeFileTypeFromAsset(asset.file_name ?? null, asset.mime_type ?? null);
|
const officeFileType = officeFileTypeFromAsset(asset.file_name ?? null, asset.mime_type ?? null);
|
||||||
if (officeFileType) {
|
if (officeFileType) {
|
||||||
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
|
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
|
||||||
@@ -571,15 +571,34 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
})();
|
})();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = asset.signed_url ?? asset.file_url;
|
void (async () => {
|
||||||
if (!url) {
|
try {
|
||||||
window.alert("暂无可用的文件链接");
|
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(asset.id)}`);
|
||||||
return;
|
if (!res.ok) {
|
||||||
}
|
const payload = await res.json().catch(() => null);
|
||||||
if (typeof window !== "undefined") {
|
throw new Error(payload?.error ?? "生成签名链接失败");
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
}
|
||||||
}
|
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]);
|
}, [activeId, editorBridge, router, setOpen]);
|
||||||
|
|
||||||
const handleFileTreeBlankMouseDown = useCallback(() => {
|
const handleFileTreeBlankMouseDown = useCallback(() => {
|
||||||
@@ -846,13 +865,28 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
window.alert("在线表格暂不支持下载(后续可做导出 JSON/Excel)");
|
window.alert("在线表格暂不支持下载(后续可做导出 JSON/Excel)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const url = asset.signed_url ?? asset.file_url;
|
try {
|
||||||
if (!url) {
|
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(asset.id)}`);
|
||||||
window.alert("暂无可用的下载链接");
|
if (!res.ok) {
|
||||||
return;
|
const payload = await res.json().catch(() => null);
|
||||||
}
|
throw new Error(payload?.error ?? "生成签名链接失败");
|
||||||
if (typeof window !== "undefined") {
|
}
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
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,确保类型正确
|
// 显式转为 boolean,确保类型正确
|
||||||
const shouldFetch = Boolean(isAuthenticated && workspaceId);
|
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,自动订阅实时更新
|
// 使用 Convex 的 useQuery,自动订阅实时更新
|
||||||
// 后端从 ctx.auth.getUserIdentity() 获取用户身份,无需前端传入 userId
|
// 后端从 ctx.auth.getUserIdentity() 获取用户身份,无需前端传入 userId
|
||||||
@@ -39,6 +46,16 @@ export function useConvexSidebarData(workspaceId: string): {
|
|||||||
shouldFetch ? { workspaceId, includeDeleted: true } : "skip"
|
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(
|
const workspacesResult = useQuery(
|
||||||
api.workspaces.fetchWorkspaceSummaries,
|
api.workspaces.fetchWorkspaceSummaries,
|
||||||
shouldFetch ? undefined : "skip"
|
shouldFetch ? undefined : "skip"
|
||||||
@@ -47,9 +64,12 @@ export function useConvexSidebarData(workspaceId: string): {
|
|||||||
// 组合数据,格式与 SidebarInitialData 一致
|
// 组合数据,格式与 SidebarInitialData 一致
|
||||||
const data: SidebarInitialData | null = useMemo(() => {
|
const data: SidebarInitialData | null = useMemo(() => {
|
||||||
// 当 skip 时,返回值是 undefined
|
// 当 skip 时,返回值是 undefined
|
||||||
if (documents === undefined ||
|
if (currentUser === undefined ||
|
||||||
|
documents === undefined ||
|
||||||
trashedDocuments === undefined ||
|
trashedDocuments === undefined ||
|
||||||
mindmaps === undefined ||
|
mindmaps === undefined ||
|
||||||
|
mediaAssets === undefined ||
|
||||||
|
trashedMediaAssets === undefined ||
|
||||||
workspacesResult === undefined) {
|
workspacesResult === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -115,22 +135,34 @@ export function useConvexSidebarData(workspaceId: string): {
|
|||||||
workspaces: workspacesResult.workspaces,
|
workspaces: workspacesResult.workspaces,
|
||||||
documents,
|
documents,
|
||||||
trashedDocuments,
|
trashedDocuments,
|
||||||
trashedMediaAssets: [],
|
trashedMediaAssets: (trashedMediaAssets ?? []) as MediaAsset[],
|
||||||
trashedMindmapAssets,
|
trashedMindmapAssets,
|
||||||
mindmapDocs,
|
mindmapDocs,
|
||||||
mindmapAssets,
|
mindmapAssets,
|
||||||
mindmapAssetChildren: {},
|
mindmapAssetChildren: {},
|
||||||
tableAssets: [],
|
tableAssets: [],
|
||||||
mediaAssets: [],
|
mediaAssets: (mediaAssets ?? []) as MediaAsset[],
|
||||||
};
|
};
|
||||||
}, [documents, trashedDocuments, mindmaps, workspacesResult, workspaceId]);
|
}, [
|
||||||
|
currentUser,
|
||||||
|
documents,
|
||||||
|
trashedDocuments,
|
||||||
|
mindmaps,
|
||||||
|
mediaAssets,
|
||||||
|
trashedMediaAssets,
|
||||||
|
workspacesResult,
|
||||||
|
workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
// loading 状态:只有当 shouldFetch 为 true 且数据未加载时才算 loading
|
// loading 状态:只有当 shouldFetch 为 true 且数据未加载时才算 loading
|
||||||
// 使用 === undefined 判断,因为 skip 时返回 undefined
|
// 使用 === undefined 判断,因为 skip 时返回 undefined
|
||||||
const isLoading = shouldFetch && (
|
const isLoading = shouldFetch && (
|
||||||
|
currentUser === undefined ||
|
||||||
documents === undefined ||
|
documents === undefined ||
|
||||||
trashedDocuments === undefined ||
|
trashedDocuments === undefined ||
|
||||||
mindmaps === undefined ||
|
mindmaps === undefined ||
|
||||||
|
mediaAssets === undefined ||
|
||||||
|
trashedMediaAssets === undefined ||
|
||||||
workspacesResult === undefined
|
workspacesResult === undefined
|
||||||
);
|
);
|
||||||
const error = null;
|
const error = null;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export function isConvexEnabled(): boolean {
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||||
return process.env.NEXT_PUBLIC_USE_CONVEX === "1";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export function isConvexEnabled(): boolean {
|
||||||
|
return Boolean(getMnoteRuntimeConfig().useConvex);
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const readFromEnv = (): MnoteRuntimeConfig => ({
|
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,
|
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
|
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
|
||||||
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ const isPublicRoute = createRouteMatcher([
|
|||||||
"/auth",
|
"/auth",
|
||||||
"/login",
|
"/login",
|
||||||
"/api/auth(.*)",
|
"/api/auth(.*)",
|
||||||
|
// 说明:ONLYOFFICE 文档服务器(容器/远端)拉取文件与回调保存不携带用户态,必须放行。
|
||||||
|
"/api/onlyoffice/proxy(.*)",
|
||||||
|
"/api/onlyoffice/callback(.*)",
|
||||||
"/api/health(.*)",
|
"/api/health(.*)",
|
||||||
"/_next(.*)",
|
"/_next(.*)",
|
||||||
"/favicon.ico",
|
"/favicon.ico",
|
||||||
@@ -34,4 +37,3 @@ export const config = {
|
|||||||
// 说明:排除静态资源,避免无意义的中间件开销。
|
// 说明:排除静态资源,避免无意义的中间件开销。
|
||||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user