chore: init monorepo snapshot

This commit is contained in:
liaibo
2025-11-23 10:55:04 +08:00
commit c70ff52869
941 changed files with 246586 additions and 0 deletions
@@ -0,0 +1,30 @@
import type { Json } from "@/types/supabase";
/**
* 提取文档内容中的块数组,兼容数组和 { blocks: [] } 两种结构。
*/
export const extractBlocksFromContent = (content: unknown): Json[] => {
if (Array.isArray(content)) {
return content as Json[];
}
if (content && typeof content === "object" && Array.isArray((content as { blocks?: Json[] }).blocks)) {
return ((content as { blocks?: Json[] }).blocks ?? []) as Json[];
}
return [];
};
/**
* 将新的块数组写回内容,保持原有结构额外字段。
*/
export const composeContentWithBlocks = (content: unknown, blocks: Json[]): Json => {
if (Array.isArray(content)) {
return blocks as Json;
}
if (content && typeof content === "object") {
return {
...(content as Record<string, unknown>),
blocks,
} as Json;
}
return { blocks } as Json;
};
+63
View File
@@ -0,0 +1,63 @@
export interface DocumentRecord {
access_scope: "private" | "shared" | "public";
id: string;
workspace_id: string;
title: string | null;
parent_id: string | null;
sort_order: number | null;
is_starred: boolean | null;
is_template: boolean;
created_at: string;
updated_at: string | null;
}
export interface DocumentNode extends DocumentRecord {
children: DocumentNode[];
}
export function buildDocumentTree(records: DocumentRecord[]): DocumentNode[] {
const nodeMap = new Map<string, DocumentNode>();
records.forEach((record) => {
nodeMap.set(record.id, { ...record, children: [] });
});
const roots: DocumentNode[] = [];
records.forEach((record) => {
const node = nodeMap.get(record.id);
if (!node) return;
if (record.parent_id && nodeMap.has(record.parent_id)) {
nodeMap.get(record.parent_id)!.children.push(node);
} else {
roots.push(node);
}
});
const sortTree = (nodes: DocumentNode[]) => {
nodes.sort((a, b) => {
const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) {
return orderA - orderB;
}
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
});
nodes.forEach((child) => sortTree(child.children));
};
sortTree(roots);
return roots;
}
export function findBreadcrumb(records: DocumentRecord[], targetId: string): DocumentRecord[] {
const map = new Map<string, DocumentRecord>();
records.forEach((item) => map.set(item.id, item));
const path: DocumentRecord[] = [];
let current: DocumentRecord | undefined = map.get(targetId);
while (current) {
path.unshift(current);
current = current.parent_id ? map.get(current.parent_id) : undefined;
}
return path;
}
@@ -0,0 +1,14 @@
export async function recordRecentPage(workspaceId: string | null, documentId: string) {
if (!workspaceId) {
return;
}
try {
await fetch("/api/search/recent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId, documentId }),
});
} catch (error) {
console.error("recordRecentPage failed", error);
}
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { buildSearchRequest } from "./request";
describe("buildSearchRequest", () => {
it("combines标题过滤与时间范围", () => {
const payload = buildSearchRequest({
workspaceId: "ws-1",
activeDocumentId: "doc-1",
query: "demo",
filters: {
titleOnly: true,
exact: false,
onlyCurrentPage: false,
includeOcr: false,
timeField: "updated",
customRange: undefined,
},
timeRange: "7d",
});
expect(payload).not.toBeNull();
expect(payload?.filters.titleOnly).toBe(true);
expect(payload?.filters.timeRange).toBe("7d");
});
it("自动重置仅当前页面过滤条件", () => {
const payload = buildSearchRequest({
workspaceId: "ws-2",
activeDocumentId: null,
query: "",
filters: {
titleOnly: false,
exact: false,
onlyCurrentPage: true,
includeOcr: false,
timeField: "created",
customRange: { from: "2025-02-01", to: "2025-02-07" },
},
timeRange: "any",
});
expect(payload?.filters.onlyCurrentPage).toBe(false);
expect(payload?.documentId).toBeUndefined();
});
});
+38
View File
@@ -0,0 +1,38 @@
import type {
DocumentSearchFilters,
DocumentSearchRequest,
DocumentSearchTimeRange,
} from "@/types/search";
interface BuildSearchRequestArgs {
workspaceId: string | null;
activeDocumentId: string | null;
query: string;
filters: Omit<DocumentSearchFilters, "timeRange">;
timeRange: DocumentSearchTimeRange;
}
export function buildSearchRequest({
workspaceId,
activeDocumentId,
query,
filters,
timeRange,
}: BuildSearchRequestArgs): DocumentSearchRequest | null {
if (!workspaceId) {
return null;
}
const normalizedFilters: DocumentSearchFilters = {
...filters,
timeRange,
onlyCurrentPage: filters.onlyCurrentPage && Boolean(activeDocumentId),
};
return {
workspaceId,
documentId: normalizedFilters.onlyCurrentPage ? activeDocumentId ?? undefined : undefined,
query,
filters: normalizedFilters,
};
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { resolveSearchOpenMode } from "./shortcuts";
describe("resolveSearchOpenMode", () => {
it("Ctrl/Cmd+Enter 打开新窗口", () => {
expect(resolveSearchOpenMode({ ctrlKey: true })).toBe("new-window");
expect(resolveSearchOpenMode({ metaKey: true, altKey: true })).toBe("new-window");
});
it("Alt+Enter 打开右侧预览", () => {
expect(resolveSearchOpenMode({ altKey: true })).toBe("sidebar");
});
it("默认回车在当前窗口打开", () => {
expect(resolveSearchOpenMode({})).toBe("main");
});
});
@@ -0,0 +1,17 @@
export type SearchOpenMode = "main" | "new-window" | "sidebar";
interface ShortcutLikeEvent {
metaKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
}
export const resolveSearchOpenMode = (event: ShortcutLikeEvent): SearchOpenMode => {
if (event.metaKey || event.ctrlKey) {
return "new-window";
}
if (event.altKey) {
return "sidebar";
}
return "main";
};
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { buildSnippet } from "./snippet";
describe("buildSnippet", () => {
it("命中关键字时高亮 OCR 片段", () => {
const snippet = buildSnippet("图像 OCR 测试文本,用于高亮", "OCR");
expect(snippet).toContain("<mark>OCR</mark>");
});
it("无关键字时返回截断文本", () => {
const snippet = buildSnippet("一段很长的文字用于测试截断逻辑", null);
expect(snippet.endsWith("…") || snippet.length <= 120).toBe(true);
});
});
+24
View File
@@ -0,0 +1,24 @@
const escapeHtml = (value: string): string =>
value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export const buildSnippet = (text: string | null, keyword: string | null): string => {
const source = (text ?? "").trim();
if (!source) {
return "暂无正文内容";
}
const safeSource = escapeHtml(source);
if (!keyword) {
return `${safeSource.slice(0, 120)}${safeSource.length > 120 ? "…" : ""}`;
}
const pattern = new RegExp(escapeRegExp(keyword), "gi");
const match = pattern.exec(safeSource);
if (!match) {
return `${safeSource.slice(0, 120)}${safeSource.length > 120 ? "…" : ""}`;
}
const start = Math.max(0, match.index - 20);
const end = Math.min(safeSource.length, match.index + keyword.length + 80);
const segment = safeSource.slice(start, end);
return segment.replace(pattern, (found) => `<mark>${found}</mark>`);
};
+46
View File
@@ -0,0 +1,46 @@
"use server";
import { cookies } from "next/headers";
type RequestCookies = Awaited<ReturnType<typeof cookies>>;
const decodeValue = (value?: string) => {
if (!value) return value;
return value.startsWith("base64-") ? Buffer.from(value.slice(7), "base64").toString("utf8") : value;
};
const encodeValue = (value: string) => {
if (!value) return value;
// Next.js 会自动 base64 编码,我们只需处理已有 base64 前缀的情况
if (value.startsWith("base64-")) {
return value;
}
return value;
};
const wrapCookies = (store: RequestCookies) => {
return {
get: (name: string) => {
const cookie = store.get(name);
if (!cookie) return cookie;
return { ...cookie, value: decodeValue(cookie.value) };
},
getAll: (...args: Parameters<RequestCookies["getAll"]>) =>
store.getAll(...args).map((cookie) => ({ ...cookie, value: decodeValue(cookie.value) })),
set: (...args: Parameters<RequestCookies["set"]>) => {
const [name, value, options] = args;
if (typeof value === "string") {
store.set(name, encodeValue(value), options);
} else {
store.set(name, value);
}
},
delete: (...args: Parameters<RequestCookies["delete"]>) => store.delete(...args),
has: (...args: Parameters<RequestCookies["has"]>) => store.has(...args),
};
};
export const getDecodedCookies = async () => {
const store = await cookies();
return wrapCookies(store) as RequestCookies;
};
+141
View File
@@ -0,0 +1,141 @@
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 { buildDocumentTree } from "@/lib/documents";
type TypedClient = SupabaseClient<Database>;
export interface SidebarSectionSnapshot {
id: SidebarSectionId;
title: string;
icon?: string;
nodes: DocumentNode[];
}
export interface FlattenedTreeNode {
node: DocumentNode;
depth: number;
parentId: string | null;
}
const SECTION_META: Record<SidebarSectionId, { title: string; icon: string }> = {
starred: { title: "星标置顶", icon: "star" },
public: { title: "公共页面", icon: "globe" },
shared: { title: "共享页面", icon: "users" },
private: { title: "私有 / 我的页面", icon: "lock" },
templates: { title: "模板中心", icon: "grid" },
};
export async function fetchSidebarDataset(
client: TypedClient,
workspaceId: string,
): Promise<{ documents: DocumentRecord[]; trashedDocuments: TrashRecord[] }> {
const { data: documentRows, error } = await client
.from("documents")
.select(
"id,title,parent_id,sort_order,is_starred,access_scope,is_template,created_at,updated_at,workspace_id",
)
.eq("workspace_id", workspaceId)
.is("deleted_at", null)
.order("sort_order", { ascending: true, nullsFirst: false })
.order("created_at", { ascending: true });
if (error) {
throw new Error(`获取工作空间文档列表失败:${error.message}`);
}
const documents: DocumentRecord[] = (documentRows ?? []).map((row) => ({
access_scope: (row.access_scope ?? "private") as DocumentRecord["access_scope"],
id: row.id,
workspace_id: row.workspace_id,
title: row.title ?? "无标题",
parent_id: row.parent_id,
sort_order: row.sort_order,
is_starred: row.is_starred,
is_template: row.is_template ?? false,
created_at: row.created_at ?? "",
updated_at: row.updated_at ?? null,
}));
const { data: trashRows, error: trashError } = await client
.from("documents")
.select("id,title,parent_id,deleted_at,access_scope")
.eq("workspace_id", workspaceId)
.not("deleted_at", "is", null)
.order("deleted_at", { ascending: false })
.limit(100);
if (trashError) {
throw new Error(`获取垃圾桶内容失败:${trashError.message}`);
}
const trashedDocuments: TrashRecord[] = (trashRows ?? []).map((row) => ({
id: row.id,
title: row.title,
parent_id: row.parent_id,
deleted_at: row.deleted_at!,
access_scope: (row.access_scope ?? "private") as DocumentRecord["access_scope"],
}));
return {
documents,
trashedDocuments,
};
}
type NodePredicate = (node: DocumentNode) => boolean;
function projectTree(nodes: DocumentNode[], predicate: NodePredicate): DocumentNode[] {
const result: DocumentNode[] = [];
nodes.forEach((node) => {
const projectedChildren = projectTree(node.children, predicate);
if (predicate(node)) {
result.push({
...node,
children: projectedChildren,
});
} else {
result.push(...projectedChildren);
}
});
return result;
}
export function flattenDocumentTree(
nodes: DocumentNode[],
expanded: Set<string>,
depth = 0,
parentId: string | null = null,
): FlattenedTreeNode[] {
const list: FlattenedTreeNode[] = [];
nodes.forEach((node) => {
list.push({ node, depth, parentId });
if (node.children.length > 0 && expanded.has(node.id)) {
list.push(...flattenDocumentTree(node.children, expanded, depth + 1, node.id));
}
});
return list;
}
export function buildSidebarSections(records: DocumentRecord[]): SidebarSectionSnapshot[] {
const tree = buildDocumentTree(records);
return buildSidebarSectionsFromTree(tree);
}
export function buildSidebarSectionsFromTree(tree: DocumentNode[]): SidebarSectionSnapshot[] {
const sections: Array<{ id: SidebarSectionId; predicate: NodePredicate }> = [
{ id: "starred", predicate: (node) => Boolean(node.is_starred) },
{ id: "public", predicate: (node) => node.access_scope === "public" },
{ id: "shared", predicate: (node) => node.access_scope === "shared" },
{ id: "private", predicate: (node) => node.access_scope === "private" },
{ id: "templates", predicate: (node) => node.is_template },
];
return sections.map(({ id, predicate }) => ({
id,
title: SECTION_META[id].title,
icon: SECTION_META[id].icon,
nodes: projectTree(tree, predicate),
}));
}
+13
View File
@@ -0,0 +1,13 @@
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("缺少 Supabase 环境变量,请在 .env.local 配置 NEXT_PUBLIC_SUPABASE_URL 与 NEXT_PUBLIC_SUPABASE_ANON_KEY");
}
export const supabaseBrowser = createClientComponentClient({
supabaseUrl,
supabaseKey: supabaseAnonKey,
});
+16
View File
@@ -0,0 +1,16 @@
import { createServerComponentClient, createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { getDecodedCookies } from "@/lib/server-cookies";
export const createSupabaseServerClient = async () => {
const cookieStore = await getDecodedCookies();
return createServerComponentClient({
cookies: () => cookieStore,
});
};
export const createSupabaseRouteClient = async () => {
const cookieStore = await getDecodedCookies();
return createRouteHandlerClient({
cookies: () => cookieStore,
});
};
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+142
View File
@@ -0,0 +1,142 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/types/supabase";
type TypedClient = SupabaseClient<Database>;
interface WorkspaceMembershipRow {
workspace_id: string;
is_default: boolean;
workspaces: {
id: string;
name: string;
type: "personal" | "team";
icon_url: string | null;
} | null;
}
export interface WorkspaceSummary {
id: string;
name: string;
type: "personal" | "team";
iconUrl: string | null;
memberCount: number;
isDefault: boolean;
}
export async function ensureDefaultWorkspace(client: TypedClient, userId: string, fallbackName: string): Promise<void> {
const { data: memberships, error } = await client
.from("workspace_members")
.select("workspace_id")
.eq("user_id", userId)
.limit(1);
if (error) {
throw new Error(`获取工作空间成员信息失败:${error.message}`);
}
if (memberships && memberships.length > 0) {
return;
}
const workspaceName = fallbackName.trim() ? `${fallbackName.trim()} 的空间` : "我的空间";
const { data: workspace, error: workspaceError } = await client
.from("workspaces")
.insert({
name: workspaceName,
type: "personal",
created_by: userId,
})
.select("id")
.single();
if (workspaceError || !workspace) {
throw new Error(`创建默认工作空间失败:${workspaceError?.message ?? "未知错误"}`);
}
const { error: memberError } = await client.from("workspace_members").insert({
workspace_id: workspace.id,
user_id: userId,
role: "owner",
is_default: true,
});
if (memberError) {
throw new Error(`创建工作空间成员失败:${memberError.message}`);
}
}
export async function fetchWorkspaceSummaries(
client: TypedClient,
userId: string,
): Promise<{ workspaces: WorkspaceSummary[]; activeWorkspaceId: string }> {
const { data: memberRows, error } = await client
.from("workspace_members")
.select("workspace_id,is_default,workspaces(id,name,type,icon_url)")
.eq("user_id", userId)
.order("created_at", { ascending: true });
if (error) {
throw new Error(`拉取工作空间列表失败:${error.message}`);
}
const rows: WorkspaceMembershipRow[] = memberRows ?? [];
const workspaceIds = rows.map((row) => row.workspace_id);
const memberCountMap: Record<string, number> = {};
if (workspaceIds.length > 0) {
const { data: memberCounts, error: countError } = await client
.from("workspace_members")
.select("workspace_id")
.in("workspace_id", workspaceIds);
if (countError) {
throw new Error(`统计成员数失败:${countError.message}`);
}
(memberCounts ?? []).forEach((item) => {
const workspaceId = item.workspace_id as string;
memberCountMap[workspaceId] = (memberCountMap[workspaceId] ?? 0) + 1;
});
}
const summaries: WorkspaceSummary[] = rows
.map((row) => {
if (!row.workspaces) {
return null;
}
return {
id: row.workspaces.id,
name: row.workspaces.name,
type: row.workspaces.type,
iconUrl: row.workspaces.icon_url,
memberCount: memberCountMap[row.workspaces.id] ?? 1,
isDefault: row.is_default,
} satisfies WorkspaceSummary;
})
.filter(Boolean) as WorkspaceSummary[];
const defaultWorkspace = summaries.find((workspace) => workspace.isDefault);
const activeWorkspaceId = defaultWorkspace?.id ?? summaries[0]?.id ?? "";
return {
workspaces: summaries,
activeWorkspaceId,
};
}
export async function resolveActiveWorkspaceId(client: TypedClient, userId: string): Promise<string> {
const { data, error } = await client
.from("workspace_members")
.select("workspace_id,is_default")
.eq("user_id", userId)
.order("is_default", { ascending: false })
.order("created_at", { ascending: true })
.limit(1);
if (error) {
throw new Error(`获取当前工作空间失败:${error.message}`);
}
return data?.[0]?.workspace_id ?? "";
}