search.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  1. <template>
  2. <view class="container">
  3. <!-- 顶部搜索栏 -->
  4. <view class="search-header">
  5. <view class="search-input-wrapper">
  6. <text class="search-icon">🔍</text>
  7. <input
  8. class="search-input"
  9. v-model="searchKeyword"
  10. placeholder="西游记"
  11. :focus="isFocus"
  12. @input="handleInput"
  13. @confirm="handleSearch"
  14. @focus="handleFocus"
  15. @blur="handleBlur"
  16. />
  17. </view>
  18. <text class="cancel-btn" @click="handleCancel">取消</text>
  19. </view>
  20. <!-- 搜索内容区域 -->
  21. <scroll-view class="search-content" scroll-y v-if="!showSearchResults">
  22. <!-- 搜索历史 -->
  23. <view class="section" v-if="searchHistory.length > 0">
  24. <view class="section-header">
  25. <text class="section-title">搜索历史</text>
  26. <text class="clear-btn" @click="clearHistory">清空</text>
  27. </view>
  28. <view class="tag-list">
  29. <view
  30. class="tag-item"
  31. v-for="(item, index) in searchHistory"
  32. :key="index"
  33. @click="searchByHistory(item)"
  34. >
  35. <text class="tag-text">{{ item }}</text>
  36. </view>
  37. </view>
  38. </view>
  39. <!-- 热门搜索 -->
  40. <view class="section">
  41. <view class="section-header">
  42. <text class="section-title">热门搜索</text>
  43. </view>
  44. <view class="tag-list">
  45. <view
  46. class="tag-item"
  47. v-for="(item, index) in popularSearches"
  48. :key="index"
  49. @click="searchByTag(item)"
  50. >
  51. <text class="tag-text">{{ item }}</text>
  52. </view>
  53. </view>
  54. </view>
  55. </scroll-view>
  56. <!-- 搜索结果 -->
  57. <scroll-view
  58. class="search-results"
  59. scroll-y
  60. v-if="showSearchResults"
  61. @scrolltolower="loadMore"
  62. :lower-threshold="100"
  63. >
  64. <view class="results-header">
  65. <text class="results-title">搜索结果</text>
  66. <text class="results-count" v-if="searchResults.length > 0">找到 {{ searchResults.length }} 本相关书籍</text>
  67. </view>
  68. <!-- 加载中 -->
  69. <view class="loading-container" v-if="isLoading && searchResults.length === 0">
  70. <text class="loading-text">搜索中...</text>
  71. </view>
  72. <!-- 搜索结果列表 -->
  73. <view class="book-list" v-else-if="searchResults.length > 0">
  74. <view
  75. class="book-item"
  76. v-for="(book, index) in searchResults"
  77. :key="book.id || index"
  78. @click="goToBookDetail(book)"
  79. >
  80. <image
  81. class="book-cover"
  82. :src="book.cover || book.image"
  83. mode="aspectFill"
  84. :lazy-load="true"
  85. @error="handleImageError(index)"
  86. ></image>
  87. <view class="book-info">
  88. <text class="book-title">{{ book.title }}</text>
  89. <text class="book-desc" v-if="book.desc">{{ book.desc }}</text>
  90. <text class="book-author" v-if="book.author">{{ book.author }}</text>
  91. </view>
  92. </view>
  93. </view>
  94. <!-- 无结果提示 -->
  95. <view class="no-results" v-else-if="!isLoading">
  96. <text class="no-results-text">暂无搜索结果</text>
  97. <text class="no-results-hint">试试其他关键词吧</text>
  98. </view>
  99. <!-- 加载更多提示 -->
  100. <view class="load-more" v-if="isLoading && searchResults.length > 0">
  101. <text class="load-more-text">加载中...</text>
  102. </view>
  103. <view class="load-more" v-else-if="!hasMore && searchResults.length > 0">
  104. <text class="load-more-text">没有更多了</text>
  105. </view>
  106. </scroll-view>
  107. </view>
  108. </template>
  109. <script>
  110. import { searchBooks, getPopularKeywords, searchAudiobooks, recordSearchHistory, getSearchHistory, clearSearchHistory } from '../../utils/api.js'
  111. export default {
  112. data() {
  113. return {
  114. searchKeyword: '',
  115. isFocus: false,
  116. showSearchResults: false,
  117. searchHistory: [],
  118. popularSearches: [],
  119. searchResults: [],
  120. isLoading: false,
  121. currentPage: 1,
  122. pageSize: 20,
  123. hasMore: true,
  124. mode: 'book', // 'book' or 'audio'
  125. userInfo: null
  126. }
  127. },
  128. onLoad(options) {
  129. // 模式:书籍 or 听书
  130. if (options.mode === 'audio') {
  131. this.mode = 'audio'
  132. }
  133. // 获取用户信息
  134. try {
  135. const userInfo = uni.getStorageSync('userInfo')
  136. if (userInfo && userInfo.id) {
  137. this.userInfo = userInfo
  138. }
  139. } catch (e) {
  140. console.error('获取用户信息失败', e)
  141. }
  142. // 从数据库加载搜索历史
  143. this.loadSearchHistory();
  144. // 加载热门搜索
  145. this.loadPopularSearches();
  146. // 如果有传入的关键词,直接搜索
  147. if (options.keyword) {
  148. this.searchKeyword = decodeURIComponent(options.keyword);
  149. this.performSearch(this.searchKeyword);
  150. }
  151. },
  152. methods: {
  153. handleInput(e) {
  154. this.searchKeyword = e.detail.value;
  155. },
  156. handleFocus() {
  157. this.isFocus = true;
  158. },
  159. handleBlur() {
  160. this.isFocus = false;
  161. },
  162. handleSearch() {
  163. if (this.searchKeyword.trim()) {
  164. this.performSearch(this.searchKeyword.trim());
  165. }
  166. },
  167. handleCancel() {
  168. if (this.showSearchResults) {
  169. // 如果在搜索结果页面,返回搜索首页
  170. this.showSearchResults = false;
  171. this.searchKeyword = '';
  172. this.searchResults = [];
  173. } else {
  174. // 否则返回上一页
  175. uni.navigateBack({
  176. delta: 1
  177. });
  178. }
  179. },
  180. searchByHistory(keyword) {
  181. this.searchKeyword = keyword;
  182. this.performSearch(keyword);
  183. },
  184. searchByTag(keyword) {
  185. this.searchKeyword = keyword;
  186. this.performSearch(keyword);
  187. },
  188. performSearch(keyword) {
  189. if (!keyword || !keyword.trim()) {
  190. return;
  191. }
  192. // 保存搜索历史
  193. this.saveToHistory(keyword);
  194. // 显示搜索结果
  195. this.showSearchResults = true;
  196. // 重置分页
  197. this.currentPage = 1;
  198. this.hasMore = true;
  199. // 调用搜索API
  200. this.searchDispatcher(keyword, 1);
  201. },
  202. async loadPopularSearches() {
  203. try {
  204. const res = await getPopularKeywords(10);
  205. if (res && res.code === 200 && res.data && Array.isArray(res.data)) {
  206. this.popularSearches = res.data;
  207. }
  208. } catch (e) {
  209. console.error('加载热门搜索失败:', e);
  210. // 如果加载失败,使用默认值
  211. this.popularSearches = ['西游记', '三体', '大侦探', '窗边的小豆豆'];
  212. }
  213. },
  214. async searchDispatcher(keyword, page = 1) {
  215. if (this.mode === 'audio') {
  216. await this.searchAudio(keyword, page)
  217. } else {
  218. await this.searchBook(keyword, page)
  219. }
  220. },
  221. async searchBook(keyword, page = 1) {
  222. if (!keyword || !keyword.trim()) {
  223. this.searchResults = [];
  224. return;
  225. }
  226. try {
  227. this.isLoading = true;
  228. const res = await searchBooks(keyword.trim(), page, this.pageSize);
  229. if (res && res.code === 200 && res.data) {
  230. const pageResult = res.data;
  231. const books = pageResult.list || pageResult.data || [];
  232. // 处理书籍数据
  233. const processed = books.map(b => ({
  234. id: b.id,
  235. title: b.title || '',
  236. author: b.author || '未知作者',
  237. desc: b.desc || b.brief || b.introduction || '',
  238. cover: b.cover || b.image || '',
  239. image: b.image || b.cover || ''
  240. }))
  241. if (page === 1) this.searchResults = processed; else this.searchResults = this.searchResults.concat(processed)
  242. const total = pageResult.total || 0;
  243. this.hasMore = this.searchResults.length < total;
  244. this.currentPage = page;
  245. } else {
  246. if (page === 1) this.searchResults = []
  247. uni.showToast({ title: res.message || '搜索失败', icon: 'none' })
  248. }
  249. } catch (e) {
  250. console.error('搜索书籍失败:', e);
  251. if (page === 1) this.searchResults = []
  252. uni.showToast({ title: e.message || '搜索失败,请重试', icon: 'none' })
  253. } finally {
  254. this.isLoading = false;
  255. }
  256. },
  257. async searchAudio(keyword, page = 1) {
  258. if (!keyword || !keyword.trim()) {
  259. this.searchResults = [];
  260. return;
  261. }
  262. try {
  263. this.isLoading = true
  264. const res = await searchAudiobooks({ keyword: keyword.trim(), page, size: this.pageSize })
  265. if (res && res.code === 200 && res.data) {
  266. const pageResult = res.data
  267. const list = pageResult.list || []
  268. const processed = list.map(a => ({
  269. id: a.id,
  270. title: a.title || '',
  271. author: a.narrator || a.author || '未知主播',
  272. desc: a.brief || a.desc || '',
  273. cover: a.image || a.cover || '',
  274. image: a.image || a.cover || '',
  275. isAudio: true
  276. }))
  277. if (page === 1) this.searchResults = processed; else this.searchResults = this.searchResults.concat(processed)
  278. const total = pageResult.total || 0
  279. this.hasMore = this.searchResults.length < total
  280. this.currentPage = page
  281. } else {
  282. if (page === 1) this.searchResults = []
  283. uni.showToast({ title: res.message || '搜索失败', icon: 'none' })
  284. }
  285. } catch (e) {
  286. console.error('搜索听书失败:', e)
  287. if (page === 1) this.searchResults = []
  288. uni.showToast({ title: e.message || '搜索失败,请重试', icon: 'none' })
  289. } finally {
  290. this.isLoading = false
  291. }
  292. },
  293. async saveToHistory(keyword) {
  294. if (!this.userInfo || !this.userInfo.id) {
  295. // 如果未登录,使用本地存储作为备用
  296. const index = this.searchHistory.findIndex(item =>
  297. (typeof item === 'string' ? item : item.keyword) === keyword
  298. );
  299. if (index > -1) {
  300. this.searchHistory.splice(index, 1);
  301. }
  302. this.searchHistory.unshift(keyword);
  303. if (this.searchHistory.length > 10) {
  304. this.searchHistory = this.searchHistory.slice(0, 10);
  305. }
  306. try {
  307. uni.setStorageSync('searchHistory', this.searchHistory);
  308. } catch (e) {
  309. console.error('保存搜索历史失败', e);
  310. }
  311. return;
  312. }
  313. // 保存到数据库
  314. try {
  315. await recordSearchHistory(this.userInfo.id, keyword);
  316. // 重新加载搜索历史
  317. await this.loadSearchHistory();
  318. } catch (e) {
  319. console.error('保存搜索历史失败', e);
  320. }
  321. },
  322. async loadSearchHistory() {
  323. if (!this.userInfo || !this.userInfo.id) {
  324. // 如果未登录,从本地存储加载
  325. try {
  326. const history = uni.getStorageSync('searchHistory');
  327. if (history && Array.isArray(history)) {
  328. this.searchHistory = history.map(item =>
  329. typeof item === 'string' ? item : item.keyword
  330. );
  331. }
  332. } catch (e) {
  333. console.error('加载搜索历史失败', e);
  334. }
  335. return;
  336. }
  337. // 从数据库加载
  338. try {
  339. const res = await getSearchHistory(this.userInfo.id, 10);
  340. if (res && res.code === 200 && res.data) {
  341. this.searchHistory = res.data.map(item => item.keyword);
  342. }
  343. } catch (e) {
  344. console.error('加载搜索历史失败', e);
  345. // 如果加载失败,尝试从本地存储加载
  346. try {
  347. const history = uni.getStorageSync('searchHistory');
  348. if (history && Array.isArray(history)) {
  349. this.searchHistory = history.map(item =>
  350. typeof item === 'string' ? item : item.keyword
  351. );
  352. }
  353. } catch (err) {
  354. console.error('从本地存储加载搜索历史失败', err);
  355. }
  356. }
  357. },
  358. async clearHistory() {
  359. uni.showModal({
  360. title: '提示',
  361. content: '确定要清空搜索历史吗?',
  362. success: async (res) => {
  363. if (res.confirm) {
  364. if (this.userInfo && this.userInfo.id) {
  365. // 清空数据库中的搜索历史
  366. try {
  367. uni.showLoading({
  368. title: '清空中...',
  369. mask: true
  370. });
  371. const result = await clearSearchHistory(this.userInfo.id);
  372. uni.hideLoading();
  373. if (result && result.code === 200) {
  374. this.searchHistory = [];
  375. uni.showToast({
  376. title: '已清空',
  377. icon: 'success'
  378. });
  379. } else {
  380. uni.showToast({
  381. title: result.message || '清空失败',
  382. icon: 'none'
  383. });
  384. }
  385. } catch (e) {
  386. uni.hideLoading();
  387. console.error('清空搜索历史失败', e);
  388. uni.showToast({
  389. title: '清空失败,请重试',
  390. icon: 'none'
  391. });
  392. }
  393. } else {
  394. // 清空本地存储
  395. this.searchHistory = [];
  396. try {
  397. uni.removeStorageSync('searchHistory');
  398. uni.showToast({
  399. title: '已清空',
  400. icon: 'success'
  401. });
  402. } catch (e) {
  403. console.error('清空搜索历史失败', e);
  404. }
  405. }
  406. }
  407. }
  408. });
  409. },
  410. loadMore() {
  411. // 加载更多搜索结果
  412. if (!this.isLoading && this.hasMore && this.searchKeyword.trim()) {
  413. this.searchDispatcher(this.searchKeyword.trim(), this.currentPage + 1);
  414. }
  415. },
  416. goToBookDetail(book) {
  417. if (!book || !book.id) {
  418. uni.showToast({ title: '信息不完整', icon: 'none' })
  419. return
  420. }
  421. if (this.mode === 'audio' || book.isAudio) {
  422. uni.navigateTo({ url: `/pages/listen-detail/listen-detail?audiobookId=${book.id}` })
  423. } else {
  424. uni.navigateTo({ url: `/pages/book-detail/book-detail?bookId=${book.id}` })
  425. }
  426. },
  427. handleImageError(index) {
  428. // 图片加载失败时使用备用图片
  429. if (this.searchResults[index]) {
  430. this.searchResults[index].cover = 'https://via.placeholder.com/200x300?text=No+Image';
  431. this.searchResults[index].image = 'https://via.placeholder.com/200x300?text=No+Image';
  432. }
  433. }
  434. }
  435. }
  436. </script>
  437. <style scoped>
  438. .container {
  439. width: 100%;
  440. height: 100vh;
  441. background-color: #FFFFFF;
  442. display: flex;
  443. flex-direction: column;
  444. padding-top: 50px;
  445. box-sizing: border-box;
  446. }
  447. /* 顶部搜索栏 */
  448. .search-header {
  449. display: flex;
  450. align-items: center;
  451. padding: 20rpx 30rpx;
  452. background-color: #FFFFFF;
  453. border-bottom: 1rpx solid #E5E5E5;
  454. }
  455. .search-input-wrapper {
  456. flex: 1;
  457. display: flex;
  458. align-items: center;
  459. background-color: #F5F5F5;
  460. border-radius: 40rpx;
  461. padding: 0 24rpx;
  462. height: 70rpx;
  463. margin-right: 20rpx;
  464. }
  465. .search-icon {
  466. font-size: 32rpx;
  467. color: #999999;
  468. margin-right: 12rpx;
  469. }
  470. .search-input {
  471. flex: 1;
  472. font-size: 28rpx;
  473. color: #333333;
  474. height: 70rpx;
  475. line-height: 70rpx;
  476. }
  477. .cancel-btn {
  478. font-size: 28rpx;
  479. color: #666666;
  480. }
  481. /* 搜索内容区域 */
  482. .search-content {
  483. flex: 1;
  484. width: 100%;
  485. height: 0;
  486. overflow: hidden;
  487. padding: 30rpx;
  488. background-color: #FFFFFF;
  489. }
  490. /* 搜索结果区域 */
  491. .search-results {
  492. flex: 1;
  493. width: 100%;
  494. height: 0;
  495. overflow: hidden;
  496. background-color: #FFFFFF;
  497. }
  498. /* 区块样式 */
  499. .section {
  500. margin-bottom: 50rpx;
  501. }
  502. .section-header {
  503. display: flex;
  504. align-items: center;
  505. justify-content: space-between;
  506. margin-bottom: 24rpx;
  507. }
  508. .section-title {
  509. font-size: 32rpx;
  510. font-weight: bold;
  511. color: #333333;
  512. }
  513. .clear-btn {
  514. font-size: 28rpx;
  515. color: #4FC3F7;
  516. }
  517. /* 标签列表 */
  518. .tag-list {
  519. display: flex;
  520. flex-wrap: wrap;
  521. gap: 20rpx;
  522. }
  523. .tag-item {
  524. padding: 16rpx 28rpx;
  525. background-color: #F5F5F5;
  526. border-radius: 40rpx;
  527. }
  528. .tag-text {
  529. font-size: 28rpx;
  530. color: #666666;
  531. }
  532. /* 搜索结果头部 */
  533. .results-header {
  534. padding: 30rpx;
  535. border-bottom: 1rpx solid #E5E5E5;
  536. }
  537. .results-title {
  538. font-size: 32rpx;
  539. font-weight: bold;
  540. color: #333333;
  541. margin-bottom: 8rpx;
  542. display: block;
  543. }
  544. .results-count {
  545. font-size: 24rpx;
  546. color: #999999;
  547. display: block;
  548. }
  549. /* 书籍列表 */
  550. .book-list {
  551. padding: 0 30rpx;
  552. }
  553. .book-item {
  554. display: flex;
  555. padding: 30rpx 0;
  556. border-bottom: 1rpx solid #F0F0F0;
  557. }
  558. .book-item:last-child {
  559. border-bottom: none;
  560. }
  561. /* 书籍封面 */
  562. .book-cover {
  563. width: 160rpx;
  564. height: 220rpx;
  565. border-radius: 8rpx;
  566. margin-right: 24rpx;
  567. flex-shrink: 0;
  568. background-color: #F5F5F5;
  569. }
  570. /* 书籍信息 */
  571. .book-info {
  572. flex: 1;
  573. display: flex;
  574. flex-direction: column;
  575. justify-content: flex-start;
  576. min-width: 0;
  577. }
  578. .book-title {
  579. font-size: 32rpx;
  580. font-weight: bold;
  581. color: #000000;
  582. margin-bottom: 16rpx;
  583. line-height: 1.4;
  584. display: -webkit-box;
  585. -webkit-box-orient: vertical;
  586. -webkit-line-clamp: 2;
  587. overflow: hidden;
  588. text-overflow: ellipsis;
  589. }
  590. .book-desc {
  591. font-size: 28rpx;
  592. color: #666666;
  593. line-height: 1.6;
  594. margin-bottom: 20rpx;
  595. display: -webkit-box;
  596. -webkit-box-orient: vertical;
  597. -webkit-line-clamp: 3;
  598. overflow: hidden;
  599. text-overflow: ellipsis;
  600. }
  601. .book-author {
  602. font-size: 26rpx;
  603. color: #999999;
  604. margin-top: auto;
  605. }
  606. /* 加载中 */
  607. .loading-container {
  608. display: flex;
  609. justify-content: center;
  610. align-items: center;
  611. padding: 100rpx 30rpx;
  612. }
  613. .loading-text {
  614. font-size: 28rpx;
  615. color: #999999;
  616. }
  617. /* 加载更多 */
  618. .load-more {
  619. display: flex;
  620. justify-content: center;
  621. align-items: center;
  622. padding: 30rpx;
  623. }
  624. .load-more-text {
  625. font-size: 26rpx;
  626. color: #999999;
  627. }
  628. /* 无结果提示 */
  629. .no-results {
  630. display: flex;
  631. flex-direction: column;
  632. align-items: center;
  633. justify-content: center;
  634. padding: 100rpx 30rpx;
  635. }
  636. .no-results-text {
  637. font-size: 32rpx;
  638. color: #999999;
  639. margin-bottom: 16rpx;
  640. }
  641. .no-results-hint {
  642. font-size: 28rpx;
  643. color: #CCCCCC;
  644. }
  645. </style>