0.3.1 UI修复
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -16,7 +16,9 @@ export function getDevUser(): AuthContext {
|
||||
}
|
||||
|
||||
export function isDevAuthEnabled(): boolean {
|
||||
// 说明:目前只要启用了 USE_CONVEX,就默认启用固定用户鉴权(便于迁移与测试)。
|
||||
return process.env.USE_CONVEX === "1";
|
||||
// 说明:
|
||||
// - 固定开发用户仅用于迁移/联调阶段的“免登录”模式。
|
||||
// - 当启用 Convex Auth(middleware + /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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 默认用 “项目 ref(hostname 第一个片段)” 作为 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_URL(HTTPS),从而触发 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,
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user