feat: add handbook search navigation

This commit is contained in:
Codex
2026-06-30 18:27:01 +08:00
parent 853b95c674
commit 2f9763d352
8 changed files with 250 additions and 23 deletions

View File

@@ -1,17 +1,80 @@
import { useEffect, useState } from 'react';
import { Text, View } from '@tarojs/components';
import RichContent from '@/components/RichContent';
import { useEffect, useMemo, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Input, Text, View } from '@tarojs/components';
import RichContent, { safeTextContent } from '@/components/RichContent';
import { loadHandbookChapters, loadHandbookEntries, loadHandbookSubjects, type HandbookChapter, type HandbookEntry, type HandbookSubject } from '@/services/catalog';
import '../student.css';
interface HighlightPart {
text: string;
hit: boolean;
}
const SNIPPET_RADIUS = 42;
function normalizeQuery(value: string) {
return value.trim().replace(/\s+/g, ' ').slice(0, 80);
}
function entrySearchText(entry: HandbookEntry) {
return [entry.title, safeTextContent(entry.summary), safeTextContent(entry.content)]
.filter(Boolean)
.join('\n');
}
function entryMatches(entry: HandbookEntry, query: string) {
if (!query) return true;
return entrySearchText(entry).toLocaleLowerCase().includes(query.toLocaleLowerCase());
}
function firstSnippet(entry: HandbookEntry, query: string) {
const text = entrySearchText(entry).replace(/\s+/g, ' ').trim();
if (!text) return '';
if (!query) return text.slice(0, 110);
const lowerText = text.toLocaleLowerCase();
const lowerQuery = query.toLocaleLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index < 0) return text.slice(0, 110);
const start = Math.max(0, index - SNIPPET_RADIUS);
const end = Math.min(text.length, index + query.length + SNIPPET_RADIUS);
return `${start > 0 ? '...' : ''}${text.slice(start, end)}${end < text.length ? '...' : ''}`;
}
function highlightParts(text: string, query: string): HighlightPart[] {
if (!text) return [];
if (!query) return [{ text, hit: false }];
const lowerText = text.toLocaleLowerCase();
const lowerQuery = query.toLocaleLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index < 0) return [{ text, hit: false }];
return [
{ text: text.slice(0, index), hit: false },
{ text: text.slice(index, index + query.length), hit: true },
{ text: text.slice(index + query.length), hit: false },
].filter(part => part.text.length > 0);
}
function safeEntryDomId(entryId: string) {
return /^[a-zA-Z0-9_-]+$/.test(entryId) ? `handbook-entry-${entryId}` : '';
}
export default function StudentHandbookPage() {
const [subjects, setSubjects] = useState<HandbookSubject[]>([]);
const [chapters, setChapters] = useState<HandbookChapter[]>([]);
const [entries, setEntries] = useState<HandbookEntry[]>([]);
const [subjectId, setSubjectId] = useState('');
const [chapterId, setChapterId] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [activeEntryId, setActiveEntryId] = useState('');
const [error, setError] = useState('');
const query = useMemo(() => normalizeQuery(searchQuery), [searchQuery]);
const filteredEntries = useMemo(() => entries.filter(item => entryMatches(item, query)), [entries, query]);
const activeEntry = useMemo(
() => filteredEntries.find(item => item.id === activeEntryId) || filteredEntries[0] || null,
[activeEntryId, filteredEntries],
);
useEffect(() => {
loadHandbookSubjects()
.then(payload => {
@@ -25,6 +88,8 @@ export default function StudentHandbookPage() {
useEffect(() => {
if (!subjectId) return;
setEntries([]);
setSearchQuery('');
setActiveEntryId('');
loadHandbookChapters(subjectId)
.then(payload => {
const nextChapters = payload.items || [];
@@ -36,11 +101,39 @@ export default function StudentHandbookPage() {
useEffect(() => {
if (!chapterId) return;
setSearchQuery('');
setActiveEntryId('');
loadHandbookEntries(chapterId, true)
.then(payload => setEntries(payload.items || []))
.then(payload => {
const nextEntries = payload.items || [];
setEntries(nextEntries);
setActiveEntryId(nextEntries[0]?.id || '');
})
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '知识点加载失败'));
}, [chapterId]);
useEffect(() => {
if (!filteredEntries.length) {
setActiveEntryId('');
return;
}
if (!activeEntryId || !filteredEntries.some(item => item.id === activeEntryId)) {
setActiveEntryId(filteredEntries[0].id);
}
}, [activeEntryId, filteredEntries]);
function selectEntry(entryId: string) {
setActiveEntryId(entryId);
const domId = safeEntryDomId(entryId);
if (domId) {
Taro.pageScrollTo({
selector: `#${domId}`,
duration: 220,
offsetTop: 16,
}).catch(() => undefined);
}
}
return (
<View className='student-page'>
<View className='student-topbar'>
@@ -55,7 +148,7 @@ export default function StudentHandbookPage() {
<Text className='section-heading'></Text>
<View className='list-stack'>
{subjects.map(item => (
<View className='list-row' key={item.id} onClick={() => setSubjectId(item.id)}>
<View className={`list-row ${item.id === subjectId ? 'active' : ''}`} key={item.id} onClick={() => setSubjectId(item.id)}>
<Text className='row-main'>{item.name}</Text>
<Text className='row-meta'>{item.description || item.type || '知识手册'}</Text>
</View>
@@ -76,18 +169,64 @@ export default function StudentHandbookPage() {
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='list-stack'>
{entries.map(item => (
<View className='list-row' key={item.id}>
<Text className='row-main'>{item.title}</Text>
{item.summary ? <RichContent content={item.summary} compact muted /> : null}
<View className='handbook-entry-body'>
<RichContent content={item.content || '暂无内容'} compact />
<View className='handbook-search-panel'>
<Input
className='input'
placeholder='搜索标题、摘要或正文'
value={searchQuery}
confirmType='search'
onInput={event => setSearchQuery(String(event.detail.value || ''))}
/>
{query ? (
<Button className='secondary-button' onClick={() => setSearchQuery('')}></Button>
) : null}
</View>
{entries.length ? (
<View className='handbook-outline'>
{filteredEntries.map((item, index) => (
<View
className={`handbook-outline-chip ${activeEntry?.id === item.id ? 'active' : ''}`}
key={item.id}
onClick={() => selectEntry(item.id)}
>
<Text className='handbook-outline-index'>{String(index + 1).padStart(2, '0')}</Text>
<Text className='handbook-outline-title'>{item.title}</Text>
</View>
</View>
))}
))}
</View>
) : null}
{query && entries.length ? (
<Text className='row-meta'> {String(filteredEntries.length)} </Text>
) : null}
<View className='list-stack handbook-entry-list'>
{filteredEntries.map(item => {
const snippet = firstSnippet(item, query);
return (
<View
id={safeEntryDomId(item.id)}
className={`list-row handbook-entry-row ${activeEntry?.id === item.id ? 'active' : ''}`}
key={item.id}
>
<Text className='row-main'>{item.title}</Text>
{snippet ? (
<View className='handbook-search-snippet'>
{highlightParts(snippet, query).map((part, index) => (
<Text className={part.hit ? 'handbook-highlight' : ''} key={`${item.id}-${index}`}>
{part.text}
</Text>
))}
</View>
) : null}
{item.summary ? <RichContent content={item.summary} compact muted /> : null}
<View className='handbook-entry-body'>
<RichContent content={item.content || '暂无内容'} compact />
</View>
</View>
);
})}
</View>
{!entries.length ? <View className='empty-state'></View> : null}
{entries.length && !filteredEntries.length ? <View className='empty-state'></View> : null}
{error ? <Text className='error-text'>{error}</Text> : null}
</View>
</View>

View File

@@ -634,6 +634,94 @@
margin-top: 14px;
}
.handbook-search-panel {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.handbook-search-panel .input {
flex: 1;
min-width: 0;
}
.handbook-search-panel .secondary-button {
flex: 0 0 116px;
min-width: 116px;
}
.handbook-outline {
display: flex;
gap: 10px;
margin-bottom: 14px;
overflow-x: auto;
padding-bottom: 8px;
}
.handbook-outline-chip {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
max-width: 360px;
min-height: 58px;
padding: 0 16px;
border: 1px solid #dbe3ee;
border-radius: 8px;
background: #fff;
}
.handbook-outline-chip.active {
border-color: #2563eb;
background: #eff6ff;
}
.handbook-outline-index {
flex: 0 0 auto;
color: #94a3b8;
font-size: 20px;
font-weight: 820;
}
.handbook-outline-title {
min-width: 0;
color: #0f172a;
font-size: 22px;
font-weight: 760;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.handbook-entry-list {
margin-top: 16px;
}
.handbook-entry-row.active {
border-color: #93c5fd;
}
.handbook-search-snippet {
margin-top: 10px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
color: #475569;
font-size: 22px;
line-height: 1.55;
word-break: break-word;
}
.handbook-highlight {
border-radius: 6px;
background: #fef3c7;
color: #92400e;
font-weight: 820;
}
.report-sub-result {
margin-top: 14px;
padding-top: 12px;