diff --git a/README.md b/README.md index eed163b..8d265c7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [立即体验](https://face.dogxi.me) · [报告问题](https://github.com/dogxii/iFace/issues) · [功能建议](https://github.com/dogxii/iFace/issues) -![Version](https://img.shields.io/badge/version-1.3.0-6366f1?style=flat-square) +![Version](https://img.shields.io/badge/version-1.4.0-6366f1?style=flat-square) ![License](https://img.shields.io/badge/license-MIT-10b981?style=flat-square) ![React](https://img.shields.io/badge/React-19-61dafb?style=flat-square&logo=react) ![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178c6?style=flat-square&logo=typescript) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0992185..97be973 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # iFace 1.0 Roadmap -当前版本:`1.3.0` +当前版本:`1.4.0` iFace 1.0.0 的目标不是堆更多页面,而是把现有刷题、笔记、AI 反馈、数据备份这几条核心链路打磨到稳定可日用。 diff --git a/docs/SMOKE_RESULT_2026-05-05.md b/docs/SMOKE_RESULT_2026-05-05.md index fef4c40..820a9f6 100644 --- a/docs/SMOKE_RESULT_2026-05-05.md +++ b/docs/SMOKE_RESULT_2026-05-05.md @@ -5,7 +5,7 @@ ## 环境 - 日期:2026-05-06 -- 应用版本:`1.3.0` +- 应用版本:`1.4.0` - 生产预览:`http://127.0.0.1:4173` - 浏览器验证:Playwright CLI - 外部服务验证:真实 AI Key、真实 GitHub Gist Token,记录文件不包含密钥 diff --git a/package.json b/package.json index 26db764..9db7bb2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "iface", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.tsx b/src/App.tsx index c9fe7ce..8b07fa2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense, useEffect, useLayoutEffect } from 'react' import { BrowserRouter, Route, Routes, useLocation, useParams } from 'react-router-dom' import { Navbar } from '@/components/layout/Navbar' +import { OnboardingGuide } from '@/components/layout/OnboardingGuide' import { Spinner } from '@/components/ui' import { AppErrorBoundary } from '@/components/ui/AppErrorBoundary' import { PWAUpdatePrompt } from '@/components/ui/PWAUpdatePrompt' @@ -121,6 +122,7 @@ export default function App() {
+ diff --git a/src/components/layout/OnboardingGuide.tsx b/src/components/layout/OnboardingGuide.tsx new file mode 100644 index 0000000..7e64bcd --- /dev/null +++ b/src/components/layout/OnboardingGuide.tsx @@ -0,0 +1,716 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useLocation } from 'react-router-dom' +import { Button } from '@/components/ui' +import { type CategoryMap, DEFAULT_CATEGORY_MAP, getCategoryMap } from '@/lib/db' +import { BUILTIN_CATEGORIES } from '@/lib/questionLoader' +import { useStudyStore } from '@/store/useStudyStore' + +const ONBOARDING_DONE_KEY = 'iface_onboarding_done_v1' + +type StepId = 'welcome' | 'banks' | 'workflow' + +const steps: { id: StepId; label: string }[] = [ + { id: 'welcome', label: '欢迎' }, + { id: 'banks', label: '题库' }, + { id: 'workflow', label: '开始' }, +] + +function hasCompletedOnboarding(): boolean { + try { + return localStorage.getItem(ONBOARDING_DONE_KEY) === '1' + } catch { + return true + } +} + +function markOnboardingDone(): void { + try { + localStorage.setItem(ONBOARDING_DONE_KEY, '1') + } catch { + // ignore + } +} + +function IconCheck() { + return ( + + + + ) +} + +function IconArrowRight() { + return ( + + + + + ) +} + +function IconClose() { + return ( + + + + + ) +} + +function FeatureRow({ + title, + description, + index, +}: { + title: string + description: string + index: number +}) { + return ( +
+ + {index + 1} + +
+

+ {title} +

+

{description}

+
+
+ ) +} + +export function OnboardingGuide() { + const location = useLocation() + const { hiddenCategories, setHiddenCategories } = useStudyStore() + const [open, setOpen] = useState(false) + const [stepIndex, setStepIndex] = useState(0) + const [categoryMap, setCategoryMap] = useState({ ...DEFAULT_CATEGORY_MAP }) + + const step = steps[stepIndex] + + useEffect(() => { + getCategoryMap().then(setCategoryMap) + }, []) + + useEffect(() => { + if (location.pathname === '/api/auth' || hasCompletedOnboarding()) return + const frame = window.requestAnimationFrame(() => setOpen(true)) + return () => window.cancelAnimationFrame(frame) + }, [location.pathname]) + + useEffect(() => { + if (!open) return + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + markOnboardingDone() + setOpen(false) + } + } + + window.addEventListener('keydown', onKeyDown) + return () => { + document.body.style.overflow = previousOverflow + window.removeEventListener('keydown', onKeyDown) + } + }, [open]) + + const categories = useMemo( + () => + Object.entries(categoryMap).sort(([, a], [, b]) => { + if (a.builtin !== b.builtin) return a.builtin ? -1 : 1 + return (a.order ?? 99) - (b.order ?? 99) + }), + [categoryMap], + ) + + const visibleCount = categories.filter(([key]) => !hiddenCategories.has(key)).length + + const close = useCallback(() => { + markOnboardingDone() + setOpen(false) + }, []) + + const toggleCategory = useCallback( + (categoryName: string) => { + const nextHidden = new Set(hiddenCategories) + if (nextHidden.has(categoryName)) { + nextHidden.delete(categoryName) + } else { + nextHidden.add(categoryName) + } + setHiddenCategories([...nextHidden]) + }, + [hiddenCategories, setHiddenCategories], + ) + + const setAllCategoriesVisible = useCallback(() => { + setHiddenCategories([]) + }, [setHiddenCategories]) + + const setOnlyCategoryVisible = useCallback( + (categoryName: string) => { + setHiddenCategories(categories.filter(([key]) => key !== categoryName).map(([key]) => key)) + }, + [categories, setHiddenCategories], + ) + + if (!open) { + return null + } + + return ( + <> +
+
+
+
+
+
+ + {stepIndex + 1} / {steps.length} + +
+

+ {step.id === 'welcome' + ? '欢迎来到 iFace!' + : step.id === 'banks' + ? '题库设置' + : '准备好了'} +

+
+ +
+ +
+ + +
+ {step.id === 'welcome' && ( +
+

+ 人你好,我是项目作者 Dogxi 👋
+ 很高兴你来到这里 🎉,这里是一个永远开源免费的八股面试题网站! +
+ 接下来需要先花半分钟完成设置,能让刷题体验轻松很多哦。 +
+ iFace 拥有超多题目,以及下面三大功能,至于更多...我想你主动去发现会更有意思 👀 +
+ 项目地址:https://github.com/dogxii/iface,交流Q群:279167739 +
+ 作者主页:https://dogxi.me,最后感谢大家的喜欢 ❤️! +

+
+ + + +
+
+ )} + + {step.id === 'banks' && ( +
+

+ ⚙️ 这里是题库设置页面,你可以关闭你不需要的题目分类, +
+ 不用有任何顾虑!题目关闭之后仍然可以在设置页面重新打开 ✅ +
+ (未来 iFace 仍会添加更多但高质量的题目!) +

+ +
+ + {categories.slice(0, 4).map(([key, category]) => ( + + ))} +
+ +
+ {categories.map(([key, category]) => { + const enabled = !hiddenCategories.has(key) + const builtinCat = BUILTIN_CATEGORIES.find((item) => item.category === key) + const fileCount = builtinCat?.files.length ?? 0 + return ( + + ) + })} +
+ +

+ 当前显示 {visibleCount} 个题库。隐藏只是收起入口和统计,不会删除题目、 + 笔记或学习记录。 +

+
+ )} + + {step.id === 'workflow' && ( +
+

+ 如果你不知道从哪里开始,推荐先打开设置,看看有什么可以调整 +
+ 然后,享受刷题! +
+ 人再见,祝你生活愉快,面试顺利 🎉 +
+ ps: 本引导窗口只在首次进入页面时显示。 +
+

+ +
+ {[ + { + title: '设置', + body: '右上角设置里可以调整题库展示、答题模式、每日目标和 AI 助手。', + label: '随时调整', + }, + { + title: '题库', + body: '从顶部导航进入题库,打开任意题目后,先作答再看参考答案。', + label: '逐题学习', + }, + { + title: '练习', + body: '想集中刷一组题时,去练习页按模块、难度和学习状态组合题目。', + label: '专项刷题', + }, + ].map((item) => ( +
+ + + {item.title} + + + {item.body} + + + + {item.label} + +
+ ))} +
+ +
+ 本网站强烈推荐配合面试食用 🍜 +
+
+ )} +
+
+ +
+ +
+ {stepIndex > 0 && ( + + )} + {stepIndex < steps.length - 1 ? ( + + ) : ( + + )} +
+
+
+
+ + + + ) +} diff --git a/src/components/layout/SettingsDrawer.tsx b/src/components/layout/SettingsDrawer.tsx index 8392d3e..075028c 100644 --- a/src/components/layout/SettingsDrawer.tsx +++ b/src/components/layout/SettingsDrawer.tsx @@ -1630,7 +1630,7 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) {

- 隐藏的题库不在首页展示统计和进度,但仍可在题库、练习页面访问。 + 关闭的题库会从首页、题库和练习中隐藏,学习记录仍会保留。

{(() => { @@ -1652,9 +1652,10 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) { key={key} type="button" onClick={() => { + const nextVisible = isHidden toggleCategoryVisibility(key) showToast( - hiddenCategories.has(key) + nextVisible ? `已显示「${cat.name}」题库` : `已隐藏「${cat.name}」题库`, ) @@ -1665,7 +1666,9 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) { gap: 12, padding: '10px 14px', borderRadius: 10, - border: `1px solid ${isHidden ? 'var(--border-subtle)' : 'rgba(var(--primary-rgb),0.3)'}`, + border: `1px solid ${ + isHidden ? 'var(--border-subtle)' : 'rgba(var(--primary-rgb),0.3)' + }`, background: isHidden ? 'var(--surface-2)' : 'var(--primary-light)', cursor: 'pointer', textAlign: 'left', diff --git a/src/hooks/useQuestions.ts b/src/hooks/useQuestions.ts index 465038d..7369f5a 100644 --- a/src/hooks/useQuestions.ts +++ b/src/hooks/useQuestions.ts @@ -23,6 +23,7 @@ interface UseQuestionsReturn { getDailyIds: ( recordMap: Record, count?: number, + questionIds?: string[], ) => Promise getAdjacentIds: ( currentId: string, @@ -222,8 +223,9 @@ export function useQuestions( async ( rm: Record, count = 10, + questionIds?: string[], ): Promise => { - const allIds = allQuestions.map((q) => q.id) + const allIds = questionIds ?? allQuestions.map((q) => q.id) return getDailyRecommendations(allIds, rm, count) }, [allQuestions], diff --git a/src/lib/questionLoader.ts b/src/lib/questionLoader.ts index 5cea85f..86355d3 100644 --- a/src/lib/questionLoader.ts +++ b/src/lib/questionLoader.ts @@ -582,8 +582,10 @@ export async function getDailyRecommendations( ): Promise { const cached = await getMeta(META_KEYS.DAILY_RECS) if (cached && cached.date === todayString()) { - const valid = cached.ids.filter((id) => allIds.includes(id)) - if (valid.length > 0) return valid + const allIdSet = new Set(allIds) + const valid = cached.ids.filter((id) => allIdSet.has(id)) + const targetCount = Math.min(count, allIds.length) + if (valid.length >= targetCount) return valid.slice(0, count) } const reviewIds = allIds diff --git a/src/lib/questionVisibility.ts b/src/lib/questionVisibility.ts new file mode 100644 index 0000000..7e312e2 --- /dev/null +++ b/src/lib/questionVisibility.ts @@ -0,0 +1,27 @@ +import type { CategoryMap } from '@/lib/db' +import type { Question } from '@/types' + +export function getHiddenModules( + categoryMap: CategoryMap, + hiddenCategories: ReadonlySet, +): Set { + const modules = new Set() + + for (const [categoryName, category] of Object.entries(categoryMap)) { + if (!hiddenCategories.has(categoryName)) continue + + for (const moduleName of category.modules) { + modules.add(moduleName) + } + } + + return modules +} + +export function filterVisibleQuestions>( + questions: T[], + hiddenModules: ReadonlySet, +): T[] { + if (hiddenModules.size === 0) return questions + return questions.filter((question) => !hiddenModules.has(question.module)) +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index f618e1a..9543dbe 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -3,7 +3,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { Button, EmptyState, SegmentedRing, Skeleton } from '@/components/ui' import { useQuestions } from '@/hooks/useQuestions' -import { DEFAULT_CATEGORY_MAP, getAllQuestionNotes, getCategoryMap } from '@/lib/db' +import { + type CategoryMap, + DEFAULT_CATEGORY_MAP, + getAllQuestionNotes, + getCategoryMap, +} from '@/lib/db' +import { filterVisibleQuestions, getHiddenModules } from '@/lib/questionVisibility' import { type StreakData, useStudyStore } from '@/store/useStudyStore' import { DIFFICULTY_LABELS, @@ -1311,38 +1317,27 @@ const IconClock = () => ( // ─── Main Dashboard ─────────────────────────────────────────────────────────── export default function Dashboard() { - const { questions, allQuestions, loading, initializing, getDailyIds } = useQuestions() - const { records, getEstimatedDays, streak, dailyGoal, hiddenCategories } = useStudyStore() - - // ── Resolve which module names belong to hidden categories ──────────────── - // We read from DEFAULT_CATEGORY_MAP synchronously for instant render, then - // upgrade with the full persisted map (which may include custom categories). - const [categoryModuleMap, setCategoryModuleMap] = useState>(() => - Object.fromEntries(Object.entries(DEFAULT_CATEGORY_MAP).map(([k, v]) => [k, v.modules])), - ) + const { allQuestions, loading, initializing, getDailyIds } = useQuestions() + const { records, streak, dailyGoal, hiddenCategories } = useStudyStore() + + const [categoryMap, setCategoryMap] = useState({ ...DEFAULT_CATEGORY_MAP }) useEffect(() => { - getCategoryMap().then((map) => { - setCategoryModuleMap(Object.fromEntries(Object.entries(map).map(([k, v]) => [k, v.modules]))) - }) + getCategoryMap().then(setCategoryMap) }, []) - // Set of module names that are in at least one hidden category - const hiddenModules = useMemo>(() => { - const s = new Set() - for (const [catName, modules] of Object.entries(categoryModuleMap)) { - if (hiddenCategories.has(catName)) { - for (const m of modules) s.add(m) - } - } - return s - }, [hiddenCategories, categoryModuleMap]) - - // Visible questions: exclude any question whose module is in a hidden category + const hiddenModules = useMemo( + () => getHiddenModules(categoryMap, hiddenCategories), + [categoryMap, hiddenCategories], + ) const visibleQuestions = useMemo( - () => allQuestions.filter((q) => !hiddenModules.has(q.module)), + () => filterVisibleQuestions(allQuestions, hiddenModules), [allQuestions, hiddenModules], ) + const visibleQuestionIds = useMemo( + () => visibleQuestions.map((question) => question.id), + [visibleQuestions], + ) const [questionNotes, setQuestionNotes] = useState([]) const [dailyIds, setDailyIds] = useState([]) @@ -1380,7 +1375,11 @@ export default function Dashboard() { }, []) useEffect(() => { - if (visibleQuestions.length === 0) return + if (visibleQuestionIds.length === 0) { + setDailyIds([]) + setDailyLoading(false) + return + } setDailyLoading(true) getDailyIds( Object.fromEntries( @@ -1390,10 +1389,11 @@ export default function Dashboard() { ]), ), dailyGoal, + visibleQuestionIds, ) .then(setDailyIds) .finally(() => setDailyLoading(false)) - }, [visibleQuestions.length, records, getDailyIds, dailyGoal]) + }, [visibleQuestionIds, records, getDailyIds, dailyGoal]) // Counts based on visible questions only const counts = useMemo(() => { @@ -1413,7 +1413,9 @@ export default function Dashboard() { const totalQuestions = visibleQuestions.length const masteredPercent = totalQuestions > 0 ? Math.round((counts.mastered / totalQuestions) * 100) : 0 - const estimatedDays = getEstimatedDays(totalQuestions, dailyGoal) + const remainingQuestions = Math.max(0, totalQuestions - counts.mastered) + const estimatedDays = + remainingQuestions === 0 ? 0 : Math.ceil(remainingQuestions / Math.max(1, dailyGoal)) const recentNoteItems = useMemo(() => { const questionMap = new Map(visibleQuestions.map((q) => [q.id, q])) @@ -1427,21 +1429,21 @@ export default function Dashboard() { }, [questionNotes, visibleQuestions]) // Module progress: derive from visible questions grouped by module, - // preserving the order defined in categoryModuleMap, then appending any - // modules not covered by any category (e.g. freshly-imported custom ones). + // preserving the order defined in the category map, then appending any + // modules not covered by any category. const moduleStats = useMemo(() => { // Ordered module names from visible categories const orderedModules: string[] = [] const seen = new Set() // Sort categories by their order field - const sortedCategories = Object.entries(categoryModuleMap).sort(([a], [b]) => { - const aOrder = DEFAULT_CATEGORY_MAP[a]?.order ?? 99 - const bOrder = DEFAULT_CATEGORY_MAP[b]?.order ?? 99 + const sortedCategories = Object.entries(categoryMap).sort(([, a], [, b]) => { + const aOrder = a.order ?? 99 + const bOrder = b.order ?? 99 return aOrder - bOrder }) - for (const [catName, modules] of sortedCategories) { + for (const [catName, category] of sortedCategories) { if (hiddenCategories.has(catName)) continue - for (const m of modules) { + for (const m of category.modules) { if (!seen.has(m)) { orderedModules.push(m) seen.add(m) @@ -1462,7 +1464,7 @@ export default function Dashboard() { questions: visibleQuestions.filter((q) => q.module === mod), })) .filter((s) => s.questions.length > 0) - }, [visibleQuestions, categoryModuleMap, hiddenCategories]) + }, [visibleQuestions, categoryMap, hiddenCategories]) if (initializing) { return ( @@ -1497,7 +1499,7 @@ export default function Dashboard() { {hasNoQuestions ? '暂无题目,请先导入题库' : allHidden - ? '所有题库已隐藏,可在设置 → 刷题偏好中调整' + ? '所有题库已关闭展示,可在设置 → 刷题偏好中调整' : `共 ${totalQuestions} 道题,已掌握 ${counts.mastered} 道`}

@@ -1517,8 +1519,8 @@ export default function Dashboard() { ) : allHidden ? (
) : ( @@ -1898,7 +1900,7 @@ export default function Dashboard() { key={id} questionId={id} index={i} - questions={questions} + questions={visibleQuestions} records={records} /> ))} diff --git a/src/pages/Practice.tsx b/src/pages/Practice.tsx index 0951dd3..6f0c907 100644 --- a/src/pages/Practice.tsx +++ b/src/pages/Practice.tsx @@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { Button, EmptyState, Skeleton } from '@/components/ui' import { useQuestions } from '@/hooks/useQuestions' -import { type CategoryMap, getCategoryMap } from '@/lib/db' +import { type CategoryMap, DEFAULT_CATEGORY_MAP, getCategoryMap } from '@/lib/db' import { createPracticeSessionPath } from '@/lib/practiceSession' +import { filterVisibleQuestions, getHiddenModules } from '@/lib/questionVisibility' import { useStudyStore } from '@/store/useStudyStore' import { DIFFICULTY_LABELS, @@ -452,7 +453,7 @@ export default function Practice() { const navigate = useNavigate() const [searchParams] = useSearchParams() const { allQuestions, initializing } = useQuestions() - const { records } = useStudyStore() + const { records, hiddenCategories } = useStudyStore() const [selectedModules, setSelectedModules] = useState([]) const [selectedDifficulty, setSelectedDifficulty] = useState('all') @@ -472,25 +473,34 @@ export default function Practice() { }, [searchParams, navigate]) // ── Category map (for grouping modules) ── - const [categoryMap, setCategoryMap] = useState({}) + const [categoryMap, setCategoryMap] = useState({ ...DEFAULT_CATEGORY_MAP }) useEffect(() => { getCategoryMap().then(setCategoryMap) - }, []) // re-fetch when questions change (new imports) + }, []) + + const hiddenModules = useMemo( + () => getHiddenModules(categoryMap, hiddenCategories), + [categoryMap, hiddenCategories], + ) + const visibleQuestions = useMemo( + () => filterVisibleQuestions(allQuestions, hiddenModules), + [allQuestions, hiddenModules], + ) // ── All unique modules that actually have questions ── const activeModules = useMemo(() => { - return [...new Set(allQuestions.map((q) => q.module))] - }, [allQuestions]) + return [...new Set(visibleQuestions.map((q) => q.module))] + }, [visibleQuestions]) // ── Derived stats — for ALL active modules (not just builtin) ── const moduleStats = useMemo(() => { return activeModules.map((mod) => { - const qs = allQuestions.filter((q) => q.module === mod) + const qs = visibleQuestions.filter((q) => q.module === mod) const mastered = qs.filter((q) => records[q.id]?.status === 'mastered').length return { module: mod, total: qs.length, mastered } }) - }, [allQuestions, activeModules, records]) + }, [visibleQuestions, activeModules, records]) // ── Ordered categories with their modules (only those with questions) ── const categoriesWithModules = useMemo(() => { @@ -521,18 +531,18 @@ export default function Practice() { const difficultyStats = useMemo(() => { const base = { 1: 0, 2: 0, 3: 0 } - let filtered = allQuestions + let filtered = visibleQuestions if (selectedModules.length > 0) { const set = new Set(selectedModules) filtered = filtered.filter((q) => set.has(q.module)) } for (const q of filtered) base[q.difficulty]++ return base - }, [allQuestions, selectedModules]) + }, [visibleQuestions, selectedModules]) // ── Filtered question list ── const filteredQuestions = useMemo(() => { - let result = allQuestions + let result = visibleQuestions if (selectedModules.length > 0) { const set = new Set(selectedModules) @@ -551,10 +561,10 @@ export default function Practice() { } return result - }, [allQuestions, selectedModules, selectedDifficulty, selectedStatus, records]) + }, [visibleQuestions, selectedModules, selectedDifficulty, selectedStatus, records]) const statusCounts = useMemo(() => { - let pool = allQuestions + let pool = visibleQuestions if (selectedModules.length > 0) { const set = new Set(selectedModules) pool = pool.filter((q) => set.has(q.module)) @@ -568,7 +578,7 @@ export default function Practice() { counts[s]++ } return counts - }, [allQuestions, selectedModules, selectedDifficulty, records]) + }, [visibleQuestions, selectedModules, selectedDifficulty, records]) // ── Handlers ── const toggleModule = useCallback((mod: Module) => { @@ -589,6 +599,11 @@ export default function Practice() { }) }, []) + useEffect(() => { + const activeSet = new Set(activeModules) + setSelectedModules((prev) => prev.filter((module) => activeSet.has(module))) + }, [activeModules]) + const handleStart = useCallback(() => { if (filteredQuestions.length === 0) return @@ -648,6 +663,7 @@ export default function Practice() { } const noQuestions = allQuestions.length === 0 + const allHidden = allQuestions.length > 0 && visibleQuestions.length === 0 return (
@@ -681,6 +697,13 @@ export default function Practice() { } />
+ ) : allHidden ? ( +
+ +
) : (
0 - ? allQuestions.filter((q) => selectedModules.includes(q.module)).length - : allQuestions.length + ? visibleQuestions.filter((q) => selectedModules.includes(q.module)).length + : visibleQuestions.length } onClick={() => setSelectedDifficulty('all')} /> diff --git a/src/pages/QuestionList.tsx b/src/pages/QuestionList.tsx index c378bb5..a004f29 100644 --- a/src/pages/QuestionList.tsx +++ b/src/pages/QuestionList.tsx @@ -2,8 +2,15 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { Button, EmptyState, Skeleton } from '@/components/ui' import { applyFilters, type SortKey, useQuestions } from '@/hooks/useQuestions' -import { getAllQuestionFlags, getAllQuestionNotes } from '@/lib/db' +import { + type CategoryMap, + DEFAULT_CATEGORY_MAP, + getAllQuestionFlags, + getAllQuestionNotes, + getCategoryMap, +} from '@/lib/db' import { createPracticeSessionPath } from '@/lib/practiceSession' +import { filterVisibleQuestions, getHiddenModules } from '@/lib/questionVisibility' import { preloadRoute } from '@/lib/routePreload' import { useStudyStore } from '@/store/useStudyStore' import { @@ -1085,18 +1092,32 @@ export default function QuestionList() { const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() const { allQuestions, initializing } = useQuestions() - const { records, getStatus } = useStudyStore() + const { records, getStatus, hiddenCategories } = useStudyStore() + const [categoryMap, setCategoryMap] = useState({ ...DEFAULT_CATEGORY_MAP }) + + useEffect(() => { + getCategoryMap().then(setCategoryMap) + }, []) + + const hiddenModules = useMemo( + () => getHiddenModules(categoryMap, hiddenCategories), + [categoryMap, hiddenCategories], + ) + const visibleQuestions = useMemo( + () => filterVisibleQuestions(allQuestions, hiddenModules), + [allQuestions, hiddenModules], + ) // ── Filter state (sync with URL) ── // Derive sorted module list from actual questions (built-ins first, then custom alphabetically) const availableModules = useMemo(() => { - const moduleSet = new Set(allQuestions.map((q) => q.module)) + const moduleSet = new Set(visibleQuestions.map((q) => q.module)) const builtins = (BUILTIN_MODULES as readonly string[]).filter((m) => moduleSet.has(m)) const custom = [...moduleSet] .filter((m) => !(BUILTIN_MODULES as readonly string[]).includes(m)) .sort((a, b) => a.localeCompare(b)) return [...builtins, ...custom] - }, [allQuestions]) + }, [visibleQuestions]) const [selectedModules, setSelectedModules] = useState(() => parseModuleParams(searchParams), @@ -1354,7 +1375,7 @@ export default function QuestionList() { const filteredResult = useMemo(() => { const structuralSort: SortKey = sort === 'note-updated' ? 'default' : (sort as SortKey) const structuralQuestions = applyFilters( - allQuestions, + visibleQuestions, { modules: selectedModules, difficulties: selectedDifficulties, @@ -1392,7 +1413,7 @@ export default function QuestionList() { return { questions: sortedQuestions, noteSearchMatchedIds, noteSearchSnippets } }, [ - allQuestions, + visibleQuestions, selectedModules, selectedDifficulties, selectedStatuses, @@ -1411,13 +1432,17 @@ export default function QuestionList() { const noteSearchSnippets = filteredResult.noteSearchSnippets const notedQuestionCount = useMemo( - () => allQuestions.reduce((count, question) => count + (noteIds.has(question.id) ? 1 : 0), 0), - [allQuestions, noteIds], + () => + visibleQuestions.reduce((count, question) => count + (noteIds.has(question.id) ? 1 : 0), 0), + [visibleQuestions, noteIds], ) const starredQuestionCount = useMemo( () => - allQuestions.reduce((count, question) => count + (starredIds.has(question.id) ? 1 : 0), 0), - [allQuestions, starredIds], + visibleQuestions.reduce( + (count, question) => count + (starredIds.has(question.id) ? 1 : 0), + 0, + ), + [visibleQuestions, starredIds], ) const currentSessionIds = useMemo(() => filteredQuestions.map((q) => q.id), [filteredQuestions]) @@ -1458,28 +1483,33 @@ export default function QuestionList() { // Keep selectedModules valid when availableModules changes (e.g. after import) useEffect(() => { - if (availableModules.length === 0) return + if (allQuestions.length === 0 && availableModules.length === 0) return setSelectedModules((prev) => prev.filter((m) => availableModules.includes(m))) - }, [availableModules]) - - const emptyStateTitle = !hasFilters - ? '题库为空' - : starredOnly && starredQuestionCount === 0 - ? '还没有重点题' - : notesOnly && notedQuestionCount === 0 - ? '还没有题目笔记' - : '没有匹配的题目' - const emptyStateDescription = !hasFilters - ? '请前往「导入题目」页面加载题库' - : starredOnly && starredQuestionCount === 0 - ? '在题目详情中标记重点题后,可在这里集中复习' - : notesOnly && notedQuestionCount === 0 - ? '打开任意题目的笔记入口,记录理解或把 AI 复盘保存为笔记' - : starredOnly - ? '当前筛选条件下没有重点题,可以清除部分条件再试' - : notesOnly - ? '当前筛选条件下没有带笔记的题目,可以清除部分条件再试' - : '试试调整筛选条件,或搜索题目、标签、模块和笔记内容' + }, [allQuestions.length, availableModules]) + + const allHidden = allQuestions.length > 0 && visibleQuestions.length === 0 + const emptyStateTitle = allHidden + ? '所有题库已关闭展示' + : !hasFilters + ? '题库为空' + : starredOnly && starredQuestionCount === 0 + ? '还没有重点题' + : notesOnly && notedQuestionCount === 0 + ? '还没有题目笔记' + : '没有匹配的题目' + const emptyStateDescription = allHidden + ? '在「设置 → 刷题偏好 → 题库展示」中启用题库后,这里会重新显示题目' + : !hasFilters + ? '请前往「导入题目」页面加载题库' + : starredOnly && starredQuestionCount === 0 + ? '在题目详情中标记重点题后,可在这里集中复习' + : notesOnly && notedQuestionCount === 0 + ? '打开任意题目的笔记入口,记录理解或把 AI 复盘保存为笔记' + : starredOnly + ? '当前筛选条件下没有重点题,可以清除部分条件再试' + : notesOnly + ? '当前筛选条件下没有带笔记的题目,可以清除部分条件再试' + : '试试调整筛选条件,或搜索题目、标签、模块和笔记内容' return (
@@ -1506,8 +1536,8 @@ export default function QuestionList() {

{hasFilters - ? `当前显示 ${filteredQuestions.length} / ${allQuestions.length} 道题` - : `共 ${allQuestions.length} 道题`} + ? `当前显示 ${filteredQuestions.length} / ${visibleQuestions.length} 道题` + : `共 ${visibleQuestions.length} 道题`}

@@ -1938,7 +1968,7 @@ export default function QuestionList() { onNotesOnlyToggle={toggleNotesOnly} onClear={clearFilters} totalFiltered={filteredQuestions.length} - totalAll={allQuestions.length} + totalAll={visibleQuestions.length} availableModules={availableModules} />
@@ -2026,7 +2056,7 @@ export default function QuestionList() { onNotesOnlyToggle={toggleNotesOnly} onClear={clearFilters} totalFiltered={filteredQuestions.length} - totalAll={allQuestions.length} + totalAll={visibleQuestions.length} availableModules={availableModules} />
diff --git a/src/store/useStudyStore.ts b/src/store/useStudyStore.ts index 95c932f..9e0e07d 100644 --- a/src/store/useStudyStore.ts +++ b/src/store/useStudyStore.ts @@ -396,18 +396,34 @@ export function useStudyStore() { broadcast(action) }, []) - const toggleCategoryVisibility = useCallback((categoryName: string) => { - const next = new Set(stateRef.current.hiddenCategories) - if (next.has(categoryName)) { - next.delete(categoryName) - } else { - next.add(categoryName) - } + const setHiddenCategories = useCallback((categoryNames: string[]) => { + const next = new Set(categoryNames) saveHiddenCategories(next) const action: Action = { type: 'SET_HIDDEN_CATEGORIES', hiddenCategories: [...next] } broadcast(action) + void invalidateDailyCache() }, []) + const setCategoryVisibility = useCallback( + (categoryName: string, visible: boolean) => { + const next = new Set(stateRef.current.hiddenCategories) + if (visible) { + next.delete(categoryName) + } else { + next.add(categoryName) + } + setHiddenCategories([...next]) + }, + [setHiddenCategories], + ) + + const toggleCategoryVisibility = useCallback( + (categoryName: string) => { + setCategoryVisibility(categoryName, stateRef.current.hiddenCategories.has(categoryName)) + }, + [setCategoryVisibility], + ) + const isCategoryHidden = useCallback( (categoryName: string): boolean => stateRef.current.hiddenCategories.has(categoryName), [], @@ -501,6 +517,8 @@ export function useStudyStore() { toggleTheme, setStudyMode, setDailyGoal, + setHiddenCategories, + setCategoryVisibility, incrementStreak, resetStreak, toggleCategoryVisibility,