Files
airz-uniapp/packages/content/search/search.uvue
T
hujun 25cb06b71c fix: 修复帮助内容加载、搜索滚动和账户同步的问题
- 完善帮助内容接口的响应校验,增加数据格式和范围验证
- 优化搜索页面的滚动位置管理,避免H5页面激活时的组件重建问题
- 增加账户切换时的定时器清理,防止内存泄漏和重复请求
2026-10-08 17:43:53 +08:00

140 lines
10 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page" :style="pageStyle()">
<AppHeader title="搜索" />
<view class="search-bar">
<view class="search-box">
<image class="search-icon" src="/static/icons/search.svg" mode="aspectFit" />
<input v-model="keyword" class="search-input" placeholder="搜索新闻、融资课堂" maxlength="80" confirm-type="search" @input="resetResults" @confirm="search" />
<view v-if="keyword.length > 0" class="clear-search" @click="clearSearch"><text class="clear-mark">×</text></view>
</view>
<button class="search-button" @click="search">搜索</button>
</view>
<view class="search-tabs">
<view v-for="item in tabs" :key="item.id" :class="activeType == item.id ? 'search-tab selected-tab' : 'search-tab'" @click="selectType(item.id)"><text :class="activeType == item.id ? 'tab-label selected-label' : 'tab-label'">{{ item.label }}</text></view>
</view>
<scroll-view scroll-y :scroll-top="scrollTop" :style="contentStyle(296, false)">
<view class="search-content" :style="bottomStyle(40)">
<view v-if="!searched" class="search-guide">
<text class="guide-title">想了解什么?</text><text class="guide-copy">查找融资资讯,学习实用知识</text>
<view class="suggestion-list"><view class="suggestion" v-for="word in suggestions" :key="word" @click="searchWord(word)"><text class="suggestion-label">{{ word }}</text></view></view>
</view>
<view v-else-if="loading" class="state-panel"><text class="state-title">正在搜索…</text></view>
<view v-else-if="errorMessage.length > 0" class="state-panel"><text class="state-title">搜索暂时不可用</text><text class="state-copy">{{ errorMessage }}</text><button class="retry-button" @click="search">重新搜索</button></view>
<view v-else-if="results.length == 0" class="state-panel"><view class="empty-symbol"><image class="empty-icon" src="/static/icons/search.svg" mode="aspectFit" /></view><text class="state-title">没有找到相关内容</text><text class="state-copy">试试更简短的关键词,或切换搜索分类</text></view>
<view v-else>
<text class="result-count">找到 {{ total }} 条相关内容</text>
<view class="result-card" v-for="item in results" :key="item.type + ':' + item.id" @click="openResult(item)">
<view class="result-copy"><view class="result-heading"><text :class="item.type == 'news' ? 'result-kind' : 'result-kind lesson-kind'">{{ item.type == 'news' ? '新闻' : '融资课堂' }}</text><text class="result-category">{{ item.category }}</text></view><text class="result-title">{{ item.title }}</text><text class="result-summary">{{ item.summary }}</text><text class="result-meta">{{ item.meta }}</text></view>
<image v-if="item.image.length > 0" class="result-image" :src="item.image" mode="aspectFill" />
</view>
<text v-if="moreError.length > 0" class="state-copy">{{ moreError }}</text>
<button v-if="hasMore" class="retry-button" @click="more">{{ loadingMore ? '正在加载…' : '加载更多' }}</button>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { searchContent, ContentSearchResult } from '@/packages/content/services/search.uts'
import { validateContentAppend } from '@/services/content.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
type SearchTab = { id: string, label: string }
const tabs: SearchTab[] = [{ id: 'all', label: '全部' }, { id: 'news', label: '新闻' }, { id: 'lesson', label: '融资课堂' }]
const suggestions: string[] = ['个人信用', '企业融资', '利率', '还款', '材料', '政策']
const keyword = ref('')
const activeType = ref('all')
const searched = ref(false)
const loading = ref(false)
const errorMessage = ref('')
const results = ref<ContentSearchResult[]>([])
const scrollTop = ref(0)
const total = ref(0); const hasMore = ref(false); const loadingMore = ref(false); const moreError = ref('')
let page = 1
let requestVersion = 0
let visible = false
const resetScroll = () => {
// 保留滚动组件,避免H5返回页激活时重建导致框架读取空引用。
scrollTop.value = 1
nextTick(() => { scrollTop.value = 0 })
}
const resetResults = () => { requestVersion++; results.value = []; total.value = 0; hasMore.value = false; loadingMore.value = false; moreError.value = ''; searched.value = false; loading.value = false; errorMessage.value = ''; resetScroll() }
const clearSearch = () => { keyword.value = ''; resetResults() }
async function load(append: boolean) {
if (!visible) return
if (append && (loading.value || loadingMore.value || !hasMore.value)) return
keyword.value = keyword.value.trim()
if (keyword.value.length == 0) { clearSearch(); return }
uni.hideKeyboard()
const currentRequest = append ? requestVersion : ++requestVersion
if (append) { loadingMore.value = true; moreError.value = '' }
else { searched.value = true; loading.value = true; loadingMore.value = false; errorMessage.value = ''; moreError.value = ''; results.value = []; total.value = 0; hasMore.value = false; page = 1; resetScroll() }
try {
const result = await searchContent(keyword.value, activeType.value, append ? page + 1 : 1)
// 输入、切换分类或退出页面后,忽略旧请求,避免结果被覆盖。
if (!visible || currentRequest != requestVersion) return
if (append) validateContentAppend(results.value.map((item: ContentSearchResult): string => item.type + ':' + item.id), result.items.map((item: ContentSearchResult): string => item.type + ':' + item.id))
results.value = append ? results.value.concat(result.items) : result.items
total.value = result.total; hasMore.value = result.hasMore
if (append) page++
} catch (err) {
if (visible && currentRequest == requestVersion) {
const message = err instanceof Error ? err.message : '请检查网络连接后重试'
if (append) moreError.value = message; else errorMessage.value = message
}
} finally { if (currentRequest == requestVersion) { loading.value = false; loadingMore.value = false } }
}
const search = () => { load(false) }
const more = () => { load(true) }
const selectType = (type: string) => { if (activeType.value == type) return; activeType.value = type; if (searched.value) search() }
const searchWord = (word: string) => { keyword.value = word; search() }
const openResult = (item: ContentSearchResult) => {
const route = item.type == 'news' ? '/packages/content/article/article' : '/packages/content/lesson/lesson'
uni.navigateTo({ url: route + '?id=' + encodeURIComponent(item.id) })
}
onShow(() => { visible = true; if (searched.value) search() })
onHide(() => { visible = false; requestVersion++; loading.value = false; loadingMore.value = false })
onUnload(() => { visible = false; requestVersion++ })
</script>
<style>
.search-bar { height: 112rpx; padding: 20rpx 28rpx; flex-direction: row; align-items: center; background-color: #ffffff; }
.search-box { flex: 1; min-width: 0; height: 72rpx; border-radius: 16rpx; background-color: #f2f6fc; flex-direction: row; align-items: center; padding-left: 20rpx; }
.search-icon { width: 34rpx; height: 34rpx; margin-right: 14rpx; flex-shrink: 0; }
.search-input { flex: 1; min-width: 0; height: 72rpx; font-size: 27rpx; color: #22324a; }
.clear-search { width: 64rpx; height: 72rpx; align-items: center; justify-content: center; flex-shrink: 0; }
.clear-mark { font-size: 36rpx; line-height: 44rpx; color: #738198; }
.search-button { width: 92rpx; height: 72rpx; line-height: 72rpx; padding: 0; margin: 0 0 0 16rpx; font-size: 28rpx; font-weight: 600; color: #0868f7; background-color: transparent; border-radius: 12rpx; }
.search-tabs { height: 88rpx; padding: 0 28rpx; flex-direction: row; background-color: #ffffff; border-bottom: 1rpx solid #e8eef6; }
.search-tab { flex: 1; height: 88rpx; align-items: center; justify-content: center; border-bottom: 4rpx solid transparent; }
.selected-tab { border-bottom-color: #0868f7; }
.tab-label { font-size: 28rpx; color: #66758a; }
.selected-label { color: #0868f7; font-weight: 700; }
.search-content { padding: 28rpx 28rpx 40rpx; }
.search-guide { padding: 22rpx 4rpx; }
.guide-title { color: #172640; font-size: 34rpx; line-height: 48rpx; font-weight: 700; }
.guide-copy { color: #738198; font-size: 26rpx; line-height: 40rpx; margin-top: 10rpx; }
.suggestion-list { flex-direction: row; flex-wrap: wrap; margin-top: 28rpx; }
.suggestion { padding: 18rpx 28rpx; margin: 0 16rpx 18rpx 0; border: 1rpx solid #e3eaf4; border-radius: 14rpx; background-color: #ffffff; }
.suggestion-label { font-size: 26rpx; color: #405675; }
.result-count { font-size: 24rpx; color: #738198; margin-bottom: 20rpx; }
.result-card { flex-direction: row; align-items: center; background-color: #ffffff; border: 1rpx solid #e6edf7; border-radius: 18rpx; padding: 24rpx; margin-bottom: 18rpx; }
.result-copy { flex: 1; min-width: 0; }
.result-heading { flex-direction: row; align-items: center; flex-wrap: wrap; }
.result-kind { padding: 5rpx 10rpx; border-radius: 6rpx; font-size: 21rpx; color: #0868f7; background-color: #edf5ff; margin-right: 12rpx; }
.lesson-kind { color: #197f70; background-color: #eaf7f3; }
.result-category { color: #738198; font-size: 22rpx; line-height: 34rpx; }
.result-title { color: #172640; font-size: 29rpx; line-height: 42rpx; font-weight: 600; margin-top: 12rpx; }
.result-summary { color: #66758a; font-size: 24rpx; line-height: 36rpx; margin-top: 10rpx; }
.result-meta { color: #738198; font-size: 22rpx; line-height: 32rpx; margin-top: 14rpx; }
.result-image { width: 132rpx; height: 132rpx; border-radius: 12rpx; margin-left: 20rpx; flex-shrink: 0; }
.state-panel { padding: 100rpx 16rpx; align-items: center; }
.empty-symbol { width: 112rpx; height: 112rpx; border-radius: 56rpx; align-items: center; justify-content: center; background-color: #e8f2ff; margin-bottom: 26rpx; }
.empty-icon { width: 50rpx; height: 50rpx; }
.state-title { color: #26374f; font-size: 30rpx; line-height: 44rpx; font-weight: 600; text-align: center; }
.state-copy { color: #738198; font-size: 25rpx; line-height: 40rpx; margin-top: 14rpx; text-align: center; }
.retry-button { color: #0868f7; background-color: #e8f2ff; height: 76rpx; line-height: 76rpx; padding: 0 36rpx; margin-top: 28rpx; border-radius: 12rpx; font-size: 26rpx; }
</style>