feat: 添加文本高亮功能并优化UI样式

- 新增TextHighlighter组件实现关键词高亮功能
- 清理未使用的导入和组件
- 更新品牌图标引用方式
- 优化搜索页面布局和样式
This commit is contained in:
hy committed 2026-01-09 15:11:35 +08:00
1 parent ea8c3bca8f
commit 5b062efae6
9 files changed
+64 -90

No files matched your search

+1
View File
@@ -35,6 +35,7 @@
"apache-arrow": "^17.0.0",
"electron-updater": "^6.3.9",
"mammoth": "^1.11.0",
"mark.js": "^8.11.1",
"pdf-parse": "^2.4.5",
"react-doc-viewer": "^0.1.15",
"react-pdf": "^10.3.0",
+8
View File
@@ -50,6 +50,9 @@ importers:
mammoth:
specifier: ^1.11.0
version: 1.11.0
mark.js:
specifier: ^8.11.1
version: 8.11.1
pdf-parse:
specifier: ^2.4.5
version: 2.4.5
@@ -3633,6 +3636,9 @@ packages:
engines: {node: '>=12.0.0'}
hasBin: true
mark.js@8.11.1:
resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==}
matcher@3.0.0:
resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
engines: {node: '>=10'}
@@ -8590,6 +8596,8 @@ snapshots:
underscore: 1.13.7
xmlbuilder: 10.1.1
mark.js@8.11.1: {}
matcher@3.0.0:
dependencies:
escape-string-regexp: 4.0.0
+9 -38
View File
@@ -1,15 +1,13 @@
import React from 'react'
import { HashRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom'
import { Layout, ConfigProvider, theme, Button, Segmented, Avatar } from 'antd'
import { Layout, ConfigProvider, theme, Button } from 'antd'
import {
ArrowLeftOutlined,
SettingOutlined,
TranslationOutlined,
SearchOutlined,
MessageOutlined
SettingOutlined
} from '@ant-design/icons'
import SearchPage from './pages/SearchPage'
import ImportPage from './pages/ImportPage'
import brandIcon from '../../../resources/icon.png'
const { Header, Content } = Layout
@@ -28,11 +26,7 @@ function App(): React.JSX.Element {
Button: {
primaryShadow: 'none'
},
Segmented: {
itemSelectedBg: '#404040',
itemSelectedColor: '#ffffff',
trackBg: '#f5f5f5'
}
}
}}
>
@@ -49,12 +43,12 @@ const MainLayout: React.FC = () => {
const isSearchPage = location.pathname === '/'
return (
<Layout className="min-h-screen bg-white">
<Header className="bg-white px-4 flex items-center justify-between sticky top-0 z-10 h-16 border-none">
<Layout className="min-h-screen bg-[#fafafa]">
<Header className="bg-[#fafafa] px-4 flex items-center justify-between sticky top-0 z-10 h-16 border-b border-gray-200">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 cursor-pointer" onClick={() => navigate('/')}>
<div className="w-8 h-8 bg-[#404040] rounded-md flex items-center justify-center text-white font-bold text-lg">
<img src="/src/assets/icon.png" alt="N" className="w-6 h-6 invert filter brightness-0 invert" onError={(e) => {e.currentTarget.style.display='none'; e.currentTarget.parentElement!.innerText='N'}} />
<img src={brandIcon} alt="N" className="w-6 h-6" />
</div>
<span className="font-semibold text-lg tracking-tight text-[#404040]">NCurator</span>
</div>
@@ -69,38 +63,15 @@ const MainLayout: React.FC = () => {
)}
</div>
{isSearchPage && (
<div className="absolute left-1/2 transform -translate-x-1/2">
<Segmented
options={[
{ label: '搜索', value: 'search', icon: <SearchOutlined /> },
{ label: '聊天', value: 'chat', icon: <MessageOutlined /> }
]}
defaultValue="search"
className="bg-gray-100"
/>
</div>
)}
<div className="flex items-center gap-3">
<div className="flex items-center border border-gray-200 rounded px-2 py-1 gap-2 cursor-pointer hover:bg-gray-50">
<span className="text-sm">zh-CN</span>
<TranslationOutlined className="text-xs" />
</div>
<Button
icon={<SettingOutlined />}
onClick={() => navigate('/import')} // Temporary link to Import page via Settings for now
/>
<Avatar
shape="square"
size="small"
className="bg-white border border-gray-200 text-[#404040] font-medium"
>
S
</Avatar>
</div>
</Header>
<Content className="bg-white overflow-auto">
<Content className="bg-[#fafafa] overflow-auto">
<Routes>
<Route path="/" element={<SearchPage />} />
<Route path="/import" element={<ImportPage />} />
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

@@ -0,0 +1,28 @@
import { useEffect, useRef } from 'react'
import Mark from 'mark.js'
const TextHighlighter = ({ text, keywords, className }: {
text: string;
keywords: string[];
className?: string;
}) => {
const contentRef = useRef<HTMLParagraphElement | null>(null)
useEffect(() => {
if (!contentRef.current || keywords.length === 0) return
const mark = new Mark(contentRef.current)
mark.mark(keywords, {
separateWordSearch: false,
})
return () => {
mark.unmark()
}
}, [keywords, text])
return (
<p ref={contentRef} className={className}>{text}</p>
)
}
export default TextHighlighter
+1 -3
View File
@@ -1,14 +1,12 @@
import React, { useState, useEffect } from 'react'
import { Upload, Button, message, Card, Typography, List, Collapse, Input, Table, Modal, Tag, Space } from 'antd'
import { Upload, Button, message, Typography, Collapse, Input, Table, Modal } from 'antd'
import {
InboxOutlined,
CheckCircleOutlined,
PlusOutlined,
DeleteOutlined,
SettingOutlined,
ReloadOutlined,
SearchOutlined,
ReadOutlined,
FileTextOutlined,
FilePdfOutlined,
BookOutlined
+15 -48
View File
@@ -1,11 +1,13 @@
import React, { useState } from 'react'
import { Input, List, Card, Modal, Empty, Typography, Tag, Button, Switch, Dropdown, Space } from 'antd'
import { FileTextOutlined, FilePdfOutlined, SendOutlined, DownOutlined } from '@ant-design/icons'
import { Input, List, Card, Modal, Empty, Typography, Tag, Button, Switch } from 'antd'
import { SendOutlined } from '@ant-design/icons'
import { Document, Page, pdfjs } from 'react-pdf'
import DocViewer, { DocViewerRenderers } from 'react-doc-viewer'
import 'react-pdf/dist/Page/AnnotationLayer.css'
import 'react-pdf/dist/Page/TextLayer.css'
import type { SearchResult } from '../../../shared/types'
import TextHighlighter from '../components/TextHighlighter'
import brandIcon from '../../../../resources/icon.png'
// Configure PDF worker
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
@@ -14,7 +16,7 @@ pdfjs.GlobalWorkerOptions.workerSrc = new URL(
).toString()
const { TextArea } = Input
const { Title, Paragraph } = Typography
const { Title } = Typography
const SearchPage: React.FC = () => {
const [loading, setLoading] = useState(false)
@@ -39,31 +41,7 @@ const SearchPage: React.FC = () => {
}
}
const highlightText = (text: string, tokens: string[]) => {
if (!tokens.length) return text
// Create a regex from tokens, sorting by length descending to match longest first
const sortedTokens = [...tokens].sort((a, b) => b.length - a.length)
// Escape special regex chars in tokens
const escapedTokens = sortedTokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
const regex = new RegExp(`(${escapedTokens.join('|')})`, 'gi')
const parts = text.split(regex)
return (
<span>
{parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="bg-yellow-200 text-black rounded px-0.5 mx-0.5 font-medium">
{part}
</mark>
) : (
part
)
)}
</span>
)
}
const openPreview = (item: SearchResult) => {
setCurrentDoc(item)
@@ -110,15 +88,15 @@ const SearchPage: React.FC = () => {
}
return (
<div className="min-h-screen bg-white text-black p-4 font-sans">
<div className="min-h-screen bg-[#fafafa] text-black p-4 font-sans">
<div className="max-w-3xl mx-auto pt-8">
{/* Search Box */}
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 shadow-sm mb-6 focus-within:ring-2 focus-within:ring-black/5 transition-all">
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm mb-6 focus-within:ring-2 focus-within:ring-black/5 transition-all">
<TextArea
placeholder="基于您的资源搜索..."
autoSize={{ minRows: 2, maxRows: 6 }}
bordered={false}
className="text-lg bg-transparent mb-4 placeholder:text-gray-400 !px-0"
className="text-lg bg-transparent mb-4 placeholder:text-gray-400 !px-0 text-center"
style={{ resize: 'none' }}
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
@@ -129,16 +107,7 @@ const SearchPage: React.FC = () => {
}
}}
/>
<div className="flex justify-between items-center">
<Dropdown menu={{ items: [{ key: 'all', label: '全部知识库' }] }}>
<Button type="text" className="text-gray-500 hover:bg-gray-200/50 px-2">
<Space>
全部知识库
<DownOutlined className="text-xs" />
</Space>
</Button>
</Dropdown>
<div className="flex justify-center items-center">
<Button
type="primary"
shape="circle"
@@ -159,7 +128,7 @@ const SearchPage: React.FC = () => {
{/* Results */}
<div>
<Title level={4} className="mb-4">搜索结果</Title>
<Title level={5} className="mb-3 !text-base !font-semibold text-[#404040]">搜索结果</Title>
<div className="min-h-[200px]">
{results.length > 0 ? (
<List
@@ -176,16 +145,14 @@ const SearchPage: React.FC = () => {
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-gray-500 text-sm">
{item.sourceType === 'file' ? <FileTextOutlined /> : <FilePdfOutlined />}
<img src={brandIcon} alt="icon" className="w-4 h-4" />
<span className="font-medium text-black">{item.documentName}</span>
</div>
{item.metadata?.page && (
<Tag color="default">Page {item.metadata.page}</Tag>
)}
</div>
<Paragraph className="text-gray-600 mb-0" ellipsis={{ rows: 3, expandable: false }}>
{highlightText(item.text, tokens)}
</Paragraph>
<TextHighlighter text={item.text} keywords={tokens} className="text-gray-600 mb-0 line-clamp-3" />
</div>
</Card>
</List.Item>
@@ -194,8 +161,8 @@ const SearchPage: React.FC = () => {
) : (
!loading && (
<div className="flex flex-col items-center justify-center py-20 opacity-50">
<div className="w-24 h-24 bg-gray-100 rounded-lg mb-4 flex items-center justify-center text-4xl text-gray-300">
<FileTextOutlined />
<div className="w-24 h-24 bg-gray-100 rounded-lg mb-4 flex items-center justify-center">
<img src={brandIcon} alt="icon" className="w-12 h-12 opacity-50" />
</div>
<span className="text-gray-400">暂无数据</span>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'
import type { SearchResult, DocumentRecord, ChunkListItem } from '../../../shared/types'
import { Input, Button, Upload, message, List, Card, Typography, Segmented, Table, Modal, Space, Tag } from 'antd'
import { Input, Button, Upload, message, List, Card, Typography, Segmented, Table, Modal, Tag } from 'antd'
import { UploadOutlined, FileTextOutlined, BlockOutlined, SearchOutlined } from '@ant-design/icons'
const { Paragraph } = Typography
+1
View File
@@ -0,0 +1 @@
declare module 'mark.js';