hookehuyr

refactor(components): 重构 SearchBar 组件并更新搜索页面

- 将 SearchBar 组件从原生 input 重构为 NutUI Input 实现,简化样式和逻辑
- 更新 components.d.ts 类型声明,移除未使用的 NutConfigProvider,修正 PlanPopup 导入路径
- 重构搜索页面,使用 NutTabs 实现分类切换,支持长列表测试数据
- 为搜索结果项添加动画效果,优化用户体验
......@@ -16,7 +16,6 @@ declare module 'vue' {
NavHeader: typeof import('./src/components/NavHeader.vue')['default']
NutAvatar: typeof import('@nutui/nutui-taro')['Avatar']
NutButton: typeof import('@nutui/nutui-taro')['Button']
NutConfigProvider: typeof import('@nutui/nutui-taro')['ConfigProvider']
NutInput: typeof import('@nutui/nutui-taro')['Input']
NutPicker: typeof import('@nutui/nutui-taro')['Picker']
NutPopup: typeof import('@nutui/nutui-taro')['Popup']
......@@ -28,7 +27,7 @@ declare module 'vue' {
OfficeViewer: typeof import('./src/components/OfficeViewer.vue')['default']
PdfPreview: typeof import('./src/components/PdfPreview.vue')['default']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PlanPopup: typeof import('./src/components/PlanSchemes/PlanPopup.vue')['default']
PlanPopup: typeof import('./src/components/PlanPopup/index.vue')['default']
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
QrCode: typeof import('./src/components/qrCode.vue')['default']
QrCodeSearch: typeof import('./src/components/qrCodeSearch.vue')['default']
......
<template>
<view
class="search-bar flex items-center"
:class="containerClass"
>
<!-- Search Icon -->
<IconFont
name="search"
:size="iconSize"
:color="iconColor"
class="flex-shrink-0 mr-[16rpx]"
/>
<!-- Input -->
<input
<view class="search-bar" :class="containerClass">
<!-- NutUI Input 组件 -->
<nut-input
v-model="internalValue"
:type="inputType"
:placeholder="placeholder"
:placeholder-class="placeholderClass"
:class="inputClass"
:disabled="disabled"
@focus="handleFocus"
confirm-type="search"
:clearable="showClear"
class="search-input"
@clear="handleClear"
@blur="handleBlur"
@input="handleInput"
@focus="handleFocus"
@confirm="handleSearch"
/>
<!-- Clear Button -->
<IconFont
v-if="showClear && internalValue"
name="close"
:size="clearIconSize"
:color="clearIconColor"
class="flex-shrink-0 ml-[16rpx]"
@tap="handleClear"
/>
>
<template #left>
<IconFont
name="search"
:size="iconSize"
:color="iconColor"
class="mr-[8rpx]"
/>
</template>
</nut-input>
</view>
</template>
......@@ -42,9 +31,9 @@ import { ref, watch, computed } from 'vue'
import IconFont from '@/components/IconFont.vue'
/**
* SearchBar 组件
* SearchBar 组件(基于 NutUI Input)
*
* @description 可复用的搜索栏组件,支持多种样式变体
* @description 可复用的搜索栏组件,使用 NutUI Input 组件实现
* @author Claude Code
* @example
* <SearchBar
......@@ -136,24 +125,6 @@ const props = defineProps({
iconColor: {
type: String,
default: '#9CA3AF'
},
/**
* 清除图标大小
* @type {number|string}
* @default 16
*/
clearIconSize: {
type: [Number, String],
default: 16
},
/**
* 清除图标颜色
* @type {string}
* @default '#9CA3AF'
*/
clearIconColor: {
type: String,
default: '#9CA3AF'
}
})
......@@ -198,41 +169,17 @@ const internalValue = ref(props.modelValue)
// 容器样式类
const containerClass = computed(() => {
const base = [
'bg-white',
'shadow-sm',
'px-[40rpx]', // 左右 padding
props.variant === 'rounded' ? 'rounded-full' : 'rounded-[20rpx]'
]
if (props.showBorder) {
base.push('border', 'border-gray-200')
} else {
base.push('border', 'border-gray-50')
}
const classes = ['search-bar']
// 高度样式
if (props.variant === 'rounded') {
base.push('h-[88rpx]')
} else {
base.push('py-[24rpx]')
classes.push('search-bar-rounded')
}
return base.join(' ')
})
// 占位符样式类
const placeholderClass = 'text-gray-400 text-[28rpx]'
if (props.showBorder) {
classes.push('search-bar-bordered')
}
// 输入框样式类
const inputClass = computed(() => {
return [
'flex-1',
'text-[28rpx]',
'bg-transparent',
'outline-none',
props.disabled ? 'opacity-50' : ''
].filter(Boolean).join(' ')
return classes.join(' ')
})
// 监听 modelValue 变化
......@@ -243,12 +190,14 @@ watch(() => props.modelValue, (newValue) => {
// 监听内部值变化,触发更新
watch(internalValue, (newValue) => {
emit('update:modelValue', newValue)
emit('input', newValue)
})
/**
* 处理获得焦点
*/
function handleFocus() {
console.log('[SearchBar Component] 获得焦点')
emit('focus')
}
......@@ -256,27 +205,25 @@ function handleFocus() {
* 处理失去焦点
*/
function handleBlur() {
console.log('[SearchBar Component] 失去焦点')
emit('blur')
}
/**
* 处理输入
*/
function handleInput(e) {
emit('input', internalValue.value)
}
/**
* 处理搜索(回车)
*/
function handleSearch() {
console.log('[SearchBar Component] handleSearch 被调用')
console.log('[SearchBar Component] 当前输入值:', internalValue.value)
emit('search', internalValue.value)
console.log('[SearchBar Component] search 事件已发送')
}
/**
* 清除输入
*/
function handleClear() {
console.log('[SearchBar Component] 清除输入')
internalValue.value = ''
emit('clear')
emit('update:modelValue', '')
......@@ -284,5 +231,30 @@ function handleClear() {
</script>
<style lang="less" scoped>
/* 样式通过 TailwindCSS 类控制 */
.search-bar {
padding: 0;
background: white;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
:deep(.nut-input) {
padding: 24rpx 32rpx;
background-color: transparent;
}
&.search-bar-rounded {
border-radius: 9999rpx;
:deep(.nut-input) {
border-radius: 9999rpx;
}
}
&.search-bar-bordered {
border: 1px solid #e5e7eb;
}
}
.search-bar-rounded {
height: 88rpx;
}
</style>
......
<!--
* @Date: 2026-01-31
* @Description: 搜索页面 - 已改造为 NutTabs 版本,支持长列表和分类切换测试
-->
<template>
<view class="min-h-screen bg-[#F9FAFB] pb-[calc(160rpx+env(safe-area-inset-bottom))]">
<!-- Navigation Header -->
......@@ -15,21 +19,37 @@
:show-clear="true"
@search="handleSearch"
@clear="clearSearch"
@blur="handleBlur"
/>
</view>
<!-- Filter Tabs -->
<view class="flex overflow-x-auto no-scrollbar mb-[40rpx] space-x-[24rpx]">
<view v-for="(tab, index) in tabs" :key="index"
class="px-[32rpx] py-[16rpx] rounded-full text-[28rpx] whitespace-nowrap transition-colors"
:class="activeTab === index ? 'bg-[#2563EB] text-white' : 'bg-[#F3F4F6] text-[#6B7280]'"
@tap="activeTab = index">
{{ tab }}
</view>
<!-- Tabs Container -->
<view class="mb-[40rpx]">
<nut-tabs v-model="activeTabId">
<!-- 自定义标签栏 -->
<template #titles>
<view class="filter-tabs-wrapper">
<view
v-for="item in tabsData"
:key="item.id"
:class="[
'filter-tab-item',
activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
]"
@tap="onTabClick(item.id)"
>
<text class="filter-tab-text">{{ item.name }}</text>
</view>
</view>
</template>
</nut-tabs>
</view>
<!-- Search Results -->
<view v-if="searchResults.length > 0">
<view
v-if="searchResults.length > 0"
:key="listRenderKey"
>
<!-- Result Count -->
<view class="text-[#6B7280] text-[24rpx] mb-[24rpx]">
找到 {{ searchResults.length }} 个相关结果
......@@ -37,11 +57,12 @@
<!-- Results List -->
<view class="flex flex-col gap-[24rpx]">
<!-- Product Card -->
<!-- Product/Material Card -->
<view
v-for="(item, index) in searchResults"
:key="index"
class="bg-white rounded-[24rpx] overflow-hidden shadow-sm"
class="bg-white rounded-[24rpx] overflow-hidden shadow-sm search-result-item"
:style="{ animationDelay: `${index * 30}ms` }"
@tap="goToDetail(item)"
>
<!-- Image + Content Layout -->
......@@ -107,7 +128,7 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import Taro from '@tarojs/taro'
import { useGo } from '@/hooks/useGo'
import NavHeader from '@/components/NavHeader.vue'
......@@ -119,118 +140,184 @@ const go = useGo()
// State
const searchKeyword = ref('')
const activeTab = ref(0)
const activeTabId = ref('')
const hasSearched = ref(false)
const listRenderKey = ref(0)
// Tabs
const tabs = ['全部', '产品', '资料']
// Mock data
const mockData = ref([
{
id: 1,
title: '家庭财富传承保障计划(分红)',
type: '产品',
tag: '热卖',
views: 256,
image: 'https://picsum.photos/seed/prod1/200/140',
category: 'product'
},
{
id: 2,
title: '2024年保险市场趋势分析报告',
type: '资料',
views: 189,
image: 'https://picsum.photos/seed/mat1/200/140',
category: 'material'
},
{
id: 3,
title: '儿童教育金储备方案(分红)',
type: '产品',
tag: '推荐',
views: 342,
image: 'https://picsum.photos/seed/prod2/200/140',
category: 'product'
},
{
id: 4,
title: '高净值客户需求分析与产品匹配',
type: '资料',
views: 142,
image: 'https://picsum.photos/seed/mat2/200/140',
category: 'material'
},
{
id: 5,
title: '百万医疗保险计划',
type: '产品',
views: 267,
image: 'https://picsum.photos/seed/prod3/200/140',
category: 'product'
},
{
id: 6,
title: '保险合同条款解读与风险提示',
type: '资料',
views: 198,
image: 'https://picsum.photos/seed/mat3/200/140',
category: 'material'
},
{
id: 7,
title: '意外伤害保障计划',
type: '产品',
tag: '热卖',
views: 223,
image: 'https://picsum.photos/seed/prod4/200/140',
category: 'product'
},
{
id: 8,
title: '保险销售实战技巧分享',
type: '资料',
views: 156,
image: 'https://picsum.photos/seed/mat4/200/140',
category: 'material'
},
/**
* Tab 数据源
* @description 包含分类信息和对应的列表
*/
const tabsData = ref([
{ id: '', name: '全部', list: [] },
{ id: 'product', name: '产品', list: [] },
{ id: 'material', name: '资料', list: [] },
])
/**
* 生成大量 Mock 数据用于测试长列表
* @description 生成 50 个产品 + 50 个资料,共 100 条数据
*/
const generateMockData = () => {
const products = []
const materials = []
// 生成 50 个产品
for (let i = 1; i <= 50; i++) {
products.push({
id: i,
title: `保险产品 ${i} - ${getProductName(i)}`,
type: '产品',
tag: i % 3 === 0 ? '热卖' : (i % 5 === 0 ? '推荐' : ''),
views: Math.floor(Math.random() * 500) + 50,
image: `https://picsum.photos/seed/prod${i}/200/140`,
category: 'product'
})
}
// 生成 50 个资料
for (let i = 1; i <= 50; i++) {
materials.push({
id: 50 + i,
title: `培训资料 ${i} - ${getMaterialName(i)}`,
type: '资料',
views: Math.floor(Math.random() * 300) + 30,
image: `https://picsum.photos/seed/mat${i}/200/140`,
category: 'material'
})
}
return [...products, ...materials]
}
/**
* 获取产品名称
*/
const getProductName = (index) => {
const names = [
'终身寿险', '百万医疗', '重疾保障', '意外保险', '年金保险',
'教育金', '养老保险', '财富传承', '投资连结', '分红保险',
'万能险', '定期寿险', '终身医疗', '高端医疗', '团体保险'
]
return names[index % names.length]
}
/**
* 获取资料名称
*/
const getMaterialName = (index) => {
const names = [
'销售话术', '产品培训', '案例分析', '合规指引', '核保规则',
'理赔流程', '客户服务', '市场分析', '竞争产品对比', '政策解读',
'新人培训', '晋升考核', '团队管理', '活动策划', '产说会流程'
]
return names[index % names.length]
}
// All mock data
const allData = ref(generateMockData())
console.log('[Search] 数据生成完成,总数:', allData.value.length)
console.log('[Search] 产品数量:', allData.value.filter(item => item.category === 'product').length)
console.log('[Search] 资料数量:', allData.value.filter(item => item.category === 'material').length)
/**
* 初始化数据分布
* @description 根据分类规则将 allData 中的数据分配到各个 tab 中
*/
const initTabsData = () => {
tabsData.value.forEach((tab) => {
if (tab.id === '') {
tab.list = [...allData.value]
} else if (tab.id === 'product') {
tab.list = allData.value.filter(item => item.category === 'product')
} else if (tab.id === 'material') {
tab.list = allData.value.filter(item => item.category === 'material')
}
})
// 默认选中第一个 tab(全部)
if (tabsData.value.length > 0) {
activeTabId.value = tabsData.value[0].id
console.log('[Search] 初始化完成,默认选中:', tabsData.value[0].name)
console.log('[Search] 全部分类数据量:', tabsData.value[0].list.length)
console.log('[Search] 产品分类数据量:', tabsData.value[1].list.length)
console.log('[Search] 资料分类数据量:', tabsData.value[2].list.length)
}
}
// Search results
const searchResults = computed(() => {
if (!hasSearched.value) return []
let results = mockData.value
// 找到当前选中的 tab
const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value)
console.log('[Search Results] activeTabId:', activeTabId.value)
console.log('[Search Results] currentTab:', currentTab)
console.log('[Search Results] currentTab.list.length:', currentTab?.list?.length || 0)
// Filter by tab
if (activeTab.value === 1) {
results = results.filter(item => item.category === 'product')
} else if (activeTab.value === 2) {
results = results.filter(item => item.category === 'material')
}
if (!currentTab) return []
let results = currentTab.list
// Filter by keyword
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.toLowerCase()
console.log('[Search Results] 搜索关键词:', keyword)
console.log('[Search Results] 过滤前数量:', results.length)
results = results.filter(item =>
item.title.toLowerCase().includes(keyword)
)
console.log('[Search Results] 过滤后数量:', results.length)
}
return results
})
/**
* Tab 点击处理
*/
const onTabClick = (id) => {
activeTabId.value = id
listRenderKey.value += 1
// 自动触发搜索(如果已经搜索过)
if (hasSearched.value) {
console.log('[Search] 切换分类到:', id, '结果数量:', searchResults.value.length)
}
}
// Handle search
const handleSearch = () => {
console.log('[Search handleSearch] 被调用')
console.log('[Search handleSearch] searchKeyword:', searchKeyword.value)
if (searchKeyword.value.trim()) {
hasSearched.value = true
console.log('[Search handleSearch] hasSearched 已设置为 true')
console.log('[Search handleSearch] 搜索关键词:', searchKeyword.value)
console.log('[Search handleSearch] 当前分类:', activeTabId.value)
console.log('[Search handleSearch] 搜索结果数量:', searchResults.value.length)
} else {
console.log('[Search handleSearch] 搜索关键词为空,不执行搜索')
}
}
// Handle blur
const handleBlur = () => {
console.log('[Search handleBlur] 搜索框失去焦点')
// 可以在这里添加一些失去焦点时的逻辑,比如:
// - 收起键盘
// - 记录搜索日志
// - 其他 UI 状态更新
}
// Clear search
const clearSearch = () => {
searchKeyword.value = ''
hasSearched.value = false
listRenderKey.value += 1
}
// Go to detail
......@@ -238,7 +325,7 @@ const goToDetail = (item) => {
if (item.category === 'product') {
go('/pages/knowledge-base/index')
} else {
go('/pages/knowledge-base/index')
go('/pages/material-list/index', { title: '搜索结果' })
}
Taro.showToast({
......@@ -247,15 +334,95 @@ const goToDetail = (item) => {
duration: 1500
})
}
// 初始化数据
initTabsData()
/**
* 监听搜索关键词变化,实现实时搜索
*/
watch(searchKeyword, (newVal) => {
if (newVal.trim()) {
hasSearched.value = true
console.log('[Search Watch] 实时搜索触发,关键词:', newVal)
console.log('[Search Watch] 当前分类:', activeTabId.value)
console.log('[Search Watch] 搜索结果数量:', searchResults.value.length)
} else {
// 清空搜索关键词时,也清空搜索状态
hasSearched.value = false
}
})
</script>
<style>
.no-scrollbar::-webkit-scrollbar {
display: none;
<style lang="less">
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.no-scrollbar {
.search-result-item {
animation: slideIn 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
// FilterTabs 风格的标签栏
.filter-tabs-wrapper {
display: flex;
overflow-x: auto;
padding: 0;
gap: 24rpx;
transition: all 0.3s ease;
// 隐藏滚动条
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
.filter-tab-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0 32rpx;
border-radius: 9999rpx;
white-space: nowrap;
transition: all 0.3s ease;
flex-shrink: 0;
height: 64rpx;
}
.filter-tab-active {
background-color: #2563EB; // 蓝色背景
color: #fff;
}
.filter-tab-inactive {
background-color: #F3F4F6; // 灰色背景
color: #6B7280;
}
.filter-tab-text {
font-size: 28rpx;
font-weight: 500;
}
// 覆盖 NutUI Tabs 默认样式,隐藏原有的头部和内容(因为我们使用自定义头部和外部列表)
:deep(.nut-tabs__titles) {
display: none;
}
:deep(.nut-tabs__content) {
display: none;
}
</style>
......