hookehuyr

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

实现搜索弹窗组件,包含搜索框、筛选条件和结果展示功能
修改首页搜索框交互,点击后显示搜索弹窗而非跳转页面
......@@ -39,6 +39,7 @@ declare module 'vue' {
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchPopup: typeof import('./src/components/SearchPopup.vue')['default']
TabBar: typeof import('./src/components/TabBar.vue')['default']
}
}
......
/* 搜索弹窗样式 */
.search-page {
background-color: #f5f5f5;
min-height: 100vh;
}
/* 搜索头部固定定位 */
.search-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background-color: #fff;
}
/* 搜索内容区域 */
.search-content {
margin-top: 200rpx; /* 为固定头部预留空间 */
}
/* 搜索结果列表样式 */
.search-results-list {
width: 100%;
box-sizing: border-box;
/* 滚动条样式 */
&::-webkit-scrollbar {
width: 6rpx;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3rpx;
}
&::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3rpx;
&:hover {
background: #a8a8a8;
}
}
}
/* 加载状态样式 */
.load-more-container {
padding: 40rpx 0;
.loading-container {
display: flex;
align-items: center;
justify-content: center;
color: #666;
.loading-spinner {
width: 32rpx;
height: 32rpx;
border: 4rpx solid #f3f3f3;
border-top: 4rpx solid #f97316;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 16rpx;
}
.loading-text {
font-size: 28rpx;
color: #666;
}
}
.no-more-data {
padding: 20rpx 0;
text-align: center;
color: #999;
font-size: 28rpx;
}
}
/* 旋转动画 */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 空状态样式 */
.empty-state {
.empty-icon {
display: flex;
justify-content: center;
align-items: center;
opacity: 0.6;
}
}
.no-results-state {
.no-results-icon {
display: flex;
justify-content: center;
align-items: center;
opacity: 0.7;
}
}
/* 搜索结果卡片样式优化 */
.search-results-list {
.grid {
.bg-white {
transition: all 0.3s ease;
border: 1rpx solid #e5e7eb;
&:hover {
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.12);
transform: translateY(-2rpx);
}
}
}
}
\ No newline at end of file
<!--
* @Date: 2025-01-20 00:00:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-07-04 16:05:59
* @FilePath: /jgdl/src/components/SearchPopup.vue
* @Description: 搜索弹窗组件
-->
<template>
<nut-popup
v-model:visible="visible"
position="right"
:style="{ width: '100%', height: '100%' }"
:catch-move="true"
@close="handleClose"
>
<view class="search-page">
<view class="flex flex-col bg-white min-h-screen">
<!-- Header -->
<view class="search-header">
<view class="bg-orange-400 p-4 pt-4 pb-4">
<nut-row type="flex" justify="center" align="center">
<nut-col span="3">
<view class="text-xl font-bold text-white" @tap="handleClose">关闭</view>
</nut-col>
<nut-col span="21">
<!-- Search Bar -->
<nut-searchbar v-model="searchValue" placeholder="搜索商品名称、品牌、型号" @search="onSearch" @blur="onBlurSearch" @clear="onClearSearch" shape="round" background="transparent" input-background="#ffffff">
<template #leftin>
<Search2 />
</template>
</nut-searchbar>
</nut-col>
</nut-row>
</view>
<!-- Filter options -->
<nut-menu>
<nut-menu-item v-model="selectedBrand" :options="brandOptions" @change="onBrandChange" />
<nut-menu-item v-model="selectedYear" :options="yearOptions" @change="onYearChange" />
<nut-menu-item v-model="selectedSchool" :options="schoolOptions" @change="onSchoolChange" />
</nut-menu>
</view>
<!-- Search Results -->
<view class="flex-1 search-content">
<!-- 滚动列表 -->
<scroll-view
class="search-results-list"
:style="scrollStyle"
:scroll-y="true"
@scrolltolower="loadMore"
:lower-threshold="50"
:enable-flex="false"
>
<view class="p-4 pt-12">
<!-- 搜索结果统计 -->
<view v-if="searchResults.length > 0" class="mb-4">
<text class="text-sm text-gray-600">找到 {{ totalCount }} 个相关结果</text>
</view>
<!-- 搜索结果网格布局 -->
<view class="grid grid-cols-2 gap-3">
<view v-for="scooter in searchResults" :key="scooter.id"
class="bg-white rounded-lg shadow-sm overflow-hidden" @tap="() => onProductClick(scooter)">
<view class="relative p-2">
<image :src="scooter.imageUrl" :alt="scooter.name" mode="aspectFill"
class="w-full h-36 object-cover rounded-lg" />
<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;">
<Heart1 v-if="!favoriteIds.includes(scooter.id)" size="22" :color="'#9ca3af'" />
<HeartFill v-else size="22" :color="'#ef4444'" />
</view>
<view v-if="scooter.isVerified"
class="absolute bottom-4 right-4 text-white text-xs px-1.5 py-0.5 rounded flex items-center" style="background-color: #EB5305;">
<Check size="12" color="#ffffff" class="mr-0.5" />
<text class="text-white">认证</text>
</view>
</view>
<view class="p-2 pl-3">
<text class="font-medium text-sm block">{{ scooter.name }}</text>
<text class="text-xs text-gray-500 block mt-1 mb-1">
{{ scooter.year }} · {{ scooter.school }}
<text v-if="scooter.batteryHealth">电池健康度{{ scooter.batteryHealth }}%</text>
<text v-if="scooter.mileage"> 行驶{{ scooter.mileage }}公里</text>
</text>
<view class="mt-1">
<text class="text-orange-500 font-bold" style="font-size: 1.25rem;">
¥{{ scooter.price.toLocaleString() }}
</text>
</view>
</view>
</view>
</view>
<!-- 初始空状态 -->
<view v-if="searchResults.length === 0 && !loading && !hasSearched" class="empty-state text-center py-20">
<view class="empty-icon mb-4">
<Search2 size="80" color="#d1d5db" />
</view>
<text class="text-lg font-medium text-gray-600 block mb-2">搜索电动车</text>
<text class="text-sm text-gray-400 block">输入品牌型号,找到心仪的电动车</text>
</view>
<!-- 搜索无结果状态 -->
<view v-if="searchResults.length === 0 && !loading && hasSearched" class="no-results-state text-center py-20">
<view class="no-results-icon mb-4">
<Search2 size="60" color="#9ca3af" />
</view>
<text class="text-base font-medium text-gray-600 block mb-2">暂无搜索结果</text>
<text class="text-sm text-gray-400 block">试试其他关键词或调整筛选条件</text>
</view>
<!-- 加载更多 -->
<view v-if="searchResults.length > 0" class="load-more-container mt-6">
<view v-if="loading" class="loading-container">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="noMoreData" class="no-more-data text-center">
<text class="text-gray-500">没有更多数据了</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</nut-popup>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import Taro from '@tarojs/taro'
import { Search2, Check, Heart1, HeartFill } from '@nutui/icons-vue-taro'
import "./SearchPopup.less"
// Props
const props = defineProps({
visible: {
type: Boolean,
default: false
}
})
// Emits
const emit = defineEmits(['update:visible'])
// 内部可见状态
const visible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
// 响应式数据
const searchValue = ref('')
const favoriteIds = ref(['5', '7', '1'])
const hasSearched = ref(false)
// 滚动相关
const loading = ref(false)
const noMoreData = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const totalCount = ref(0)
// Filter states
const selectedBrand = ref('全部品牌')
const selectedYear = ref('出厂年份')
const selectedSchool = ref('所在学校')
// Menu选项数据
const brandOptions = ref([
{ text: '全部品牌', value: '全部品牌' },
{ text: '雅迪', value: '雅迪' },
{ text: '台铃', value: '台铃' },
{ text: '小鸟', value: '小鸟' },
{ text: '新日', value: '新日' },
{ text: '爱玛', value: '爱玛' },
{ text: '小牛', value: '小牛' }
])
const yearOptions = ref([
{ text: '出厂年份', value: '出厂年份' },
{ text: '2024年', value: '2024年' },
{ text: '2023年', value: '2023年' },
{ text: '2022年', value: '2022年' },
{ text: '2021年', value: '2021年' },
{ text: '2020年', value: '2020年' }
])
const schoolOptions = ref([
{ text: '所在学校', value: '所在学校' },
{ text: '上海理工大学', value: '上海理工大学' },
{ text: '上海复旦大学', value: '上海复旦大学' },
{ text: '上海同济大学', value: '上海同济大学' },
{ text: '上海交通大学', value: '上海交通大学' }
])
// 搜索结果数据
const searchResults = ref([])
// 滚动样式
const scrollStyle = computed(() => {
return {
height: 'calc(100vh - 200rpx)'
}
})
/**
* 生成模拟数据
* @param {number} page 页码
* @param {number} size 每页数量
* @returns {Array} 模拟数据数组
*/
const generateMockData = (page, size) => {
const brands = ['雅迪', '台铃', '小鸟', '新日', '爱玛', '小牛']
const schools = ['上海理工大学', '上海复旦大学', '上海同济大学', '上海交通大学']
const years = ['2024年', '2023年', '2022年', '2021年', '2020年']
const images = [
'https://images.unsplash.com/photo-1558981285-6f0c94958bb6?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=60',
'https://images.unsplash.com/photo-1558981403-c5f9899a28bc?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=60',
'https://images.unsplash.com/photo-1591637333184-19aa84b3e01f?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=60',
'https://images.unsplash.com/photo-1558980664-3a031cf67ea8?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=60',
'https://images.unsplash.com/photo-1595941069915-4ebc5197c14a?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=60'
]
const data = []
const startId = (page - 1) * size + 1
for (let i = 0; i < size; i++) {
const brand = brands[Math.floor(Math.random() * brands.length)]
const school = schools[Math.floor(Math.random() * schools.length)]
const year = years[Math.floor(Math.random() * years.length)]
const image = images[Math.floor(Math.random() * images.length)]
data.push({
id: `search_${startId + i}`,
name: `${brand} ${['豪华版', '标准版', '运动版', '经典版'][Math.floor(Math.random() * 4)]}`,
year: year,
school: school,
price: Math.floor(Math.random() * 5000) + 2000,
imageUrl: image,
batteryHealth: Math.floor(Math.random() * 30) + 70,
mileage: Math.floor(Math.random() * 5000) + 500,
brand: brand,
isVerified: Math.random() > 0.6
})
}
return data
}
/**
* 执行搜索
*/
const performSearch = () => {
if (!searchValue.value.trim()) {
clearSearchResults()
return
}
loading.value = true
hasSearched.value = true
currentPage.value = 1
noMoreData.value = false
// 模拟API调用延迟
setTimeout(() => {
const mockData = generateMockData(1, pageSize.value)
searchResults.value = mockData
totalCount.value = 50 // 模拟总数
loading.value = false
}, 500)
}
/**
* 清除搜索结果
*/
const clearSearchResults = () => {
searchResults.value = []
hasSearched.value = false
currentPage.value = 1
noMoreData.value = false
totalCount.value = 0
}
/**
* 搜索事件处理
*/
const onSearch = () => {
performSearch()
}
/**
* 搜索框失焦事件
*/
const onBlurSearch = () => {
if (searchValue.value.trim()) {
performSearch()
} else {
// 如果搜索框为空,清除搜索结果
clearSearchResults()
}
}
const onClearSearch = () => {
clearSearchResults()
}
/**
* 品牌筛选变化
*/
const onBrandChange = () => {
if (hasSearched.value) {
performSearch()
}
}
/**
* 年份筛选变化
*/
const onYearChange = () => {
if (hasSearched.value) {
performSearch()
}
}
/**
* 学校筛选变化
*/
const onSchoolChange = () => {
if (hasSearched.value) {
performSearch()
}
}
/**
* 加载更多数据
*/
const loadMore = () => {
if (loading.value || noMoreData.value) return
loading.value = true
currentPage.value++
// 模拟API调用延迟
setTimeout(() => {
const mockData = generateMockData(currentPage.value, pageSize.value)
if (mockData.length === 0 || searchResults.value.length >= totalCount.value) {
noMoreData.value = true
} else {
searchResults.value.push(...mockData)
}
loading.value = false
}, 500)
}
/**
* 切换收藏状态
* @param {string} id 商品ID
*/
const toggleFavorite = (id) => {
const index = favoriteIds.value.indexOf(id)
if (index > -1) {
favoriteIds.value.splice(index, 1)
} else {
favoriteIds.value.push(id)
}
}
/**
* 商品点击事件
* @param {Object} scooter 商品信息
*/
const onProductClick = (scooter) => {
// 关闭弹窗
handleClose()
// 跳转到详情页
Taro.navigateTo({
url: `/pages/productDetail/index?id=${scooter.id}`
})
}
/**
* 关闭弹窗
*/
const handleClose = () => {
// 重置搜索状态
resetSearchState()
emit('update:visible', false)
}
/**
* 重置搜索状态
*/
const resetSearchState = () => {
searchValue.value = ''
searchResults.value = []
hasSearched.value = false
loading.value = false
noMoreData.value = false
currentPage.value = 1
totalCount.value = 0
// 重置筛选条件
selectedBrand.value = '全部品牌'
selectedYear.value = '出厂年份'
selectedSchool.value = '所在学校'
}
onMounted(() => {
// 动态修改标题
wx.setNavigationBarTitle({
title: '搜索车源'
});
})
</script>
<script>
export default {
name: "SearchPopup",
};
</script>
<!--
* @Date: 2025-06-28 10:33:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-07-04 15:20:58
* @LastEditTime: 2025-07-04 16:04:15
* @FilePath: /jgdl/src/pages/index/index.vue
* @Description: 捡个电驴首页
-->
......@@ -16,7 +16,7 @@
</nut-col>
<nut-col span="18">
<!-- Search Bar -->
<nut-searchbar v-model="searchValue" placeholder="搜索更多商品" @focus="onFocusSearch" shape="round" background="transparent" input-background="#ffffff">
<nut-searchbar v-model="searchValue" placeholder="搜索更多商品" :disabled="true" @click-input="onSearchHandle" shape="round" background="transparent" input-background="#ffffff">
<template #leftin>
<Search2 />
</template>
......@@ -153,6 +153,9 @@
<!-- 自定义TabBar -->
<TabBar />
<!-- 搜索弹窗 -->
<SearchPopup v-model:visible="showSearchPopup" />
</view>
</template>
......@@ -163,17 +166,17 @@ import { ref, onMounted } from 'vue'
import { useDidShow, useReady } from '@tarojs/taro'
import { Clock, Star, RectRight, Check, Search2, Shop, Heart1, HeartFill } from '@nutui/icons-vue-taro'
import TabBar from '@/components/TabBar.vue'
import SearchPopup from '@/components/SearchPopup.vue'
import "./index.less";
// 响应式数据
const searchValue = ref('')
const favoriteIds = ref([])
const showSearchPopup = ref(false)
const onFocusSearch = () => {
// 跳转到搜索页面
Taro.navigateTo({
url: '/pages/search/index'
})
const onSearchHandle = () => {
// 显示搜索弹窗
showSearchPopup.value = true
}
// Banner图片
......