hookehuyr

feat: 添加搜索弹窗组件并修改首页搜索交互

实现搜索弹窗组件,包含搜索框、筛选条件和结果展示功能
修改首页搜索框交互,点击后显示搜索弹窗而非跳转页面
...@@ -39,6 +39,7 @@ declare module 'vue' { ...@@ -39,6 +39,7 @@ declare module 'vue' {
39 PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default'] 39 PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
40 RouterLink: typeof import('vue-router')['RouterLink'] 40 RouterLink: typeof import('vue-router')['RouterLink']
41 RouterView: typeof import('vue-router')['RouterView'] 41 RouterView: typeof import('vue-router')['RouterView']
42 + SearchPopup: typeof import('./src/components/SearchPopup.vue')['default']
42 TabBar: typeof import('./src/components/TabBar.vue')['default'] 43 TabBar: typeof import('./src/components/TabBar.vue')['default']
43 } 44 }
44 } 45 }
......
1 +/* 搜索弹窗样式 */
2 +.search-page {
3 + background-color: #f5f5f5;
4 + min-height: 100vh;
5 +}
6 +
7 +/* 搜索头部固定定位 */
8 +.search-header {
9 + position: fixed;
10 + top: 0;
11 + left: 0;
12 + right: 0;
13 + z-index: 1000;
14 + background-color: #fff;
15 +}
16 +
17 +/* 搜索内容区域 */
18 +.search-content {
19 + margin-top: 200rpx; /* 为固定头部预留空间 */
20 +}
21 +
22 +/* 搜索结果列表样式 */
23 +.search-results-list {
24 + width: 100%;
25 + box-sizing: border-box;
26 +
27 + /* 滚动条样式 */
28 + &::-webkit-scrollbar {
29 + width: 6rpx;
30 + }
31 +
32 + &::-webkit-scrollbar-track {
33 + background: #f1f1f1;
34 + border-radius: 3rpx;
35 + }
36 +
37 + &::-webkit-scrollbar-thumb {
38 + background: #c1c1c1;
39 + border-radius: 3rpx;
40 +
41 + &:hover {
42 + background: #a8a8a8;
43 + }
44 + }
45 +}
46 +
47 +/* 加载状态样式 */
48 +.load-more-container {
49 + padding: 40rpx 0;
50 +
51 + .loading-container {
52 + display: flex;
53 + align-items: center;
54 + justify-content: center;
55 + color: #666;
56 +
57 + .loading-spinner {
58 + width: 32rpx;
59 + height: 32rpx;
60 + border: 4rpx solid #f3f3f3;
61 + border-top: 4rpx solid #f97316;
62 + border-radius: 50%;
63 + animation: spin 1s linear infinite;
64 + margin-right: 16rpx;
65 + }
66 +
67 + .loading-text {
68 + font-size: 28rpx;
69 + color: #666;
70 + }
71 + }
72 +
73 + .no-more-data {
74 + padding: 20rpx 0;
75 + text-align: center;
76 + color: #999;
77 + font-size: 28rpx;
78 + }
79 +}
80 +
81 +/* 旋转动画 */
82 +@keyframes spin {
83 + 0% { transform: rotate(0deg); }
84 + 100% { transform: rotate(360deg); }
85 +}
86 +
87 +/* 空状态样式 */
88 +.empty-state {
89 + .empty-icon {
90 + display: flex;
91 + justify-content: center;
92 + align-items: center;
93 + opacity: 0.6;
94 + }
95 +}
96 +
97 +.no-results-state {
98 + .no-results-icon {
99 + display: flex;
100 + justify-content: center;
101 + align-items: center;
102 + opacity: 0.7;
103 + }
104 +}
105 +
106 +/* 搜索结果卡片样式优化 */
107 +.search-results-list {
108 + .grid {
109 + .bg-white {
110 + transition: all 0.3s ease;
111 + border: 1rpx solid #e5e7eb;
112 +
113 + &:hover {
114 + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.12);
115 + transform: translateY(-2rpx);
116 + }
117 + }
118 + }
119 +}
...\ No newline at end of file ...\ No newline at end of file
1 +<!--
2 + * @Date: 2025-01-20 00:00:00
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2025-07-04 16:05:59
5 + * @FilePath: /jgdl/src/components/SearchPopup.vue
6 + * @Description: 搜索弹窗组件
7 +-->
8 +<template>
9 + <nut-popup
10 + v-model:visible="visible"
11 + position="right"
12 + :style="{ width: '100%', height: '100%' }"
13 + :catch-move="true"
14 + @close="handleClose"
15 + >
16 + <view class="search-page">
17 + <view class="flex flex-col bg-white min-h-screen">
18 + <!-- Header -->
19 + <view class="search-header">
20 + <view class="bg-orange-400 p-4 pt-4 pb-4">
21 + <nut-row type="flex" justify="center" align="center">
22 + <nut-col span="3">
23 + <view class="text-xl font-bold text-white" @tap="handleClose">关闭</view>
24 + </nut-col>
25 + <nut-col span="21">
26 + <!-- Search Bar -->
27 + <nut-searchbar v-model="searchValue" placeholder="搜索商品名称、品牌、型号" @search="onSearch" @blur="onBlurSearch" @clear="onClearSearch" shape="round" background="transparent" input-background="#ffffff">
28 + <template #leftin>
29 + <Search2 />
30 + </template>
31 + </nut-searchbar>
32 + </nut-col>
33 + </nut-row>
34 + </view>
35 +
36 + <!-- Filter options -->
37 + <nut-menu>
38 + <nut-menu-item v-model="selectedBrand" :options="brandOptions" @change="onBrandChange" />
39 + <nut-menu-item v-model="selectedYear" :options="yearOptions" @change="onYearChange" />
40 + <nut-menu-item v-model="selectedSchool" :options="schoolOptions" @change="onSchoolChange" />
41 + </nut-menu>
42 + </view>
43 +
44 + <!-- Search Results -->
45 + <view class="flex-1 search-content">
46 + <!-- 滚动列表 -->
47 + <scroll-view
48 + class="search-results-list"
49 + :style="scrollStyle"
50 + :scroll-y="true"
51 + @scrolltolower="loadMore"
52 + :lower-threshold="50"
53 + :enable-flex="false"
54 + >
55 + <view class="p-4 pt-12">
56 + <!-- 搜索结果统计 -->
57 + <view v-if="searchResults.length > 0" class="mb-4">
58 + <text class="text-sm text-gray-600">找到 {{ totalCount }} 个相关结果</text>
59 + </view>
60 +
61 + <!-- 搜索结果网格布局 -->
62 + <view class="grid grid-cols-2 gap-3">
63 + <view v-for="scooter in searchResults" :key="scooter.id"
64 + class="bg-white rounded-lg shadow-sm overflow-hidden" @tap="() => onProductClick(scooter)">
65 + <view class="relative p-2">
66 + <image :src="scooter.imageUrl" :alt="scooter.name" mode="aspectFill"
67 + class="w-full h-36 object-cover rounded-lg" />
68 + <view class="absolute top-4 right-3 w-7 h-7 rounded-full bg-white bg-opacity-90" @tap.stop="() => toggleFavorite(scooter.id)" style="padding-top: 12rpx; padding-left: 10rpx;">
69 + <Heart1 v-if="!favoriteIds.includes(scooter.id)" size="22" :color="'#9ca3af'" />
70 + <HeartFill v-else size="22" :color="'#ef4444'" />
71 + </view>
72 + <view v-if="scooter.isVerified"
73 + class="absolute bottom-4 right-4 text-white text-xs px-1.5 py-0.5 rounded flex items-center" style="background-color: #EB5305;">
74 + <Check size="12" color="#ffffff" class="mr-0.5" />
75 + <text class="text-white">认证</text>
76 + </view>
77 + </view>
78 + <view class="p-2 pl-3">
79 + <text class="font-medium text-sm block">{{ scooter.name }}</text>
80 + <text class="text-xs text-gray-500 block mt-1 mb-1">
81 + {{ scooter.year }} · {{ scooter.school }}
82 + <text v-if="scooter.batteryHealth">电池健康度{{ scooter.batteryHealth }}%</text>
83 + <text v-if="scooter.mileage"> 行驶{{ scooter.mileage }}公里</text>
84 + </text>
85 + <view class="mt-1">
86 + <text class="text-orange-500 font-bold" style="font-size: 1.25rem;">
87 + ¥{{ scooter.price.toLocaleString() }}
88 + </text>
89 + </view>
90 + </view>
91 + </view>
92 + </view>
93 +
94 + <!-- 初始空状态 -->
95 + <view v-if="searchResults.length === 0 && !loading && !hasSearched" class="empty-state text-center py-20">
96 + <view class="empty-icon mb-4">
97 + <Search2 size="80" color="#d1d5db" />
98 + </view>
99 + <text class="text-lg font-medium text-gray-600 block mb-2">搜索电动车</text>
100 + <text class="text-sm text-gray-400 block">输入品牌型号,找到心仪的电动车</text>
101 + </view>
102 +
103 + <!-- 搜索无结果状态 -->
104 + <view v-if="searchResults.length === 0 && !loading && hasSearched" class="no-results-state text-center py-20">
105 + <view class="no-results-icon mb-4">
106 + <Search2 size="60" color="#9ca3af" />
107 + </view>
108 + <text class="text-base font-medium text-gray-600 block mb-2">暂无搜索结果</text>
109 + <text class="text-sm text-gray-400 block">试试其他关键词或调整筛选条件</text>
110 + </view>
111 +
112 + <!-- 加载更多 -->
113 + <view v-if="searchResults.length > 0" class="load-more-container mt-6">
114 + <view v-if="loading" class="loading-container">
115 + <view class="loading-spinner"></view>
116 + <text class="loading-text">加载中...</text>
117 + </view>
118 + <view v-else-if="noMoreData" class="no-more-data text-center">
119 + <text class="text-gray-500">没有更多数据了</text>
120 + </view>
121 + </view>
122 + </view>
123 + </scroll-view>
124 + </view>
125 + </view>
126 + </view>
127 + </nut-popup>
128 +</template>
129 +
130 +<script setup>
131 +import { ref, computed, onMounted } from 'vue'
132 +import Taro from '@tarojs/taro'
133 +import { Search2, Check, Heart1, HeartFill } from '@nutui/icons-vue-taro'
134 +import "./SearchPopup.less"
135 +
136 +// Props
137 +const props = defineProps({
138 + visible: {
139 + type: Boolean,
140 + default: false
141 + }
142 +})
143 +
144 +// Emits
145 +const emit = defineEmits(['update:visible'])
146 +
147 +// 内部可见状态
148 +const visible = computed({
149 + get: () => props.visible,
150 + set: (value) => emit('update:visible', value)
151 +})
152 +
153 +// 响应式数据
154 +const searchValue = ref('')
155 +const favoriteIds = ref(['5', '7', '1'])
156 +const hasSearched = ref(false)
157 +
158 +// 滚动相关
159 +const loading = ref(false)
160 +const noMoreData = ref(false)
161 +const currentPage = ref(1)
162 +const pageSize = ref(10)
163 +const totalCount = ref(0)
164 +
165 +// Filter states
166 +const selectedBrand = ref('全部品牌')
167 +const selectedYear = ref('出厂年份')
168 +const selectedSchool = ref('所在学校')
169 +
170 +// Menu选项数据
171 +const brandOptions = ref([
172 + { text: '全部品牌', value: '全部品牌' },
173 + { text: '雅迪', value: '雅迪' },
174 + { text: '台铃', value: '台铃' },
175 + { text: '小鸟', value: '小鸟' },
176 + { text: '新日', value: '新日' },
177 + { text: '爱玛', value: '爱玛' },
178 + { text: '小牛', value: '小牛' }
179 +])
180 +
181 +const yearOptions = ref([
182 + { text: '出厂年份', value: '出厂年份' },
183 + { text: '2024年', value: '2024年' },
184 + { text: '2023年', value: '2023年' },
185 + { text: '2022年', value: '2022年' },
186 + { text: '2021年', value: '2021年' },
187 + { text: '2020年', value: '2020年' }
188 +])
189 +
190 +const schoolOptions = ref([
191 + { text: '所在学校', value: '所在学校' },
192 + { text: '上海理工大学', value: '上海理工大学' },
193 + { text: '上海复旦大学', value: '上海复旦大学' },
194 + { text: '上海同济大学', value: '上海同济大学' },
195 + { text: '上海交通大学', value: '上海交通大学' }
196 +])
197 +
198 +// 搜索结果数据
199 +const searchResults = ref([])
200 +
201 +// 滚动样式
202 +const scrollStyle = computed(() => {
203 + return {
204 + height: 'calc(100vh - 200rpx)'
205 + }
206 +})
207 +
208 +/**
209 + * 生成模拟数据
210 + * @param {number} page 页码
211 + * @param {number} size 每页数量
212 + * @returns {Array} 模拟数据数组
213 + */
214 +const generateMockData = (page, size) => {
215 + const brands = ['雅迪', '台铃', '小鸟', '新日', '爱玛', '小牛']
216 + const schools = ['上海理工大学', '上海复旦大学', '上海同济大学', '上海交通大学']
217 + const years = ['2024年', '2023年', '2022年', '2021年', '2020年']
218 + const images = [
219 + 'https://images.unsplash.com/photo-1558981285-6f0c94958bb6?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=60',
220 + 'https://images.unsplash.com/photo-1558981403-c5f9899a28bc?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=60',
221 + 'https://images.unsplash.com/photo-1591637333184-19aa84b3e01f?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=60',
222 + 'https://images.unsplash.com/photo-1558980664-3a031cf67ea8?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=60',
223 + 'https://images.unsplash.com/photo-1595941069915-4ebc5197c14a?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=60'
224 + ]
225 +
226 + const data = []
227 + const startId = (page - 1) * size + 1
228 +
229 + for (let i = 0; i < size; i++) {
230 + const brand = brands[Math.floor(Math.random() * brands.length)]
231 + const school = schools[Math.floor(Math.random() * schools.length)]
232 + const year = years[Math.floor(Math.random() * years.length)]
233 + const image = images[Math.floor(Math.random() * images.length)]
234 +
235 + data.push({
236 + id: `search_${startId + i}`,
237 + name: `${brand} ${['豪华版', '标准版', '运动版', '经典版'][Math.floor(Math.random() * 4)]}`,
238 + year: year,
239 + school: school,
240 + price: Math.floor(Math.random() * 5000) + 2000,
241 + imageUrl: image,
242 + batteryHealth: Math.floor(Math.random() * 30) + 70,
243 + mileage: Math.floor(Math.random() * 5000) + 500,
244 + brand: brand,
245 + isVerified: Math.random() > 0.6
246 + })
247 + }
248 +
249 + return data
250 +}
251 +
252 +/**
253 + * 执行搜索
254 + */
255 +const performSearch = () => {
256 + if (!searchValue.value.trim()) {
257 + clearSearchResults()
258 + return
259 + }
260 +
261 + loading.value = true
262 + hasSearched.value = true
263 + currentPage.value = 1
264 + noMoreData.value = false
265 +
266 + // 模拟API调用延迟
267 + setTimeout(() => {
268 + const mockData = generateMockData(1, pageSize.value)
269 + searchResults.value = mockData
270 + totalCount.value = 50 // 模拟总数
271 + loading.value = false
272 + }, 500)
273 +}
274 +
275 +/**
276 + * 清除搜索结果
277 + */
278 +const clearSearchResults = () => {
279 + searchResults.value = []
280 + hasSearched.value = false
281 + currentPage.value = 1
282 + noMoreData.value = false
283 + totalCount.value = 0
284 +}
285 +
286 +/**
287 + * 搜索事件处理
288 + */
289 +const onSearch = () => {
290 + performSearch()
291 +}
292 +
293 +/**
294 + * 搜索框失焦事件
295 + */
296 +const onBlurSearch = () => {
297 + if (searchValue.value.trim()) {
298 + performSearch()
299 + } else {
300 + // 如果搜索框为空,清除搜索结果
301 + clearSearchResults()
302 + }
303 +}
304 +
305 +const onClearSearch = () => {
306 + clearSearchResults()
307 +}
308 +
309 +/**
310 + * 品牌筛选变化
311 + */
312 +const onBrandChange = () => {
313 + if (hasSearched.value) {
314 + performSearch()
315 + }
316 +}
317 +
318 +/**
319 + * 年份筛选变化
320 + */
321 +const onYearChange = () => {
322 + if (hasSearched.value) {
323 + performSearch()
324 + }
325 +}
326 +
327 +/**
328 + * 学校筛选变化
329 + */
330 +const onSchoolChange = () => {
331 + if (hasSearched.value) {
332 + performSearch()
333 + }
334 +}
335 +
336 +/**
337 + * 加载更多数据
338 + */
339 +const loadMore = () => {
340 + if (loading.value || noMoreData.value) return
341 +
342 + loading.value = true
343 + currentPage.value++
344 +
345 + // 模拟API调用延迟
346 + setTimeout(() => {
347 + const mockData = generateMockData(currentPage.value, pageSize.value)
348 +
349 + if (mockData.length === 0 || searchResults.value.length >= totalCount.value) {
350 + noMoreData.value = true
351 + } else {
352 + searchResults.value.push(...mockData)
353 + }
354 +
355 + loading.value = false
356 + }, 500)
357 +}
358 +
359 +/**
360 + * 切换收藏状态
361 + * @param {string} id 商品ID
362 + */
363 +const toggleFavorite = (id) => {
364 + const index = favoriteIds.value.indexOf(id)
365 + if (index > -1) {
366 + favoriteIds.value.splice(index, 1)
367 + } else {
368 + favoriteIds.value.push(id)
369 + }
370 +}
371 +
372 +/**
373 + * 商品点击事件
374 + * @param {Object} scooter 商品信息
375 + */
376 +const onProductClick = (scooter) => {
377 + // 关闭弹窗
378 + handleClose()
379 + // 跳转到详情页
380 + Taro.navigateTo({
381 + url: `/pages/productDetail/index?id=${scooter.id}`
382 + })
383 +}
384 +
385 +/**
386 + * 关闭弹窗
387 + */
388 +const handleClose = () => {
389 + // 重置搜索状态
390 + resetSearchState()
391 + emit('update:visible', false)
392 +}
393 +
394 +/**
395 + * 重置搜索状态
396 + */
397 +const resetSearchState = () => {
398 + searchValue.value = ''
399 + searchResults.value = []
400 + hasSearched.value = false
401 + loading.value = false
402 + noMoreData.value = false
403 + currentPage.value = 1
404 + totalCount.value = 0
405 + // 重置筛选条件
406 + selectedBrand.value = '全部品牌'
407 + selectedYear.value = '出厂年份'
408 + selectedSchool.value = '所在学校'
409 +}
410 +
411 +onMounted(() => {
412 + // 动态修改标题
413 + wx.setNavigationBarTitle({
414 + title: '搜索车源'
415 + });
416 +})
417 +</script>
418 +
419 +<script>
420 +export default {
421 + name: "SearchPopup",
422 +};
423 +</script>
1 <!-- 1 <!--
2 * @Date: 2025-06-28 10:33:00 2 * @Date: 2025-06-28 10:33:00
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-07-04 15:20:58 4 + * @LastEditTime: 2025-07-04 16:04:15
5 * @FilePath: /jgdl/src/pages/index/index.vue 5 * @FilePath: /jgdl/src/pages/index/index.vue
6 * @Description: 捡个电驴首页 6 * @Description: 捡个电驴首页
7 --> 7 -->
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
16 </nut-col> 16 </nut-col>
17 <nut-col span="18"> 17 <nut-col span="18">
18 <!-- Search Bar --> 18 <!-- Search Bar -->
19 - <nut-searchbar v-model="searchValue" placeholder="搜索更多商品" @focus="onFocusSearch" shape="round" background="transparent" input-background="#ffffff"> 19 + <nut-searchbar v-model="searchValue" placeholder="搜索更多商品" :disabled="true" @click-input="onSearchHandle" shape="round" background="transparent" input-background="#ffffff">
20 <template #leftin> 20 <template #leftin>
21 <Search2 /> 21 <Search2 />
22 </template> 22 </template>
...@@ -153,6 +153,9 @@ ...@@ -153,6 +153,9 @@
153 153
154 <!-- 自定义TabBar --> 154 <!-- 自定义TabBar -->
155 <TabBar /> 155 <TabBar />
156 +
157 + <!-- 搜索弹窗 -->
158 + <SearchPopup v-model:visible="showSearchPopup" />
156 </view> 159 </view>
157 </template> 160 </template>
158 161
...@@ -163,17 +166,17 @@ import { ref, onMounted } from 'vue' ...@@ -163,17 +166,17 @@ import { ref, onMounted } from 'vue'
163 import { useDidShow, useReady } from '@tarojs/taro' 166 import { useDidShow, useReady } from '@tarojs/taro'
164 import { Clock, Star, RectRight, Check, Search2, Shop, Heart1, HeartFill } from '@nutui/icons-vue-taro' 167 import { Clock, Star, RectRight, Check, Search2, Shop, Heart1, HeartFill } from '@nutui/icons-vue-taro'
165 import TabBar from '@/components/TabBar.vue' 168 import TabBar from '@/components/TabBar.vue'
169 +import SearchPopup from '@/components/SearchPopup.vue'
166 import "./index.less"; 170 import "./index.less";
167 171
168 // 响应式数据 172 // 响应式数据
169 const searchValue = ref('') 173 const searchValue = ref('')
170 const favoriteIds = ref([]) 174 const favoriteIds = ref([])
175 +const showSearchPopup = ref(false)
171 176
172 -const onFocusSearch = () => { 177 +const onSearchHandle = () => {
173 - // 跳转到搜索页面 178 + // 显示搜索弹窗
174 - Taro.navigateTo({ 179 + showSearchPopup.value = true
175 - url: '/pages/search/index'
176 - })
177 } 180 }
178 181
179 // Banner图片 182 // Banner图片
......