From 16934f668d51971f52a489a4bc1fdddceb918e62 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 8 Sep 2026 22:12:26 +0800 Subject: [PATCH] feat(mobile): filter notes by tag --- AGENTS.md | 4 ++ apps/mobile/src/lib/local-mirror.ts | 7 ++ apps/mobile/src/lib/mobile-locale.tsx | 4 ++ .../src/screens/WorkspaceActionSheets.tsx | 10 ++- .../mobile/src/screens/WorkspaceNotesView.tsx | 36 ++++++---- apps/mobile/src/screens/WorkspacePickers.tsx | 36 ++++++---- apps/mobile/src/screens/WorkspaceScreen.tsx | 72 ++++++++++++++++--- .../src/screens/workspace-memo-cache.test.ts | 9 +++ .../src/screens/workspace-memo-cache.ts | 4 ++ 9 files changed, 144 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6434ac58..f5ed6504 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,10 @@ - **核心链路从严**:安装器、自动升级、数据存储、认证及迁移的机制或配置变化一律按高风险变更处理,必须验证真实的旧版本到新版本链路,不能只验证新版本自身。 - **禁止无依据保证**:未完成对应平台及跨版本验证时,必须明确标注风险,严禁声称“不影响现有用户”或“不影响自动升级”。 +## Android 调测约束 + +- **USB 真机优先**:Android 功能调测必须使用通过 USB 连接的真实设备和 `adb`,不得启动或使用 QEMU、AVD 等 Android 模拟器;未连接可用真机时,明确标注真机验证未完成,不得以模拟器结果替代。 + ## GitHub Actions 与 Release 约束及流程 1. **Fork 工作流边界**:配置 GitHub Actions 时必须考虑大量用户会 Fork 仓库进行自部署;仅官方仓库需要的 Job 必须使用 `github.repository == 'tianma-if/edgeever'` 门禁,严禁在下游 Fork 中分配 Runner 或执行。 diff --git a/apps/mobile/src/lib/local-mirror.ts b/apps/mobile/src/lib/local-mirror.ts index 6477215e..d4c52bb5 100644 --- a/apps/mobile/src/lib/local-mirror.ts +++ b/apps/mobile/src/lib/local-mirror.ts @@ -29,6 +29,7 @@ export type LocalMemoListParams = { notebookId?: string | null; notebookIds?: string[]; q?: string; + tag?: string; trash?: boolean; sort?: MemoSortMode; filter?: MemoFilterMode; @@ -116,6 +117,12 @@ export const listLocalMemos = async (scope: string, params: LocalMemoListParams) const q = `%${params.q.trim()}%`; binds.push(q, q, q); } + if (params.tag?.trim()) { + conditions.push( + "EXISTS (SELECT 1 FROM json_each(mobile_memos.data_json, '$.tags') AS memo_tag WHERE LOWER(CAST(memo_tag.value AS TEXT)) = LOWER(?))" + ); + binds.push(params.tag.trim()); + } if (params.filter === "tagged") { conditions.push("tags_text <> ''"); } else if (params.filter === "untagged") { diff --git a/apps/mobile/src/lib/mobile-locale.tsx b/apps/mobile/src/lib/mobile-locale.tsx index 6523a639..26141406 100644 --- a/apps/mobile/src/lib/mobile-locale.tsx +++ b/apps/mobile/src/lib/mobile-locale.tsx @@ -108,6 +108,10 @@ const mobileOnlyTranslations = new Map([ ["笔记标签", "Note tags"], ["选择笔记本", "Choose notebook"], ["点选已有标签,或输入名称创建新标签", "Select existing tags or enter a name to create a new one"], + ["按标签筛选", "Filter by tag"], + ["选择一个标签,只查看带有该标签的笔记", "Choose a tag to view only notes with that tag"], + ["搜索标签", "Search tags"], + ["没有匹配的标签", "No matching tags"], ["搜索或输入新标签", "Search or enter a new tag"], ["没有匹配的现有标签,可直接新建", "No matching tags. You can create a new one."], ["新建", "Create"], diff --git a/apps/mobile/src/screens/WorkspaceActionSheets.tsx b/apps/mobile/src/screens/WorkspaceActionSheets.tsx index 3759457f..5685518b 100644 --- a/apps/mobile/src/screens/WorkspaceActionSheets.tsx +++ b/apps/mobile/src/screens/WorkspaceActionSheets.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import type { MemoSortMode } from "@edgeever/client"; import { Modal, ScrollView, View } from "react-native"; -import { Check, CheckSquare, FileText, Folder, List, MoreVertical, Sparkles, Trash2, X } from "../components/icons"; +import { Check, CheckSquare, FileText, Folder, List, MoreVertical, Sparkles, Tag, Trash2, X } from "../components/icons"; import { Pressable, Text } from "../components/LocalizedText"; import type { MobileMemoListDensity } from "../lib/preferences"; import { styles } from "./workspace-styles"; @@ -79,6 +79,7 @@ export const NotesActionsModal = ({ onClose, onEnterSelection, onMemoListDensityChange, + onOpenTagFilter, onSortModeChange, selectionMode, visible, @@ -92,6 +93,7 @@ export const NotesActionsModal = ({ onClose: () => void; onEnterSelection: () => void; onMemoListDensityChange: (density: MobileMemoListDensity) => void; + onOpenTagFilter: () => void; onSortModeChange: (sortMode: MemoSortMode) => void; selectionMode: boolean; visible: boolean; @@ -119,6 +121,12 @@ export const NotesActionsModal = ({ label="选择笔记" onPress={onEnterSelection} /> + } + label="按标签筛选" + onPress={onOpenTagFilter} + /> ) : null} diff --git a/apps/mobile/src/screens/WorkspaceNotesView.tsx b/apps/mobile/src/screens/WorkspaceNotesView.tsx index 5ad32591..4118762c 100644 --- a/apps/mobile/src/screens/WorkspaceNotesView.tsx +++ b/apps/mobile/src/screens/WorkspaceNotesView.tsx @@ -31,6 +31,7 @@ export const NotesView = ({ notebooks, onCreate, onCreateFromTemplate, + onClearTag, onClearSelection, onFilterModeChange, onOpenActions, @@ -43,6 +44,7 @@ export const NotesView = ({ onSearchTextChange, onSetMemoView, searchText, + selectedTag, totalMemoCount, selectedMemoIds, selectionMode, @@ -62,6 +64,7 @@ export const NotesView = ({ notebooks: Notebook[]; onCreate: () => void; onCreateFromTemplate?: () => void; + onClearTag: () => void; onClearSelection: () => void; onFilterModeChange: (filterMode: MemoFilterMode) => void; onOpenActions: () => void; @@ -74,6 +77,7 @@ export const NotesView = ({ onSearchTextChange: (value: string) => void; onSetMemoView: (memoView: MemoView) => void; searchText: string; + selectedTag: string | null; totalMemoCount: number; selectionMode: boolean; selectedMemoIds: Set; @@ -81,15 +85,17 @@ export const NotesView = ({ const { resolvedTheme } = useMobileTheme(); const { preference: localePreference, translate } = useMobileLocale(); const searchActive = searchText.trim().length > 0; - const filterActive = memoFilterMode !== "all"; + const filterActive = memoFilterMode !== "all" || Boolean(selectedTag); const searchStatusLabel = translate("正在搜索"); const searchResultLabel = translate(`${totalMemoCount} 条结果`); const exitSearchLabel = translate("退出搜索"); - const activeFilterLabel = memoFilterMode === "pinned" - ? translate("置顶") - : memoFilterMode === "tagged" - ? translate("有标签") - : translate("无标签"); + const activeFilterLabel = selectedTag + ? `#${selectedTag}` + : memoFilterMode === "pinned" + ? translate("置顶") + : memoFilterMode === "tagged" + ? translate("有标签") + : translate("无标签"); const filterResultLabel = translate(`筛选:${activeFilterLabel} · ${totalMemoCount} 条`); const resetFilterLabel = translate("重置"); @@ -107,16 +113,16 @@ export const NotesView = ({ ) : null} onSetMemoView("notebook") : onOpenNotebookPicker} + onPress={memoView === "trash" ? () => onSetMemoView("notebook") : selectedTag ? onClearTag : onOpenNotebookPicker} style={styles.mobileNotebookTitleButton} > - {memoView === "trash" ? : null} + {memoView === "trash" || selectedTag ? : null} - {memoView === "trash" ? "回收站" : activeNotebook?.name ?? "全部笔记"} + {memoView === "trash" ? "回收站" : selectedTag ? `#${selectedTag}` : activeNotebook?.name ?? "全部笔记"} - {memoView === "notebook" ? : null} + {memoView === "notebook" && !selectedTag ? : null} @@ -177,7 +183,7 @@ export const NotesView = ({ onSearchTextChange("") : () => onFilterModeChange("all")} + onPress={searchActive ? () => onSearchTextChange("") : selectedTag ? onClearTag : () => onFilterModeChange("all")} > {searchActive ? exitSearchLabel : resetFilterLabel} @@ -189,7 +195,7 @@ export const NotesView = ({ 0 && !searchActive && memoFilterMode === "all" + emptyActions={memoView === "notebook" && notebooks.length > 0 && !searchActive && !filterActive ? [ { label: "新建笔记", onPress: onCreate, variant: "primary" as const }, ...(onCreateFromTemplate @@ -197,8 +203,8 @@ export const NotesView = ({ : []), ] : undefined} - emptyDescription={searchActive ? "换个关键词再试" : memoFilterMode !== "all" ? "试试切换筛选条件,或调整搜索关键词。" : memoView === "trash" ? "删除的笔记会显示在这里。" : "先创建一条笔记,之后可以在这里快速预览、搜索和批量整理。"} - emptyTitle={searchActive ? "没有找到匹配笔记" : memoFilterMode !== "all" ? "没有符合筛选的笔记" : memoView === "trash" ? "回收站为空" : "暂无笔记"} + emptyDescription={searchActive ? "换个关键词再试" : filterActive ? "试试切换筛选条件,或调整搜索关键词。" : memoView === "trash" ? "删除的笔记会显示在这里。" : "先创建一条笔记,之后可以在这里快速预览、搜索和批量整理。"} + emptyTitle={searchActive ? "没有找到匹配笔记" : filterActive ? "没有符合筛选的笔记" : memoView === "trash" ? "回收站为空" : "暂无笔记"} error={error} initialSyncProgress={initialSyncProgress} isError={isError} diff --git a/apps/mobile/src/screens/WorkspacePickers.tsx b/apps/mobile/src/screens/WorkspacePickers.tsx index 3a3b117a..3b20c1a2 100644 --- a/apps/mobile/src/screens/WorkspacePickers.tsx +++ b/apps/mobile/src/screens/WorkspacePickers.tsx @@ -351,16 +351,24 @@ export const SmartTagButton = ({ }; export const TagPickerModal = ({ + allowCreate = true, dataScope, + description = "点选已有标签,或输入名称创建新标签", + maxSelections = 24, onChange, onClose, selectedTags, + title = "选择标签", visible, }: { + allowCreate?: boolean; dataScope: string; + description?: string; + maxSelections?: number; onChange: (tags: string[]) => void; onClose: () => void; selectedTags: string[]; + title?: string; visible: boolean; }) => { const { translate } = useMobileLocale(); @@ -382,10 +390,14 @@ export const TagPickerModal = ({ } }, [visible]); - const commit = (nextTags: string[]) => onChange(Array.from(new Set(nextTags)).slice(0, 24)); - const toggleTag = (name: string) => commit( - selectedTags.includes(name) ? selectedTags.filter((tag) => tag !== name) : [...selectedTags, name] - ); + const commit = (nextTags: string[]) => onChange(Array.from(new Set(nextTags)).slice(0, maxSelections)); + const toggleTag = (name: string) => { + if (selectedTags.includes(name)) { + commit(selectedTags.filter((tag) => tag !== name)); + return; + } + commit(maxSelections === 1 ? [name] : [...selectedTags, name]); + }; const createTag = () => { const additions = parseTags(normalizedSearch); if (additions.length === 0) return; @@ -400,8 +412,8 @@ export const TagPickerModal = ({ - {translate("选择标签")} - {translate("点选已有标签,或输入名称创建新标签")} + {translate(title)} + {translate(description)} @@ -423,18 +435,18 @@ export const TagPickerModal = ({ - {normalizedSearch && !exactMatch && selectedTags.length < 24 ? ( + {allowCreate && normalizedSearch && !exactMatch && selectedTags.length < maxSelections ? ( {translate("新建")} @@ -446,7 +458,7 @@ export const TagPickerModal = ({ ) : visibleTags.length === 0 ? ( - {translate("没有匹配的现有标签,可直接新建")} + {translate(allowCreate ? "没有匹配的现有标签,可直接新建" : "没有匹配的标签")} ) : visibleTags.map((tag) => { const selected = selectedTags.includes(tag.name); diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index 1258ff56..6d64cdba 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -102,6 +102,7 @@ import { MemoDetailModal } from "./WorkspaceMemoDetail"; import { MoveSelectionModal, NotebookPickerModal, + TagPickerModal, } from "./WorkspacePickers"; import { RevisionHistoryModal } from "./WorkspaceRevisionHistory"; import { CreateMemoModal, RichEditorModal } from "./WorkspaceEditors"; @@ -152,6 +153,7 @@ export const WorkspaceScreen = ({ const autoSelectedDemoNotebookRef = useRef(false); const [memoView, setMemoView] = useState("notebook"); const [memoFilterMode, setMemoFilterMode] = useState("all"); + const [selectedTag, setSelectedTag] = useState(null); const [memoSortMode, setMemoSortMode] = useState("updated-desc"); const [memoListDensity, setMemoListDensity] = useState("preview"); const [imageCompressionEnabled, setImageCompressionEnabled] = useState(true); @@ -167,6 +169,7 @@ export const WorkspaceScreen = ({ const [isImportingShare, setIsImportingShare] = useState(false); const [notesActionsOpen, setNotesActionsOpen] = useState(false); const [notebookPickerOpen, setNotebookPickerOpen] = useState(false); + const [tagFilterPickerOpen, setTagFilterPickerOpen] = useState(false); const [richEditingSession, setRichEditingSession] = useState(null); const [revisionMemo, setRevisionMemo] = useState(null); const { @@ -234,7 +237,7 @@ export const WorkspaceScreen = ({ return; } - if (!autoSelectedDemoNotebookRef.current && activeNotebookId === ALL_NOTES_ID) { + if (!autoSelectedDemoNotebookRef.current && activeNotebookId === ALL_NOTES_ID && !selectedTag) { autoSelectedDemoNotebookRef.current = true; setActiveNotebookId(preferredNotebookId); return; @@ -243,16 +246,16 @@ export const WorkspaceScreen = ({ if (activeNotebookId === alternateNotebookId) { setActiveNotebookId(preferredNotebookId); } - }, [activeNotebookId, localePreference, notebooks]); + }, [activeNotebookId, localePreference, notebooks, selectedTag]); const activeNotebook = notebooks.find((notebook) => notebook.id === activeNotebookId) ?? null; const activeNotebookDescendantIds = useMemo( - () => (activeNotebookId === ALL_NOTES_ID ? [] : getNotebookDescendantIds(notebooks, activeNotebookId)), - [activeNotebookId, notebooks] + () => (activeNotebookId === ALL_NOTES_ID || selectedTag ? [] : getNotebookDescendantIds(notebooks, activeNotebookId)), + [activeNotebookId, notebooks, selectedTag] ); const memosQuery = useInfiniteQuery({ - queryKey: ["mobile", "memos", memoView, activeNotebookId, memoFilterMode, memoSortMode, activeNotebookDescendantIds, "paged-v2"], + queryKey: ["mobile", "memos", memoView, activeNotebookId, memoFilterMode, memoSortMode, activeNotebookDescendantIds, selectedTag, "paged-v3"], initialPageParam: 0, queryFn: async ({ pageParam }) => { if (!client) { @@ -262,6 +265,7 @@ export const WorkspaceScreen = ({ return listLocalMemos(dataScope, { notebookIds: activeNotebookDescendantIds, filter: memoFilterMode, + tag: memoView === "notebook" ? selectedTag ?? undefined : undefined, limit: 50, offset: pageParam, sort: memoSortMode, @@ -274,7 +278,7 @@ export const WorkspaceScreen = ({ }); const searchQuery = useInfiniteQuery({ - queryKey: ["mobile", "search", memoView, debouncedSearchText, activeNotebookId, memoFilterMode, memoSortMode, activeNotebookDescendantIds, "paged-v4"], + queryKey: ["mobile", "search", memoView, debouncedSearchText, activeNotebookId, memoFilterMode, memoSortMode, activeNotebookDescendantIds, selectedTag, "paged-v5"], initialPageParam: 0, queryFn: async ({ pageParam }) => { if (!client) { @@ -285,6 +289,7 @@ export const WorkspaceScreen = ({ q: debouncedSearchText, notebookIds: activeNotebookDescendantIds, filter: memoFilterMode, + tag: memoView === "notebook" ? selectedTag ?? undefined : undefined, limit: 50, offset: pageParam, sort: memoSortMode, @@ -342,6 +347,11 @@ export const WorkspaceScreen = ({ } return true; } + if (selectedTag) { + setSelectedTag(null); + setMemoFilterMode("all"); + return true; + } if (memoView === "trash") { setMemoView("notebook"); return true; @@ -349,7 +359,7 @@ export const WorkspaceScreen = ({ return false; }); return () => subscription.remove(); - }, [activeView, clearSelection, memoView, searchText, selectedMemoId, selectionMode]); + }, [activeView, clearSelection, memoView, searchText, selectedMemoId, selectedTag, selectionMode]); useEffect(() => { if (notebooksQuery.data && memosQuery.data) { @@ -386,13 +396,28 @@ export const WorkspaceScreen = ({ setActiveView("notes"); setMemoView("notebook"); setActiveNotebookId(ALL_NOTES_ID); + setSelectedTag(null); setSearchText(""); clearSelection(); }; + const clearTagFilter = () => { + setSelectedTag(null); + setMemoFilterMode("all"); + clearSelection(); + }; + + const handleMemoFilterModeChange = (nextFilterMode: MemoFilterMode) => { + if (nextFilterMode !== "all") { + setSelectedTag(null); + } + setMemoFilterMode(nextFilterMode); + }; + const showTrash = () => { setMemoView("trash"); setActiveNotebookId(ALL_NOTES_ID); + setSelectedTag(null); setSearchText(""); clearSelection(); }; @@ -671,7 +696,7 @@ export const WorkspaceScreen = ({ useEffect(() => { clearSelection(); - }, [activeNotebookId, clearSelection, memoFilterMode, memoSortMode, memoView]); + }, [activeNotebookId, clearSelection, memoFilterMode, memoSortMode, memoView, selectedTag]); useEffect(() => { let mounted = true; @@ -1211,8 +1236,9 @@ export const WorkspaceScreen = ({ notebooks={notebooks} onCreate={() => openCreateMemo()} onCreateFromTemplate={canCreateMemo ? openCreateFromTemplate : undefined} + onClearTag={clearTagFilter} onClearSelection={clearSelection} - onFilterModeChange={setMemoFilterMode} + onFilterModeChange={handleMemoFilterModeChange} onOpenActions={() => setNotesActionsOpen(true)} onOpenNotebookPicker={() => setNotebookPickerOpen(true)} onMemoPress={handleMemoPress} @@ -1233,6 +1259,7 @@ export const WorkspaceScreen = ({ }} onSetMemoView={(nextMemoView) => nextMemoView === "trash" ? showTrash() : showAllNotes()} searchText={searchText} + selectedTag={selectedTag} totalMemoCount={searchActive ? searchQuery.data?.pages[0]?.totalCount ?? searchResults.length : memosQuery.data?.pages[0]?.totalCount ?? memos.length} @@ -1290,12 +1317,33 @@ export const WorkspaceScreen = ({ notebooks={notebooks} onClose={() => setNotebookPickerOpen(false)} onSelect={(notebookId) => { + setSelectedTag(null); setActiveNotebookId(notebookId); setNotebookPickerOpen(false); }} visible /> : null} + {tagFilterPickerOpen ? { + setSelectedTag(tags[0] ?? null); + setMemoFilterMode("all"); + setMemoView("notebook"); + setActiveNotebookId(ALL_NOTES_ID); + setSearchText(""); + clearSelection(); + setTagFilterPickerOpen(false); + }} + onClose={() => setTagFilterPickerOpen(false)} + selectedTags={selectedTag ? [selectedTag] : []} + title="按标签筛选" + visible + /> : null} + {revisionMemo ? setRevisionMemo(null)} @@ -1366,13 +1414,17 @@ export const WorkspaceScreen = ({ memoListDensity={memoListDensity} memoSortMode={memoSortMode} listDescription={`${searchActive ? searchQuery.data?.pages[0]?.totalCount ?? searchResults.length : memosQuery.data?.pages[0]?.totalCount ?? memos.length} 条笔记`} - listTitle={memoView === "trash" ? "回收站" : activeNotebook?.name ?? "全部笔记"} + listTitle={memoView === "trash" ? "回收站" : selectedTag ? `#${selectedTag}` : activeNotebook?.name ?? "全部笔记"} onClose={() => setNotesActionsOpen(false)} onEnterSelection={() => { setNotesActionsOpen(false); enterSelectionMode(); }} onMemoListDensityChange={handleMemoListDensityChange} + onOpenTagFilter={() => { + setNotesActionsOpen(false); + setTagFilterPickerOpen(true); + }} onSortModeChange={setMemoSortMode} selectionMode={selectionMode} visible diff --git a/apps/mobile/src/screens/workspace-memo-cache.test.ts b/apps/mobile/src/screens/workspace-memo-cache.test.ts index 369ad4c8..592a618d 100644 --- a/apps/mobile/src/screens/workspace-memo-cache.test.ts +++ b/apps/mobile/src/screens/workspace-memo-cache.test.ts @@ -49,4 +49,13 @@ describe("mobile workspace memo cache", () => { expect(memoMatchesListQuery(memo, ["mobile", "memos", "notebook", "notebook_2", "all", "updated-desc", ["notebook_2"]])).toBe(false); expect(memoMatchesListQuery(memo, ["mobile", "memos", "notebook", "all", "tagged", "updated-desc", []])).toBe(false); }); + + test("matches one exact tag without depending on case", () => { + const taggedMemo = { ...memo, tags: ["Project Alpha", "Work"] }; + const query = (tag: string) => ["mobile", "memos", "notebook", "all", "all", "updated-desc", [], tag]; + + expect(memoMatchesListQuery(taggedMemo, query("project alpha"))).toBe(true); + expect(memoMatchesListQuery(taggedMemo, query("project"))).toBe(false); + expect(memoMatchesListQuery(taggedMemo, query("homework"))).toBe(false); + }); }); diff --git a/apps/mobile/src/screens/workspace-memo-cache.ts b/apps/mobile/src/screens/workspace-memo-cache.ts index 71a53ed0..a813d3e1 100644 --- a/apps/mobile/src/screens/workspace-memo-cache.ts +++ b/apps/mobile/src/screens/workspace-memo-cache.ts @@ -44,6 +44,7 @@ export const memoMatchesListQuery = (memo: MemoSummary, queryKey: readonly unkno const notebookId = queryKey[3]; const filter = queryKey[4]; const notebookIds = Array.isArray(queryKey[6]) ? queryKey[6] : []; + const tag = typeof queryKey[7] === "string" ? queryKey[7].trim().toLocaleLowerCase() : ""; if ((view === "trash") !== memo.isDeleted) { return false; @@ -51,6 +52,9 @@ export const memoMatchesListQuery = (memo: MemoSummary, queryKey: readonly unkno if (notebookId !== ALL_NOTES_ID && !notebookIds.includes(memo.notebookId)) { return false; } + if (tag && !memo.tags.some((memoTag) => memoTag.toLocaleLowerCase() === tag)) { + return false; + } if (filter === "tagged" && memo.tags.length === 0) { return false; }