diff --git a/src/components/Button/thumb.module.css b/src/components/Button/thumb.module.css index 3934e3b..a85ca8f 100644 --- a/src/components/Button/thumb.module.css +++ b/src/components/Button/thumb.module.css @@ -31,7 +31,6 @@ left: 0; width: 100%; height: 100%; - z-index: 30; opacity: 0; } diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 2d79257..3651a2b 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -3,14 +3,17 @@ import Auth from './Auth'; import Nav from './Nav'; -import { Logo } from '@/components'; +import { Logo, Search } from '@/components'; export default function Header({ type = 'server', className }: { type?: NextComponentType; className?: string }) { return ( -
-
- -
+
+
+
+ + +
+
diff --git a/src/components/Icon/heart.module.css b/src/components/Icon/heart.module.css index 303e8e4..a6beba6 100644 --- a/src/components/Icon/heart.module.css +++ b/src/components/Icon/heart.module.css @@ -42,7 +42,6 @@ left: 0; width: 100%; height: 100%; - z-index: 99; opacity: 0; } diff --git a/src/components/Journal/MiniJournalCard.tsx b/src/components/Journal/MiniJournalCard.tsx index 04e3586..ae24ab9 100644 --- a/src/components/Journal/MiniJournalCard.tsx +++ b/src/components/Journal/MiniJournalCard.tsx @@ -5,13 +5,15 @@ export default function MiniJournalCard({ image, journalNo, title, + onClick, }: { image: string; journalNo: string; title: string; + onClick?: () => void; }) { return ( - +
void; +} + +export default function MiniJournalCardList({ list, className, onClick }: IProps) { return (
{list.map((item) => ( - + ))}
); diff --git a/src/components/Search/Dropdown.tsx b/src/components/Search/Dropdown.tsx new file mode 100644 index 0000000..05be763 --- /dev/null +++ b/src/components/Search/Dropdown.tsx @@ -0,0 +1,30 @@ +import style from './index.module.css'; +import Journal from './widget/Journal'; +import NoData from './widget/NoData'; +import Single from './widget/Single'; + +interface IProps { + journalData: JournalInfo[]; + songData: SongInfo[]; + className?: string; + onClose: () => void; +} + +export default function SearchDropdown({ journalData, songData, className, onClose }: IProps) { + const haveData = journalData.length || songData.length; + + return ( +
+ {haveData ? ( +
+ {!!songData.length && } + {!!journalData.length && } +
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/Search/Input.tsx b/src/components/Search/Input.tsx new file mode 100644 index 0000000..e3e3176 --- /dev/null +++ b/src/components/Search/Input.tsx @@ -0,0 +1,22 @@ +interface IProps { + className?: string; + onInput: (value: string) => void; + onFocus: () => void; +} + +import IconSearch from './widget/IconSearch'; + +export default function SearchInput({ className, onInput, onFocus }: IProps) { + return ( +
+ onInput(e.target.value)} + onFocus={onFocus} + style={{ boxShadow: '0px 6px 34px 0px rgba(0, 0, 0, 0.1)' }} + /> + + +
+ ); +} diff --git a/src/components/Search/Search.tsx b/src/components/Search/Search.tsx new file mode 100644 index 0000000..de752a9 --- /dev/null +++ b/src/components/Search/Search.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useRef, useState, useEffect } from 'react'; + +import debounce from 'lodash/debounce'; + +import Dropdown from './Dropdown'; +import Input from './Input'; + +import { apiSearchJournal, apiSearchSong } from '@/services'; + +interface IProps { + className?: string; +} + +export default function Search({ className }: IProps) { + const [value, setValue] = useState(''); + const [journalData, setJournalData] = useState([]); + const [songData, setSongData] = useState([]); + const [showDropDown, setShowDropDown] = useState(false); + const searchRef = useRef(null); + + const handleInput: (value: string) => void = (value) => { + setShowDropDown(!!value.trim()); + + if (value.trim()) { + setValue(value); + handleSearch(value); + } + }; + + const handleSearch = async (keyword: string) => { + const [journalRes, songRes] = await Promise.all([ + apiSearchJournal({ keyword, pageNum: 1, pageSize: 20 }), + apiSearchSong({ keyword, pageNum: 1, pageSize: 20 }), + ]); + + if (journalRes.code === 200) { + setJournalData(journalRes.data.rows); + } else { + setJournalData([]); + } + + if (songRes.code === 200) { + setSongData(songRes.data.rows); + } else { + setSongData([]); + } + }; + + // input 聚焦时,展示下拉框 + const handleInputFocus = () => { + if (value.trim()) setShowDropDown(true); + }; + + const handleClickOutside = (e: Event) => { + if (searchRef.current && !searchRef.current.contains(e.target as Node)) { + setShowDropDown(false); + } + }; + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }); + + return ( +
+ handleInput(value), 200)} onFocus={handleInputFocus} /> + + {showDropDown && ( + setShowDropDown(false)} /> + )} +
+ ); +} diff --git a/src/components/Search/index.module.css b/src/components/Search/index.module.css new file mode 100644 index 0000000..e7968fa --- /dev/null +++ b/src/components/Search/index.module.css @@ -0,0 +1,21 @@ +.dropdownScrollbar::-webkit-scrollbar { + width: 4px; /* 滚动条宽度 */ +} + +/* 滚动条轨道透明度 */ +.dropdownScrollbar::-webkit-scrollbar-track { + margin: 24px 0 40px 0; + opacity: 0; +} + +/* 滚动条滑块颜色 */ +.dropdownScrollbar::-webkit-scrollbar-thumb { + border-radius: 5px; + background: rgba(0, 0, 0, 0.2); + transition: all 0.3s; +} + +/* 滚动条滑块 hover */ +.dropdownScrollbar::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.3); +} diff --git a/src/components/Search/widget/IconSearch.tsx b/src/components/Search/widget/IconSearch.tsx new file mode 100644 index 0000000..8a909cb --- /dev/null +++ b/src/components/Search/widget/IconSearch.tsx @@ -0,0 +1,20 @@ +export default function IconSearch({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/components/Search/widget/Journal.tsx b/src/components/Search/widget/Journal.tsx new file mode 100644 index 0000000..477cc40 --- /dev/null +++ b/src/components/Search/widget/Journal.tsx @@ -0,0 +1,16 @@ +import { MiniJournalCardList } from '@/components'; + +interface IProps { + data: JournalInfo[]; + className?: string; + onClose: () => void; +} + +export default function Journal({ data, className, onClose }: IProps) { + return ( +
+

期刊

+ +
+ ); +} diff --git a/src/components/Search/widget/NoData.tsx b/src/components/Search/widget/NoData.tsx new file mode 100644 index 0000000..3481239 --- /dev/null +++ b/src/components/Search/widget/NoData.tsx @@ -0,0 +1,11 @@ +interface IProps { + className?: string; +} + +export default function NoData({ className }: IProps) { + return ( +
+

没有搜索到内容

+
+ ); +} diff --git a/src/components/Search/widget/Single.tsx b/src/components/Search/widget/Single.tsx new file mode 100644 index 0000000..b5a5c1c --- /dev/null +++ b/src/components/Search/widget/Single.tsx @@ -0,0 +1,21 @@ +import { SongCardList } from '@/components'; + +interface IProps { + data: SongInfo[]; + className?: string; +} + +export default function Single({ data, className }: IProps) { + return ( +
+

单曲

+ +
+ ); +} diff --git a/src/components/SongCard/SongCardList.tsx b/src/components/SongCard/SongCardList.tsx index d153aca..08781e2 100644 --- a/src/components/SongCard/SongCardList.tsx +++ b/src/components/SongCard/SongCardList.tsx @@ -9,41 +9,68 @@ import { SongCard } from '@/components'; import { useAudioStore } from '@/store'; interface Props { - /** 列表类型 + /** + * @description 列表类型 * vol: 期刊 * collectSingle: 收藏单曲 * playerCard: 播放器内 */ listInfo: { type: 'vol' | 'collectSingle' | 'playerCard'; id: string | null }; + /** + * @description 点击单曲事件 + * playList: 播放当前列表 + * playPush: push 到歌单 + */ + clickType?: 'playList' | 'playPush'; songList: SongInfo[]; /** 收藏按钮的显示逻辑 always: 总是显示 playing: 播放时显示 */ collectType?: 'always' | 'playing'; className?: string; } -export default function SongCardList({ listInfo, songList, className, collectType = 'always' }: Props) { - const { playState, audioId, playListInfo, playQueue, setPlayState, setPlayListInfo, setPlayList, setAudioId } = - useAudioStore( - useShallow((state) => ({ - playState: state.playState, - audioId: state.audioId, - playListInfo: state.playListInfo, - playQueue: state.playQueue, - setPlayState: state.setPlayState, - setPlayListInfo: state.setPlayListInfo, - setPlayList: state.setPlayList, - setAudioId: state.setAudioId, - })), - ); +export default function SongCardList({ + listInfo, + songList, + className, + collectType = 'always', + clickType = 'playList', +}: Props) { + const { + playState, + audioId, + playListInfo, + playList, + playQueue, + setPlayState, + setPlayListInfo, + setPlayQueue, + setPlayList, + setAudioId, + } = useAudioStore( + useShallow((state) => ({ + playState: state.playState, + audioId: state.audioId, + playListInfo: state.playListInfo, + playList: state.playList, + playQueue: state.playQueue, + setPlayState: state.setPlayState, + setPlayListInfo: state.setPlayListInfo, + setPlayList: state.setPlayList, + setPlayQueue: state.setPlayQueue, + setAudioId: state.setAudioId, + })), + ); - const handlePlayList = (id: string) => { + const handlePlay = (id: string) => { if (!id) return; + // 如果是播放中的歌曲,改变播放状态 if (id === audioId) { setPlayState(!playState); return; } + // 如果选择的歌曲在队列中,直接切歌 const isPlayingList = listInfo.type === playListInfo.type && listInfo.id === playListInfo.id; // 正在播放当前歌单 const isInQueue = playQueue.some((item) => item.id === id); // 所选的歌在队列中 @@ -52,6 +79,15 @@ export default function SongCardList({ listInfo, songList, className, collectTyp return; } + // 修改 playList、playQueue + if (clickType === 'playList') { + handlePlayList(id); + } else if (clickType === 'playPush') { + handlePlayPush(id); + } + }; + + const handlePlayList = (id: string) => { // 重新设置播放列表 const res = setPlayListInfo(listInfo as IPlayListInfo); if (!res) return; @@ -59,6 +95,15 @@ export default function SongCardList({ listInfo, songList, className, collectTyp setAudioId(id); }; + const handlePlayPush = (id: string) => { + const newAudio: SongInfo | null = songList.find((item) => item.id === id) ?? null; + if (!newAudio) return; + + if (playListInfo.type !== 'fm') setPlayList([...playList, newAudio]); + setPlayQueue([...playQueue, newAudio]); + setAudioId(id); + }; + return (
{songList.map((item: SongInfo) => ( @@ -67,7 +112,7 @@ export default function SongCardList({ listInfo, songList, className, collectTyp {...item} playState={playState} showEq={item.id === audioId} - onPlay={(audioId: string) => handlePlayList(audioId)} + onPlay={(audioId: string) => handlePlay(audioId)} showCollect={collectType === 'always' || (collectType === 'playing' && item.id === audioId)} /> ))} diff --git a/src/components/index.ts b/src/components/index.ts index 6ac7051..a3cfa6d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -63,3 +63,4 @@ export { default as QRCode } from './common/QRCode'; export { default as QRCodeDialog } from './common/QRCodeDialog'; export { default as InfiniteScroller } from './common/InfiniteScroller'; export { default as Pagination } from './Pagination/Pagination'; +export { default as Search } from './Search/Search'; diff --git a/src/services/client/audio.ts b/src/services/client/audio.ts index 4063656..51a8022 100644 --- a/src/services/client/audio.ts +++ b/src/services/client/audio.ts @@ -71,3 +71,38 @@ export const apiGetSongWithCollectVol = async ({ ); return result; }; + +/** + * @description 搜索期刊 + */ +export const apiSearchJournal = async ({ + keyword, + pageNum, + pageSize, +}: { + keyword: string; + pageNum: number; + pageSize: number; +}) => { + const result: FetchResponse> = await clientHttp.get( + `/queyueapi/music/search/fuzzy/journal?keyword=${keyword}&pageNum=${pageNum}&pageSize=${pageSize}`, + ); + return result; +}; +/** + * @description 搜索单曲 + */ +export const apiSearchSong = async ({ + keyword, + pageNum, + pageSize, +}: { + keyword: string; + pageNum: number; + pageSize: number; +}) => { + const result: FetchResponse> = await clientHttp.get( + `/queyueapi/music/search/fuzzy/song?keyword=${keyword}&pageNum=${pageNum}&pageSize=${pageSize}`, + ); + return result; +};