0.4.0 convex及界面修改

This commit is contained in:
liaibo
2026-02-01 08:47:40 +08:00
parent d1f055f51a
commit af92c4b149
636 changed files with 7522 additions and 1815 deletions
@@ -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 (
+66 -2
View File
@@ -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) });
+2 -36
View File
@@ -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();
+66 -1
View File
@@ -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>
);
}
+140 -21
View File
@@ -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;
+2
View File
@@ -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-serverNext 代理到 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]);