feat(mobile): filter notes by tag

This commit is contained in:
tianma-if committed 2026-09-08 22:12:26 +08:00
1 parent b55e81713b
commit 16934f668d
9 files changed
+144 -38

No files matched your search

+4
View File
@@ -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 或执行。
+7
View File
@@ -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") {
+4
View File
@@ -108,6 +108,10 @@ const mobileOnlyTranslations = new Map<string, string>([
["笔记标签", "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"],
@@ -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}
/>
<ActionSheetItem
compact
icon={<Tag color="#0f172a" size={18} />}
label="按标签筛选"
onPress={onOpenTagFilter}
/>
<View style={styles.listActionDivider} />
</>
) : null}
+21 -15
View File
@@ -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<string>;
@@ -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}
<View style={styles.mobileListTitleRow}>
<Pressable
accessibilityLabel={memoView === "trash" ? "返回笔记列表" : "切换笔记本"}
accessibilityLabel={memoView === "trash" || selectedTag ? "返回笔记列表" : "切换笔记本"}
accessibilityRole="button"
onPress={memoView === "trash" ? () => onSetMemoView("notebook") : onOpenNotebookPicker}
onPress={memoView === "trash" ? () => onSetMemoView("notebook") : selectedTag ? onClearTag : onOpenNotebookPicker}
style={styles.mobileNotebookTitleButton}
>
{memoView === "trash" ? <ChevronLeft color="#475569" size={18} /> : null}
{memoView === "trash" || selectedTag ? <ChevronLeft color="#475569" size={18} /> : null}
<Text numberOfLines={1} style={styles.mobileNotebookTitle}>
{memoView === "trash" ? "回收站" : activeNotebook?.name ?? "全部笔记"}
{memoView === "trash" ? "回收站" : selectedTag ? `#${selectedTag}` : activeNotebook?.name ?? "全部笔记"}
</Text>
{memoView === "notebook" ? <ChevronDown color="#64748b" size={16} /> : null}
{memoView === "notebook" && !selectedTag ? <ChevronDown color="#64748b" size={16} /> : null}
</Pressable>
<Pressable accessibilityLabel={selectionMode ? "批量操作" : "列表选项"} accessibilityRole="button" onPress={onOpenActions} style={styles.mobileMoreButton}>
<MoreHorizontal color="#475569" size={20} />
@@ -177,7 +183,7 @@ export const NotesView = ({
<Pressable
accessibilityLabel={searchActive ? exitSearchLabel : resetFilterLabel}
accessibilityRole="button"
onPress={searchActive ? () => onSearchTextChange("") : () => onFilterModeChange("all")}
onPress={searchActive ? () => onSearchTextChange("") : selectedTag ? onClearTag : () => onFilterModeChange("all")}
>
<Text style={[styles.mobileListConstraintAction, !searchActive && styles.mobileListConstraintActionFilter]}>
{searchActive ? exitSearchLabel : resetFilterLabel}
@@ -189,7 +195,7 @@ export const NotesView = ({
</View>
<MemoList
emptyActions={memoView === "notebook" && notebooks.length > 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}
+24 -12
View File
@@ -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 = ({
<View style={styles.actionSheetHandle} />
<View style={styles.notebookPickerHeader}>
<View style={styles.notebookPickerHeaderText}>
<Text style={styles.actionSheetTitle}>{translate("选择标签")}</Text>
<Text style={styles.panelLabel}>{translate("点选已有标签,或输入名称创建新标签")}</Text>
<Text style={styles.actionSheetTitle}>{translate(title)}</Text>
<Text style={styles.panelLabel}>{translate(description)}</Text>
</View>
<Pressable accessibilityLabel="关闭" accessibilityRole="button" onPress={onClose} style={styles.notebookPickerCloseButton}>
<X color="#0f172a" size={20} />
@@ -423,18 +435,18 @@ export const TagPickerModal = ({
<View style={styles.notebookPickerSearchBox}>
<Search color="#64748b" size={18} />
<TextInput
accessibilityLabel="搜索或输入新标签"
accessibilityLabel={allowCreate ? "搜索或输入新标签" : "搜索标签"}
autoCapitalize="none"
autoCorrect={false}
onChangeText={setSearchText}
onSubmitEditing={createTag}
placeholder="搜索或输入新标签"
onSubmitEditing={allowCreate ? createTag : undefined}
placeholder={allowCreate ? "搜索或输入新标签" : "搜索标签"}
placeholderTextColor="#94a3b8"
returnKeyType="done"
returnKeyType={allowCreate ? "done" : "search"}
style={styles.notebookPickerSearchInput}
value={searchText}
/>
{normalizedSearch && !exactMatch && selectedTags.length < 24 ? (
{allowCreate && normalizedSearch && !exactMatch && selectedTags.length < maxSelections ? (
<Pressable accessibilityLabel={`新建标签 ${normalizedSearch}`} accessibilityRole="button" onPress={createTag}>
<Text style={styles.tagPickerCreateText}>{translate("新建")}</Text>
</Pressable>
@@ -446,7 +458,7 @@ export const TagPickerModal = ({
) : visibleTags.length === 0 ? (
<View style={styles.emptyInlinePanel}>
<Tag color="#94a3b8" size={28} />
<Text style={styles.mutedText}>{translate("没有匹配的现有标签,可直接新建")}</Text>
<Text style={styles.mutedText}>{translate(allowCreate ? "没有匹配的现有标签,可直接新建" : "没有匹配的标签")}</Text>
</View>
) : visibleTags.map((tag) => {
const selected = selectedTags.includes(tag.name);
+62 -10
View File
@@ -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<MemoView>("notebook");
const [memoFilterMode, setMemoFilterMode] = useState<MemoFilterMode>("all");
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [memoSortMode, setMemoSortMode] = useState<MemoSortMode>("updated-desc");
const [memoListDensity, setMemoListDensity] = useState<MobileMemoListDensity>("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<RichEditingSession | null>(null);
const [revisionMemo, setRevisionMemo] = useState<MemoDetail | null>(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 ? <TagPickerModal
allowCreate={false}
dataScope={dataScope}
description="选择一个标签,只查看带有该标签的笔记"
maxSelections={1}
onChange={(tags) => {
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 ? <RevisionHistoryModal
memo={revisionMemo}
onClose={() => 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
@@ -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);
});
});
@@ -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;
}