0.4.0 convex及界面修改
This commit is contained in:
@@ -35,12 +35,20 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
showStructure: doc.show_structure ?? false,
|
||||
protectEditing: doc.protect_editing ?? false,
|
||||
showWordCount: doc.show_word_count ?? true,
|
||||
collapseBacklinks: (doc as any).collapse_backlinks ?? false,
|
||||
pageFont: (doc as any).page_font ?? "default",
|
||||
layoutDensity: (doc as any).layout_density ?? "normal",
|
||||
hideChildPages: (doc as any).hide_child_pages ?? false,
|
||||
showBlockRefCount: (doc as any).show_block_ref_count ?? false,
|
||||
embedDefaultBlockId: (doc as any).embed_default_block_id ?? null,
|
||||
};
|
||||
|
||||
const initialStats: DocumentStats = {
|
||||
wordCount: doc.word_count ?? 0,
|
||||
characterCount: doc.character_count ?? 0,
|
||||
blockCount: doc.block_count ?? 0,
|
||||
todoTotal: (doc as any).todo_total ?? (doc as any).todo_total_count ?? 0,
|
||||
todoDone: (doc as any).todo_done ?? (doc as any).todo_done_count ?? 0,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,6 @@ import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||
import { Breadcrumb } from "@/components/breadcrumb";
|
||||
import { BottomToolbar } from "@/components/bottom-toolbar";
|
||||
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
@@ -154,6 +153,70 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
};
|
||||
});
|
||||
|
||||
const tables = await client.query(api.tables.listByWorkspaceForSearch, {
|
||||
userId: auth.userId,
|
||||
workspaceId: activeWorkspaceId,
|
||||
includeArchived: true,
|
||||
limit: 3000,
|
||||
});
|
||||
|
||||
const tableAssets: MediaAsset[] = (tables ?? [])
|
||||
.filter((row) => !row.is_archived)
|
||||
.map((row) => {
|
||||
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
|
||||
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
|
||||
return {
|
||||
id: row.id,
|
||||
workspace_id: row.workspace_id ?? activeWorkspaceId,
|
||||
document_id: row.document_id,
|
||||
asset_type: "luckysheet",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: fileName,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: row.created_at ?? "",
|
||||
updated_at: row.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
const trashedTableAssets: MediaAsset[] = (tables ?? [])
|
||||
.filter((row) => Boolean(row.is_archived))
|
||||
.map((row) => {
|
||||
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
|
||||
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
|
||||
return {
|
||||
id: row.id,
|
||||
workspace_id: row.workspace_id ?? activeWorkspaceId,
|
||||
document_id: row.document_id,
|
||||
asset_type: "luckysheet",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: fileName,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
deleted_at: row.deleted_at ?? row.updated_at ?? null,
|
||||
deleted_by: row.deleted_by ?? null,
|
||||
purged_at: row.purged_at ?? null,
|
||||
signed_url: null,
|
||||
created_at: row.created_at ?? "",
|
||||
updated_at: row.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
sidebarInitialData = {
|
||||
activeWorkspaceId,
|
||||
workspaces,
|
||||
@@ -161,10 +224,12 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
trashedDocuments: trashedDocs as unknown as SidebarInitialData["trashedDocuments"],
|
||||
trashedMediaAssets: [],
|
||||
trashedMindmapAssets,
|
||||
trashedTableAssets,
|
||||
mediaAssets: [],
|
||||
mindmapDocs,
|
||||
mindmapAssets,
|
||||
mindmapAssetChildren,
|
||||
tableAssets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,7 +242,6 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
<Breadcrumb documents={documents} />
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||
<BottomToolbar />
|
||||
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,8 +34,15 @@ export async function POST(request: Request) {
|
||||
|
||||
const target = await client.query(api.documents.getContent, { id: targetDocumentId });
|
||||
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
const targetMeta = await client.query(api.documents.getMeta, { id: targetDocumentId });
|
||||
|
||||
const targetBlocks = getBlocksFromDocumentContent(target.content);
|
||||
const anchorId = (targetMeta as any)?.embed_default_block_id as string | null | undefined;
|
||||
const anchorIndex =
|
||||
typeof anchorId === "string" && anchorId.trim()
|
||||
? targetBlocks.findIndex((b) => String((b as any)?.id ?? "") === anchorId)
|
||||
: -1;
|
||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
|
||||
const referenceBlock = {
|
||||
id: randomUUID(),
|
||||
type: "blockReference",
|
||||
@@ -48,7 +55,7 @@ export async function POST(request: Request) {
|
||||
children: [],
|
||||
};
|
||||
|
||||
const nextBlocks = [...targetBlocks, referenceBlock];
|
||||
const nextBlocks = [...targetBlocks.slice(0, insertIndex), referenceBlock, ...targetBlocks.slice(insertIndex)];
|
||||
const payload = withBlocksWrittenBack(target.content, nextBlocks);
|
||||
await client.mutation(api.documents.updateContent, { id: targetDocumentId, content: payload });
|
||||
|
||||
|
||||
@@ -30,10 +30,18 @@ export async function POST(request: Request) {
|
||||
if (!targetContent) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
const targetMeta = await client.query(api.documents.getMeta, { id: targetId });
|
||||
|
||||
const currentBlocks = extractBlocksFromContent(targetContent.content);
|
||||
const anchorId = (targetMeta as any)?.embed_default_block_id as string | null | undefined;
|
||||
const anchorIndex =
|
||||
typeof anchorId === "string" && anchorId.trim()
|
||||
? currentBlocks.findIndex((b) => typeof b === "object" && b !== null && (b as any).id === anchorId)
|
||||
: -1;
|
||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
|
||||
|
||||
const nextBlocks: Json[] = [
|
||||
...currentBlocks,
|
||||
...currentBlocks.slice(0, insertIndex),
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
@@ -42,6 +50,7 @@ export async function POST(request: Request) {
|
||||
title: sourceDoc.title ?? "无标题",
|
||||
},
|
||||
},
|
||||
...currentBlocks.slice(insertIndex),
|
||||
];
|
||||
|
||||
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
|
||||
|
||||
@@ -27,6 +27,12 @@ export async function POST(request: Request) {
|
||||
showStructure: options.showStructure,
|
||||
protectEditing: options.protectEditing,
|
||||
showWordCount: options.showWordCount,
|
||||
collapseBacklinks: options.collapseBacklinks,
|
||||
pageFont: options.pageFont,
|
||||
layoutDensity: options.layoutDensity,
|
||||
hideChildPages: options.hideChildPages,
|
||||
showBlockRefCount: options.showBlockRefCount,
|
||||
embedDefaultBlockId: typeof options.embedDefaultBlockId === "string" ? options.embedDefaultBlockId : null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export async function POST(request: Request) {
|
||||
wordCount: stats.wordCount,
|
||||
characterCount: stats.characterCount,
|
||||
blockCount: stats.blockCount,
|
||||
todoTotal: stats.todoTotal,
|
||||
todoDone: stats.todoDone,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
type TemplatePayload = {
|
||||
documentId: string;
|
||||
isTemplate: boolean;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { documentId, isTemplate }: TemplatePayload = await request.json();
|
||||
if (!documentId || typeof isTemplate !== "boolean") {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.setTemplate, { id: documentId, isTemplate });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -37,7 +38,13 @@ export async function GET(request: Request) {
|
||||
limit: Number.isNaN(limit) ? 12 : limit,
|
||||
});
|
||||
|
||||
return NextResponse.json({ items: (items ?? []) as MediaAsset[] });
|
||||
const safeItems = (items ?? []).map((a: any) => ({
|
||||
...a,
|
||||
file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")),
|
||||
thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")),
|
||||
})) as MediaAsset[];
|
||||
|
||||
return NextResponse.json({ items: safeItems });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
@@ -149,7 +156,13 @@ export async function POST(request: Request) {
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ asset });
|
||||
const safeAsset = {
|
||||
...asset,
|
||||
file_url: maybeProxyForBrowserUrl(request, String(asset?.file_url ?? "")),
|
||||
thumbnail_url: maybeProxyForBrowserUrl(request, String(asset?.thumbnail_url ?? asset?.file_url ?? "")),
|
||||
} as MediaAsset;
|
||||
|
||||
return NextResponse.json({ asset: safeAsset });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -200,8 +201,14 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ items: results });
|
||||
}
|
||||
const safeItems = (results ?? []).map((a: any) => ({
|
||||
...a,
|
||||
file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")),
|
||||
thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ items: safeItems });
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -34,9 +35,14 @@ export async function GET(request: Request) {
|
||||
limit: Number.isNaN(limit) ? 200 : limit,
|
||||
});
|
||||
|
||||
return NextResponse.json({ items: (items ?? []) as MediaAsset[] });
|
||||
const safeItems = (items ?? []).map((a: any) => ({
|
||||
...a,
|
||||
file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")),
|
||||
thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")),
|
||||
})) as MediaAsset[];
|
||||
|
||||
return NextResponse.json({ items: safeItems });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -10,21 +10,10 @@ interface EmptyTrashPayload {
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
function resolveGraceSeconds(): number {
|
||||
const raw =
|
||||
process.env.DELETE_GRACE_SECONDS ??
|
||||
process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ??
|
||||
"600";
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 600;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function makeExpiredDeletedAt(): string {
|
||||
const graceSeconds = resolveGraceSeconds();
|
||||
return new Date(Date.now() - (graceSeconds + 5) * 1000).toISOString();
|
||||
function makeExpiredDeletedAtForForceEmpty(): string {
|
||||
// “清空附件垃圾桶”是一个显式的强制操作:直接清空所有垃圾桶内的附件,不受 10 分钟宽限期限制。
|
||||
// Convex 侧按 `deleted_at <= expiredDeletedAt` 做过滤,因此这里给一个很靠后的时间上界即可。
|
||||
return new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100).toISOString();
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -48,7 +37,7 @@ export async function POST(request: Request) {
|
||||
const res = await client.mutation(api.mediaAssets.emptyTrashByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
expiredDeletedAt: makeExpiredDeletedAt(),
|
||||
expiredDeletedAt: makeExpiredDeletedAtForForceEmpty(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, ...(res as any) });
|
||||
|
||||
@@ -3,44 +3,10 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { HttpError } from "@/lib/auth/authContext";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const base64UrlEncodeUtf8 = (input: string) => {
|
||||
return Buffer.from(input, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
const getRequestOrigin = (request: Request) => {
|
||||
const xfProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const xfHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const host = xfHost || request.headers.get("host") || new URL(request.url).host;
|
||||
const protocol = (xfProto || new URL(request.url).protocol.replace(/:$/, "")) + ":";
|
||||
return `${protocol}//${host}`;
|
||||
};
|
||||
|
||||
const isLocalHostname = (hostname: string) =>
|
||||
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
|
||||
|
||||
const maybeProxyForBrowser = (request: Request, rawUrl: string) => {
|
||||
const input = String(rawUrl || "").trim();
|
||||
if (!input) return input;
|
||||
if (input.startsWith("/api/onlyoffice/proxy")) return input;
|
||||
try {
|
||||
const u = new URL(input);
|
||||
if (!isLocalHostname(u.hostname)) return input;
|
||||
const origin = getRequestOrigin(request);
|
||||
const proxy = new URL("/api/onlyoffice/proxy", origin);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
|
||||
return proxy.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
@@ -75,7 +41,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
signedUrl: maybeProxyForBrowser(request, signedUrl),
|
||||
signedUrl: maybeProxyForBrowserUrl(request, signedUrl),
|
||||
asset: {
|
||||
id: asset.id,
|
||||
file_name: asset.file_name,
|
||||
|
||||
@@ -1,44 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const base64UrlEncodeUtf8 = (input: string) => {
|
||||
return Buffer.from(input, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
const getRequestOrigin = (request: Request) => {
|
||||
const xfProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const xfHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const host = xfHost || request.headers.get("host") || new URL(request.url).host;
|
||||
const protocol = (xfProto || new URL(request.url).protocol.replace(/:$/, "")) + ":";
|
||||
return `${protocol}//${host}`;
|
||||
};
|
||||
|
||||
const isLocalHostname = (hostname: string) =>
|
||||
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
|
||||
|
||||
const maybeProxyForBrowser = (request: Request, rawUrl: string) => {
|
||||
const input = String(rawUrl || "").trim();
|
||||
if (!input) return input;
|
||||
if (input.startsWith("/api/onlyoffice/proxy")) return input;
|
||||
try {
|
||||
const u = new URL(input);
|
||||
if (!isLocalHostname(u.hostname)) return input;
|
||||
const origin = getRequestOrigin(request);
|
||||
const proxy = new URL("/api/onlyoffice/proxy", origin);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
|
||||
return proxy.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
try {
|
||||
@@ -58,7 +24,7 @@ export async function GET(request: Request) {
|
||||
|
||||
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
|
||||
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
|
||||
return NextResponse.json({ signedUrl: maybeProxyForBrowser(request, fileUrl) });
|
||||
return NextResponse.json({ signedUrl: maybeProxyForBrowserUrl(request, fileUrl) });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -89,9 +90,14 @@ export async function POST(request: Request) {
|
||||
});
|
||||
|
||||
const asset = created as unknown as MediaAsset;
|
||||
const safeAsset = {
|
||||
...(asset as any),
|
||||
file_url: maybeProxyForBrowserUrl(request, String((asset as any)?.file_url ?? "")),
|
||||
thumbnail_url: maybeProxyForBrowserUrl(request, String((asset as any)?.thumbnail_url ?? (asset as any)?.file_url ?? "")),
|
||||
} as MediaAsset;
|
||||
|
||||
return NextResponse.json({
|
||||
asset,
|
||||
asset: safeAsset,
|
||||
mindmapUrl: `asset:${asset.id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,6 +3,11 @@ import { detectLocalMindmapFiles } from "@/lib/server/mindmap-files";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
import { HttpError } from "@/lib/auth/authContext";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -39,7 +44,89 @@ async function listTestPdfs(): Promise<AgentAssetItem[]> {
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const documentId = String(searchParams.get("documentId") ?? "").trim();
|
||||
const q = String(searchParams.get("q") ?? "").trim().toLowerCase();
|
||||
const workspaceOnly = String(searchParams.get("workspaceOnly") ?? "").trim() === "1";
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
let auth: { userId: string };
|
||||
let client: any;
|
||||
try {
|
||||
const res = await getAuthedConvexClient();
|
||||
auth = res.auth;
|
||||
client = res.client;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
}
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const doc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
if (!doc) {
|
||||
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (workspaceOnly) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
workspaceId: (doc as any).workspace_id ?? null,
|
||||
documentId,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
// 说明:这里的附件列表主要用于 Mindmap AI Agent 选择引用素材。
|
||||
const mediaRows = (await client.query(api.mediaAssets.listByDocument, {
|
||||
userId: auth.userId,
|
||||
documentId,
|
||||
limit: 200,
|
||||
})) as unknown as MediaAsset[];
|
||||
|
||||
const mediaAssets: AgentAssetItem[] = (mediaRows ?? []).map((row) => ({
|
||||
kind: "media" as const,
|
||||
id: String((row as any).id ?? ""),
|
||||
title: String((row as any).file_name ?? (row as any).id ?? "附件"),
|
||||
fileUrl: maybeProxyForBrowserUrl(request, String((row as any).file_url ?? "")),
|
||||
mimeType: (row as any).mime_type ?? null,
|
||||
assetType: (row as any).asset_type ?? null,
|
||||
fileName: (row as any).file_name ?? null,
|
||||
}));
|
||||
|
||||
const localMindmaps = (await detectLocalMindmapFiles([documentId]))
|
||||
.filter((x) => x.documentId === documentId)
|
||||
.map((x) => ({
|
||||
kind: "local-mindmap" as const,
|
||||
id: `mindmap:${x.mindmapId}`,
|
||||
title: x.fileName,
|
||||
fileUrl: `/documents/${documentId}/${x.fileName}`,
|
||||
mimeType: "application/json",
|
||||
assetType: "mindmap",
|
||||
fileName: x.fileName,
|
||||
}));
|
||||
|
||||
const testPdfs = await listTestPdfs();
|
||||
|
||||
let items: AgentAssetItem[] = [...localMindmaps, ...mediaAssets, ...testPdfs];
|
||||
if (q) {
|
||||
items = items.filter((it) => {
|
||||
const hay = `${it.title} ${it.fileName ?? ""}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
workspaceId: (doc as any).workspace_id ?? null,
|
||||
documentId,
|
||||
items,
|
||||
});
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
|
||||
@@ -109,6 +109,13 @@ export async function GET(request: Request) {
|
||||
limit: 2000,
|
||||
});
|
||||
|
||||
const tables = await client.query(api.tables.listByWorkspaceForSearch, {
|
||||
userId: auth.userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
includeArchived: true,
|
||||
limit: 3000,
|
||||
});
|
||||
|
||||
const activeMindmaps = (mindmapRows ?? []).filter((r) => !r.deleted_at);
|
||||
const trashedMindmaps = (mindmapRows ?? []).filter((r) => !!r.deleted_at);
|
||||
|
||||
@@ -173,6 +180,63 @@ export async function GET(request: Request) {
|
||||
};
|
||||
});
|
||||
|
||||
const tableAssets: MediaAsset[] = (tables ?? [])
|
||||
.filter((row) => !row.is_archived)
|
||||
.map((row) => {
|
||||
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
|
||||
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
|
||||
return {
|
||||
id: row.id,
|
||||
workspace_id: row.workspace_id ?? targetWorkspaceId,
|
||||
document_id: row.document_id,
|
||||
asset_type: "luckysheet",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: fileName,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: row.created_at ?? "",
|
||||
updated_at: row.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
const trashedTableAssets: MediaAsset[] = (tables ?? [])
|
||||
.filter((row) => Boolean(row.is_archived))
|
||||
.map((row) => {
|
||||
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
|
||||
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
|
||||
return {
|
||||
id: row.id,
|
||||
workspace_id: row.workspace_id ?? targetWorkspaceId,
|
||||
document_id: row.document_id,
|
||||
asset_type: "luckysheet",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: fileName,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
deleted_at: row.deleted_at ?? row.updated_at ?? null,
|
||||
deleted_by: row.deleted_by ?? null,
|
||||
purged_at: row.purged_at ?? null,
|
||||
signed_url: null,
|
||||
created_at: row.created_at ?? "",
|
||||
updated_at: row.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
const payload: SidebarInitialData = {
|
||||
activeWorkspaceId: targetWorkspaceId,
|
||||
workspaces,
|
||||
@@ -180,10 +244,11 @@ export async function GET(request: Request) {
|
||||
trashedDocuments,
|
||||
trashedMediaAssets: (trashedMediaAssets ?? []) as MediaAsset[],
|
||||
trashedMindmapAssets,
|
||||
trashedTableAssets,
|
||||
mindmapDocs,
|
||||
mindmapAssets,
|
||||
mindmapAssetChildren,
|
||||
tableAssets: [],
|
||||
tableAssets,
|
||||
mediaAssets: (mediaAssets ?? []) as MediaAsset[],
|
||||
};
|
||||
|
||||
|
||||
@@ -103,7 +103,8 @@ export async function DELETE(_request: Request, context: RouteContext) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
try {
|
||||
await client.mutation(api.tables.purge, {
|
||||
// 软删除:进入垃圾桶(可恢复)
|
||||
await client.mutation(api.tables.remove, {
|
||||
userId: auth.userId,
|
||||
tableId,
|
||||
});
|
||||
@@ -117,4 +118,3 @@ export async function DELETE(_request: Request, context: RouteContext) {
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
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";
|
||||
|
||||
interface EmptyTrashPayload {
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
function makeExpiredDeletedAtForForceEmpty(): string {
|
||||
// “清空附件垃圾桶”是一个显式的强制操作:直接清空所有垃圾桶内的表格,不受 10 分钟宽限期限制。
|
||||
// Convex 侧按 `deleted_at <= expiredDeletedAt` 做过滤,因此这里给一个很靠后的时间上界即可。
|
||||
return new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100).toISOString();
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
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 { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
try {
|
||||
const res = await client.mutation(api.tables.emptyTrashByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
expiredDeletedAt: makeExpiredDeletedAtForForceEmpty(),
|
||||
});
|
||||
return NextResponse.json({ success: true, ...(res as any) });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "清空失败";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
const { tableId } = (await request.json().catch(() => ({}))) as { tableId?: string };
|
||||
if (!tableId) {
|
||||
return NextResponse.json({ error: "缺少 tableId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
try {
|
||||
await client.mutation(api.tables.purge, { userId: auth.userId, tableId });
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Convex API error:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
const { tableId } = (await request.json().catch(() => ({}))) as { tableId?: string };
|
||||
if (!tableId) {
|
||||
return NextResponse.json({ error: "缺少 tableId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
try {
|
||||
await client.mutation(api.tables.restore, { userId: auth.userId, tableId });
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Convex API error:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { BooleanPageOptionKey, DocumentStats, PageFont, PageLayoutDensity, PageOptionsState } from "@/types/page-options";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { ImagePickerProvider } from "@/components/media/image-picker-context";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const defaultOptions: PageOptionsState = {
|
||||
wideLayout: false,
|
||||
smallText: false,
|
||||
showHeadingNumbers: true,
|
||||
showToc: false,
|
||||
showStructure: false,
|
||||
protectEditing: false,
|
||||
showWordCount: true,
|
||||
collapseBacklinks: false,
|
||||
pageFont: "default",
|
||||
layoutDensity: "normal",
|
||||
hideChildPages: false,
|
||||
showBlockRefCount: false,
|
||||
embedDefaultBlockId: null,
|
||||
};
|
||||
|
||||
const defaultStats: DocumentStats = {
|
||||
wordCount: 0,
|
||||
characterCount: 0,
|
||||
blockCount: 0,
|
||||
todoTotal: 0,
|
||||
todoDone: 0,
|
||||
};
|
||||
|
||||
export default function PageOptionsPlaygroundPage() {
|
||||
const editorBridge = useEditorBridgeStore((s) => s.bridge);
|
||||
const [options, setOptions] = useState<PageOptionsState>(defaultOptions);
|
||||
const [stats, setStats] = useState<DocumentStats>(defaultStats);
|
||||
const documentId = "dev-page-options";
|
||||
const workspaceId = "dev-workspace";
|
||||
|
||||
const initialContent = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "h1",
|
||||
type: "heading",
|
||||
props: { level: 1 },
|
||||
content: [{ type: "text", text: "标题一" }],
|
||||
},
|
||||
{
|
||||
id: "p1",
|
||||
type: "paragraph",
|
||||
props: {},
|
||||
content: [{ type: "text", text: "这是用于回归测试页面选项的示例段落。" }],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleOption = useCallback((key: BooleanPageOptionKey) => {
|
||||
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
|
||||
const setPageFont = useCallback((font: PageFont) => setOptions((prev) => ({ ...prev, pageFont: font })), []);
|
||||
const setLayoutDensity = useCallback(
|
||||
(density: PageLayoutDensity) => setOptions((prev) => ({ ...prev, layoutDensity: density })),
|
||||
[],
|
||||
);
|
||||
|
||||
const pageRootClass = cn(
|
||||
"flex h-[calc(100vh-64px)] overflow-hidden bg-wolai-bg",
|
||||
options.pageFont === "song" && "wolai-page-font-song",
|
||||
options.pageFont === "kai" && "wolai-page-font-kai",
|
||||
options.layoutDensity === "compact" && "wolai-page-density-compact",
|
||||
options.layoutDensity === "spacious" && "wolai-page-density-spacious",
|
||||
options.smallText && "wolai-small-text",
|
||||
options.hideChildPages && "wolai-hide-child-pages",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-4 text-sm text-gray-500">
|
||||
Dev Playground:用于 Playwright 回归页面选项(不写入后端)
|
||||
</div>
|
||||
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
||||
<div className={pageRootClass}>
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b border-wolai-border px-12 pb-6 pt-8">
|
||||
<div className="text-3xl font-semibold text-wolai-text-primary">页面选项回归测试</div>
|
||||
<p className="mt-1 text-sm text-wolai-text-secondary">该页面不会保存任何更改。</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-12 py-6">
|
||||
<BlockNoteEditor
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={initialContent as unknown}
|
||||
pageOptions={options}
|
||||
readOnly={false}
|
||||
onStatsChange={setStats}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageOptionsSidebar
|
||||
documentId={documentId}
|
||||
options={options}
|
||||
stats={stats}
|
||||
onToggle={toggleOption}
|
||||
onSetPageFont={setPageFont}
|
||||
onSetLayoutDensity={setLayoutDensity}
|
||||
onSetEmbedDefaultToCursor={() => window.alert("该页面为 Dev Playground,不写入后端")}
|
||||
onClearEmbedDefault={() => setOptions((prev) => ({ ...prev, embedDefaultBlockId: null }))}
|
||||
onExport={() => window.alert("该页面为 Dev Playground,不提供导出")}
|
||||
onOpenHistory={() => window.alert("该页面为 Dev Playground,不提供历史")}
|
||||
onOpenComments={() => window.alert("该页面为 Dev Playground,不提供评论")}
|
||||
onUndo={() => editorBridge?.undo?.()}
|
||||
onRedo={() => editorBridge?.redo?.()}
|
||||
onDeletePage={() => window.alert("该页面为 Dev Playground,不提供删除")}
|
||||
onOpenMoveEmbedPicker={() => window.alert("该页面为 Dev Playground,不提供移动/嵌入")}
|
||||
onCopyPageLink={() => window.alert("该页面为 Dev Playground,不提供复制链接")}
|
||||
onCopyPageReference={() => window.alert("该页面为 Dev Playground,不提供引用")}
|
||||
onAddToTemplates={() => window.alert("该页面为 Dev Playground,不提供模板")}
|
||||
/>
|
||||
</div>
|
||||
</ImagePickerProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -207,52 +207,72 @@ body {
|
||||
.wolai-editor .bn-block-group:hover .bn-drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] {
|
||||
.wolai-editor.wolai-heading-numbering {
|
||||
counter-reset: wolai-h1 wolai-h2 wolai-h3 wolai-h4 wolai-h5;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h1 {
|
||||
|
||||
/* 说明:BlockNote 的 heading 主要由 .bn-block-content[data-content-type=heading] 承载。
|
||||
为避免不同渲染结构导致“标题编号”失效,这里以块级容器为准实现编号。 */
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"]:not([data-level]),
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="1"] {
|
||||
counter-increment: wolai-h1;
|
||||
counter-reset: wolai-h2;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h1::before {
|
||||
content: counter(wolai-h1) ". ";
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"]:not([data-level])::before,
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="1"]::before {
|
||||
content: counter(wolai-h1) ". " !important;
|
||||
color: #94a3b8;
|
||||
margin-right: 8px;
|
||||
margin-right: 8px !important;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h2 {
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="2"] {
|
||||
counter-increment: wolai-h2;
|
||||
counter-reset: wolai-h3;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h2::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) ". ";
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="2"]::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) ". " !important;
|
||||
color: #94a3b8;
|
||||
margin-right: 6px;
|
||||
margin-right: 6px !important;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h3 {
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="3"] {
|
||||
counter-increment: wolai-h3;
|
||||
counter-reset: wolai-h4;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h3::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) ". ";
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="3"]::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) ". " !important;
|
||||
color: #cbd5f5;
|
||||
margin-right: 4px;
|
||||
margin-right: 4px !important;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h4 {
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="4"] {
|
||||
counter-increment: wolai-h4;
|
||||
counter-reset: wolai-h5;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h4::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) ". ";
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="4"]::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) ". " !important;
|
||||
color: #d0d7e7;
|
||||
margin-right: 4px;
|
||||
margin-right: 4px !important;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h5 {
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="5"] {
|
||||
counter-increment: wolai-h5;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h5::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) "." counter(wolai-h5) ". ";
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="5"]::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) "." counter(wolai-h5) ". " !important;
|
||||
color: #d4d4d8;
|
||||
margin-right: 4px;
|
||||
margin-right: 4px !important;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.wolai-editor-show-structure .bn-block-outer {
|
||||
outline: 1px dashed #d4d4d8;
|
||||
@@ -587,6 +607,105 @@ body {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 页面选项:小字体(覆盖 BlockNote 的 .bn-default-styles 默认字号) */
|
||||
.wolai-small-text .wolai-editor.bn-default-styles,
|
||||
.wolai-small-text .wolai-editor .bn-default-styles {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 自定义页面:字体 */
|
||||
.wolai-page-font-song {
|
||||
font-family: "宋体", SimSun, "Songti SC", serif;
|
||||
}
|
||||
|
||||
.wolai-page-font-song .wolai-editor.bn-default-styles,
|
||||
.wolai-page-font-song .wolai-editor .bn-default-styles {
|
||||
font-family: "宋体", SimSun, "Songti SC", serif;
|
||||
}
|
||||
|
||||
.wolai-page-font-kai {
|
||||
font-family: "楷体", "楷体_GB2312", SimKai, STKaiti, serif;
|
||||
}
|
||||
|
||||
.wolai-page-font-kai .wolai-editor.bn-default-styles,
|
||||
.wolai-page-font-kai .wolai-editor .bn-default-styles {
|
||||
font-family: "楷体", "楷体_GB2312", SimKai, STKaiti, serif;
|
||||
}
|
||||
|
||||
/* 自定义页面:布局密度(紧凑/默认/宽容) */
|
||||
.wolai-page-density-compact .wolai-editor .bn-block-content {
|
||||
line-height: 1.45;
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.wolai-page-density-spacious .wolai-editor .bn-block-content {
|
||||
line-height: 1.75;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 页面选项:小字体(强制覆盖 BlockNote 默认字号) */
|
||||
.wolai-small-text .wolai-editor.bn-default-styles,
|
||||
.wolai-small-text .wolai-editor .bn-default-styles,
|
||||
.wolai-editor.wolai-small-text.bn-default-styles,
|
||||
.wolai-editor.wolai-small-text .bn-default-styles {
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
/* 自定义页面:字体(强制覆盖 BlockNote 默认字体) */
|
||||
.wolai-page-font-song .wolai-editor.bn-default-styles,
|
||||
.wolai-page-font-song .wolai-editor .bn-default-styles,
|
||||
.wolai-editor.wolai-page-font-song.bn-default-styles,
|
||||
.wolai-editor.wolai-page-font-song .bn-default-styles {
|
||||
font-family: "宋体", SimSun, "Songti SC", serif !important;
|
||||
}
|
||||
|
||||
.wolai-page-font-kai .wolai-editor.bn-default-styles,
|
||||
.wolai-page-font-kai .wolai-editor .bn-default-styles,
|
||||
.wolai-editor.wolai-page-font-kai.bn-default-styles,
|
||||
.wolai-editor.wolai-page-font-kai .bn-default-styles {
|
||||
font-family: "楷体", "楷体_GB2312", SimKai, STKaiti, serif !important;
|
||||
}
|
||||
|
||||
/* 自定义页面:布局密度(强制覆盖 BlockNote 默认行高/间距) */
|
||||
.wolai-page-density-compact .wolai-editor .bn-block-content,
|
||||
.wolai-editor.wolai-page-density-compact .bn-block-content {
|
||||
line-height: 1.45 !important;
|
||||
padding-top: 1px !important;
|
||||
padding-bottom: 1px !important;
|
||||
}
|
||||
|
||||
.wolai-page-density-spacious .wolai-editor .bn-block-content,
|
||||
.wolai-editor.wolai-page-density-spacious .bn-block-content {
|
||||
line-height: 1.75 !important;
|
||||
padding-top: 6px !important;
|
||||
padding-bottom: 6px !important;
|
||||
}
|
||||
|
||||
.wolai-page-density-compact .wolai-editor h1 {
|
||||
margin-top: 1.2em;
|
||||
}
|
||||
|
||||
.wolai-page-density-spacious .wolai-editor h1 {
|
||||
margin-top: 1.8em;
|
||||
}
|
||||
|
||||
.wolai-page-density-compact .wolai-editor h2,
|
||||
.wolai-page-density-compact .wolai-editor h3 {
|
||||
margin-top: 1.1em;
|
||||
}
|
||||
|
||||
.wolai-page-density-spacious .wolai-editor h2,
|
||||
.wolai-page-density-spacious .wolai-editor h3 {
|
||||
margin-top: 1.6em;
|
||||
}
|
||||
|
||||
/* 自定义页面:隐藏子页面(仅隐藏通过 /ym 创建的子页面块) */
|
||||
.wolai-hide-child-pages [data-child-page="true"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 标题样式 */
|
||||
.wolai-editor h1 {
|
||||
font-size: 30px;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { QueryProvider } from "@/components/providers/query-provider";
|
||||
import { ConvexClientProvider } from "@/components/providers/convex-provider";
|
||||
import { AppPreferencesHydrator } from "@/components/providers/app-preferences-hydrator";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
|
||||
@@ -42,6 +43,7 @@ export default async function RootLayout({
|
||||
<body className={`${inter.variable} antialiased`}>
|
||||
<ConvexAuthNextjsServerProvider>
|
||||
<ConvexClientProvider>
|
||||
<AppPreferencesHydrator />
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</ConvexClientProvider>
|
||||
</ConvexAuthNextjsServerProvider>
|
||||
|
||||
@@ -375,6 +375,12 @@ export default function OnlyOfficePage() {
|
||||
if (!uniq.includes(s)) uniq.push(s);
|
||||
};
|
||||
|
||||
const isPageLocal = (() => {
|
||||
if (typeof window === "undefined") return true;
|
||||
const host = window.location.hostname;
|
||||
return host === "127.0.0.1" || host === "localhost";
|
||||
})();
|
||||
|
||||
// 说明:网页端优先走同源 /onlyoffice-server(Next 代理到 ONLYOFFICE_INTERNAL_URL),
|
||||
// 避免配置里误写成 https 自签证书域名导致浏览器报 ERR_CERT_AUTHORITY_INVALID。
|
||||
// 同时同源路径也更利于缓存与跨环境迁移(无需改域名/端口)。
|
||||
@@ -390,7 +396,10 @@ export default function OnlyOfficePage() {
|
||||
if (channel === "web") {
|
||||
push(runtimeConfig.onlyofficeBaseUrlWeb);
|
||||
push(runtimeConfig.onlyofficeBaseUrl);
|
||||
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
// 说明:Web 端通过公网访问时,不应回退到 localhost/127(会触发浏览器“本地网络”权限提示,且必然不可达)。
|
||||
if (isPageLocal) {
|
||||
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
}
|
||||
return uniq;
|
||||
}
|
||||
if (channel === "desktop") {
|
||||
@@ -404,7 +413,9 @@ export default function OnlyOfficePage() {
|
||||
if (runtimeConfig.isDesktop) {
|
||||
push(runtimeConfig.onlyofficeBaseUrlWeb);
|
||||
} else {
|
||||
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
if (isPageLocal) {
|
||||
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
}
|
||||
}
|
||||
return uniq;
|
||||
}, [channel, runtimeConfig]);
|
||||
|
||||
Reference in New Issue
Block a user