index.vue
13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
<!--
* @Date: 2026-01-31
* @Description: 搜索页面 - 固定搜索栏和Tab,列表可滚动
-->
<template>
<view class="h-screen bg-[#F9FAFB] flex flex-col">
<!-- 固定顶部:导航栏 + 搜索栏 -->
<view class="bg-[#F9FAFB] z-10">
<NavHeader title="搜索" />
<!-- Search Input -->
<view class="px-[40rpx] mt-[32rpx]">
<SearchBar
v-model="searchKeyword"
placeholder="搜索培训资料、案例、产品..."
variant="rounded"
:show-border="true"
:show-clear="true"
@search="handleSearch"
@clear="clearSearch"
@blur="handleBlur"
/>
</view>
</view>
<!-- Tabs + 列表容器 -->
<view class="flex-1 min-h-0 flex flex-col mt-[32rpx] px-[40rpx]">
<!-- Tabs Container -->
<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>
<!-- Result Count -->
<view v-if="searchResults.length > 0" class="text-[#6B7280] text-[24rpx] mb-[24rpx]">
找到 {{ searchResults.length }} 个相关结果
</view>
<!-- 可滚动列表区域 -->
<view
class="flex-1 min-h-0 overflow-y-auto pb-[calc(160rpx+env(safe-area-inset-bottom))] box-border"
>
<!-- Search Results -->
<view
v-if="searchResults.length > 0"
:key="listRenderKey"
>
<!-- Results List -->
<view class="flex flex-col gap-[24rpx] pb-[40rpx]">
<!-- Product/Material Card -->
<view
v-for="(item, index) in searchResults"
:key="index"
class="bg-white rounded-[24rpx] overflow-hidden shadow-sm search-result-item"
:style="{ animationDelay: `${index * 30}ms` }"
@tap="goToDetail(item)"
>
<!-- Image + Content Layout -->
<view class="flex gap-[24rpx] p-[24rpx]">
<!-- Image -->
<image
class="w-[200rpx] h-[140rpx] rounded-[16rpx] bg-gray-100 flex-shrink-0"
:src="item.image"
mode="aspectFill"
/>
<!-- Content -->
<view class="flex-1 flex flex-col justify-between py-[4rpx]">
<!-- Title -->
<view class="text-[#1F2937] text-[28rpx] font-medium leading-[1.4] line-clamp-2">
{{ item.title }}
</view>
<!-- Meta Info -->
<view class="flex justify-between items-center">
<view class="flex gap-[12rpx]">
<!-- Type Tag -->
<view
class="px-[12rpx] py-[4rpx] rounded-[8rpx] text-[22rpx]"
:class="item.type === '产品' ? 'bg-blue-50 text-blue-600' : 'bg-green-50 text-green-600'"
>
{{ item.type }}
</view>
<!-- Hot Tag -->
<view v-if="item.tag" class="bg-red-50 text-red-600 text-[22rpx] px-[12rpx] py-[4rpx] rounded-[8rpx]">
{{ item.tag }}
</view>
</view>
<view class="text-[#6B7280] text-[24rpx]">
{{ item.views || 0 }}人查看
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- Empty State (已搜索但无结果) -->
<view v-else-if="hasSearched && searchResults.length === 0" class="flex flex-col items-center justify-center py-[120rpx]">
<!-- <image
class="w-[320rpx] h-[320rpx] mb-[40rpx]"
src="https://picsum.photos/seed/empty/320/320"
mode="aspectFit"
/> -->
<view class="text-[#6B7280] text-[28rpx]">暂无搜索结果</view>
<view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">试试其他关键词吧</view>
</view>
<!-- Initial State (从未搜索过) -->
<view v-else-if="isInitialState" class="flex flex-col items-center justify-center py-[120rpx]">
<IconFont name="search" class="text-gray-300 mb-[24rpx]" size="64" />
<view class="text-[#6B7280] text-[28rpx]">搜索培训资料、案例、产品</view>
<view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">输入关键词开始搜索</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import Taro from '@tarojs/taro'
import { useGo } from '@/hooks/useGo'
import NavHeader from '@/components/NavHeader.vue'
import IconFont from '@/components/IconFont.vue'
import SearchBar from '@/components/SearchBar.vue'
// Navigation
const go = useGo()
// State
const searchKeyword = ref('')
const activeTabId = ref('')
/**
* 是否已经搜索过
* @description 一旦用户搜索过,此值将保持为 true,即使清空关键词也不会重置
* 用于区分"初始状态"和"空搜索结果"
*/
const hasSearched = ref(false)
const listRenderKey = ref(0)
/**
* 是否显示初始状态
* @description 只有在从未搜索过且没有关键词时才显示初始状态
*/
const isInitialState = computed(() => {
return !hasSearched.value && !searchKeyword.value.trim()
})
/**
* 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 []
// ✅ 如果没有关键词,返回空数组(不显示全部数据)
if (!searchKeyword.value.trim()) {
console.log('[Search Results] 没有关键词,返回空数组')
return []
}
// 找到当前选中的 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)
if (!currentTab) return []
let results = currentTab.list
// Filter by keyword
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 状态更新
}
/**
* 清空搜索
* @description 清空搜索关键词,但保持 hasSearched 状态
* 以显示"暂无搜索结果"而不是"初始状态"
*/
const clearSearch = () => {
console.log('[Search Clear] 清空搜索关键词')
searchKeyword.value = ''
// ❌ 不要重置 hasSearched,保持"已搜索"状态
// hasSearched.value = false
listRenderKey.value += 1
console.log('[Search Clear] hasSearched 保持为:', hasSearched.value)
}
// Go to detail
const goToDetail = (item) => {
if (item.category === 'product') {
go('/pages/knowledge-base/index')
} else {
go('/pages/material-list/index', { title: '搜索结果' })
}
Taro.showToast({
title: `查看${item.type}详情`,
icon: 'none',
duration: 1500
})
}
// 初始化数据
initTabsData()
/**
* 监听搜索关键词变化,实现实时搜索
* @description 当用户输入关键词时,自动触发搜索,并标记"已搜索"状态
*/
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)
console.log('[Search Watch] hasSearched 设置为 true')
}
// ✅ 清空关键词时,不要重置 hasSearched
// 这样可以保持"已搜索"状态,显示"暂无搜索结果"而不是"初始状态"
})
</script>
<style lang="less">
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.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: 24rpx 24rpx;
gap: 24rpx;
transition: all 0.3s ease;
background-color: #F9FAFB;
width: 100%;
// 隐藏滚动条
&::-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;
}
.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>