mirror of
https://github.com/saveweb/saveweb-search-frontend.git
synced 2024-09-18 18:55:31 -07:00
feat: add search page
This commit is contained in:
parent
3e278944a8
commit
5d71ba0e3f
@ -13,7 +13,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/cssinjs": "^1.7.1",
|
||||
"@ant-design/icons": "^5.0.1",
|
||||
"antd": "^5.3.3",
|
||||
"axios": "^1.3.4",
|
||||
"dayjs": "^1.11.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.10.0"
|
||||
|
@ -2,6 +2,7 @@ lockfileVersion: 5.4
|
||||
|
||||
specifiers:
|
||||
'@ant-design/cssinjs': ^1.7.1
|
||||
'@ant-design/icons': ^5.0.1
|
||||
'@trivago/prettier-plugin-sort-imports': ^4.1.1
|
||||
'@types/react': ^18.0.28
|
||||
'@types/react-dom': ^18.0.11
|
||||
@ -10,6 +11,8 @@ specifiers:
|
||||
'@vitejs/plugin-react': ^3.1.0
|
||||
antd: ^5.3.3
|
||||
autoprefixer: ^10.4.14
|
||||
axios: ^1.3.4
|
||||
dayjs: ^1.11.7
|
||||
eslint: ^8.37.0
|
||||
eslint-config-prettier: ^8.8.0
|
||||
eslint-plugin-prettier: ^4.2.1
|
||||
@ -28,7 +31,10 @@ specifiers:
|
||||
|
||||
dependencies:
|
||||
'@ant-design/cssinjs': 1.7.1_biqbaboplfbrettd7655fr4n2y
|
||||
'@ant-design/icons': 5.0.1_biqbaboplfbrettd7655fr4n2y
|
||||
antd: 5.3.3_biqbaboplfbrettd7655fr4n2y
|
||||
axios: 1.3.4
|
||||
dayjs: 1.11.7
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0_react@18.2.0
|
||||
react-router-dom: 6.10.0_biqbaboplfbrettd7655fr4n2y
|
||||
@ -1195,6 +1201,10 @@ packages:
|
||||
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
|
||||
dev: false
|
||||
|
||||
/asynckit/0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
dev: false
|
||||
|
||||
/autoprefixer/10.4.14_postcss@8.4.21:
|
||||
resolution: {integrity: sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@ -1216,6 +1226,16 @@ packages:
|
||||
engines: {node: '>= 0.4'}
|
||||
dev: true
|
||||
|
||||
/axios/1.3.4:
|
||||
resolution: {integrity: sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==}
|
||||
dependencies:
|
||||
follow-redirects: 1.15.2
|
||||
form-data: 4.0.0
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
dev: false
|
||||
|
||||
/balanced-match/1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
dev: true
|
||||
@ -1365,6 +1385,13 @@ packages:
|
||||
resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
|
||||
dev: true
|
||||
|
||||
/combined-stream/1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
dev: false
|
||||
|
||||
/commander/10.0.0:
|
||||
resolution: {integrity: sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA==}
|
||||
engines: {node: '>=14'}
|
||||
@ -1439,6 +1466,11 @@ packages:
|
||||
object-keys: 1.1.1
|
||||
dev: true
|
||||
|
||||
/delayed-stream/1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
dev: false
|
||||
|
||||
/didyoumean/1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
dev: true
|
||||
@ -1846,12 +1878,31 @@ packages:
|
||||
resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==}
|
||||
dev: true
|
||||
|
||||
/follow-redirects/1.15.2:
|
||||
resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
dev: false
|
||||
|
||||
/for-each/0.3.3:
|
||||
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
dev: true
|
||||
|
||||
/form-data/4.0.0:
|
||||
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
|
||||
engines: {node: '>= 6'}
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
mime-types: 2.1.35
|
||||
dev: false
|
||||
|
||||
/fraction.js/4.2.0:
|
||||
resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==}
|
||||
dev: true
|
||||
@ -2410,6 +2461,18 @@ packages:
|
||||
picomatch: 2.3.1
|
||||
dev: true
|
||||
|
||||
/mime-db/1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/mime-types/2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
dev: false
|
||||
|
||||
/mimic-fn/2.1.0:
|
||||
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
|
||||
engines: {node: '>=6'}
|
||||
@ -2746,6 +2809,10 @@ packages:
|
||||
react-is: 16.13.1
|
||||
dev: true
|
||||
|
||||
/proxy-from-env/1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
dev: false
|
||||
|
||||
/punycode/2.3.0:
|
||||
resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==}
|
||||
engines: {node: '>=6'}
|
||||
|
6
src/api/index.ts
Normal file
6
src/api/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { service } from './service';
|
||||
import type { SearchReq, SearchRes } from './types';
|
||||
|
||||
export const getSearch = (params: SearchReq): Promise<SearchRes> => {
|
||||
return service.get('/search', { params: { ...params, h: true } });
|
||||
};
|
23
src/api/service.ts
Normal file
23
src/api/service.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { message } from 'antd';
|
||||
import axios from 'axios';
|
||||
|
||||
export const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_APP_API_BASE_URL,
|
||||
});
|
||||
|
||||
service.interceptors.request.use((config) => {
|
||||
message.loading({ content: '加载中', key: 'loading' });
|
||||
return config;
|
||||
});
|
||||
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
message.destroy('loading');
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
message.destroy('loading');
|
||||
message.error('请求失败');
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
24
src/api/types.ts
Normal file
24
src/api/types.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export type SearchReq = {
|
||||
// 关键字
|
||||
q: string;
|
||||
// 显示全文
|
||||
f: boolean;
|
||||
// 页数
|
||||
p: number;
|
||||
};
|
||||
|
||||
export type Post = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
author: string;
|
||||
date: string;
|
||||
link: string;
|
||||
tags: string;
|
||||
// id_feed: string;
|
||||
// lastSeen: string;
|
||||
};
|
||||
export type SearchRes = {
|
||||
estimatedTotalHits: number;
|
||||
hits: Array<Post>;
|
||||
};
|
@ -2,7 +2,7 @@ import { Outlet } from 'react-router-dom';
|
||||
|
||||
const Layout = () => {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-8 h-[100dvh]">
|
||||
<div className="max-w-5xl mx-auto p-8 min-h-[100dvh]">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
|
43
src/components/PostCard.tsx
Normal file
43
src/components/PostCard.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { CalendarOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { Card, Tag } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import type { Post } from '../api/types';
|
||||
import { PRIMARY_COLOR } from '../constant';
|
||||
|
||||
// TODO: 关键字高亮
|
||||
const PostCard = ({ post }: { post: Post }) => {
|
||||
return (
|
||||
<Card
|
||||
title={post.title}
|
||||
className="w-full"
|
||||
extra={<a href={post.link}>查看原文</a>}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
{post.author && (
|
||||
<Tag icon={<UserOutlined />} color={PRIMARY_COLOR}>
|
||||
{post.author.slice(1)}
|
||||
</Tag>
|
||||
)}
|
||||
<Tag icon={<CalendarOutlined />} color={PRIMARY_COLOR}>
|
||||
{dayjs.unix(Number(post.date)).format('YYYY/MM/DD HH:mm:ss')}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="break-words">{post.content}</div>
|
||||
{post.tags && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{post.tags
|
||||
.slice(1)
|
||||
.split(' #')
|
||||
.map((tag, index) => (
|
||||
<Tag key={index}>#{tag}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostCard;
|
55
src/components/SearchForm.tsx
Normal file
55
src/components/SearchForm.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { Checkbox, Form, Input } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
type SearchFormData = { keyword?: string; showFull: boolean };
|
||||
|
||||
interface SearchFormProps {
|
||||
initialData?: SearchFormData;
|
||||
}
|
||||
|
||||
const SearchForm = ({
|
||||
initialData = { keyword: undefined, showFull: false },
|
||||
}: SearchFormProps) => {
|
||||
const [form] = Form.useForm<SearchFormData>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="w-full items-center gap-4 justify-center sm:flex-nowrap"
|
||||
onFinish={(data) => {
|
||||
navigate(`/search?q=${data.keyword}&f=${data.showFull}&p=0`);
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="keyword"
|
||||
className="flex-grow"
|
||||
initialValue={initialData.keyword}
|
||||
>
|
||||
<Input.Search
|
||||
size="large"
|
||||
enterButton
|
||||
placeholder="请输入关键字"
|
||||
className="w-full"
|
||||
onSearch={(keyword) => {
|
||||
if (keyword) {
|
||||
form.submit();
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="showFull"
|
||||
valuePropName="checked"
|
||||
initialValue={initialData.showFull}
|
||||
>
|
||||
<Checkbox className="w-full">展开全文</Checkbox>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchForm;
|
7
src/constant.ts
Normal file
7
src/constant.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export const SEARCH_PARAMS = {
|
||||
KEYWORD: 'q',
|
||||
SHOW_FULL: 'f',
|
||||
PAGE: 'p',
|
||||
} as const;
|
||||
|
||||
export const PRIMARY_COLOR = '#28303d';
|
25
src/main.tsx
25
src/main.tsx
@ -2,28 +2,17 @@ import { StyleProvider } from '@ant-design/cssinjs';
|
||||
import 'antd/dist/reset.css';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
|
||||
import Layout from './components/Layout';
|
||||
import Home from './pages/Home';
|
||||
import Search from './pages/Search';
|
||||
import AntdProvider from './providers/antd';
|
||||
import RouterProvider from './providers/router';
|
||||
import './tailwind.css';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ path: '/', element: <Home /> },
|
||||
{ path: '/search', element: <Search /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<StyleProvider hashPriority="high">
|
||||
<RouterProvider router={router} />
|
||||
</StyleProvider>
|
||||
<AntdProvider>
|
||||
<StyleProvider hashPriority="high">
|
||||
<RouterProvider />
|
||||
</StyleProvider>
|
||||
</AntdProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { Checkbox, Form, Input, List } from 'antd';
|
||||
import { List } from 'antd';
|
||||
|
||||
import SearchForm from '../components/SearchForm';
|
||||
|
||||
const tips = [
|
||||
'全文搜索,模糊搜索,简繁同搜,拼音,同音字。',
|
||||
@ -8,16 +10,12 @@ const tips = [
|
||||
'如需添加收录,给我发消息 TG: @yzqzss / Email: yzqzss@othing.xyz',
|
||||
];
|
||||
|
||||
// FIXME: 垂直居中
|
||||
// TODO: 头图
|
||||
const Home = () => {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-5/6 flex-col gap-3">
|
||||
<Form
|
||||
layout="inline"
|
||||
className="w-full items-center gap-4 justify-center sm:flex-nowrap"
|
||||
>
|
||||
<Input.Search size="large" enterButton placeholder="请输入关键字" />
|
||||
<Checkbox className="sm:w-32">展开全文</Checkbox>
|
||||
</Form>
|
||||
<SearchForm />
|
||||
<List
|
||||
dataSource={tips}
|
||||
bordered
|
||||
|
@ -1,5 +1,46 @@
|
||||
import { Pagination } from 'antd';
|
||||
import { useLoaderData, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import type { SearchRes } from '../api/types';
|
||||
import PostCard from '../components/PostCard';
|
||||
import SearchForm from '../components/SearchForm';
|
||||
import { SEARCH_PARAMS } from '../constant';
|
||||
|
||||
const Search = () => {
|
||||
return <div>Search</div>;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const searchRes = useLoaderData() as SearchRes;
|
||||
|
||||
const SearchPagination = () => (
|
||||
<Pagination
|
||||
showSizeChanger={false}
|
||||
defaultCurrent={Number(searchParams.get(SEARCH_PARAMS.PAGE)) + 1}
|
||||
total={searchRes.estimatedTotalHits}
|
||||
pageSize={10}
|
||||
onChange={(page) => {
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
p: String(page - 1),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<SearchForm
|
||||
initialData={{
|
||||
keyword: searchParams.get(SEARCH_PARAMS.KEYWORD)!,
|
||||
showFull: searchParams.get(SEARCH_PARAMS.SHOW_FULL) === 'true',
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm">约 {searchRes.estimatedTotalHits} 条结果</div>
|
||||
<SearchPagination />
|
||||
{searchRes.hits.map((post) => (
|
||||
<PostCard post={post} key={post.id} />
|
||||
))}
|
||||
<SearchPagination />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
||||
|
20
src/providers/antd.tsx
Normal file
20
src/providers/antd.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { ConfigProvider } from 'antd';
|
||||
import type React from 'react';
|
||||
|
||||
import { PRIMARY_COLOR } from '../constant';
|
||||
|
||||
const AntdProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: PRIMARY_COLOR,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AntdProvider;
|
40
src/providers/router.tsx
Normal file
40
src/providers/router.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import {
|
||||
createBrowserRouter,
|
||||
RouterProvider as ReactRouterProvider,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { getSearch } from '../api';
|
||||
import type { SearchReq } from '../api/types';
|
||||
import Layout from '../components/Layout';
|
||||
import { SEARCH_PARAMS } from '../constant';
|
||||
import Home from '../pages/Home';
|
||||
import Search from '../pages/Search';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ path: '/', element: <Home /> },
|
||||
{
|
||||
path: '/search',
|
||||
element: <Search />,
|
||||
loader: async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const searchParams: SearchReq = {
|
||||
q: url.searchParams.get(SEARCH_PARAMS.KEYWORD)!,
|
||||
f: url.searchParams.get(SEARCH_PARAMS.SHOW_FULL) === 'true',
|
||||
p: Number(url.searchParams.get(SEARCH_PARAMS.PAGE)),
|
||||
};
|
||||
return await getSearch(searchParams);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const RouterProvider = () => {
|
||||
return <ReactRouterProvider router={router} />;
|
||||
};
|
||||
|
||||
export default RouterProvider;
|
Loading…
Reference in New Issue
Block a user