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
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
@@ -34,211 +33,12 @@ const TIME_FIELD_COLUMN = {
created: "created_at",
} as const;
const escapeLike = (value: string): string => value.replace(/[%_\\]/g, (match) => `\\${match}`);
interface DocumentRow {
id: string;
title: string | null;
raw_text: string | null;
updated_at: string | null;
created_at: string | null;
}
const buildIsoBoundary = (value: string, isEnd = false): string | null => {
if (!value) {
return null;
}
const normalized = value.trim();
if (!normalized) {
return null;
}
const suffix = isEnd ? "T23:59:59.999Z" : "T00:00:00.000Z";
const date = new Date(`${normalized}${suffix}`);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString();
};
const mapDocumentToResult = (
row: DocumentRow,
keyword: string | null,
forcedMatch?: DocumentSearchResult["matchField"],
): DocumentSearchResult => {
const normalizedKeyword = keyword?.trim() ?? "";
const normalizedTitle = row.title ?? "无标题";
let matchField: DocumentSearchResult["matchField"] = "recent";
if (forcedMatch) {
matchField = forcedMatch;
} else if (normalizedKeyword) {
const matchesTitle = normalizedTitle.toLowerCase().includes(normalizedKeyword.toLowerCase());
matchField = matchesTitle ? "title" : "content";
}
return {
id: row.id,
title: normalizedTitle,
snippet: buildSnippet(row.raw_text, normalizedKeyword || null),
updatedAt: row.updated_at,
createdAt: row.created_at,
matchField,
hasOcr: Boolean(row.raw_text),
publicPath: `/documents/${row.id}`,
score: matchField === "title" ? 2 : 1,
};
};
type RouteSupabaseClient = Awaited<ReturnType<typeof createSupabaseRouteClient>>;
const fetchRecentResults = async (
supabase: RouteSupabaseClient,
userId: string,
workspaceId: string,
): Promise<DocumentSearchResult[]> => {
const { data: recentRows, error: recentError } = await supabase
.from("user_recent_pages")
.select("document_id,last_accessed_at")
.eq("user_id", userId)
.eq("workspace_id", workspaceId)
.order("last_accessed_at", { ascending: false })
.limit(10);
if (recentError || !recentRows || recentRows.length === 0) {
return [];
}
const documentIds = recentRows.map((row) => row.document_id);
const { data: docRows, error: docsError } = await supabase
.from("documents")
.select("id,title,updated_at,created_at,raw_text")
.in("id", documentIds)
.is("deleted_at", null);
if (docsError || !docRows) {
return [];
}
const docMap = new Map(docRows.map((row) => [row.id, row]));
return recentRows
.map((row) => docMap.get(row.document_id))
.filter((row): row is DocumentRow => Boolean(row))
.map((row) => mapDocumentToResult(row, null, "recent"));
};
const fetchOcrMatches = async (
supabase: RouteSupabaseClient,
workspaceId: string,
likePattern: string,
limit: number,
) => {
const { data, error } = await supabase
.from("media_assets")
.select("document_id,ocr_text")
.eq("workspace_id", workspaceId)
.is("deleted_at", null)
.not("ocr_text", "is", null)
.ilike("ocr_text", likePattern)
.limit(limit);
if (error || !data) {
return [];
}
return data;
};
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
const payload = (await request.json()) as DocumentSearchRequest;
const workspaceId = payload.workspaceId;
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const filters: DocumentSearchFilters = {
...DEFAULT_FILTERS,
...payload.filters,
};
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
const normalizedQuery = payload.query?.trim() ?? "";
const normalizedLower = normalizedQuery.toLowerCase();
const docs = await client.query(api.documents.listByWorkspace, {
userId: auth.userId,
workspaceId,
});
const sortedByRecent = [...docs].sort((a, b) => {
const ta = a.updated_at ?? a.created_at ?? "";
const tb = b.updated_at ?? b.created_at ?? "";
return tb.localeCompare(ta);
});
const recentRows = await client.query(api.recents.listByWorkspace, {
userId: auth.userId,
workspaceId,
limit: 10,
});
const docMap = new Map(docs.map((d) => [d.id, d]));
const recent: DocumentSearchResult[] = recentRows
.map((r) => docMap.get(r.document_id))
.filter((row): row is (typeof docs)[number] => Boolean(row))
.map((row) => ({
id: row.id,
title: row.title ?? "无标题",
snippet: "",
updatedAt: row.updated_at ?? null,
createdAt: row.created_at ?? null,
matchField: "recent",
hasOcr: false,
publicPath: `/documents/${row.id}`,
score: 0,
}));
if (!normalizedQuery) {
const response: DocumentSearchResponse = { results: [], recent };
return NextResponse.json(response);
}
const narrowed = sortedByRecent.filter((row) => {
if (filters.onlyCurrentPage && payload.documentId) {
if (row.id !== payload.documentId) return false;
}
const title = (row.title ?? "无标题").toLowerCase();
return title.includes(normalizedLower);
});
const results: DocumentSearchResult[] = narrowed.slice(0, limit).map((row) => ({
id: row.id,
title: row.title ?? "无标题",
snippet: buildSnippet(row.title ?? "", normalizedQuery),
updatedAt: row.updated_at ?? null,
createdAt: row.created_at ?? null,
matchField: "title",
hasOcr: false,
publicPath: `/documents/${row.id}`,
score: 2,
}));
const response: DocumentSearchResponse = {
results,
recent,
};
return NextResponse.json(response);
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
const { auth, client } = await getAuthedConvexClient();
const payload = (await request.json()) as DocumentSearchRequest;
const workspaceId = payload.workspaceId;
@@ -251,140 +51,82 @@ export async function POST(request: Request) {
...payload.filters,
};
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.limit(1);
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership || membership.length === 0) {
return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 });
}
if (filters.onlyCurrentPage && !payload.documentId) {
filters.onlyCurrentPage = false;
}
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
const normalizedQuery = payload.query?.trim() ?? "";
const likePattern = filters.exact ? normalizedQuery : `%${escapeLike(normalizedQuery)}%`;
const timeColumn = TIME_FIELD_COLUMN[filters.timeField ?? "updated"];
const normalizedLower = normalizedQuery.toLowerCase();
let builder = supabase
.from("documents")
.select("id,title,updated_at,created_at,raw_text,is_starred,access_scope")
.eq("workspace_id", workspaceId)
.is("deleted_at", null)
.order(timeColumn, { ascending: false })
.limit(limit);
const docs = await client.query(api.documents.listByWorkspace, { workspaceId });
const docMap = new Map(docs.map((d) => [d.id, d]));
if (filters.onlyCurrentPage && payload.documentId) {
builder = builder.eq("id", payload.documentId);
const recentRows = await client.query(api.recents.listByWorkspace, {
userId: auth.userId,
workspaceId,
limit: 10,
});
const recent: DocumentSearchResult[] = recentRows
.map((r) => docMap.get(r.document_id))
.filter((row): row is (typeof docs)[number] => Boolean(row))
.map((row) => ({
id: row.id,
title: row.title ?? "无标题",
snippet: "",
updatedAt: row.updated_at ?? null,
createdAt: row.created_at ?? null,
matchField: "recent",
hasOcr: false,
publicPath: `/documents/${row.id}`,
score: 0,
}));
if (!normalizedQuery) {
const response: DocumentSearchResponse = { results: [], recent };
return NextResponse.json(response);
}
const customFrom = filters.customRange?.from ? buildIsoBoundary(filters.customRange.from, false) : null;
const customTo = filters.customRange?.to ? buildIsoBoundary(filters.customRange.to, true) : null;
const timeRangeMs = TIME_RANGE_TO_MS[filters.timeRange];
const timeField = TIME_FIELD_COLUMN[filters.timeField];
const boundaryIso =
typeof timeRangeMs === "number"
? new Date(Date.now() - timeRangeMs).toISOString()
: null;
if (customFrom) {
builder = builder.gte(timeColumn, customFrom);
}
if (customTo) {
builder = builder.lte(timeColumn, customTo);
}
if (!customFrom && !customTo) {
const now = Date.now();
const offset = TIME_RANGE_TO_MS[filters.timeRange];
if (offset) {
const from = new Date(now - offset).toISOString();
builder = builder.gte(timeColumn, from);
}
}
if (normalizedQuery) {
if (filters.titleOnly) {
builder = filters.exact ? builder.eq("title", normalizedQuery) : builder.ilike("title", likePattern);
} else {
const clauses = [
`title.${filters.exact ? `eq.${normalizedQuery}` : `ilike.${likePattern}`}`,
];
if (filters.includeOcr) {
clauses.push(`raw_text.ilike.${likePattern}`);
const narrowed = [...docs]
.sort((a, b) => {
const ta = a.updated_at ?? a.created_at ?? "";
const tb = b.updated_at ?? b.created_at ?? "";
return tb.localeCompare(ta);
})
.filter((row) => {
if (filters.onlyCurrentPage && payload.documentId) {
if (row.id !== payload.documentId) return false;
}
builder = builder.or(clauses.join(","));
}
}
const shouldSearchOcr = Boolean(filters.includeOcr && normalizedQuery);
const [{ data, error }, recentResults, ocrMatches] = await Promise.all([
builder,
fetchRecentResults(supabase, session.user.id, workspaceId),
shouldSearchOcr ? fetchOcrMatches(supabase, workspaceId, likePattern, limit) : Promise.resolve([]),
]);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
let results: DocumentSearchResult[] = (data ?? []).map((row) =>
mapDocumentToResult(row, normalizedQuery || null),
);
if (ocrMatches.length > 0 && normalizedQuery) {
const snippetMap = new Map<string, string>();
ocrMatches.forEach((row: { document_id: string; ocr_text: string | null }) => {
if (!row.ocr_text) return;
if (!snippetMap.has(row.document_id)) {
snippetMap.set(row.document_id, row.ocr_text);
if (boundaryIso) {
const ts = (row as any)?.[timeField] ?? null;
if (!ts || typeof ts !== "string") return false;
if (ts < boundaryIso) return false;
}
const title = (row.title ?? "无标题").toLowerCase();
if (filters.exact) {
return title === normalizedLower;
}
return title.includes(normalizedLower);
});
if (snippetMap.size > 0) {
const resultMap = new Map(results.map((item) => [item.id, item]));
const missingDocIds: string[] = [];
snippetMap.forEach((text, docId) => {
const snippet = buildSnippet(text, normalizedQuery || null);
if (resultMap.has(docId)) {
const existing = resultMap.get(docId)!;
resultMap.set(docId, {
...existing,
snippet: snippet || existing.snippet,
hasOcr: true,
matchField: "content",
});
} else {
missingDocIds.push(docId);
}
});
let extraResults: DocumentSearchResult[] = [];
if (missingDocIds.length > 0) {
const { data: extraDocs } = await supabase
.from("documents")
.select("id,title,updated_at,created_at,raw_text")
.in("id", missingDocIds)
.is("deleted_at", null);
extraResults =
extraDocs?.map((row) => {
const snippetText = snippetMap.get(row.id) ?? row.raw_text ?? "";
return {
...mapDocumentToResult(row, normalizedQuery || null, "content"),
snippet: buildSnippet(snippetText, normalizedQuery || null),
hasOcr: true,
};
}) ?? [];
}
results = [...resultMap.values(), ...extraResults];
}
}
const response: DocumentSearchResponse = {
results,
recent: recentResults,
};
const results: DocumentSearchResult[] = narrowed.slice(0, limit).map((row) => ({
id: row.id,
title: row.title ?? "无标题",
snippet: buildSnippet(row.title ?? "", normalizedQuery),
updatedAt: row.updated_at ?? null,
createdAt: row.created_at ?? null,
matchField: "title",
hasOcr: false,
publicPath: `/documents/${row.id}`,
score: 2,
}));
const response: DocumentSearchResponse = { results, recent };
return NextResponse.json(response);
}
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
@@ -28,6 +27,9 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -72,4 +74,5 @@ export async function POST(request: Request) {
}
return NextResponse.json({ ok: true });
*/
}