- 完善帮助内容接口的响应校验,增加数据格式和范围验证 - 优化搜索页面的滚动位置管理,避免H5页面激活时的组件重建问题 - 增加账户切换时的定时器清理,防止内存泄漏和重复请求
140 lines
10 KiB
Plaintext
140 lines
10 KiB
Plaintext
<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>
|