feat: 添加文本高亮功能并优化UI样式
- 新增TextHighlighter组件实现关键词高亮功能 - 清理未使用的导入和组件 - 更新品牌图标引用方式 - 优化搜索页面布局和样式
This commit is contained in:
1 parent
ea8c3bca8f
commit
5b062efae6
9 files changed
+64
-90
No files matched your search
@@ -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",
|
||||
|
||||
Generated
+8
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module 'mark.js';
|
||||
Reference in new issue
Block a user