diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b9ff1044..06bd2daa 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -696,7 +696,7 @@ app.get("/api/v1/memos", async (c) => { const notebookId = c.req.query("notebookId"); const q = c.req.query("q")?.trim(); const includeTrash = c.req.query("trash") === "1"; - const limit = clampNumber(Number(c.req.query("limit") ?? 80), 1, 100); + const limit = clampNumber(Number(c.req.query("limit") ?? 5000), 1, 5000); const deletedClause = includeTrash ? "m.is_deleted = 1" : "m.is_deleted = 0"; if (q) { @@ -704,57 +704,97 @@ app.get("/api/v1/memos", async (c) => { const likeQuery = `%${escapeLike(q)}%`; if (ftsQuery) { - const rows = await c.env.DB.prepare( - `WITH raw_matches(memo_id, rank) AS ( - SELECT memo_id, bm25(memos_fts) - FROM memos_fts - WHERE memos_fts MATCH ? + const [rows, totalRow] = await Promise.all([ + c.env.DB.prepare( + `WITH raw_matches(memo_id, rank) AS ( + SELECT memo_id, bm25(memos_fts) + FROM memos_fts + WHERE memos_fts MATCH ? - UNION ALL + UNION ALL - SELECT m.id, 100.0 - FROM memos m + SELECT m.id, 100.0 + FROM memos m + INNER JOIN memo_contents c ON c.memo_id = m.id + WHERE m.title LIKE ? ESCAPE '\\' + OR c.content_text LIKE ? ESCAPE '\\' + OR m.tags_json LIKE ? ESCAPE '\\' + ), + search_matches AS ( + SELECT memo_id, MIN(rank) AS rank + FROM raw_matches + GROUP BY memo_id + ) + SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, + m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision + FROM search_matches s + INNER JOIN memos m ON m.id = s.memo_id INNER JOIN memo_contents c ON c.memo_id = m.id - WHERE m.title LIKE ? ESCAPE '\\' - OR c.content_text LIKE ? ESCAPE '\\' - OR m.tags_json LIKE ? ESCAPE '\\' - ), - search_matches AS ( - SELECT memo_id, MIN(rank) AS rank - FROM raw_matches - GROUP BY memo_id - ) - SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, - m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision - FROM search_matches s - INNER JOIN memos m ON m.id = s.memo_id - INNER JOIN memo_contents c ON c.memo_id = m.id - WHERE ${deletedClause} - AND (? IS NULL OR m.notebook_id = ?) - ORDER BY s.rank ASC, m.is_pinned DESC, m.updated_at DESC - LIMIT ?` - ) - .bind(ftsQuery, likeQuery, likeQuery, likeQuery, notebookId ?? null, notebookId ?? null, limit) - .all(); + WHERE ${deletedClause} + AND (? IS NULL OR m.notebook_id = ?) + ORDER BY s.rank ASC, m.is_pinned DESC, m.updated_at DESC + LIMIT ?` + ) + .bind(ftsQuery, likeQuery, likeQuery, likeQuery, notebookId ?? null, notebookId ?? null, limit) + .all(), + c.env.DB.prepare( + `WITH raw_matches(memo_id) AS ( + SELECT memo_id + FROM memos_fts + WHERE memos_fts MATCH ? - return c.json({ memos: rows.results.map(mapMemoSummary) }); + UNION ALL + + SELECT m.id + FROM memos m + INNER JOIN memo_contents c ON c.memo_id = m.id + WHERE m.title LIKE ? ESCAPE '\\' + OR c.content_text LIKE ? ESCAPE '\\' + OR m.tags_json LIKE ? ESCAPE '\\' + ), + search_matches AS ( + SELECT memo_id + FROM raw_matches + GROUP BY memo_id + ) + SELECT COUNT(*) AS count + FROM search_matches s + INNER JOIN memos m ON m.id = s.memo_id + WHERE ${deletedClause} + AND (? IS NULL OR m.notebook_id = ?)` + ) + .bind(ftsQuery, likeQuery, likeQuery, likeQuery, notebookId ?? null, notebookId ?? null) + .first<{ count: number }>(), + ]); + + return c.json({ memos: rows.results.map(mapMemoSummary), totalCount: totalRow?.count ?? rows.results.length }); } } - const rows = await c.env.DB.prepare( - `SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, - m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision - FROM memos m - INNER JOIN memo_contents c ON c.memo_id = m.id - WHERE ${deletedClause} - AND (? IS NULL OR m.notebook_id = ?) - ORDER BY ${includeTrash ? "m.deleted_at DESC," : "m.is_pinned DESC,"} m.updated_at DESC - LIMIT ?` - ) - .bind(notebookId ?? null, notebookId ?? null, limit) - .all(); + const [rows, totalRow] = await Promise.all([ + c.env.DB.prepare( + `SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, + m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision + FROM memos m + INNER JOIN memo_contents c ON c.memo_id = m.id + WHERE ${deletedClause} + AND (? IS NULL OR m.notebook_id = ?) + ORDER BY ${includeTrash ? "m.deleted_at DESC," : "m.is_pinned DESC,"} m.updated_at DESC + LIMIT ?` + ) + .bind(notebookId ?? null, notebookId ?? null, limit) + .all(), + c.env.DB.prepare( + `SELECT COUNT(*) AS count + FROM memos m + WHERE ${deletedClause} + AND (? IS NULL OR m.notebook_id = ?)` + ) + .bind(notebookId ?? null, notebookId ?? null) + .first<{ count: number }>(), + ]); - return c.json({ memos: rows.results.map(mapMemoSummary) }); + return c.json({ memos: rows.results.map(mapMemoSummary), totalCount: totalRow?.count ?? rows.results.length }); }); app.post("/api/v1/memos", zValidator("json", MemoCreateSchema), async (c) => { diff --git a/apps/web/src/components/MemoListPane.tsx b/apps/web/src/components/MemoListPane.tsx index 11c5fb5d..5c66b447 100644 --- a/apps/web/src/components/MemoListPane.tsx +++ b/apps/web/src/components/MemoListPane.tsx @@ -304,6 +304,7 @@ export const MemoListPane = ({ notebooks, notebook, memos, + totalMemoCount, selectedMemoId, selectedMemoIds, selectionMode, @@ -364,6 +365,7 @@ export const MemoListPane = ({ notebooks: Notebook[]; notebook: Notebook | null; memos: MemoSummary[]; + totalMemoCount: number; selectedMemoId: string | null; selectedMemoIds: Set; selectionMode: boolean; @@ -445,7 +447,7 @@ export const MemoListPane = ({ const listTitle = view === "trash" ? "回收站" : notebook?.name ?? "全部笔记"; const listContextLabel = view === "trash" ? "已删除笔记" : notebook ? "当前笔记本" : "所有笔记本"; - const listCountLabel = `${filteredMemos.length}${filterMode !== "all" ? ` / ${memos.length}` : ""} ${ + const listCountLabel = `${filteredMemos.length}${filterMode !== "all" || filteredMemos.length !== totalMemoCount ? ` / ${totalMemoCount}` : ""} ${ view === "trash" ? "条已删除" : "条笔记" }`; const selectionCountLabel = getSelectionCountLabel(selectedMemoIds.size); diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 1c0378d6..fab29a9d 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -143,8 +143,9 @@ const cacheMemoDetail = (queryClient: QueryClient, memo: MemoDetail, view: MemoV const prependMemoSummaryToList = (queryClient: QueryClient, queryKey: readonly unknown[], memo: MemoDetail) => { const summary = memoToSummary(memo); - queryClient.setQueryData<{ memos: MemoSummary[] }>(queryKey, (current) => ({ + queryClient.setQueryData<{ memos: MemoSummary[]; totalCount?: number }>(queryKey, (current) => ({ memos: [summary, ...(current?.memos ?? []).filter((item) => item.id !== memo.id)], + totalCount: (current?.totalCount ?? current?.memos.length ?? 0) + 1, })); }; @@ -918,6 +919,7 @@ export const WorkspaceApp = ({ }); const memos = memosQuery.data?.memos ?? []; + const totalMemoCount = memosQuery.data?.totalCount ?? memos.length; const selectedMemoIndex = selectedMemoId ? memos.findIndex((memo) => memo.id === selectedMemoId) : -1; const previousMemoId = selectedMemoIndex > 0 ? memos[selectedMemoIndex - 1]?.id : null; const nextMemoId = @@ -1040,12 +1042,13 @@ export const WorkspaceApp = ({ ]); const memoIdSet = new Set(memoIds); - const previousMemoQueries = queryClient.getQueriesData<{ memos: MemoSummary[] }>({ queryKey: ["memos"] }); + const previousMemoQueries = queryClient.getQueriesData<{ memos: MemoSummary[]; totalCount?: number }>({ queryKey: ["memos"] }); const previousMemoDetailQueries = queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] }); - queryClient.setQueriesData<{ memos: MemoSummary[] }>({ queryKey: ["memos"] }, (current) => + queryClient.setQueriesData<{ memos: MemoSummary[]; totalCount?: number }>({ queryKey: ["memos"] }, (current) => current ? { + ...current, memos: current.memos.map((memo) => (memoIdSet.has(memo.id) ? { ...memo, isPinned } : memo)), } : current @@ -1994,6 +1997,7 @@ export const WorkspaceApp = ({ notebooks={notebooks} view={memoView} memos={memos} + totalMemoCount={totalMemoCount} selectedMemoId={selectedMemoId} selectedMemoIds={selectedMemoIds} selectionMode={memoSelectionModeActive} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index f4bd23c7..4f4853fa 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -19,6 +19,7 @@ type ListNotebooksResponse = { type ListMemosResponse = { memos: MemoSummary[]; + totalCount: number; }; type ListMemoRevisionsResponse = { diff --git a/docs/openapi.json b/docs/openapi.json index 66551e80..d83f9224 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -377,8 +377,8 @@ "schema": { "type": "integer", "minimum": 1, - "maximum": 100, - "default": 80 + "maximum": 5000, + "default": 5000 } } ], @@ -395,9 +395,13 @@ "items": { "$ref": "#/components/schemas/MemoSummary" } + }, + "totalCount": { + "type": "integer", + "minimum": 0 } }, - "required": ["memos"] + "required": ["memos", "totalCount"] } } }