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)
-
+



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 (
+
({ ...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,