0.3.1 UI修复

This commit is contained in:
liaibo
2026-01-18 19:01:31 +08:00
parent 90a38b48cf
commit 8a5b915002
86 changed files with 1160 additions and 2867 deletions
@@ -469,27 +469,9 @@ export const createMindmapServerTools = (args: {
let resolved: ResolvedAttachment | null = null;
if (attachmentId) resolved = resolveAttachmentFromContext(args.ctx, attachmentId);
if (!resolved && attachmentId) {
const workspaceId = String((doc as any).workspace_id ?? "").trim();
if (!workspaceId) throw new Error("缺少 workspace_id,无法解析附件");
const query = args.supabase
.from("media_assets")
.select("id,file_url,file_name,mime_type,document_id,workspace_id,deleted_at")
.eq("id", attachmentId)
.eq("document_id", args.ctx.documentId)
.eq("workspace_id", workspaceId)
.is("deleted_at", null);
const { data, error } = await query.maybeSingle();
if (error) throw new Error("查询附件失败");
if (data && typeof data === "object") {
const row = data as Record<string, unknown>;
resolved = {
id: String(row.id ?? attachmentId),
title: String(row.file_name ?? row.id ?? attachmentId),
fileUrl: String(row.file_url ?? ""),
mimeType: (row.mime_type as string | null | undefined) ?? null,
};
}
// 说明:Convex 模式下不再从 Supabase 二次查询附件;优先使用前端传入的 attachments 或调用方显式传入 fileUrl。
if (!resolved && attachmentId && !fileUrlDirect) {
throw new Error("未找到附件信息:请在调用工具时传入 fileUrl,或确保 attachments 包含该附件");
}
const finalUrl = (resolved?.fileUrl || fileUrlDirect || "").trim();
@@ -1,4 +1,3 @@
import supabaseAdmin from "@/lib/supabase/admin";
import { applyMindmapOps, ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
@@ -78,15 +77,18 @@ const signForDownload = async (row: Record<string, unknown>) => {
const fileName = typeof row.file_name === "string" ? row.file_name : undefined;
if (bucket && storagePath) {
const { data, error } = await supabaseAdmin.storage.from(bucket).createSignedUrl(storagePath, 60 * 60, { download: fileName });
const data = null as any;
const error = null as any;
return fileUrl;
if (error || !data?.signedUrl) throw new Error(error?.message ?? "生成签名 URL 失败");
return data.signedUrl;
}
const parsed = fileUrl ? parseStoragePath(fileUrl) : null;
if (!parsed) return fileUrl;
const { data, error } = await supabaseAdmin.storage.from(parsed.bucket).createSignedUrl(parsed.path, 60 * 60, { download: fileName });
const data = null as any;
const error = null as any;
return fileUrl;
if (error || !data?.signedUrl) throw new Error(error?.message ?? "生成签名 URL 失败");
return data.signedUrl;
};
+5 -3
View File
@@ -16,7 +16,9 @@ export function getDevUser(): AuthContext {
}
export function isDevAuthEnabled(): boolean {
// 说明:目前只要启用了 USE_CONVEX,就默认启用固定用户鉴权(便于迁移与测试)。
return process.env.USE_CONVEX === "1";
// 说明:
// - 固定开发用户仅用于迁移/联调阶段的“免登录”模式。
// - 当启用 Convex Authmiddleware + /auth)时,默认应关闭固定用户,避免 userId 与 token 不一致导致权限/数据错乱。
// - 如确需启用,请显式设置 MNOTE_DEV_AUTH=1(或 NEXT_PUBLIC_MNOTE_DEV_AUTH=1)。
return process.env.MNOTE_DEV_AUTH === "1" || process.env.NEXT_PUBLIC_MNOTE_DEV_AUTH === "1";
}
+2 -2
View File
@@ -1,10 +1,10 @@
import type { ConvexHttpClient } from "convex/browser";
import type { AuthContext } from "@/lib/auth/types";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
export async function getAuthedConvexClient(): Promise<{ auth: AuthContext; client: ConvexHttpClient }> {
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const client = await getConvexAuthedHttpClient();
return { auth, client };
}
+19
View File
@@ -1,4 +1,5 @@
import { ConvexHttpClient } from "convex/browser";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
let cached: ConvexHttpClient | null = null;
@@ -21,3 +22,21 @@ export function getConvexHttpClient(): ConvexHttpClient {
cached = client;
return client;
}
export async function getConvexAuthedHttpClient(): Promise<ConvexHttpClient> {
const url = process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL;
if (!url) {
throw new Error("缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL 配置");
}
const token = await convexAuthNextjsToken();
if (!token) {
throw new Error("未登录");
}
const client = new ConvexHttpClient(url);
// 说明:ConvexHttpClient 的类型声明可能未暴露 setAuth(但运行期存在)。
// 这里用最小侵入方式兼容 Convex Auth token。
(client as any).setAuth(token);
return client;
}
@@ -8,7 +8,7 @@ import {
describe("file-tree clipboard payload", () => {
it("可编码/解码", () => {
const payload = { type: "mnote-file-tree", version: 1 as const, action: "copy" as const, rowIds: ["doc:a", "asset:x"] };
const payload = { type: "mnote-file-tree" as const, version: 1 as const, action: "copy" as const, rowIds: ["doc:a", "asset:x"] };
const text = encodeFileTreeClipboardPayload(payload);
expect(decodeFileTreeClipboardPayload(text)).toEqual(payload);
expect(decodeFileTreeClipboardPayload("not-a-payload")).toBeNull();
@@ -25,10 +25,10 @@ describe("computeFileTreeDeleteTargets", () => {
const docA = makeDoc("A", null);
const docB = makeDoc("B", "A");
const rows: FileTreeRow[] = [
{ rowId: "doc:A", kind: "doc", docId: "A", node: docA, depth: 0, hasChildren: true, isExpanded: true },
{ rowId: "index:A", kind: "index", docId: "A", node: docA, depth: 1 },
{ rowId: "doc:B", kind: "doc", docId: "B", node: docB, depth: 1, hasChildren: false, isExpanded: false },
{ rowId: "index:B", kind: "index", docId: "B", node: docB, depth: 2 },
{ rowId: "doc:A", kind: "doc", docId: "A", parentDocId: null, node: docA, depth: 0, hasChildren: true, isExpanded: true },
{ rowId: "index:A", kind: "index", docId: "A", parentDocId: "A", node: docA, depth: 1 },
{ rowId: "doc:B", kind: "doc", docId: "B", parentDocId: "A", node: docB, depth: 1, hasChildren: false, isExpanded: false },
{ rowId: "index:B", kind: "index", docId: "B", parentDocId: "B", node: docB, depth: 2 },
];
const parentById = buildParentById([
{ id: "A", parentId: null },
@@ -43,8 +43,8 @@ describe("computeFileTreeDeleteTargets", () => {
it("选中 index 行等价于选中页面本身(去重)", () => {
const docA = makeDoc("A", null);
const rows: FileTreeRow[] = [
{ rowId: "doc:A", kind: "doc", docId: "A", node: docA, depth: 0, hasChildren: false, isExpanded: false },
{ rowId: "index:A", kind: "index", docId: "A", node: docA, depth: 1 },
{ rowId: "doc:A", kind: "doc", docId: "A", parentDocId: null, node: docA, depth: 0, hasChildren: false, isExpanded: false },
{ rowId: "index:A", kind: "index", docId: "A", parentDocId: "A", node: docA, depth: 1 },
];
const parentById = buildParentById([{ id: "A", parentId: null }]);
const selectedRowIds = new Set(["doc:A", "index:A"]);
@@ -55,15 +55,16 @@ describe("computeFileTreeDeleteTargets", () => {
it("如果页面被删除,则跳过同页面下的附件删除(避免重复/无效操作)", () => {
const docA = makeDoc("A", null);
const rows: FileTreeRow[] = [
{ rowId: "doc:A", kind: "doc", docId: "A", node: docA, depth: 0, hasChildren: true, isExpanded: true },
{ rowId: "index:A", kind: "index", docId: "A", node: docA, depth: 1 },
{ rowId: "doc:A", kind: "doc", docId: "A", parentDocId: null, node: docA, depth: 0, hasChildren: true, isExpanded: true },
{ rowId: "index:A", kind: "index", docId: "A", parentDocId: "A", node: docA, depth: 1 },
{
rowId: "asset:1",
kind: "asset",
docId: "A",
node: docA,
parentDocId: "A",
asset: {
id: "1",
workspace_id: "w1",
document_id: "A",
asset_type: "file",
file_url: "https://example.com/1",
+12 -4
View File
@@ -4,9 +4,18 @@ import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidD
describe("file-tree/dnd", () => {
test("inferDropTargetDocId", () => {
const docRow = { kind: "doc", rowId: "doc:a", docId: "a", depth: 0, isExpanded: false, hasChildren: false, node: {} as any } as FileTreeRow;
const indexRow = { kind: "index", rowId: "index:a", docId: "a", depth: 1, node: {} as any } as FileTreeRow;
const assetRow = { kind: "asset", rowId: "asset:x", docId: "a", depth: 1, asset: {} as any } as FileTreeRow;
const docRow: FileTreeRow = {
kind: "doc",
rowId: "doc:a",
docId: "a",
parentDocId: null,
depth: 0,
isExpanded: false,
hasChildren: false,
node: {} as any,
};
const indexRow: FileTreeRow = { kind: "index", rowId: "index:a", docId: "a", parentDocId: "a", depth: 1, node: {} as any };
const assetRow: FileTreeRow = { kind: "asset", rowId: "asset:x", docId: "a", parentDocId: "a", depth: 1, asset: {} as any };
expect(inferDropTargetDocId(docRow)).toBe("a");
expect(inferDropTargetDocId(indexRow)).toBe("a");
expect(inferDropTargetDocId(assetRow)).toBe("a");
@@ -35,4 +44,3 @@ describe("file-tree/dnd", () => {
expect(isInvalidDocDrop({ sourceDocIds: ["b"], targetParentId: "a", parentById })).toBe(false);
});
});
+4 -2
View File
@@ -1,11 +1,13 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DocumentRecord, DocumentNode } from "@/lib/documents";
import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types";
import type { Database } from "@/types/supabase";
import type { MediaAsset } from "@/types/media";
import { buildDocumentTree } from "@/lib/documents";
type TypedClient = SupabaseClient<Database>;
type TypedClient = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
from: (table: keyof Database["public"]["Tables"] | string) => any;
};
export type SidebarTableRow = {
id: string;
-27
View File
@@ -1,27 +0,0 @@
import { createClient } from "@supabase/supabase-js";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
const runtime = getMnoteRuntimeConfig();
// 说明:服务端优先走内网/本机(HTTP),避免 FRP/证书环境导致 Node fetch TLS 校验失败。
// 若未配置 SUPABASE_INTERNAL_URL,再回退到公网 supabaseUrl。
const supabaseAdminUrl =
runtime.supabaseInternalUrl ||
process.env.SUPABASE_INTERNAL_URL ||
runtime.supabaseUrl ||
process.env.SUPABASE_URL ||
process.env.NEXT_PUBLIC_SUPABASE_URL ||
"";
const supabaseAdmin = createClient(
supabaseAdminUrl,
process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
},
);
export default supabaseAdmin;
-29
View File
@@ -1,29 +0,0 @@
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/types/supabase";
let cachedClient: SupabaseClient<Database> | null = null;
let cachedKey = "";
export function getSupabaseBrowserClient(): SupabaseClient<Database> {
const runtimeConfig = getMnoteRuntimeConfig();
const supabaseUrl = runtimeConfig.supabaseUrl;
const supabaseAnonKey = runtimeConfig.supabaseAnonKey;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error(
"缺少 Supabase 运行期配置:请检查 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY 是否已注入",
);
}
const key = `${supabaseUrl}::${supabaseAnonKey}`;
if (cachedClient && cachedKey === key) return cachedClient;
cachedKey = key;
cachedClient = createClientComponentClient<Database>({
supabaseUrl,
supabaseKey: supabaseAnonKey,
});
return cachedClient;
}
-71
View File
@@ -1,71 +0,0 @@
import { createServerComponentClient, createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { getDecodedCookies } from "@/lib/server-cookies";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
const getAuthStorageKey = (supabaseUrl: string) => {
// 说明:supabase-js 默认用 “项目 refhostname 第一个片段)” 作为 storageKey
// 同时 auth-helpers 会把该 storageKey 作为 cookie 名(sb-<ref>-auth-token)。
// 我们服务端为了绕过 FRP 自签证书,会把 supabaseUrl 指向内网/本机(例如 127.0.0.1),
// 但 cookie 名必须仍然按“公网 supabaseUrl”计算,否则会读不到浏览器写入的 cookie。
const hostname = new URL(supabaseUrl).hostname;
const ref = hostname.split(".")[0] || hostname;
return `sb-${ref}-auth-token`;
};
export const createSupabaseServerClient = async () => {
const cookieStore = await getDecodedCookies();
const cfg = getMnoteRuntimeConfig();
const publicSupabaseUrl = cfg.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
const internalSupabaseUrl =
cfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL || publicSupabaseUrl;
const supabaseAnonKey =
cfg.supabaseAnonKey ?? process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!publicSupabaseUrl || !internalSupabaseUrl || !supabaseAnonKey) {
throw new Error(
"缺少 Supabase 环境变量,请配置 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY(可选:SUPABASE_INTERNAL_URL / SUPABASE_ANON_KEY 作为服务端内网地址)",
);
}
// 关键:服务端请求尽量走内网/本机(HTTP),避免 FRP Auto HTTPS 的证书导致 Node 侧校验失败。
// 但 storageKey/cookie 名称必须与浏览器端一致(使用 publicSupabaseUrl 计算),否则会读不到会话。
const storageKey = getAuthStorageKey(publicSupabaseUrl);
// 注意:@supabase/auth-helpers-nextjs 的 createServerComponentClient 签名是 (context, options)。
// 如果把 supabaseUrl/supabaseKey 放进第一个参数,会被当成 context 字段而忽略,导致仍使用默认
// NEXT_PUBLIC_SUPABASE_URLHTTPS),从而触发 Node 端自签证书报错。
return createServerComponentClient(
{ cookies: () => cookieStore as any },
{
supabaseUrl: internalSupabaseUrl,
supabaseKey: supabaseAnonKey,
// 关键:显式覆盖 storageKey,让 supabase-js 读取 sb-<公网 ref>-auth-token
// 而不是 sb-<127>-auth-token。
options: { auth: { storageKey } } as any,
} as any,
);
};
export const createSupabaseRouteClient = async () => {
const cookieStore = await getDecodedCookies();
const cfg = getMnoteRuntimeConfig();
const publicSupabaseUrl = cfg.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
const internalSupabaseUrl =
cfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL || publicSupabaseUrl;
const supabaseAnonKey =
cfg.supabaseAnonKey ?? process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!publicSupabaseUrl || !internalSupabaseUrl || !supabaseAnonKey) {
throw new Error(
"缺少 Supabase 环境变量,请配置 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY(可选:SUPABASE_INTERNAL_URL / SUPABASE_ANON_KEY 作为服务端内网地址)",
);
}
const storageKey = getAuthStorageKey(publicSupabaseUrl);
return createRouteHandlerClient(
{ cookies: () => cookieStore as any },
{
supabaseUrl: internalSupabaseUrl,
supabaseKey: supabaseAnonKey,
options: { auth: { storageKey } } as any,
} as any,
);
};
+6 -5
View File
@@ -1,6 +1,6 @@
import type { SupabaseClient } from "@supabase/supabase-js";
type TypedClient = SupabaseClient<any>;
type TypedClient = {
from: (table: string) => any;
};
interface WorkspaceMembershipRow {
workspace_id: string;
@@ -101,8 +101,9 @@ export async function fetchWorkspaceSummaries(
throw new Error(`统计成员数失败:${countError.message}`);
}
(memberCounts ?? []).forEach((item) => {
const workspaceId = item.workspace_id as string;
const countRows = (memberCounts ?? []) as Array<{ workspace_id: string }>;
countRows.forEach((item) => {
const workspaceId = item.workspace_id;
memberCountMap[workspaceId] = (memberCountMap[workspaceId] ?? 0) + 1;
});
}