chore: init monorepo snapshot
This commit is contained in:
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
const escapeHtml = (value: string): string =>
|
||||
value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
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>`);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
Reference in New Issue
Block a user